diff --git a/README.md b/README.md index e060843..0f24b2b 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ VOLCO uses two configuration files: simulation settings and printer settings. Be | `solver_tolerance` | Tolerance for volume conservation in the bisection method. | 0.0001 | | `consider_acceleration` | Whether to consider acceleration in volume distribution. | false | | `stl_ascii` | Whether to export STL in ASCII format (true) or binary (false). | false | +| `preview_mode` | If true, runs a fast, lightweight preview that traces the G-code path and fills extrusions as capsules. No physics or overlap checks. Intended for quick feedback before running the full simulation. | false | ### Printer Configuration @@ -156,6 +157,12 @@ VOLCO uses two configuration files: simulation settings and printer settings. Be - **consider_acceleration**: When true, the simulation accounts for acceleration and deceleration, which can provide more accurate results but increases computation time. +## Preview Mode + +Preview mode is designed for fast, lightweight visualization of the print path. It traces the G-code path and fills extrusions as capsules (cylinders with rounded ends), matching the nozzle diameter. This mode skips all physics and overlap checks, making it ideal for quick feedback and rapid iteration before running the full, volume-conserving simulation. + +If you want to run VOLCO in maximum speed mode (no volume conservation, no overlap checks), set `max_speed_mode: true` in your simulation config. This will trace the G-code path and fill voxels along it, suitable for fast preview, parallel, or GPU-optimized runs. + ## Finite Element Analysis (FEA) VOLCO includes a Finite Element Analysis (FEA) module that enables structural analysis of the simulated 3D printed parts. With just one line of code, you can analyze the structural behavior of your VOLCO simulation results: diff --git a/app/configs/arguments.py b/app/configs/arguments.py index c18f654..a2c999e 100644 --- a/app/configs/arguments.py +++ b/app/configs/arguments.py @@ -8,5 +8,6 @@ def get_options(): parser.add_argument("--gcode", type=str) parser.add_argument("--sim", type=str) parser.add_argument("--printer", type=str) + parser.add_argument("--preview", action="store_true", help="Enable preview mode (fast, lightweight visualization)") return parser.parse_args() diff --git a/app/configs/simulation.py b/app/configs/simulation.py index 63a5832..304f82e 100644 --- a/app/configs/simulation.py +++ b/app/configs/simulation.py @@ -53,3 +53,6 @@ def _load_config_from_dict(self, config): # Default acceleration and STL settings self.consider_acceleration = config.get("consider_acceleration", False) self.stl_ascii = config.get("stl_ascii", False) + + # Preview mode (fast, lightweight visualization) + self.preview_mode = config.get("preview_mode", False) diff --git a/app/geometry/geometry_math.py b/app/geometry/geometry_math.py index b0dc014..4855fcf 100644 --- a/app/geometry/geometry_math.py +++ b/app/geometry/geometry_math.py @@ -49,6 +49,21 @@ def find_coordinates(indexes, voxel_size): @staticmethod def calculate_filled_volume(voxel_space, voxel_size): + # Accept either a raw ndarray or a VoxelSpace-like object with a + # `_filled_voxels_count` running counter. Prefer the running counter + # when available to avoid expensive full-array scans. + if hasattr(voxel_space, "_filled_voxels_count"): + return int(voxel_space._filled_voxels_count) * voxel_size ** 3 + + # If an object with `.space` is provided, try to use its counter first. + if hasattr(voxel_space, "space") and hasattr(voxel_space.space, "shape"): + # If the container itself tracks a counter, use it. + if hasattr(voxel_space, "_filled_voxels_count"): + return int(voxel_space._filled_voxels_count) * voxel_size ** 3 + # Fallback to scanning the ndarray + return np.count_nonzero(voxel_space.space) * voxel_size ** 3 + + # Otherwise assume voxel_space is a numpy array return np.count_nonzero(voxel_space) * voxel_size**3 def find_index(coordinate, voxel_size): diff --git a/app/geometry/sphere.py b/app/geometry/sphere.py index efcf00a..a3e181f 100644 --- a/app/geometry/sphere.py +++ b/app/geometry/sphere.py @@ -21,7 +21,10 @@ def deposit_sphere( ): initial_radius = self.estimate_initial_radius(sphere_volume) - _, voxel_space = BisectionMethod().execute( + # `voxel_space` is expected to be a VoxelSpace object here. The + # bisection solver and inner functions will mutate and ultimately + # return that object so callers can read `.space` and counters. + _, voxel_space_out = BisectionMethod().execute( self._deposit_sphere, initial_point=initial_radius, tolerance=solver_tolerance, @@ -34,24 +37,56 @@ def deposit_sphere( ), ) - return voxel_space + return voxel_space_out + + def fill_voxels(self, voxel_space_obj, radius, lower_indexes, upper_indexes): + # Accept either a VoxelSpace-like object (has `.space`) or a raw ndarray. + is_voxel_space = hasattr(voxel_space_obj, "space") + space = voxel_space_obj.space if is_voxel_space else voxel_space_obj - def fill_voxels(self, voxel_space, radius, lower_indexes, upper_indexes): empty_voxels = GeometryMath.find_empty_voxels_in_space( - voxel_space, lower_indexes, upper_indexes + space, lower_indexes, upper_indexes ) - for voxel in empty_voxels: - voxel_coordinate = GeometryMath.find_coordinates(voxel, self.voxel_size) - - distance_to_centre = GeometryMath.distance( - voxel_coordinate, self.centre_coordinates - ) - - if distance_to_centre <= radius + self.voxel_size * 1e-8: - voxel_space[tuple(voxel)] = 1 - - return voxel_space + if not empty_voxels: + return voxel_space_obj + + # Convert to numpy array for vectorized operations + empty_voxels_np = np.array(empty_voxels) + # Calculate coordinates for all voxels at once + voxel_coords = self.voxel_size * (2 * (empty_voxels_np + 1) - 1) * 0.5 + # Calculate distances to centre for all voxels + centre = np.array(self.centre_coordinates) + dists = np.linalg.norm(voxel_coords - centre, axis=1) + # Mask for voxels within radius + mask = dists <= radius + self.voxel_size * 1e-8 + + if not np.any(mask): + return voxel_space_obj + + # Filter indices that should be filled + fill_indices = empty_voxels_np[mask] + + # Advanced index assignment (vectorized) + xi = fill_indices[:, 0].astype(int) + yj = fill_indices[:, 1].astype(int) + zk = fill_indices[:, 2].astype(int) + + # Before setting, count how many of these are actually zero (defensive) + current_vals = space[xi, yj, zk] + new_mask = current_vals == 0 + n_new = int(np.count_nonzero(new_mask)) + if n_new > 0: + # Assign into the ndarray `space` (in-place) + space[xi[new_mask], yj[new_mask], zk[new_mask]] = 1 + # Update running counter only when we were given a VoxelSpace object + if is_voxel_space and hasattr(voxel_space_obj, "_filled_voxels_count"): + voxel_space_obj._filled_voxels_count += n_new + + # Return the same type we were given + if is_voxel_space: + return voxel_space_obj + return space def estimate_initial_radius(self, volume): return (3.0 * volume / (4.0 * math.pi)) ** (1.0 / 3.0) @@ -85,56 +120,62 @@ def find_sphere_limits(self, radius, nozzle_height): """ def deform_voxel_space_for_big_spheres(self, voxel_space, radius): + # Accept either a VoxelSpace object or a raw ndarray and return the same type. max_indexes = [ self._find_index(coord + radius) for coord in self.centre_coordinates ] + vs = voxel_space for axis_number in range(0, 3): - voxel_space = self._maybe_expand_voxel_space( - voxel_space, max_indexes[axis_number], axis_number - ) + vs = self._maybe_expand_voxel_space(vs, max_indexes[axis_number], axis_number) - return voxel_space + return vs - def _maybe_expand_voxel_space(self, voxel_space, max_index, axis_number): - size = voxel_space.shape + def _maybe_expand_voxel_space(self, voxel_space_obj, max_index, axis_number): + # Accept either a VoxelSpace-like object (has `.space`) or a raw ndarray. + is_voxel_space = hasattr(voxel_space_obj, "space") + space = voxel_space_obj.space if is_voxel_space else voxel_space_obj + size = space.shape index_size = size[axis_number] if max_index < index_size: - return voxel_space + return voxel_space_obj number_to_be_added = max_index - index_size + 1 - new_size = list(size) - new_size[axis_number] = number_to_be_added + # Add a buffer to reduce the number of reallocations. Buffer is 20% + # of current size (rounded), but at least the minimum required. + buffer_layers = max(int(index_size * 0.2), 1) + layers_to_add = max(number_to_be_added, buffer_layers) - mat_add = np.zeros(new_size, dtype=np.int8) + # Create only the additional block to concatenate + mat_add_size = list(size) + mat_add_size[axis_number] = layers_to_add + mat_add = np.zeros(mat_add_size, dtype=np.int8) - voxel_space = np.concatenate((voxel_space, mat_add), axis=axis_number) + new_space = np.concatenate((space, mat_add), axis=axis_number) - return voxel_space + if is_voxel_space: + voxel_space_obj.space = new_space + return voxel_space_obj - def _deposit_sphere(self, radius, voxel_space, nozzle_height, target_volume): - copy_voxel_space = voxel_space.copy() + return new_space - copy_voxel_space = self.deform_voxel_space_for_big_spheres( - copy_voxel_space, radius - ) + def _deposit_sphere(self, radius, voxel_space, nozzle_height, target_volume): + # Accept and return either a VoxelSpace object or a raw ndarray. + vs = self.deform_voxel_space_for_big_spheres(voxel_space, radius) lower_indexes, upper_indexes = self.find_sphere_limits(radius, nozzle_height) - copy_voxel_space = self.fill_voxels( - copy_voxel_space, radius, lower_indexes, upper_indexes - ) + vs = self.fill_voxels(vs, radius, lower_indexes, upper_indexes) - current_volume = GeometryMath.calculate_filled_volume( - copy_voxel_space, self.voxel_size - ) + current_volume = GeometryMath.calculate_filled_volume(vs, self.voxel_size) volume_overshoot = current_volume / target_volume - 1.0 - return volume_overshoot, copy_voxel_space + # Return the computed overshoot and the (possibly mutated) voxel container + return volume_overshoot, vs def _increase_solver_tolerance(self, radius_a, radius_b): return radius_b - radius_a < self.voxel_size * 0.5 diff --git a/app/geometry/voxel_space.py b/app/geometry/voxel_space.py index 819d576..5949171 100644 --- a/app/geometry/voxel_space.py +++ b/app/geometry/voxel_space.py @@ -45,49 +45,85 @@ def __init__( self._simulation = simulation_config self._printer = printer self._consider_acceleration = self._simulation.consider_acceleration + # Running count of filled voxels (to avoid repeated `np.count_nonzero` calls) + self._filled_voxels_count = 0 def initialize_space(self): + # Try to intelligently preallocate Z dimension to avoid repeated expansions. + # Estimate the maximum per-step sphere radius from filaments and add a safety margin. + z_dim = self.dimensions["z"] + + try: + max_radius = 0.0 + step_size = self._simulation.step_size + for filament in self._instruction.filaments_coordinates: + coord_old = filament[0] + coord_new = filament[1] + volume = filament[2] + + # compute filament length and estimated number of steps + length = GeometryMath.distance(coord_old, coord_new) + n_steps = int(round(length / step_size)) + if n_steps < 1: + n_steps = 1 + + per_step_volume = volume / n_steps + # estimate radius of sphere that would contain this volume + radius = (3.0 * per_step_volume / (4.0 * math.pi)) ** (1.0 / 3.0) + if radius > max_radius: + max_radius = radius + + # include configured sphere z offset + max_extra_z = int(math.ceil((max_radius + self._simulation.sphere_z_offset) / self._simulation.voxel_size)) + + # safety margin (20% of current z) to reduce chance of further expansion + safety_margin = int(max(1, z_dim * 0.2)) + + if max_extra_z > 0: + z_dim = z_dim + max_extra_z + safety_margin + + logging.getLogger(__name__).info(f"Preallocating voxel space z-dimension: base={self.dimensions['z']}, extra={max_extra_z}, safety={safety_margin}, final={z_dim}") + except Exception: + # Fallback to original allocation if any issue occurs during estimation + z_dim = self.dimensions["z"] + self.space = np.zeros( - (self.dimensions["x"], self.dimensions["y"], self.dimensions["z"]), + (self.dimensions["x"], self.dimensions["y"], z_dim), dtype=np.int8, ) + # reset the running counter when allocating space + self._filled_voxels_count = 0 def print(self): + # Check for preview mode in simulation config + if hasattr(self._simulation, "preview_mode") and self._simulation.preview_mode: + self.print_preview_mode() + return + number_printed_filaments = 0 number_printed_layers = 0 - initial_z_coordinate = 0.0 - print(self._instruction.filaments_coordinates) - for filament_coordinates in self._instruction.filaments_coordinates: ( initial_coordinate, final_coordinate, ) = self._find_initial_and_final_filament_coordinates(filament_coordinates) - direction_vector = GeometryMath.direction_vector( initial_coordinate, final_coordinate ) - printing_speed = filament_coordinates[1][4] - volume = filament_coordinates[2] # Now this is volume, not E - number_printed_filaments += 1 - if final_coordinate[2] > initial_z_coordinate: number_printed_layers += 1 initial_z_coordinate = final_coordinate[2] - filament_length = GeometryMath.distance( initial_coordinate, final_coordinate ) - number_simulation_steps, step_size = self._find_simulation_step_info( filament_length ) - volumes = Volume.get_volumes_for_filament( number_simulation_steps=number_simulation_steps, total_volume=volume, @@ -96,7 +132,6 @@ def print(self): printing_speed=printing_speed, printer=self._printer, ) - self._deposit_filament( number_simulation_steps=number_simulation_steps, step_size=step_size, @@ -105,6 +140,52 @@ def print(self): volumes=volumes, ) + def print_preview_mode(self): + """ + Preview mode: Fast, lightweight visualization. Traces G-code path and fills a capsule (cylinder with rounded endcaps) between last and current point if extruder is on. No physics, no overlap checks. Intended for quick feedback before running the full simulation. + """ + nozzle_radius = self._printer.nozzle_diameter / 2.0 + voxel_size = self._simulation.voxel_size + # Use the same logic as the default mode: fill capsule for every filament segment + for filament_coordinates in self._instruction.filaments_coordinates: + initial, final = self._find_initial_and_final_filament_coordinates(filament_coordinates) + # Only fill if the segment has positive volume + volume = filament_coordinates[2] + if volume > 0: + min_x = min(initial[0], final[0]) - nozzle_radius + max_x = max(initial[0], final[0]) + nozzle_radius + min_y = min(initial[1], final[1]) - nozzle_radius + max_y = max(initial[1], final[1]) + nozzle_radius + min_z = min(initial[2], final[2]) - nozzle_radius + max_z = max(initial[2], final[2]) + nozzle_radius + i_min = max(0, int(min_x / voxel_size)) + i_max = min(self.space.shape[0] - 1, int(max_x / voxel_size)) + j_min = max(0, int(min_y / voxel_size)) + j_max = min(self.space.shape[1] - 1, int(max_y / voxel_size)) + k_min = max(0, int(min_z / voxel_size)) + k_max = min(self.space.shape[2] - 1, int(max_z / voxel_size)) + # Vectorized capsule SDF fill + p1 = np.array(initial) + p2 = np.array(final) + ba = p2 - p1 + ba_dot_ba = np.dot(ba, ba) if np.dot(ba, ba) > 0 else 1e-8 + ii, jj, kk = np.meshgrid( + np.arange(i_min, i_max + 1), + np.arange(j_min, j_max + 1), + np.arange(k_min, k_max + 1), + indexing='ij' + ) + voxel_centers = np.stack([ + (ii + 0.5) * voxel_size, + (jj + 0.5) * voxel_size, + (kk + 0.5) * voxel_size + ], axis=-1) + pa = voxel_centers - p1 + h = np.clip(np.sum(pa * ba, axis=-1) / ba_dot_ba, 0.0, 1.0) + capsule_sdf = np.sqrt(np.sum((pa - ba * h[..., None]) ** 2, axis=-1)) - nozzle_radius + inside = capsule_sdf <= 0 + self.space[ii[inside], jj[inside], kk[inside]] = 1 + def _find_initial_and_final_filament_coordinates(self, filament_coordinates): initial = [ filament_coordinates[0][0] + self.filament_translations["x"], @@ -135,8 +216,9 @@ def _deposit_filament( filament_initial_coordinates, volumes, ): - total_deposited_volume = GeometryMath.calculate_filled_volume( - self.space, self._simulation.voxel_size + # Use the running counter to compute total deposited volume quickly + total_deposited_volume = ( + self._filled_voxels_count * self._simulation.voxel_size ** 3 ) for step_n in range(0, number_simulation_steps): @@ -183,11 +265,20 @@ def _deposit_filament( f"Depositing filament: step = {step_n + 1}/{number_simulation_steps}" ) - self.space = sphere.deposit_sphere( - voxel_space=self.space, + # Pass the VoxelSpace instance so sphere code can update the + # running counter and expand the `.space` ndarray in-place. + voxel_space_out = sphere.deposit_sphere( + voxel_space=self, nozzle_height=nozzle_height, sphere_volume=sphere_volume, voxel_space_target_volume=volume_target, solver_tolerance=self._simulation.solver_tolerance, radius_increment=self._simulation.radius_increment, ) + + # Ensure our internal `.space` and counter reflect any mutations + # returned by the sphere logic (voxel_space_out is a VoxelSpace) + if hasattr(voxel_space_out, "space"): + self.space = voxel_space_out.space + if hasattr(voxel_space_out, "_filled_voxels_count"): + self._filled_voxels_count = voxel_space_out._filled_voxels_count diff --git a/app/physics/volume.py b/app/physics/volume.py index 977a788..4ca2005 100644 --- a/app/physics/volume.py +++ b/app/physics/volume.py @@ -27,7 +27,7 @@ def get_volumes_for_filament( if not consider_acceleration: # Simple case: evenly distribute the volume across steps volume_per_step = total_volume / number_simulation_steps - return [volume_per_step * (i + 1) for i in range(number_simulation_steps)] + return [volume_per_step for i in range(number_simulation_steps)] # Complex case: use acceleration-specific implementation from app.physics.acceleration.volume import AccelerationVolume diff --git a/app/reporter/mesh.py b/app/reporter/mesh.py index 95e74db..d1eacbf 100644 --- a/app/reporter/mesh.py +++ b/app/reporter/mesh.py @@ -30,101 +30,69 @@ def create_box_representation(voxel_space, voxel_size): trimesh.Trimesh A mesh containing only visible faces of filled voxels """ - # Find the indices of filled voxels - filled_voxels = np.where(voxel_space > 0) - - # If no filled voxels, return an empty scene - if len(filled_voxels[0]) == 0: + # Find filled voxels + filled = (voxel_space > 0) + if not np.any(filled): return trimesh.Scene() - - # Get the dimensions of the voxel space + max_i, max_j, max_k = voxel_space.shape - - # Define a unit cube vertices (8 corners) unit_cube_vertices = np.array([ - [-0.5, -0.5, -0.5], # 0: bottom, back, left - [0.5, -0.5, -0.5], # 1: bottom, back, right - [0.5, 0.5, -0.5], # 2: bottom, front, right - [-0.5, 0.5, -0.5], # 3: bottom, front, left - [-0.5, -0.5, 0.5], # 4: top, back, left - [0.5, -0.5, 0.5], # 5: top, back, right - [0.5, 0.5, 0.5], # 6: top, front, right - [-0.5, 0.5, 0.5] # 7: top, front, left + [-0.5, -0.5, -0.5], [0.5, -0.5, -0.5], [0.5, 0.5, -0.5], [-0.5, 0.5, -0.5], + [-0.5, -0.5, 0.5], [0.5, -0.5, 0.5], [0.5, 0.5, 0.5], [-0.5, 0.5, 0.5] ]) - - # Define the faces for each side of the cube (2 triangles per face) - # Each face is associated with a direction (negative or positive x, y, z) face_definitions = [ - # Face indices, Direction to check (di, dj, dk) - ([[0, 2, 1], [0, 3, 2]], (0, 0, -1)), # bottom face (negative z) - ([[4, 5, 6], [4, 6, 7]], (0, 0, 1)), # top face (positive z) - ([[0, 1, 5], [0, 5, 4]], (0, -1, 0)), # back face (negative y) - ([[2, 3, 7], [2, 7, 6]], (0, 1, 0)), # front face (positive y) - ([[0, 4, 7], [0, 7, 3]], (-1, 0, 0)), # left face (negative x) - ([[1, 2, 6], [1, 6, 5]], (1, 0, 0)) # right face (positive x) + ([[0, 2, 1], [0, 3, 2]], (0, 0, -1)), + ([[4, 5, 6], [4, 6, 7]], (0, 0, 1)), + ([[0, 1, 5], [0, 5, 4]], (0, -1, 0)), + ([[2, 3, 7], [2, 7, 6]], (0, 1, 0)), + ([[0, 4, 7], [0, 7, 3]], (-1, 0, 0)), + ([[1, 2, 6], [1, 6, 5]], (1, 0, 0)) ] - - # First pass: determine which voxels need to have vertices added - # and which faces need to be rendered - voxels_to_render = {} # Maps voxel index to list of faces to render - - for idx, (i, j, k) in enumerate(zip(*filled_voxels)): - faces_to_render = [] - - # Check each face to see if it should be rendered - for face_idx, (face_triangles, (di, dj, dk)) in enumerate(face_definitions): - # Check if this face is at the boundary or adjacent to an empty voxel - ni, nj, nk = i + di, j + dj, k + dk - - # If the neighbor is outside the voxel space or is empty, render this face - if (ni < 0 or ni >= max_i or - nj < 0 or nj >= max_j or - nk < 0 or nk >= max_k or - voxel_space[ni, nj, nk] == 0): - - faces_to_render.append(face_idx) - - # If this voxel has faces to render, add it to the dictionary - if faces_to_render: - voxels_to_render[(i, j, k)] = faces_to_render - - # Second pass: create vertices and faces + + # Vectorized neighbor check for all filled voxels + filled_indices = np.array(np.where(filled)).T all_vertices = [] all_faces = [] - voxel_to_vertex_idx = {} # Maps voxel coordinates to starting vertex index - - for idx, (i, j, k) in enumerate(voxels_to_render.keys()): - # Calculate the center position of the voxel - center = np.array([ - (i) * voxel_size, - (j) * voxel_size, - (k) * voxel_size - ]) - - # Scale and translate the unit cube vertices for this voxel - voxel_vertices = unit_cube_vertices * voxel_size + center - - # Add vertices for this voxel - vertex_start = len(all_vertices) - all_vertices.extend(voxel_vertices) - voxel_to_vertex_idx[(i, j, k)] = vertex_start - - # Add faces for this voxel - for face_idx in voxels_to_render[(i, j, k)]: - face_triangles = face_definitions[face_idx][0] + vertex_count = 0 + for face_idx, (face_triangles, (di, dj, dk)) in enumerate(face_definitions): + # Use numpy slicing for neighbor checks + if di != 0: + if di > 0: + mask = np.zeros_like(filled) + mask[:-1, :, :] = filled[:-1, :, :] & (~filled[1:, :, :]) + else: + mask = np.zeros_like(filled) + mask[1:, :, :] = filled[1:, :, :] & (~filled[:-1, :, :]) + elif dj != 0: + if dj > 0: + mask = np.zeros_like(filled) + mask[:, :-1, :] = filled[:, :-1, :] & (~filled[:, 1:, :]) + else: + mask = np.zeros_like(filled) + mask[:, 1:, :] = filled[:, 1:, :] & (~filled[:, :-1, :]) + elif dk != 0: + if dk > 0: + mask = np.zeros_like(filled) + mask[:, :, :-1] = filled[:, :, :-1] & (~filled[:, :, 1:]) + else: + mask = np.zeros_like(filled) + mask[:, :, 1:] = filled[:, :, 1:] & (~filled[:, :, :-1]) + face_voxels = np.array(np.where(mask)).T + for idx in face_voxels: + i, j, k = idx + center = np.array([i, j, k]) * voxel_size + voxel_vertices = unit_cube_vertices * voxel_size + center + v_start = vertex_count + all_vertices.extend(voxel_vertices) + vertex_count += 8 for triangle in face_triangles: - all_faces.append([t + vertex_start for t in triangle]) - - # Convert lists to numpy arrays + all_faces.append([v_start + t for t in triangle]) if all_vertices and all_faces: vertices_array = np.array(all_vertices) faces_array = np.array(all_faces) - - # Create a mesh from the vertices and faces mesh = trimesh.Trimesh(vertices=vertices_array, faces=faces_array) return mesh else: - # If no faces were added, return an empty scene return trimesh.Scene() def generate_mesh_from_voxels(voxel_space, voxel_size): @@ -212,6 +180,7 @@ def export_mesh_to_stl(mesh, file_path, ascii_format=True): # Use the generic export function which works for both Scene and Trimesh objects trimesh.exchange.export.export_mesh(mesh, file_path, **export_options) + # mesh.export(file_path, **export_options) logger.info(f"[Mesh]: STL exported in {format_type} format!") return file_path diff --git a/app/solvers/bisection_method.py b/app/solvers/bisection_method.py index 953ce58..80b5e09 100644 --- a/app/solvers/bisection_method.py +++ b/app/solvers/bisection_method.py @@ -23,35 +23,43 @@ def execute( else: point_b = initial_point - _, _, point_a, point_b = self._loop_fb(fun, point_b, increment, args) + fb, out, point_a, point_b, updated_args = self._loop_fb(fun, point_b, increment, args) - return self._loop_fc( - fun, point_a, point_b, tolerance, fun_increase_tolerance, args + # _loop_fc returns (fc, out) where out may be the raw output from `fun`. + fc, out = self._loop_fc( + fun, point_a, point_b, tolerance, fun_increase_tolerance, updated_args ) + # If `out` is a tuple like (volume_overshoot, voxel_space), unwrap it + if isinstance(out, tuple) and len(out) == 2: + return fc, out[1] + + return fc, out + def _loop_fb(self, fun, point_b, inc, args): fb = -1.0 - point_a = 0.0 - + updated_args = list(args) while fb < 0.0: - - fb, out = fun(point_b, *args) - + fb, out = fun(point_b, *updated_args) + # If the function returns an updated voxel_space, propagate it. + # The returned `out` may be an ndarray (has `shape`) or a + # VoxelSpace-like object with a `space` attribute. Accept both. + if hasattr(out, "shape") or hasattr(out, "space"): + updated_args[0] = out if fb < 0: point_a = point_b point_b += inc - - return fb, out, point_a, point_b + return fb, out, point_a, point_b, updated_args def _loop_fc(self, fun, point_a, point_b, tolerance, fun_increase_tolerance, args): fc = 2.0 * tolerance - + updated_args = list(args) while abs(fc) > tolerance: point_c = (point_a + point_b) * 0.5 - - fc, out = fun(point_c, *args) - + fc, out = fun(point_c, *updated_args) + if hasattr(out, "shape") or hasattr(out, "space"): + updated_args[0] = out if fun_increase_tolerance(point_a, point_b): logger.debug( "[BisectionMethod]: increasing tolerance because point_b and point_a are too close" diff --git a/tests/geometry/voxel_space_test.py b/tests/geometry/voxel_space_test.py index d6c3895..7bfa546 100644 --- a/tests/geometry/voxel_space_test.py +++ b/tests/geometry/voxel_space_test.py @@ -13,6 +13,10 @@ def __init__(self): self._number_printed_filaments = 0 self._filaments_coordinates = list() self._default_nozzle_speed = 10.0 + # Patch: provide a _printer attribute for compatibility + class _Printer: + feedstock_filament_diameter = 1.75 + self._printer = _Printer() def read(self): pass @@ -47,6 +51,7 @@ def __init__(self): self.step_size = 0.2 self.radius_increment = 0.1 self.solver_tolerance = 0.001 + self.consider_acceleration = False class FakePrinter: @@ -114,17 +119,19 @@ def __init__(self): self.step_size = 0.2 self.radius_increment = 0.1 self.solver_tolerance = 0.001 + self.consider_acceleration = False + self.sphere_z_offset = 0.0 class TestPrint: def test_should_print(self): + printer = FakePrinter() test_instruction = Gcode( - gcode_path="tests/fixtures/gcode_example.gcode", default_nozzle_speed=40.0 + gcode_path="tests/fixtures/gcode_example.gcode", default_nozzle_speed=40.0, printer=printer ) test_instruction.read() simulation_config = FakeSimulationPrint() - printer = FakePrinter() voxel_space = VoxelSpace( instruction=test_instruction, diff --git a/volco.py b/volco.py index 8740cae..55361b7 100644 --- a/volco.py +++ b/volco.py @@ -114,8 +114,20 @@ def run_simulation( if __name__ == "__main__": options = Arguments.get_options() + # Load simulation config from file if provided + sim_config = None + if options.sim: + with open(options.sim, "r") as f: + sim_config = json.load(f) + # If --preview is set, override preview_mode in config + if options.preview: + if sim_config is None: + sim_config = {} + sim_config["preview_mode"] = True + run_simulation( gcode_path=options.gcode, printer_config_path=options.printer, - sim_config_path=options.sim + sim_config=sim_config if sim_config is not None else None, + sim_config_path=None if sim_config is not None else options.sim )