Source code for cubeforge.model

# cubeforge/model.py
import logging
from .constants import CubeAnchor
from .writers import get_writer # Use the generalized writer system

# --- Logging Configuration Removed ---
# Get a logger instance for this module. Configuration is left to the application.
logger = logging.getLogger(__name__)


[docs] class VoxelModel: """ Represents a 3D model composed of voxels. Each voxel can have independent dimensions (width, height, depth). Allows adding voxels based on coordinates and anchor points, and exporting the resulting shape using various mesh writers. Logging messages are emitted via the standard 'logging' module; configuration is up to the application. """
[docs] def __init__(self, voxel_dimensions=(1.0, 1.0, 1.0), coordinate_system='y_up'): """ Initializes the VoxelModel. Args: voxel_dimensions (tuple): A tuple of three positive numbers (x_size, y_size, z_size) representing the default size of each voxel along each axis. Always in (x, y, z) order regardless of coordinate system. coordinate_system (str): The coordinate system to use. Either 'y_up' (default) or 'z_up'. Use 'z_up' for 3D printing to ensure correct orientation in most slicers. - 'y_up': Y axis is vertical (mathematical convention) - 'z_up': Z axis is vertical (3D printing convention) """ if not (isinstance(voxel_dimensions, (tuple, list)) and len(voxel_dimensions) == 3 and all(isinstance(dim, (int, float)) and dim > 0 for dim in voxel_dimensions)): raise ValueError("voxel_dimensions must be a tuple or list of three positive numbers.") if coordinate_system not in ('y_up', 'z_up'): raise ValueError("coordinate_system must be either 'y_up' or 'z_up'.") self.voxel_dimensions = tuple(float(dim) for dim in voxel_dimensions) # Stores voxel data as a dictionary: # key: integer grid coordinate (ix, iy, iz) # value: tuple of dimensions (width, height, depth) for that voxel self._voxels = {} # Coordinate system: 'y_up' (default) or 'z_up' self._coordinate_system = coordinate_system self._dimension_snap_warning_emitted = False logger.info(f"VoxelModel initialized with default voxel_dimensions={self.voxel_dimensions}, coordinate_system={coordinate_system}")
def _swap_yz_if_needed(self, x, y, z): """Helper method to swap Y and Z coordinates when in Z-up mode.""" if self._coordinate_system == 'z_up': return x, z, y return x, y, z def _calculate_min_corner(self, x, y, z, anchor, dimensions): """ Calculates the minimum corner coordinates based on anchor point and voxel dimensions. Internal helper method used by add_voxel and remove_voxel. Args: x (float): X-coordinate of the anchor point. y (float): Y-coordinate of the anchor point. z (float): Z-coordinate of the anchor point. anchor (CubeAnchor): The anchor type. dimensions (tuple): The (width, height, depth) of the voxel. Returns: tuple: (min_x, min_y, min_z) coordinates of the voxel's minimum corner. Raises: ValueError: If an invalid anchor point is provided. """ size_x, size_y, size_z = dimensions half_x, half_y, half_z = size_x / 2.0, size_y / 2.0, size_z / 2.0 if anchor == CubeAnchor.CORNER_NEG: min_x, min_y, min_z = x, y, z elif anchor == CubeAnchor.CENTER: min_x, min_y, min_z = x - half_x, y - half_y, z - half_z elif anchor == CubeAnchor.CORNER_POS: min_x, min_y, min_z = x - size_x, y - size_y, z - size_z elif anchor == CubeAnchor.BOTTOM_CENTER: # In Y-up: center of min Y face; In Z-up: center of min Z face if self._coordinate_system == 'z_up': min_x, min_y, min_z = x - half_x, y - half_y, z else: min_x, min_y, min_z = x - half_x, y, z - half_z elif anchor == CubeAnchor.TOP_CENTER: # In Y-up: center of max Y face; In Z-up: center of max Z face if self._coordinate_system == 'z_up': min_x, min_y, min_z = x - half_x, y - half_y, z - size_z else: min_x, min_y, min_z = x - half_x, y - size_y, z - half_z else: raise ValueError(f"Invalid anchor point: {anchor}") return min_x, min_y, min_z
[docs] def add_voxel(self, x, y, z, anchor=CubeAnchor.CORNER_NEG, dimensions=None): """ Adds a voxel to the model. Replaces add_cube. Args: x (float): X-coordinate of the voxel's anchor point. y (float): Y-coordinate of the voxel's anchor point (Y-up mode) or depth coordinate (Z-up mode). z (float): Z-coordinate of the voxel's anchor point (Y-up mode) or vertical coordinate (Z-up mode). anchor (CubeAnchor): The reference point within the voxel that (x, y, z) corresponds to. Defaults to CubeAnchor.CORNER_NEG. dimensions (tuple, optional): Custom dimensions (x_size, y_size, z_size) for this voxel. Always in (x, y, z) order regardless of coordinate system. Dimensions are snapped to the model's voxel grid spacing. If None, the model's default dimensions are used. """ # Swap coordinates if in Z-up mode x, y, z = self._swap_yz_if_needed(x, y, z) # Get dimensions and validate if dimensions is None: voxel_dims = self.voxel_dimensions else: voxel_dims = tuple(float(d) for d in dimensions) if not (isinstance(voxel_dims, (tuple, list)) and len(voxel_dims) == 3 and all(isinstance(d, (int, float)) and d > 0 for d in voxel_dims)): raise ValueError("Custom dimensions must be a tuple or list of three positive numbers.") # Swap custom dimensions if in Z-up mode to convert to internal Y-up representation if self._coordinate_system == 'z_up': voxel_dims = (voxel_dims[0], voxel_dims[2], voxel_dims[1]) voxel_dims, snapped = self._snap_dimensions(voxel_dims) if snapped and not self._dimension_snap_warning_emitted: grid_dim_x, grid_dim_y, grid_dim_z = self._grid_dimensions() logger.warning( "Custom voxel dimensions snapped to grid spacing (%.6f, %.6f, %.6f).", grid_dim_x, grid_dim_y, grid_dim_z ) self._dimension_snap_warning_emitted = True min_x, min_y, min_z = self._calculate_min_corner(x, y, z, anchor, voxel_dims) # Calculate grid coordinates based on minimum corner and *default* dimensions # This ensures voxels snap to a consistent grid. # In Z-up mode, we need to use swapped dimensions for grid calculation # because internally we work in Y-up space grid_dim_x, grid_dim_y, grid_dim_z = self.voxel_dimensions if self._coordinate_system == 'z_up': grid_dim_y, grid_dim_z = grid_dim_z, grid_dim_y raw_x = min_x / grid_dim_x raw_y = min_y / grid_dim_y raw_z = min_z / grid_dim_z grid_x = round(raw_x) grid_y = round(raw_y) grid_z = round(raw_z) # Warn if rounding actually occurred (i.e., not exactly on grid) if (grid_x != raw_x) or (grid_y != raw_y) or (grid_z != raw_z): logger.warning( f"Voxel at ({x}, {y}, {z}) with anchor {anchor} and dimensions {voxel_dims} " f"does not align exactly to grid; rounded from ({raw_x:.6f}, {raw_y:.6f}, {raw_z:.6f}) " f"to ({grid_x}, {grid_y}, {grid_z})" ) grid_coord = (grid_x, grid_y, grid_z) self._voxels[grid_coord] = voxel_dims
# logger.debug(f"Added voxel at grid {grid_coord} (from anchor {anchor} at ({x},{y},{z}))") # Alias add_cube to add_voxel for backward compatibility (optional, but can be helpful) add_cube = add_voxel
[docs] def add_voxels(self, coordinates, anchor=CubeAnchor.CORNER_NEG, dimensions=None): """ Adds multiple voxels from an iterable. Replaces add_cubes. Args: coordinates (iterable): An iterable of (x, y, z) tuples or lists. anchor (CubeAnchor): The anchor point to use for all voxels added in this call. dimensions (tuple, optional): The dimensions to apply to all voxels in this call. Dimensions are snapped to the model's voxel grid spacing. If None, defaults are used. """ for x_coord, y_coord, z_coord in coordinates: self.add_voxel(x_coord, y_coord, z_coord, anchor, dimensions)
# Alias add_cubes to add_voxels add_cubes = add_voxels
[docs] def remove_voxel(self, x, y, z, anchor=CubeAnchor.CORNER_NEG): """ Removes a voxel from the model based on its anchor coordinates. Replaces remove_cube. Args: x (float): X-coordinate of the voxel's anchor point. y (float): Y-coordinate of the voxel's anchor point (Y-up mode) or depth coordinate (Z-up mode). z (float): Z-coordinate of the voxel's anchor point (Y-up mode) or vertical coordinate (Z-up mode). anchor (CubeAnchor): The reference point within the voxel that (x, y, z) corresponds to. """ # Swap coordinates if in Z-up mode x, y, z = self._swap_yz_if_needed(x, y, z) # Note: Removal does not need custom dimensions, as it identifies the # voxel by its position on the grid, which is calculated using default dimensions. min_x, min_y, min_z = self._calculate_min_corner(x, y, z, anchor, self.voxel_dimensions) raw_x = min_x / self.voxel_dimensions[0] raw_y = min_y / self.voxel_dimensions[1] raw_z = min_z / self.voxel_dimensions[2] grid_x = round(raw_x) grid_y = round(raw_y) grid_z = round(raw_z) if (grid_x != raw_x) or (grid_y != raw_y) or (grid_z != raw_z): logger.warning( f"Voxel removal at ({x}, {y}, {z}) with anchor {anchor} " f"does not align exactly to grid; rounded from " f"({raw_x:.6f}, {raw_y:.6f}, {raw_z:.6f}) to " f"({grid_x}, {grid_y}, {grid_z})" ) grid_coord = (grid_x, grid_y, grid_z) if grid_coord in self._voxels: del self._voxels[grid_coord]
# logger.debug(f"Attempted removal at grid {grid_coord}") # Alias remove_cube to remove_voxel remove_cube = remove_voxel
[docs] def clear(self): """Removes all voxels from the model.""" self._voxels.clear() logger.info("VoxelModel cleared.")
def _grid_dimensions(self): grid_dim_x, grid_dim_y, grid_dim_z = self.voxel_dimensions if self._coordinate_system == 'z_up': grid_dim_y, grid_dim_z = grid_dim_z, grid_dim_y return grid_dim_x, grid_dim_y, grid_dim_z def _snap_to_grid(self, value, grid_dim, eps=1e-9): layers = int(round(value / grid_dim)) if layers < 1: layers = 1 snapped = layers * grid_dim return snapped, abs(snapped - value) > eps def _snap_dimensions(self, dimensions): grid_dim_x, grid_dim_y, grid_dim_z = self._grid_dimensions() snapped_dims = [] changed = False for value, grid_dim in zip(dimensions, (grid_dim_x, grid_dim_y, grid_dim_z)): snapped, did_change = self._snap_to_grid(value, grid_dim) snapped_dims.append(snapped) changed = changed or did_change return tuple(snapped_dims), changed def _voxel_min_corner(self, grid_coord): grid_dim_x, grid_dim_y, grid_dim_z = self._grid_dimensions() gx, gy, gz = grid_coord return gx * grid_dim_x, gy * grid_dim_y, gz * grid_dim_z def _can_use_greedy_meshing(self): if not self._voxels: return False default_dims = self._grid_dimensions() return all(dims == default_dims for dims in self._voxels.values()) def _append_face_rectangles(self, triangles, axis, direction, pos_on_axis, rectangles): normal = [0, 0, 0] normal[axis] = 1 if direction == 1 else -1 normal = tuple(normal) output_normal = normal swap_output = self._coordinate_system == 'z_up' if swap_output: output_normal = (normal[0], normal[2], normal[1]) for u0, v0, u1, v1 in rectangles: u_length = u1 - u0 v_length = v1 - v0 if u_length <= 0 or v_length <= 0: continue verts = self._build_rect_vertices(axis, direction, pos_on_axis, u0, v0, u_length, v_length) if swap_output: verts = [(v[0], v[2], v[1]) for v in verts] triangles.append((output_normal, verts[0], verts[2], verts[1])) triangles.append((output_normal, verts[0], verts[3], verts[2])) else: triangles.append((output_normal, verts[0], verts[1], verts[2])) triangles.append((output_normal, verts[0], verts[2], verts[3])) def _heightmap_mesh(self, optimize=False): grid_dim_x, grid_dim_y, grid_dim_z = self._grid_dimensions() eps = 1e-9 heights = {} base_gy = None for (gx, gy, gz), (size_x, size_y, size_z) in self._voxels.items(): if base_gy is None: base_gy = gy elif gy != base_gy: return None if abs(size_x - grid_dim_x) > eps or abs(size_z - grid_dim_z) > eps: return None key = (gx, gz) if key in heights: return None heights[key] = size_y if not heights: return [] base_y = base_gy * grid_dim_y snapped = False quantized = {} for key, height in heights.items(): layers = int(round(height / grid_dim_y)) if layers < 1: layers = 1 quant_height = layers * grid_dim_y if abs(quant_height - height) > eps: snapped = True quantized[key] = quant_height if snapped: logger.warning( "Heightmap meshing snapped voxel heights to grid spacing %.6f to avoid partial-height artifacts.", grid_dim_y ) heights = quantized triangles = [] unique_heights = sorted({0.0} | set(heights.values())) cells = list(heights.items()) if optimize: uniform_dims = self._grid_dimensions() temp_model = VoxelModel(voxel_dimensions=self.voxel_dimensions, coordinate_system=self._coordinate_system) temp_model._voxels = {} for (gx, gz), height in heights.items(): if height <= eps: continue layers = int(round(height / grid_dim_y)) for layer in range(layers): temp_model._voxels[(gx, base_gy + layer, gz)] = uniform_dims return temp_model._greedy_mesh() for i in range(len(unique_heights) - 1): h0 = unique_heights[i] h1 = unique_heights[i + 1] if h1 <= h0 + eps: continue y0 = base_y + h0 y1 = base_y + h1 for (gx, gz), height in cells: if height <= h0 + eps: continue x0 = gx * grid_dim_x x1 = x0 + grid_dim_x z0 = gz * grid_dim_z z1 = z0 + grid_dim_z if heights.get((gx - 1, gz), 0.0) <= h0 + eps: rect = (y0, z0, y1, z1) self._append_face_rectangles(triangles, axis=0, direction=0, pos_on_axis=x0, rectangles=[rect]) if heights.get((gx + 1, gz), 0.0) <= h0 + eps: rect = (y0, z0, y1, z1) self._append_face_rectangles(triangles, axis=0, direction=1, pos_on_axis=x1, rectangles=[rect]) if heights.get((gx, gz - 1), 0.0) <= h0 + eps: rect = (x0, y0, x1, y1) self._append_face_rectangles(triangles, axis=2, direction=0, pos_on_axis=z0, rectangles=[rect]) if heights.get((gx, gz + 1), 0.0) <= h0 + eps: rect = (x0, y0, x1, y1) self._append_face_rectangles(triangles, axis=2, direction=1, pos_on_axis=z1, rectangles=[rect]) if not optimize: if height <= h1 + eps: rect_top = (z0, x0, z1, x1) self._append_face_rectangles(triangles, axis=1, direction=1, pos_on_axis=y1, rectangles=[rect_top]) if i == 0: rect_bottom = (z0, x0, z1, x1) self._append_face_rectangles(triangles, axis=1, direction=0, pos_on_axis=y0, rectangles=[rect_bottom]) return triangles def _greedy_mesh(self): """ Generates an optimized mesh using greedy meshing algorithm. Merges adjacent coplanar faces into larger rectangles to reduce triangle count. Returns: list: A list of tuples, where each tuple is a triangle defined as (normal, vertex1, vertex2, vertex3). """ if not self._voxels: return [] logger.info(f"Generating optimized mesh using greedy meshing for {len(self._voxels)} voxels...") triangles = [] # For each axis direction, collect exposed faces and merge them # Axes: 0=X, 1=Y, 2=Z; Directions: 0=negative, 1=positive for axis in range(3): # X, Y, Z for direction in [0, 1]: # negative, positive # Get all exposed faces for this direction faces = self._collect_faces_for_direction(axis, direction) # Group faces by their position along the normal axis (slices) slices = {} for face in faces: slice_pos = face['pos_on_axis'] if slice_pos not in slices: slices[slice_pos] = [] slices[slice_pos].append(face) # Apply greedy meshing to each slice for slice_pos, slice_faces in slices.items(): merged = self._greedy_merge_slice(slice_faces, axis) triangles.extend(merged) logger.info(f"Greedy mesh generation complete. Optimized to {len(triangles)} triangles.") return triangles def _collect_faces_for_direction(self, axis, direction): """ Collects all exposed faces for a given axis direction. Args: axis (int): 0=X, 1=Y, 2=Z direction (int): 0=negative face, 1=positive face Returns: list: List of face dictionaries with position and size information """ faces = [] offset = [0, 0, 0] offset[axis] = -1 if direction == 0 else 1 for (gx, gy, gz), (size_x, size_y, size_z) in self._voxels.items(): neighbor_coord = (gx + offset[0], gy + offset[1], gz + offset[2]) neighbor_dims = self._voxels.get(neighbor_coord) # Dimensions are already stored in internal Y-up representation build_size_x, build_size_y, build_size_z = size_x, size_y, size_z grid_dim_x, grid_dim_y, grid_dim_z = self.voxel_dimensions if self._coordinate_system == 'z_up': grid_dim_y, grid_dim_z = grid_dim_z, grid_dim_y # Face is exposed if no neighbor or neighbor has different dimensions if not neighbor_dims or neighbor_dims != (size_x, size_y, size_z): # Calculate face position in world coordinates min_cx = gx * grid_dim_x min_cy = gy * grid_dim_y min_cz = gz * grid_dim_z # Position along the normal axis pos_on_axis = [min_cx, min_cy, min_cz][axis] if direction == 1: # Positive face pos_on_axis += [build_size_x, build_size_y, build_size_z][axis] # The two axes perpendicular to the normal u_axis = (axis + 1) % 3 v_axis = (axis + 2) % 3 u_pos = [min_cx, min_cy, min_cz][u_axis] v_pos = [min_cx, min_cy, min_cz][v_axis] u_size = [build_size_x, build_size_y, build_size_z][u_axis] v_size = [build_size_x, build_size_y, build_size_z][v_axis] faces.append({ 'axis': axis, 'direction': direction, 'pos_on_axis': pos_on_axis, 'u_pos': u_pos, 'v_pos': v_pos, 'u_size': u_size, 'v_size': v_size, 'grid_coord': (gx, gy, gz), 'dimensions': (size_x, size_y, size_z) }) return faces def _greedy_merge_slice(self, faces, axis): """ Merges coplanar faces in a slice using greedy meshing algorithm. Args: faces (list): List of face dictionaries in the same slice axis (int): The normal axis (0=X, 1=Y, 2=Z) Returns: list: List of triangles for the merged faces """ if not faces: return [] triangles = [] direction = faces[0]['direction'] pos_on_axis = faces[0]['pos_on_axis'] # Build a grid of faces by their u,v positions and sizes # Face positions are in internal Y-up space, so use swapped grid dims if needed grid_dims = list(self.voxel_dimensions) if self._coordinate_system == 'z_up': grid_dims[1], grid_dims[2] = grid_dims[2], grid_dims[1] face_grid = {} for face in faces: # Use grid coordinates for lookup (only works for uniform voxels) u_idx = int(round(face['u_pos'] / grid_dims[(axis + 1) % 3])) v_idx = int(round(face['v_pos'] / grid_dims[(axis + 2) % 3])) u_size = face['u_size'] v_size = face['v_size'] key = (u_idx, v_idx, u_size, v_size) face_grid[key] = face # Greedy meshing: merge adjacent faces with same dimensions used = set() sorted_faces = sorted(face_grid.items(), key=lambda x: (x[0][1], x[0][0])) # Sort by v, then u for (u_idx, v_idx, u_size, v_size), face in sorted_faces: if (u_idx, v_idx) in used: continue # Try to extend in u direction u_end = u_idx while (u_end + 1, v_idx, u_size, v_size) in face_grid and (u_end + 1, v_idx) not in used: u_end += 1 # Try to extend in v direction v_end = v_idx can_extend = True while can_extend: # Check if entire row exists for v_end + 1 for u in range(u_idx, u_end + 1): if (u, v_end + 1, u_size, v_size) not in face_grid or (u, v_end + 1) in used: can_extend = False break if can_extend: v_end += 1 # Mark all merged faces as used for v in range(v_idx, v_end + 1): for u in range(u_idx, u_end + 1): used.add((u, v)) # Create merged rectangle u_axis_idx = (axis + 1) % 3 v_axis_idx = (axis + 2) % 3 # Use the already-swapped grid_dims from above u_start = u_idx * grid_dims[u_axis_idx] v_start = v_idx * grid_dims[v_axis_idx] u_length = (u_end - u_idx + 1) * u_size v_length = (v_end - v_idx + 1) * v_size # Build vertices for the merged rectangle verts = self._build_rect_vertices(axis, direction, pos_on_axis, u_start, v_start, u_length, v_length) # Create normal normal = [0, 0, 0] normal[axis] = 1 if direction == 1 else -1 normal = tuple(normal) # Swap Y/Z for Z-up mode if self._coordinate_system == 'z_up': verts = [(v[0], v[2], v[1]) for v in verts] normal = (normal[0], normal[2], normal[1]) # Create triangles with proper winding if self._coordinate_system == 'z_up': triangles.append((normal, verts[0], verts[2], verts[1])) triangles.append((normal, verts[0], verts[3], verts[2])) else: triangles.append((normal, verts[0], verts[1], verts[2])) triangles.append((normal, verts[0], verts[2], verts[3])) return triangles def _build_rect_vertices(self, axis, direction, pos_on_axis, u_start, v_start, u_length, v_length): """ Builds the 4 vertices of a rectangle for a given axis direction. Returns: list: List of 4 vertex tuples in counter-clockwise order """ u_axis = (axis + 1) % 3 v_axis = (axis + 2) % 3 # Build 4 corners verts = [] for v_offset in [0, v_length]: for u_offset in [0, u_length]: vert = [0, 0, 0] vert[axis] = pos_on_axis vert[u_axis] = u_start + u_offset vert[v_axis] = v_start + v_offset verts.append(tuple(vert)) # Reorder for CCW winding based on axis and direction # Order: bottom-left, bottom-right, top-right, top-left if axis == 0: # X axis (YZ plane) if direction == 0: # -X face verts = [verts[0], verts[2], verts[3], verts[1]] else: # +X face verts = [verts[0], verts[1], verts[3], verts[2]] elif axis == 1: # Y axis (XZ plane) if direction == 0: # -Y face (looking up from below) verts = [verts[0], verts[2], verts[3], verts[1]] else: # +Y face (looking down from above) verts = [verts[0], verts[1], verts[3], verts[2]] else: # Z axis (XY plane) if direction == 0: # -Z face verts = [verts[0], verts[2], verts[3], verts[1]] else: # +Z face verts = [verts[0], verts[1], verts[3], verts[2]] return verts
[docs] def generate_mesh(self, optimize=True): """ Generates a list of triangles representing the exposed faces of the voxels. Ensures consistent counter-clockwise winding order (right-hand rule) for outward-facing normals. Args: optimize (bool): If True, uses greedy meshing algorithm to merge adjacent coplanar faces, significantly reducing triangle count. Default: True (recommended for most use cases). Returns: list: A list of tuples, where each tuple is a triangle defined as (normal, vertex1, vertex2, vertex3). Coordinates are in the model's world space. Returns an empty list if no voxels have been added. """ if not self._voxels: return [] use_greedy = optimize and self._can_use_greedy_meshing() if use_greedy: return self._greedy_mesh() heightmap_triangles = self._heightmap_mesh(optimize=optimize) if heightmap_triangles is not None: if optimize: logger.warning("Using heightmap meshing for non-uniform voxel dimensions.") return heightmap_triangles if optimize: logger.warning("Greedy meshing disabled for non-uniform voxel dimensions; using partial adjacency meshing.") logger.info(f"Generating mesh for {len(self._voxels)} voxels...") triangles = [] eps = 1e-9 faces_data = [ (0, 1, (1, 0, 0)), # +X (0, 0, (-1, 0, 0)), # -X (1, 1, (0, 1, 0)), # +Y (1, 0, (0, -1, 0)), # -Y (2, 1, (0, 0, 1)), # +Z (2, 0, (0, 0, -1)), # -Z ] processed_faces = 0 for grid_coord, voxel_dims in self._voxels.items(): min_cx, min_cy, min_cz = self._voxel_min_corner(grid_coord) min_corner = (min_cx, min_cy, min_cz) size_x, size_y, size_z = voxel_dims sizes = (size_x, size_y, size_z) gx, gy, gz = grid_coord for axis, direction, offset in faces_data: neighbor_coord = (gx + offset[0], gy + offset[1], gz + offset[2]) neighbor_dims = self._voxels.get(neighbor_coord) pos_on_axis = min_corner[axis] + (sizes[axis] if direction == 1 else 0) u_axis = (axis + 1) % 3 v_axis = (axis + 2) % 3 u_start = min_corner[u_axis] v_start = min_corner[v_axis] u_length = sizes[u_axis] v_length = sizes[v_axis] rectangles = [(u_start, v_start, u_start + u_length, v_start + v_length)] if neighbor_dims: neighbor_min = self._voxel_min_corner(neighbor_coord) neighbor_sizes = neighbor_dims neighbor_plane = neighbor_min[axis] if direction == 1 else neighbor_min[axis] + neighbor_sizes[axis] if abs(pos_on_axis - neighbor_plane) <= eps: n_u_start = neighbor_min[u_axis] n_v_start = neighbor_min[v_axis] n_u_length = neighbor_sizes[u_axis] n_v_length = neighbor_sizes[v_axis] x0 = u_start x1 = u_start + u_length y0 = v_start y1 = v_start + v_length nx0 = n_u_start nx1 = n_u_start + n_u_length ny0 = n_v_start ny1 = n_v_start + n_v_length ix0 = max(x0, nx0) ix1 = min(x1, nx1) iy0 = max(y0, ny0) iy1 = min(y1, ny1) if ix1 > ix0 + eps and iy1 > iy0 + eps: rectangles = [] if ix0 > x0 + eps: rectangles.append((x0, y0, ix0, y1)) if ix1 < x1 - eps: rectangles.append((ix1, y0, x1, y1)) if iy0 > y0 + eps: rectangles.append((ix0, y0, ix1, iy0)) if iy1 < y1 - eps: rectangles.append((ix0, iy1, ix1, y1)) if rectangles: processed_faces += len(rectangles) self._append_face_rectangles(triangles, axis, direction, pos_on_axis, rectangles) logger.info(f"Mesh generation complete. Emitted {processed_faces} face segments, resulting in {len(triangles)} triangles.") return triangles
[docs] def save_mesh(self, filename, format='stl_binary', optimize=True, **kwargs): """ Generates the mesh and saves it to a file using the specified format. Args: filename (str): The path to the output file. format (str): The desired output format identifier (e.g/, 'stl_binary', 'stl_ascii'). Case-insensitive. Defaults to 'stl_binary'. optimize (bool): If True, uses greedy meshing to reduce triangle count by merging adjacent coplanar faces. Can reduce file size by 10-100x for regular voxel structures. Default: True. **kwargs: Additional arguments passed directly to the specific file writer (e.g., 'solid_name' for STL formats). """ triangles = self.generate_mesh(optimize=optimize) if not triangles: logger.warning("No voxels in the model. Mesh file will not be generated.") return try: writer = get_writer(format) writer.write(triangles, filename, **kwargs) # No need for logger.info here, the writer handles its own success message except ValueError as e: logger.error(f"Failed to save mesh: {e}") raise except Exception as e: logger.error(f"An error occurred during mesh saving to '{filename}': {e}") raise