diff --git a/.antigravity.md b/.antigravity.md index d710b22..cc023e4 100644 --- a/.antigravity.md +++ b/.antigravity.md @@ -48,6 +48,6 @@ This generates a zip file in the `release/` directory using Blender's extension - **Registration**: All Blender classes (Nodes, Sockets, Operators, Menus) must be decorated with `@register_class` from `registry.py` to ensure they are registered correctly during initialization. - **Node Evaluation**: Nodes follow a pull-based evaluation model. Use `resolve_input(self.inputs.get("SocketName"))` within a node's `evaluate_output` method to trigger the evaluation of upstream nodes. - **NumPy First**: For any geometry processing, prioritize NumPy vectorized operations over standard Python loops to maintain high performance. -- **Scaling**: 2D polygon operations via Pyclipper use a `SCALE` factor (defined in `core/slice.py`) to convert floats to integers, as Pyclipper operates in integer space. +- **Scaling & Integer Math**: 2D polygon and geometric operations use a `SCALE = 1000` factor (defined in `core/slice.py`) representing 1 micron ($1\ \mu\text{m} = 0.001\text{ mm}$) integer math units to eliminate floating-point precision issues. - **Type Hinting**: Use exhaustive type hints for all new functions and classes. - **Documentation**: Update `doc/NODES.md` when adding new nodes and `doc/MODULES.md` when adding new modules. diff --git a/AGENTS.md b/AGENTS.md index 5d34e81..f039bef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,8 @@ Nodes follow a pull-based evaluation model. Use `resolve_input(self.inputs.get(" ### Performance For any geometry processing, prioritize NumPy vectorized operations over standard Python loops to maintain high performance. -### Scaling -2D polygon operations via Pyclipper use a `SCALE` factor (defined in `core/slice.py`) to convert floats to integers, as Pyclipper operates in integer space. +### Scaling & Integer Math +2D polygon and geometric operations use a `SCALE = 1000` factor (defined in `core/slice.py`) representing 1 micron ($1\ \mu\text{m} = 0.001\text{ mm}$) integer math units to eliminate floating-point precision issues. ### Type Hinting Use exhaustive type hints for all new functions and classes. diff --git a/GEMINI.md b/GEMINI.md index 3b9e9e1..381e15c 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -48,7 +48,7 @@ This generates a zip file in the `release/` directory using Blender's extension - **Registration**: All Blender classes (Nodes, Sockets, Operators, Menus) must be decorated with `@register_class` from `registry.py` to ensure they are registered correctly during initialization. - **Node Evaluation**: Nodes follow a pull-based evaluation model. Use `resolve_input(self.inputs.get("SocketName"))` within a node's `evaluate_output` method to trigger the evaluation of upstream nodes. - **NumPy First**: For any geometry processing, prioritize NumPy vectorized operations over standard Python loops to maintain high performance. -- **Scaling**: 2D polygon operations via Pyclipper use a `SCALE` factor (defined in `core/slice.py`) to convert floats to integers, as Pyclipper operates in integer space. +- **Scaling & Integer Math**: 2D polygon and geometric operations use a `SCALE = 1000` factor (defined in `core/slice.py`) representing 1 micron ($1\ \mu\text{m} = 0.001\text{ mm}$) integer math units to eliminate floating-point precision issues. - **Type Hinting**: Use exhaustive type hints for all new functions and classes. - **Documentation**: Update `doc/NODES.md` when adding new nodes and `doc/MODULES.md` when adding new modules. - **Token-Efficient Testing**: diff --git a/blender_manifest.toml b/blender_manifest.toml index 57cec05..861811b 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -59,7 +59,6 @@ wheels = [ "./wheels/python_libnest2d-0.1.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", "./wheels/python_libnest2d-0.1.3-cp313-cp313-win_amd64.whl", - "./wheels/cudaslicer-1.0.0-cp310-cp310-linux_x86_64.whl", "./wheels/shapely_polyskel-0.1.2-py3-none-any.whl", "./wheels/euclid3-0.1-py3-none-any.whl", "./wheels/pyransac3d-0.6.0-py3-none-any.whl", diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 6b162c2..b24c5b8 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -5,7 +5,7 @@ import numpy as np from ..bpy_ns_types import NfEnumProperty, NfBoolProperty, NfFloatProperty, notify_tree_changed from ....registry import register_class from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner, SlicingGroup -from ...slice import align_path_seam_sharpest +from ...slice import align_path_seam_sharpest, is_closed_path @register_class class SLICENODE_GetPaths(SliceNode): @@ -109,20 +109,26 @@ class SLICENODE_SliceShells(SliceNode): merged_object.mesh[:, :3, :] = merged_object.mesh[:, :3, :] @ mat.T merged_object.mesh[:, 3:4, :] = merged_object.mesh[:, 3:4, :] @ mat.T - min_z = merged_object.mesh[:,0:3,2:3].min() + min_z = float(merged_object.mesh[:, 0:3, 2:3].min()) from math import floor min_z = floor(min_z / layer_height) * layer_height - max_z = merged_object.mesh[:,0:3,2:3].max() + max_z = float(merged_object.mesh[:, 0:3, 2:3].max()) h_first = fl_height if fl_height is not None else layer_height - z_first = min_z + h_first - z_rest_start = min_z + h_first + layer_height - if z_rest_start < max_z: - z_rest = np.arange(z_rest_start, max_z, layer_height) - z_values = np.concatenate(([z_first], z_rest)) - elif z_first < max_z: - z_values = np.array([z_first]) + min_z_um = int(round(min_z * 1000)) + lh_um = int(round(layer_height * 1000)) + flh_um = int(round(h_first * 1000)) + max_z_um = int(round(max_z * 1000)) + + z_first_um = min_z_um + flh_um + z_rest_start_um = z_first_um + lh_um + + if z_rest_start_um < max_z_um and lh_um > 0: + z_um_values = list(range(z_rest_start_um, max_z_um, lh_um)) + z_values = np.array([z_first_um] + z_um_values, dtype=np.float64) / 1000.0 + elif z_first_um < max_z_um: + z_values = np.array([z_first_um], dtype=np.float64) / 1000.0 else: z_values = np.array([]) @@ -156,9 +162,9 @@ class SLICENODE_SliceShells(SliceNode): if len(z_values) > 0 and fl_height is not None and layer_height > 0: mult = fl_height / layer_height - z_first = z_values[0] + z_first_um = int(round(z_values[0] * 1000)) for i, p in enumerate(slices): - if len(p) > 0 and abs(p[0, 2] - z_first) < 1e-4: + if len(p) > 0 and int(round(p[0, 2] * 1000)) == z_first_um: slices.extrusion_rate[i] = slices.extrusion_rate[i] * mult if rotation and any(rotation): @@ -326,7 +332,7 @@ class SLICENODE_CudaSliceMesh(SliceNode): if len(cnt) < 2: continue cnt_3d = np.column_stack([cnt, np.full(len(cnt), z)]) - if not np.allclose(cnt_3d[0], cnt_3d[-1]): + if not is_closed_path(cnt_3d): cnt_3d = np.vstack([cnt_3d, cnt_3d[0]]) cnt_3d = align_path_seam_sharpest(cnt_3d) perim_paths.append(cnt_3d) @@ -336,7 +342,7 @@ class SLICENODE_CudaSliceMesh(SliceNode): if len(cnt) < 2: continue cnt_3d = np.column_stack([cnt, np.full(len(cnt), z)]) - if not np.allclose(cnt_3d[0], cnt_3d[-1]): + if not is_closed_path(cnt_3d): cnt_3d = np.vstack([cnt_3d, cnt_3d[0]]) cnt_3d = align_path_seam_sharpest(cnt_3d) infill_paths.append(cnt_3d) @@ -367,8 +373,9 @@ class SLICENODE_CudaSliceMesh(SliceNode): if first_layer_z is not None and fl_height is not None and layer_height > 0: mult = fl_height / layer_height + first_z_um = int(round(first_layer_z * 1000)) for i, p in enumerate(perim_sp): - if len(p) > 0 and abs(p[0, 2] - first_layer_z) < 1e-4: + if len(p) > 0 and int(round(p[0, 2] * 1000)) == first_z_um: perim_sp.extrusion_rate[i] = perim_sp.extrusion_rate[i] * mult if rotation and any(rotation) and mat_inv is not None: @@ -387,8 +394,9 @@ class SLICENODE_CudaSliceMesh(SliceNode): if first_layer_z is not None and fl_height is not None and layer_height > 0: mult = fl_height / layer_height + first_z_um = int(round(first_layer_z * 1000)) for i, p in enumerate(infill_sp): - if len(p) > 0 and abs(p[0, 2] - first_layer_z) < 1e-4: + if len(p) > 0 and int(round(p[0, 2] * 1000)) == first_z_um: infill_sp.extrusion_rate[i] = infill_sp.extrusion_rate[i] * mult if rotation and any(rotation) and mat_inv is not None: diff --git a/core/slice.py b/core/slice.py index 36411f5..a51343d 100644 --- a/core/slice.py +++ b/core/slice.py @@ -12,8 +12,14 @@ from ..infra.blender.blender_mesh_capture import PdfProperties from .geometry import ndarray_lru_cache -# Scaling factor for pyclipper (it works with integers) -SCALE = 1_000_000 +# Scaling factor for 1 micron unit integer math (1 mm = 1000 microns) +SCALE = 1000 + +def is_closed_path(path: np.ndarray) -> bool: + """Checks if a 2D or 3D path forms a closed loop in integer micron space (1 micron unit).""" + if len(path) < 3: + return False + return bool(np.all(np.round(path[0, :2] * SCALE) == np.round(path[-1, :2] * SCALE))) def _transfer_path_attr(src_attr: np.ndarray, orig_pts: np.ndarray, new_pts: np.ndarray) -> np.ndarray: """ @@ -27,7 +33,7 @@ def _transfer_path_attr(src_attr: np.ndarray, orig_pts: np.ndarray, new_pts: np. return np.zeros(0, dtype=src_attr.dtype if hasattr(src_attr, 'dtype') else float) if N == 0: return np.zeros(M, dtype=src_attr.dtype if hasattr(src_attr, 'dtype') else float) - if N == M and np.allclose(orig_pts, new_pts): + if N == M and is_closed_path(orig_pts) == is_closed_path(new_pts) and np.all(np.round(orig_pts * SCALE) == np.round(new_pts * SCALE)): return src_attr.copy() if len(src_attr) > 0 and np.all(src_attr == src_attr[0]): return np.full(M, src_attr[0], dtype=src_attr.dtype if hasattr(src_attr, 'dtype') else float) @@ -100,12 +106,12 @@ def align_path_seam_sharpest(path: np.ndarray) -> np.ndarray: falls back to the sharpest angle overall. If path is open or has fewer than 3 distinct vertices, returns path unchanged. - Uses vectorized NumPy operations for high performance. + Uses exact 1-micron integer arithmetic for orientation and angle selection. """ if len(path) < 4: return path - if not np.allclose(path[0], path[-1]): + if not is_closed_path(path): return path pts = path[:-1] @@ -113,36 +119,39 @@ def align_path_seam_sharpest(path: np.ndarray) -> np.ndarray: if M < 3: return path - pts2d = pts[:, :2] - x = pts2d[:, 0] - y = pts2d[:, 1] + pts_um = np.round(pts[:, :2] * SCALE).astype(np.int64) + x = pts_um[:, 0] + y = pts_um[:, 1] x_next = np.roll(x, -1) y_next = np.roll(y, -1) - signed_area = 0.5 * np.sum(x * y_next - x_next * y) - if abs(signed_area) < 1e-9: + signed_area_um = np.sum(x * y_next - x_next * y) + if signed_area_um == 0: return path - prev_pts = np.roll(pts2d, 1, axis=0) - next_pts = np.roll(pts2d, -1, axis=0) + prev_pts = np.roll(pts_um, 1, axis=0) + next_pts = np.roll(pts_um, -1, axis=0) - v_in = pts2d - prev_pts - v_out = next_pts - pts2d + v_in = pts_um - prev_pts + v_out = next_pts - pts_um - l_in = np.linalg.norm(v_in, axis=1) - l_out = np.linalg.norm(v_out, axis=1) + l2_in = np.sum(v_in**2, axis=1) + l2_out = np.sum(v_out**2, axis=1) - valid = (l_in > 1e-6) & (l_out > 1e-6) + valid = (l2_in > 0) & (l2_out > 0) if not np.any(valid): return path cross_2d = v_in[:, 0] * v_out[:, 1] - v_in[:, 1] * v_out[:, 0] - if signed_area > 0: - concave_mask = valid & (cross_2d < -1e-6) + if signed_area_um > 0: + concave_mask = valid & (cross_2d < 0) else: - concave_mask = valid & (cross_2d > 1e-6) + concave_mask = valid & (cross_2d > 0) + + l_in = np.sqrt(l2_in.astype(np.float64)) + l_out = np.sqrt(l2_out.astype(np.float64)) cos_angles = np.full(M, 1.0) - dot_prods = np.sum(v_in[valid] * v_out[valid], axis=1) + dot_prods = np.sum(v_in[valid] * v_out[valid], axis=1).astype(np.float64) cos_angles[valid] = dot_prods / (l_in[valid] * l_out[valid]) if np.any(concave_mask): @@ -436,14 +445,6 @@ class SlicePaths(list[np.ndarray]): return self.copy_with(new_paths) - new_paths = [] - for p in self: - # Shift to center, rotate, shift back - new_p = (p - center) @ rot_mat.T + center - new_paths.append(new_p) - - return self.copy_with(new_paths) - def get_first_layer_midpoint(self) -> np.ndarray: if not self: return np.zeros(3) @@ -515,6 +516,7 @@ class SlicePaths(list[np.ndarray]): """ Aligns the seams (starting point of closed paths) in SlicePaths. Supported modes: 'sharpest'. + Uses 1-micron integer math. """ if not self or mode != "sharpest": return self @@ -525,7 +527,7 @@ class SlicePaths(list[np.ndarray]): new_extra: dict[str, list[np.ndarray]] = {k: [] for k in self.extra_attributes} for i, p in enumerate(self): - if len(p) < 4 or not np.allclose(p[0], p[-1]): + if len(p) < 4 or not is_closed_path(p): new_paths.append(p) new_ext.append(self.extrusion_rate[i]) new_feed.append(self.feedrate[i]) @@ -543,34 +545,37 @@ class SlicePaths(list[np.ndarray]): new_extra[k].append(self.extra_attributes[k][i]) continue - pts2d = pts[:, :2] - x = pts2d[:, 0] - y = pts2d[:, 1] + pts_um = np.round(pts[:, :2] * SCALE).astype(np.int64) + x = pts_um[:, 0] + y = pts_um[:, 1] x_next = np.roll(x, -1) y_next = np.roll(y, -1) - signed_area = 0.5 * np.sum(x * y_next - x_next * y) + signed_area_um = np.sum(x * y_next - x_next * y) - prev_pts = np.roll(pts2d, 1, axis=0) - next_pts = np.roll(pts2d, -1, axis=0) + prev_pts = np.roll(pts_um, 1, axis=0) + next_pts = np.roll(pts_um, -1, axis=0) - v_in = pts2d - prev_pts - v_out = next_pts - pts2d + v_in = pts_um - prev_pts + v_out = next_pts - pts_um - l_in = np.linalg.norm(v_in, axis=1) - l_out = np.linalg.norm(v_out, axis=1) + l2_in = np.sum(v_in**2, axis=1) + l2_out = np.sum(v_out**2, axis=1) - valid = (l_in > 1e-6) & (l_out > 1e-6) - if not np.any(valid) or abs(signed_area) < 1e-9: + valid = (l2_in > 0) & (l2_out > 0) + if not np.any(valid) or signed_area_um == 0: best_idx = 0 else: cross_2d = v_in[:, 0] * v_out[:, 1] - v_in[:, 1] * v_out[:, 0] - if signed_area > 0: - concave_mask = valid & (cross_2d < -1e-6) + if signed_area_um > 0: + concave_mask = valid & (cross_2d < 0) else: - concave_mask = valid & (cross_2d > 1e-6) + concave_mask = valid & (cross_2d > 0) + + l_in = np.sqrt(l2_in.astype(np.float64)) + l_out = np.sqrt(l2_out.astype(np.float64)) cos_angles = np.full(M, 1.0) - dot_prods = np.sum(v_in[valid] * v_out[valid], axis=1) + dot_prods = np.sum(v_in[valid] * v_out[valid], axis=1).astype(np.float64) cos_angles[valid] = dot_prods / (l_in[valid] * l_out[valid]) if np.any(concave_mask): @@ -679,36 +684,41 @@ def slice_at_z(edges, normal_vector=np.array([0,0,1]), tolerance=1e-15): segment_count = filtered_arr.shape[-2] // 2 segments = filtered_arr.reshape(segment_count, 2, 3) - segments = np.around(segments, 6) + # Quantize to micron resolution + segments = np.round(segments * SCALE) / SCALE all_segments.append(segments) return all_segments -def link_segments(segments: np.ndarray, tolerance=1e-6) -> list[np.ndarray]: - """Links segments (N, 2, 3) into continuous paths.""" +def link_segments(segments: np.ndarray) -> list[np.ndarray]: + """Links segments (N, 2, 3) into continuous paths using 1-micron integer grid.""" if len(segments) == 0: return [] z = segments[0, 0, 2] pts = segments[:, :, :2] - def to_key(p): return tuple((p / tolerance).round().astype(np.int64)) + # Quantize to integer micron tuples: (x_um, y_um) + pts_um = np.round(pts * SCALE).astype(np.int64) - adj = {} + adj: dict[tuple[int, int], list[tuple[tuple[int, int], int]]] = {} for i in range(len(segments)): - p1, p2 = pts[i, 0], pts[i, 1] - k1, k2 = to_key(p1), to_key(p2) - if k1 == k2: continue + k1 = (int(pts_um[i, 0, 0]), int(pts_um[i, 0, 1])) + k2 = (int(pts_um[i, 1, 0]), int(pts_um[i, 1, 1])) + if k1 == k2: + continue adj.setdefault(k1, []).append((k2, i)) adj.setdefault(k2, []).append((k1, i)) paths = [] used = set() for i in range(len(segments)): - if i in used: continue - p1, p2 = pts[i, 0], pts[i, 1] - k1, k2 = to_key(p1), to_key(p2) - if k1 == k2: continue + if i in used: + continue + k1 = (int(pts_um[i, 0, 0]), int(pts_um[i, 0, 1])) + k2 = (int(pts_um[i, 1, 0]), int(pts_um[i, 1, 1])) + if k1 == k2: + continue - path_pts = [p1, p2] + path_pts = [pts[i, 0], pts[i, 1]] used.add(i) # Forward @@ -717,14 +727,15 @@ def link_segments(segments: np.ndarray, tolerance=1e-6) -> list[np.ndarray]: found = False for next_k, idx in adj.get(curr_k, []): if idx not in used: - pk1, pk2 = to_key(pts[idx, 0]), to_key(pts[idx, 1]) + pk1 = (int(pts_um[idx, 0, 0]), int(pts_um[idx, 0, 1])) other_pt = pts[idx, 1] if pk1 == curr_k else pts[idx, 0] path_pts.append(other_pt) used.add(idx) - curr_k = to_key(other_pt) + curr_k = next_k found = True break - if not found: break + if not found: + break # Backward curr_k = k1 @@ -732,16 +743,21 @@ def link_segments(segments: np.ndarray, tolerance=1e-6) -> list[np.ndarray]: found = False for next_k, idx in adj.get(curr_k, []): if idx not in used: - pk1, pk2 = to_key(pts[idx, 0]), to_key(pts[idx, 1]) + pk1 = (int(pts_um[idx, 0, 0]), int(pts_um[idx, 0, 1])) other_pt = pts[idx, 1] if pk1 == curr_k else pts[idx, 0] path_pts.insert(0, other_pt) used.add(idx) - curr_k = to_key(other_pt) + curr_k = next_k found = True break - if not found: break + if not found: + break - res = np.column_stack([np.array(path_pts), np.full(len(path_pts), z)]) + p_arr = np.array(path_pts) + if len(p_arr) > 2 and is_closed_path(p_arr): + p_arr[-1] = p_arr[0] + + res = np.column_stack([p_arr, np.full(len(p_arr), z)]) paths.append(res) return paths @@ -755,7 +771,7 @@ def segments_to_slice(segments: np.ndarray, ignore_holes: bool = False) -> list[ open_paths = [] for p in linked: path_pts = p[:, :2] - if len(path_pts) > 3 and np.allclose(path_pts[0], path_pts[-1]): + if len(path_pts) > 3 and is_closed_path(p): closed_paths.append(p) else: open_paths.append(p) @@ -768,7 +784,7 @@ def segments_to_slice(segments: np.ndarray, ignore_holes: bool = False) -> list[ for p in closed_paths: path_pts = p[:, :2] path_pts = path_pts[:-1] - scaled = (path_pts * SCALE).astype(np.int64).tolist() + scaled = (path_pts * SCALE).round().astype(np.int64).tolist() pc.AddPath(scaled, pyclipper.PT_SUBJECT, closed = True) # pyright: ignore[reportAttributeAccessIssue] if ignore_holes: @@ -789,7 +805,7 @@ def segments_to_slice(segments: np.ndarray, ignore_holes: bool = False) -> list[ for path in solution: if not path: continue - fpath = np.array(path) / SCALE + fpath = np.array(path, dtype=np.float64) / SCALE fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)]) # Close loop by appending the first point for internal consistency fpath_3d = np.vstack([fpath_3d, fpath_3d[0]]) @@ -824,10 +840,9 @@ def get_boundary_path(pt1: np.ndarray, pt2: np.ndarray, loops: list[np.ndarray]) min_len = float('inf') for loop in loops: - # Pyclipper paths are usually not closed in the array (last != first) if ET_CLOSEDPOLYGON - # but NodeSlicer usually keeps them closed. Ensure it's closed for logic. + # Ensure it's closed for logic. working_loop = loop - if not np.allclose(loop[0], loop[-1]): + if not is_closed_path(loop): working_loop = np.vstack([loop, loop[0]]) idx1, u1 = find_point_on_loop(pt1, working_loop) @@ -876,12 +891,12 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI object_dict: dict[int, list[np.ndarray]] = {} for s in subject: - z = int(round(s[0, 2] * 1000)) + z = int(round(s[0, 2] * SCALE)) all_z_keys.add(z) subject_dict.setdefault(z, []).append(s) for s in object: - z = int(round(s[0, 2] * 1000)) + z = int(round(s[0, 2] * SCALE)) all_z_keys.add(z) object_dict.setdefault(z, []).append(s) @@ -912,10 +927,10 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI def add_paths_to_clipper(paths, role): for s in paths: path_pts = s[:, :2] - is_closed = len(path_pts) > 2 and np.allclose(path_pts[0], path_pts[-1]) + is_closed = is_closed_path(s) if is_closed: path_pts = path_pts[:-1] - scaled = (path_pts * SCALE).astype(np.int64).tolist() + scaled = (path_pts * SCALE).round().astype(np.int64).tolist() pc.AddPath(scaled, role, is_closed) # pyright: ignore[reportAttributeAccessIssue] layer_results = [] @@ -923,26 +938,26 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI def process_op(subs, clips_scaled, clips_pts, op_type): for s in subs: path_pts = s[:, :2] - is_closed = len(path_pts) > 2 and np.allclose(path_pts[0], path_pts[-1]) + is_closed = is_closed_path(s) if not connect_along_boundary or is_closed: pc.Clear() if is_closed: path_pts = path_pts[:-1] - pc.AddPath((path_pts * SCALE).astype(np.int64).tolist(), pyclipper.PT_SUBJECT, is_closed) # pyright: ignore[reportAttributeAccessIssue] + pc.AddPath((path_pts * SCALE).round().astype(np.int64).tolist(), pyclipper.PT_SUBJECT, is_closed) # pyright: ignore[reportAttributeAccessIssue] for c in clips_scaled: pc.AddPath(c, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] for path in pyclipper.ClosedPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue] - fpath = np.array(path) / SCALE + fpath = np.array(path, dtype=np.float64) / SCALE fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)]) layer_results.append(np.vstack([fpath_3d, fpath_3d[0]])) for path in pyclipper.OpenPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue] - fpath = np.array(path) / SCALE + fpath = np.array(path, dtype=np.float64) / SCALE layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)])) else: pc.Clear() - pc.AddPath((path_pts * SCALE).astype(np.int64).tolist(), pyclipper.PT_SUBJECT, False) # pyright: ignore[reportAttributeAccessIssue] + pc.AddPath((path_pts * SCALE).round().astype(np.int64).tolist(), pyclipper.PT_SUBJECT, False) # pyright: ignore[reportAttributeAccessIssue] for c in clips_scaled: pc.AddPath(c, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] @@ -966,7 +981,7 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI segments = [] for path in open_results: - fpath = np.array(path) / SCALE + fpath = np.array(path, dtype=np.float64) / SCALE p_start = get_p(fpath[0]) p_end = get_p(fpath[-1]) if p_start > p_end: @@ -997,20 +1012,20 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI layer_results.append(np.column_stack([path_to_add, np.full(len(path_to_add), z)])) if operation == 'INTERSECTION': - obj_closed_pts = [s[:, :2] for s in object_slices if len(s) > 2 and np.allclose(s[0], s[-1])] - obj_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in obj_closed_pts] + obj_closed_pts = [s[:, :2] for s in object_slices if is_closed_path(s)] + obj_closed_scaled = [(pts[:-1] * SCALE).round().astype(np.int64).tolist() for pts in obj_closed_pts] process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_INTERSECTION) # pyright: ignore[reportAttributeAccessIssue] # Pass 2 for B_open ∩ A_closed - b_open = [s for s in object_slices if not (len(s) > 2 and np.allclose(s[0], s[-1]))] + b_open = [s for s in object_slices if not is_closed_path(s)] if b_open: - sub_closed_pts = [s[:, :2] for s in subject_slices if len(s) > 2 and np.allclose(s[0], s[-1])] - sub_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in sub_closed_pts] + sub_closed_pts = [s[:, :2] for s in subject_slices if is_closed_path(s)] + sub_closed_scaled = [(pts[:-1] * SCALE).round().astype(np.int64).tolist() for pts in sub_closed_pts] process_op(b_open, sub_closed_scaled, sub_closed_pts, pyclipper.CT_INTERSECTION) # pyright: ignore[reportAttributeAccessIssue] elif operation == 'DIFFERENCE': - obj_closed_pts = [s[:, :2] for s in object_slices if len(s) > 2 and np.allclose(s[0], s[-1])] - obj_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in obj_closed_pts] + obj_closed_pts = [s[:, :2] for s in object_slices if is_closed_path(s)] + obj_closed_scaled = [(pts[:-1] * SCALE).round().astype(np.int64).tolist() for pts in obj_closed_pts] process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_DIFFERENCE) # pyright: ignore[reportAttributeAccessIssue] # DIFFERENCE is not symmetric for open paths in the same way, usually A - B means A is the main focus. @@ -1020,7 +1035,7 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI obj_closed = [] obj_open = [] for s in object_slices: - if len(s) > 2 and np.allclose(s[0], s[-1]): obj_closed.append(s) + if is_closed_path(s): obj_closed.append(s) else: obj_open.append(s) if operation in ['UNION', 'XOR']: @@ -1028,7 +1043,7 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI for s in obj_closed: path_pts = s[:, :2] - scaled = (path_pts[:-1] * SCALE).astype(np.int64).tolist() + scaled = (path_pts[:-1] * SCALE).round().astype(np.int64).tolist() pc.AddPath(scaled, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue] @@ -1097,12 +1112,11 @@ def buffer_curve_slice( from shapely.geometry import LineString - # Group paths by Z height to avoid flattening the entire model into 2D + # Group paths by Z height in integer microns groups: dict[int, list[np.ndarray]] = {} for p in slice_paths: z = p[0, 2] - # Use scaled integer for stable dictionary grouping of floats - z_key = int(round(z * 1000)) + z_key = int(round(z * SCALE)) groups.setdefault(z_key, []).append(p) paths_list: list[np.ndarray] = [] @@ -1172,11 +1186,11 @@ def offset_curve_slice( from shapely.geometry import LineString, Polygon, LinearRing - # Group paths by Z height + # Group paths by Z height in integer microns groups: dict[int, list[np.ndarray]] = {} for p in slice_paths: z = p[0, 2] - z_key = int(round(z * 1000)) + z_key = int(round(z * SCALE)) groups.setdefault(z_key, []).append(p) paths_list: list[np.ndarray] = [] @@ -1192,9 +1206,7 @@ def offset_curve_slice( continue pts_2d = p[:, :2] - - # Check if path is closed - is_closed = len(pts_2d) > 2 and np.allclose(pts_2d[0], pts_2d[-1]) + is_closed = is_closed_path(p) if is_closed: try: @@ -1242,7 +1254,7 @@ def offset_curve_slice( return paths_list, groups_list def paths_to_shapely_polygon(paths: list[np.ndarray]) -> BaseGeometry: - """Converts a list of paths on a layer to a single Shapely geometry using pyclipper to resolve holes.""" + """Converts a list of paths on a layer to a single Shapely geometry using pyclipper to resolve holes in 1-micron space.""" if not paths: return Polygon() @@ -1254,9 +1266,9 @@ def paths_to_shapely_polygon(paths: list[np.ndarray]) -> BaseGeometry: if len(p) < 3: continue pts_2d = p[:, :2] - if np.allclose(pts_2d[0], pts_2d[-1]): + if is_closed_path(p): pts_2d = pts_2d[:-1] - scaled = (pts_2d * SCALE).astype(np.int64).tolist() + scaled = (pts_2d * SCALE).round().astype(np.int64).tolist() pc.AddPath(scaled, pyclipper.PT_SUBJECT, True) # pyright: ignore[reportAttributeAccessIssue] tree = pc.Execute2(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] @@ -1265,13 +1277,13 @@ def paths_to_shapely_polygon(paths: list[np.ndarray]) -> BaseGeometry: polygons = [] if hasattr(node, 'Contour') and node.Contour and not node.IsHole: if len(node.Contour) >= 3: - exterior = np.array(node.Contour) / SCALE + exterior = np.array(node.Contour, dtype=np.float64) / SCALE interiors = [] child_islands = [] for child in node.Childs: if child.IsHole: if child.Contour and len(child.Contour) >= 3: - interiors.append(np.array(child.Contour) / SCALE) + interiors.append(np.array(child.Contour, dtype=np.float64) / SCALE) for grandchild in child.Childs: child_islands.append(grandchild) else: @@ -1296,13 +1308,13 @@ def get_infill_mask_slice(slice_paths: list[np.ndarray]) -> tuple[list[np.ndarra if not slice_paths: return [], [] - # Group paths by their Z height + # Group paths by their Z height in integer microns groups: dict[int, list[np.ndarray]] = {} for p in slice_paths: if len(p) == 0: continue z = p[0, 2] - z_key = int(round(z * 1000)) + z_key = int(round(z * SCALE)) groups.setdefault(z_key, []).append(p) paths_list: list[np.ndarray] = [] @@ -1392,12 +1404,11 @@ def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', if not slice_paths: return [], [] - # Group paths by Z height to avoid flattening the entire model into 2D + # Group paths by Z height in integer microns groups: dict[int, list[np.ndarray]] = {} for p in slice_paths: z = p[0, 2] - # Use scaled integer for stable dictionary grouping of floats - z_key = int(round(z * 1000)) + z_key = int(round(z * SCALE)) groups.setdefault(z_key, []).append(p) join_types = { @@ -1410,7 +1421,7 @@ def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', paths_list: list[np.ndarray] = [] groups_list: list[int] = [] - pco = pyclipper.PyclipperOffset(arc_tolerance=tolerance * SCALE) # pyright: ignore[reportAttributeAccessIssue] + pco = pyclipper.PyclipperOffset(arc_tolerance=int(round(tolerance * SCALE))) # pyright: ignore[reportAttributeAccessIssue] # Buffer each layer independently group_id = 0 @@ -1423,21 +1434,21 @@ def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', for p in layer_paths: path_pts = p[:, :2] # Strip redundant closing points - if len(path_pts) > 2 and np.allclose(path_pts[0], path_pts[-1]): + if is_closed_path(p): path_pts = path_pts[:-1] if len(path_pts) < 3: continue - scaled_paths.append((path_pts * SCALE).astype(np.int64).tolist()) + scaled_paths.append((path_pts * SCALE).round().astype(np.int64).tolist()) if not scaled_paths: continue # Add all paths for this specific Z layer pco.AddPaths(scaled_paths, jt, pyclipper.ET_CLOSEDPOLYGON) # pyright: ignore[reportAttributeAccessIssue] - solution = pco.Execute(distance * SCALE) + solution = pco.Execute(int(round(distance * SCALE))) for path in solution: if not path: continue - fpath = np.array(path) / SCALE + fpath = np.array(path, dtype=np.float64) / SCALE fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)]) # Re-close loop fpath_3d = np.vstack([fpath_3d, fpath_3d[0]]) @@ -1451,7 +1462,7 @@ def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', @ndarray_lru_cache(maxsize=32) def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float = 0.0, infill_type: str = 'GRID') -> tuple[list[np.ndarray], list[int]]: - """Generates a grid or rectilinear infill clipped by the provided paths. Consistent across layers.""" + """Generates a grid or rectilinear infill clipped by the provided paths. Consistent across layers using 1-micron integer grid.""" if not slice_paths or density <= 0: return [], [] rotation = np.radians(rotation_deg) @@ -1478,39 +1489,43 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float rg_min_x, rg_min_y = rotated_corners[:, 0].min(), rotated_corners[:, 1].min() rg_max_x, rg_max_y = rotated_corners[:, 0].max(), rotated_corners[:, 1].max() - # Generate the global static grid lines in de-rotated space - rg_min_x -= density - rg_max_x += density - rg_min_y -= density - rg_max_y += density + density_um = int(round(density * SCALE)) + if density_um <= 0: + return [], [] + + # Generate the global static grid lines in de-rotated space using integer microns + rg_min_x_um = int(np.floor(rg_min_x * SCALE)) - density_um + rg_max_x_um = int(np.ceil(rg_max_x * SCALE)) + density_um + rg_min_y_um = int(np.floor(rg_min_y * SCALE)) - density_um + rg_max_y_um = int(np.ceil(rg_max_y * SCALE)) + density_um vertical_lines = [] - x = rg_min_x - while x <= rg_max_x: + x_um = rg_min_x_um + while x_um <= rg_max_x_um: vertical_lines.append([ - (int(x * SCALE), int(rg_min_y * SCALE)), - (int(x * SCALE), int(rg_max_y * SCALE)) + (x_um, rg_min_y_um), + (x_um, rg_max_y_um) ]) - x += density + x_um += density_um horizontal_lines = [] - y = rg_min_y - while y <= rg_max_y: + y_um = rg_min_y_um + while y_um <= rg_max_y_um: horizontal_lines.append([ - (int(rg_min_x * SCALE), int(y * SCALE)), - (int(rg_max_x * SCALE), int(y * SCALE)) + (rg_min_x_um, y_um), + (rg_max_x_um, y_um) ]) - y += density + y_um += density_um - # Group by Z + # Group by Z in integer microns groups: dict[int, list[np.ndarray]] = {} - if hasattr(slice_paths, 'groups'): + if hasattr(slice_paths, 'groups') and slice_paths.groups: for p, g in zip(slice_paths, slice_paths.groups): groups.setdefault(g, []).append(p) else: # Fallback if it's just a list for p in slice_paths: - z_key = int(round(p[0, 2] * 1000)) + z_key = int(round(p[0, 2] * SCALE)) groups.setdefault(z_key, []).append(p) output_paths: list[np.ndarray] = [] @@ -1539,15 +1554,15 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float offset_2d = np.array([global_center[0], global_center[1], 0]) active_paths = [(p - offset_2d) @ rot_mat_inv.T + offset_2d for p in layer_paths] - # Clip lines with active_paths + # Clip lines with active_paths in integer micron space pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue] for p in active_paths: path_pts = p[:, :2] - if len(path_pts) > 2 and np.allclose(path_pts[0], path_pts[-1]): + if is_closed_path(p): path_pts = path_pts[:-1] if len(path_pts) < 3: continue - scaled = (path_pts * SCALE).astype(np.int64).tolist() + scaled = (path_pts * SCALE).round().astype(np.int64).tolist() pc.AddPath(scaled, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] if current_grid_paths: @@ -1559,7 +1574,7 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float # Rotate back for path in clipped_lines: if not path: continue - fpath = np.array(path) / SCALE + fpath = np.array(path, dtype=np.float64) / SCALE fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)]) rotated_fpath_3d = (fpath_3d - offset_2d) @ rot_mat.T + offset_2d output_paths.append(rotated_fpath_3d) @@ -1570,7 +1585,7 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float def group_segments_by_z(segments: np.ndarray) -> dict[int, np.ndarray]: z_values: np.ndarray = segments[:, 0, 2] unique_z: np.ndarray = np.unique(z_values) - groups: dict[int, np.ndarray] = {int(round(z*1000)): segments[z_values == z] for z in unique_z} + groups: dict[int, np.ndarray] = {int(round(z * SCALE)): segments[z_values == z] for z in unique_z} return groups ############################################################ diff --git a/tests/test_slice.py b/tests/test_slice.py index 9d6891c..86315ce 100644 --- a/tests/test_slice.py +++ b/tests/test_slice.py @@ -951,6 +951,43 @@ class TestSlice(unittest.TestCase): mock_layout_missing = MagicMock() node_missing.draw_buttons(None, mock_layout_missing) + def test_micron_integer_math(self): + from NodeSlicer.core.slice import SCALE, is_closed_path, align_path_seam_sharpest, SlicePaths + self.assertEqual(SCALE, 1000) + + # A square with tiny float noise (< 0.5 micron) at endpoint + sq = np.array([ + [0.0, 0.0, 1.0], + [10.0, 0.0, 1.0], + [10.0, 10.0, 1.0], + [0.0, 10.0, 1.0], + [0.0001, -0.0001, 1.0] # 0.1 micron noise + ]) + self.assertTrue(is_closed_path(sq)) + + # Seam alignment with integer math + aligned = align_path_seam_sharpest(sq) + self.assertEqual(len(aligned), len(sq)) + + # Test buffer with 1-micron precision + sp = SlicePaths([sq], groups=[0]) + buffered = sp.buffer(1.0) + self.assertGreater(len(buffered), 0) + + def test_micron_segment_linking(self): + from NodeSlicer.core.slice import link_segments, is_closed_path + # Segments of a triangle with slight float jitter (0.2 micron) at shared vertices + z = 2.5 + segments = np.array([ + [[0.0002, 0.0, z], [10.0, 0.0001, z]], + [[10.0, 0.0, z], [5.0, 8.660254, z]], + [[5.0, 8.660254, z], [0.0, 0.0, z]], + ]) + paths = link_segments(segments) + self.assertEqual(len(paths), 1) + self.assertTrue(is_closed_path(paths[0])) + self.assertTrue(np.allclose(paths[0][0], paths[0][-1])) + if __name__ == '__main__': unittest.main()