This commit is contained in:
Nicolas Predella 2026-09-27 13:51:21 +03:00
parent da83351d98
commit 5c5fa1c589
7 changed files with 218 additions and 159 deletions

View file

@ -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. - **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. - **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. - **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. - **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. - **Documentation**: Update `doc/NODES.md` when adding new nodes and `doc/MODULES.md` when adding new modules.

View file

@ -20,8 +20,8 @@ Nodes follow a pull-based evaluation model. Use `resolve_input(self.inputs.get("
### Performance ### Performance
For any geometry processing, prioritize NumPy vectorized operations over standard Python loops to maintain high performance. For any geometry processing, prioritize NumPy vectorized operations over standard Python loops to maintain high performance.
### Scaling ### Scaling & Integer Math
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. 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 ### Type Hinting
Use exhaustive type hints for all new functions and classes. Use exhaustive type hints for all new functions and classes.

View file

@ -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. - **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. - **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. - **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. - **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. - **Documentation**: Update `doc/NODES.md` when adding new nodes and `doc/MODULES.md` when adding new modules.
- **Token-Efficient Testing**: - **Token-Efficient Testing**:

View file

@ -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-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl",
"./wheels/python_libnest2d-0.1.3-cp313-cp313-win_amd64.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/shapely_polyskel-0.1.2-py3-none-any.whl",
"./wheels/euclid3-0.1-py3-none-any.whl", "./wheels/euclid3-0.1-py3-none-any.whl",
"./wheels/pyransac3d-0.6.0-py3-none-any.whl", "./wheels/pyransac3d-0.6.0-py3-none-any.whl",

View file

@ -5,7 +5,7 @@ import numpy as np
from ..bpy_ns_types import NfEnumProperty, NfBoolProperty, NfFloatProperty, notify_tree_changed from ..bpy_ns_types import NfEnumProperty, NfBoolProperty, NfFloatProperty, notify_tree_changed
from ....registry import register_class from ....registry import register_class
from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner, SlicingGroup 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 @register_class
class SLICENODE_GetPaths(SliceNode): 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, :] = merged_object.mesh[:, :3, :] @ mat.T
merged_object.mesh[:, 3:4, :] = merged_object.mesh[:, 3:4, :] @ 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 from math import floor
min_z = floor(min_z / layer_height) * layer_height 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 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: min_z_um = int(round(min_z * 1000))
z_rest = np.arange(z_rest_start, max_z, layer_height) lh_um = int(round(layer_height * 1000))
z_values = np.concatenate(([z_first], z_rest)) flh_um = int(round(h_first * 1000))
elif z_first < max_z: max_z_um = int(round(max_z * 1000))
z_values = np.array([z_first])
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: else:
z_values = np.array([]) 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: if len(z_values) > 0 and fl_height is not None and layer_height > 0:
mult = fl_height / layer_height 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): 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 slices.extrusion_rate[i] = slices.extrusion_rate[i] * mult
if rotation and any(rotation): if rotation and any(rotation):
@ -326,7 +332,7 @@ class SLICENODE_CudaSliceMesh(SliceNode):
if len(cnt) < 2: if len(cnt) < 2:
continue continue
cnt_3d = np.column_stack([cnt, np.full(len(cnt), z)]) 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 = np.vstack([cnt_3d, cnt_3d[0]])
cnt_3d = align_path_seam_sharpest(cnt_3d) cnt_3d = align_path_seam_sharpest(cnt_3d)
perim_paths.append(cnt_3d) perim_paths.append(cnt_3d)
@ -336,7 +342,7 @@ class SLICENODE_CudaSliceMesh(SliceNode):
if len(cnt) < 2: if len(cnt) < 2:
continue continue
cnt_3d = np.column_stack([cnt, np.full(len(cnt), z)]) 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 = np.vstack([cnt_3d, cnt_3d[0]])
cnt_3d = align_path_seam_sharpest(cnt_3d) cnt_3d = align_path_seam_sharpest(cnt_3d)
infill_paths.append(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: if first_layer_z is not None and fl_height is not None and layer_height > 0:
mult = fl_height / layer_height mult = fl_height / layer_height
first_z_um = int(round(first_layer_z * 1000))
for i, p in enumerate(perim_sp): 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 perim_sp.extrusion_rate[i] = perim_sp.extrusion_rate[i] * mult
if rotation and any(rotation) and mat_inv is not None: 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: if first_layer_z is not None and fl_height is not None and layer_height > 0:
mult = fl_height / layer_height mult = fl_height / layer_height
first_z_um = int(round(first_layer_z * 1000))
for i, p in enumerate(infill_sp): 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 infill_sp.extrusion_rate[i] = infill_sp.extrusion_rate[i] * mult
if rotation and any(rotation) and mat_inv is not None: if rotation and any(rotation) and mat_inv is not None:

View file

@ -12,8 +12,14 @@ from ..infra.blender.blender_mesh_capture import PdfProperties
from .geometry import ndarray_lru_cache from .geometry import ndarray_lru_cache
# Scaling factor for pyclipper (it works with integers) # Scaling factor for 1 micron unit integer math (1 mm = 1000 microns)
SCALE = 1_000_000 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: 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) return np.zeros(0, dtype=src_attr.dtype if hasattr(src_attr, 'dtype') else float)
if N == 0: if N == 0:
return np.zeros(M, dtype=src_attr.dtype if hasattr(src_attr, 'dtype') else float) 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() return src_attr.copy()
if len(src_attr) > 0 and np.all(src_attr == src_attr[0]): 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) 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. falls back to the sharpest angle overall.
If path is open or has fewer than 3 distinct vertices, returns path unchanged. 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: if len(path) < 4:
return path return path
if not np.allclose(path[0], path[-1]): if not is_closed_path(path):
return path return path
pts = path[:-1] pts = path[:-1]
@ -113,36 +119,39 @@ def align_path_seam_sharpest(path: np.ndarray) -> np.ndarray:
if M < 3: if M < 3:
return path return path
pts2d = pts[:, :2] pts_um = np.round(pts[:, :2] * SCALE).astype(np.int64)
x = pts2d[:, 0] x = pts_um[:, 0]
y = pts2d[:, 1] y = pts_um[:, 1]
x_next = np.roll(x, -1) x_next = np.roll(x, -1)
y_next = np.roll(y, -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)
if abs(signed_area) < 1e-9: if signed_area_um == 0:
return path return path
prev_pts = np.roll(pts2d, 1, axis=0) prev_pts = np.roll(pts_um, 1, axis=0)
next_pts = np.roll(pts2d, -1, axis=0) next_pts = np.roll(pts_um, -1, axis=0)
v_in = pts2d - prev_pts v_in = pts_um - prev_pts
v_out = next_pts - pts2d v_out = next_pts - pts_um
l_in = np.linalg.norm(v_in, axis=1) l2_in = np.sum(v_in**2, axis=1)
l_out = np.linalg.norm(v_out, 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): if not np.any(valid):
return path return path
cross_2d = v_in[:, 0] * v_out[:, 1] - v_in[:, 1] * v_out[:, 0] cross_2d = v_in[:, 0] * v_out[:, 1] - v_in[:, 1] * v_out[:, 0]
if signed_area > 0: if signed_area_um > 0:
concave_mask = valid & (cross_2d < -1e-6) concave_mask = valid & (cross_2d < 0)
else: 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) 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]) cos_angles[valid] = dot_prods / (l_in[valid] * l_out[valid])
if np.any(concave_mask): if np.any(concave_mask):
@ -436,14 +445,6 @@ class SlicePaths(list[np.ndarray]):
return self.copy_with(new_paths) 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: def get_first_layer_midpoint(self) -> np.ndarray:
if not self: if not self:
return np.zeros(3) return np.zeros(3)
@ -515,6 +516,7 @@ class SlicePaths(list[np.ndarray]):
""" """
Aligns the seams (starting point of closed paths) in SlicePaths. Aligns the seams (starting point of closed paths) in SlicePaths.
Supported modes: 'sharpest'. Supported modes: 'sharpest'.
Uses 1-micron integer math.
""" """
if not self or mode != "sharpest": if not self or mode != "sharpest":
return self 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} new_extra: dict[str, list[np.ndarray]] = {k: [] for k in self.extra_attributes}
for i, p in enumerate(self): 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_paths.append(p)
new_ext.append(self.extrusion_rate[i]) new_ext.append(self.extrusion_rate[i])
new_feed.append(self.feedrate[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]) new_extra[k].append(self.extra_attributes[k][i])
continue continue
pts2d = pts[:, :2] pts_um = np.round(pts[:, :2] * SCALE).astype(np.int64)
x = pts2d[:, 0] x = pts_um[:, 0]
y = pts2d[:, 1] y = pts_um[:, 1]
x_next = np.roll(x, -1) x_next = np.roll(x, -1)
y_next = np.roll(y, -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) prev_pts = np.roll(pts_um, 1, axis=0)
next_pts = np.roll(pts2d, -1, axis=0) next_pts = np.roll(pts_um, -1, axis=0)
v_in = pts2d - prev_pts v_in = pts_um - prev_pts
v_out = next_pts - pts2d v_out = next_pts - pts_um
l_in = np.linalg.norm(v_in, axis=1) l2_in = np.sum(v_in**2, axis=1)
l_out = np.linalg.norm(v_out, 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) or abs(signed_area) < 1e-9: if not np.any(valid) or signed_area_um == 0:
best_idx = 0 best_idx = 0
else: else:
cross_2d = v_in[:, 0] * v_out[:, 1] - v_in[:, 1] * v_out[:, 0] cross_2d = v_in[:, 0] * v_out[:, 1] - v_in[:, 1] * v_out[:, 0]
if signed_area > 0: if signed_area_um > 0:
concave_mask = valid & (cross_2d < -1e-6) concave_mask = valid & (cross_2d < 0)
else: 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) 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]) cos_angles[valid] = dot_prods / (l_in[valid] * l_out[valid])
if np.any(concave_mask): 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 segment_count = filtered_arr.shape[-2] // 2
segments = filtered_arr.reshape(segment_count, 2, 3) 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) all_segments.append(segments)
return all_segments return all_segments
def link_segments(segments: np.ndarray, tolerance=1e-6) -> list[np.ndarray]: def link_segments(segments: np.ndarray) -> list[np.ndarray]:
"""Links segments (N, 2, 3) into continuous paths.""" """Links segments (N, 2, 3) into continuous paths using 1-micron integer grid."""
if len(segments) == 0: return [] if len(segments) == 0: return []
z = segments[0, 0, 2] z = segments[0, 0, 2]
pts = segments[:, :, :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)): for i in range(len(segments)):
p1, p2 = pts[i, 0], pts[i, 1] k1 = (int(pts_um[i, 0, 0]), int(pts_um[i, 0, 1]))
k1, k2 = to_key(p1), to_key(p2) k2 = (int(pts_um[i, 1, 0]), int(pts_um[i, 1, 1]))
if k1 == k2: continue if k1 == k2:
continue
adj.setdefault(k1, []).append((k2, i)) adj.setdefault(k1, []).append((k2, i))
adj.setdefault(k2, []).append((k1, i)) adj.setdefault(k2, []).append((k1, i))
paths = [] paths = []
used = set() used = set()
for i in range(len(segments)): for i in range(len(segments)):
if i in used: continue if i in used:
p1, p2 = pts[i, 0], pts[i, 1] continue
k1, k2 = to_key(p1), to_key(p2) k1 = (int(pts_um[i, 0, 0]), int(pts_um[i, 0, 1]))
if k1 == k2: continue 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) used.add(i)
# Forward # Forward
@ -717,14 +727,15 @@ def link_segments(segments: np.ndarray, tolerance=1e-6) -> list[np.ndarray]:
found = False found = False
for next_k, idx in adj.get(curr_k, []): for next_k, idx in adj.get(curr_k, []):
if idx not in used: 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] other_pt = pts[idx, 1] if pk1 == curr_k else pts[idx, 0]
path_pts.append(other_pt) path_pts.append(other_pt)
used.add(idx) used.add(idx)
curr_k = to_key(other_pt) curr_k = next_k
found = True found = True
break break
if not found: break if not found:
break
# Backward # Backward
curr_k = k1 curr_k = k1
@ -732,16 +743,21 @@ def link_segments(segments: np.ndarray, tolerance=1e-6) -> list[np.ndarray]:
found = False found = False
for next_k, idx in adj.get(curr_k, []): for next_k, idx in adj.get(curr_k, []):
if idx not in used: 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] other_pt = pts[idx, 1] if pk1 == curr_k else pts[idx, 0]
path_pts.insert(0, other_pt) path_pts.insert(0, other_pt)
used.add(idx) used.add(idx)
curr_k = to_key(other_pt) curr_k = next_k
found = True found = True
break 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) paths.append(res)
return paths return paths
@ -755,7 +771,7 @@ def segments_to_slice(segments: np.ndarray, ignore_holes: bool = False) -> list[
open_paths = [] open_paths = []
for p in linked: for p in linked:
path_pts = p[:, :2] 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) closed_paths.append(p)
else: else:
open_paths.append(p) 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: for p in closed_paths:
path_pts = p[:, :2] path_pts = p[:, :2]
path_pts = path_pts[:-1] 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] pc.AddPath(scaled, pyclipper.PT_SUBJECT, closed = True) # pyright: ignore[reportAttributeAccessIssue]
if ignore_holes: if ignore_holes:
@ -789,7 +805,7 @@ def segments_to_slice(segments: np.ndarray, ignore_holes: bool = False) -> list[
for path in solution: for path in solution:
if not path: continue 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)]) fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
# Close loop by appending the first point for internal consistency # Close loop by appending the first point for internal consistency
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]]) 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') min_len = float('inf')
for loop in loops: for loop in loops:
# Pyclipper paths are usually not closed in the array (last != first) if ET_CLOSEDPOLYGON # Ensure it's closed for logic.
# but NodeSlicer usually keeps them closed. Ensure it's closed for logic.
working_loop = loop working_loop = loop
if not np.allclose(loop[0], loop[-1]): if not is_closed_path(loop):
working_loop = np.vstack([loop, loop[0]]) working_loop = np.vstack([loop, loop[0]])
idx1, u1 = find_point_on_loop(pt1, working_loop) 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]] = {} object_dict: dict[int, list[np.ndarray]] = {}
for s in subject: for s in subject:
z = int(round(s[0, 2] * 1000)) z = int(round(s[0, 2] * SCALE))
all_z_keys.add(z) all_z_keys.add(z)
subject_dict.setdefault(z, []).append(s) subject_dict.setdefault(z, []).append(s)
for s in object: for s in object:
z = int(round(s[0, 2] * 1000)) z = int(round(s[0, 2] * SCALE))
all_z_keys.add(z) all_z_keys.add(z)
object_dict.setdefault(z, []).append(s) 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): def add_paths_to_clipper(paths, role):
for s in paths: for s in paths:
path_pts = s[:, :2] 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: if is_closed:
path_pts = path_pts[:-1] 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] pc.AddPath(scaled, role, is_closed) # pyright: ignore[reportAttributeAccessIssue]
layer_results = [] 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): def process_op(subs, clips_scaled, clips_pts, op_type):
for s in subs: for s in subs:
path_pts = s[:, :2] 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: if not connect_along_boundary or is_closed:
pc.Clear() pc.Clear()
if is_closed: path_pts = path_pts[:-1] 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: for c in clips_scaled:
pc.AddPath(c, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] pc.AddPath(c, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue]
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # 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] 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)]) fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
layer_results.append(np.vstack([fpath_3d, fpath_3d[0]])) layer_results.append(np.vstack([fpath_3d, fpath_3d[0]]))
for path in pyclipper.OpenPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue] 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)])) layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)]))
else: else:
pc.Clear() 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: for c in clips_scaled:
pc.AddPath(c, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] 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 = [] segments = []
for path in open_results: 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_start = get_p(fpath[0])
p_end = get_p(fpath[-1]) p_end = get_p(fpath[-1])
if p_start > p_end: 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)])) layer_results.append(np.column_stack([path_to_add, np.full(len(path_to_add), z)]))
if operation == 'INTERSECTION': 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_pts = [s[:, :2] for s in object_slices if is_closed_path(s)]
obj_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in obj_closed_pts] 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] process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_INTERSECTION) # pyright: ignore[reportAttributeAccessIssue]
# Pass 2 for B_open ∩ A_closed # 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: 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_pts = [s[:, :2] for s in subject_slices if is_closed_path(s)]
sub_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in sub_closed_pts] 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] process_op(b_open, sub_closed_scaled, sub_closed_pts, pyclipper.CT_INTERSECTION) # pyright: ignore[reportAttributeAccessIssue]
elif operation == 'DIFFERENCE': 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_pts = [s[:, :2] for s in object_slices if is_closed_path(s)]
obj_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in obj_closed_pts] 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] 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. # 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_closed = []
obj_open = [] obj_open = []
for s in object_slices: 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) else: obj_open.append(s)
if operation in ['UNION', 'XOR']: if operation in ['UNION', 'XOR']:
@ -1028,7 +1043,7 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI
for s in obj_closed: for s in obj_closed:
path_pts = s[:, :2] 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] pc.AddPath(scaled, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue]
op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue] op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue]
@ -1097,12 +1112,11 @@ def buffer_curve_slice(
from shapely.geometry import LineString 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]] = {} groups: dict[int, list[np.ndarray]] = {}
for p in slice_paths: for p in slice_paths:
z = p[0, 2] z = p[0, 2]
# Use scaled integer for stable dictionary grouping of floats z_key = int(round(z * SCALE))
z_key = int(round(z * 1000))
groups.setdefault(z_key, []).append(p) groups.setdefault(z_key, []).append(p)
paths_list: list[np.ndarray] = [] paths_list: list[np.ndarray] = []
@ -1172,11 +1186,11 @@ def offset_curve_slice(
from shapely.geometry import LineString, Polygon, LinearRing 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]] = {} groups: dict[int, list[np.ndarray]] = {}
for p in slice_paths: for p in slice_paths:
z = p[0, 2] z = p[0, 2]
z_key = int(round(z * 1000)) z_key = int(round(z * SCALE))
groups.setdefault(z_key, []).append(p) groups.setdefault(z_key, []).append(p)
paths_list: list[np.ndarray] = [] paths_list: list[np.ndarray] = []
@ -1192,9 +1206,7 @@ def offset_curve_slice(
continue continue
pts_2d = p[:, :2] pts_2d = p[:, :2]
is_closed = is_closed_path(p)
# Check if path is closed
is_closed = len(pts_2d) > 2 and np.allclose(pts_2d[0], pts_2d[-1])
if is_closed: if is_closed:
try: try:
@ -1242,7 +1254,7 @@ def offset_curve_slice(
return paths_list, groups_list return paths_list, groups_list
def paths_to_shapely_polygon(paths: list[np.ndarray]) -> BaseGeometry: 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: if not paths:
return Polygon() return Polygon()
@ -1254,9 +1266,9 @@ def paths_to_shapely_polygon(paths: list[np.ndarray]) -> BaseGeometry:
if len(p) < 3: if len(p) < 3:
continue continue
pts_2d = p[:, :2] pts_2d = p[:, :2]
if np.allclose(pts_2d[0], pts_2d[-1]): if is_closed_path(p):
pts_2d = pts_2d[:-1] 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] 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] 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 = [] polygons = []
if hasattr(node, 'Contour') and node.Contour and not node.IsHole: if hasattr(node, 'Contour') and node.Contour and not node.IsHole:
if len(node.Contour) >= 3: if len(node.Contour) >= 3:
exterior = np.array(node.Contour) / SCALE exterior = np.array(node.Contour, dtype=np.float64) / SCALE
interiors = [] interiors = []
child_islands = [] child_islands = []
for child in node.Childs: for child in node.Childs:
if child.IsHole: if child.IsHole:
if child.Contour and len(child.Contour) >= 3: 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: for grandchild in child.Childs:
child_islands.append(grandchild) child_islands.append(grandchild)
else: else:
@ -1296,13 +1308,13 @@ def get_infill_mask_slice(slice_paths: list[np.ndarray]) -> tuple[list[np.ndarra
if not slice_paths: if not slice_paths:
return [], [] return [], []
# Group paths by their Z height # Group paths by their Z height in integer microns
groups: dict[int, list[np.ndarray]] = {} groups: dict[int, list[np.ndarray]] = {}
for p in slice_paths: for p in slice_paths:
if len(p) == 0: if len(p) == 0:
continue continue
z = p[0, 2] z = p[0, 2]
z_key = int(round(z * 1000)) z_key = int(round(z * SCALE))
groups.setdefault(z_key, []).append(p) groups.setdefault(z_key, []).append(p)
paths_list: list[np.ndarray] = [] 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 [], [] 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]] = {} groups: dict[int, list[np.ndarray]] = {}
for p in slice_paths: for p in slice_paths:
z = p[0, 2] z = p[0, 2]
# Use scaled integer for stable dictionary grouping of floats z_key = int(round(z * SCALE))
z_key = int(round(z * 1000))
groups.setdefault(z_key, []).append(p) groups.setdefault(z_key, []).append(p)
join_types = { join_types = {
@ -1410,7 +1421,7 @@ def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round',
paths_list: list[np.ndarray] = [] paths_list: list[np.ndarray] = []
groups_list: list[int] = [] 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 # Buffer each layer independently
group_id = 0 group_id = 0
@ -1423,21 +1434,21 @@ def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round',
for p in layer_paths: for p in layer_paths:
path_pts = p[:, :2] path_pts = p[:, :2]
# Strip redundant closing points # 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] path_pts = path_pts[:-1]
if len(path_pts) < 3: continue 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 if not scaled_paths: continue
# Add all paths for this specific Z layer # Add all paths for this specific Z layer
pco.AddPaths(scaled_paths, jt, pyclipper.ET_CLOSEDPOLYGON) # pyright: ignore[reportAttributeAccessIssue] 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: for path in solution:
if not path: continue 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)]) fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
# Re-close loop # Re-close loop
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]]) 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) @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]]: 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 [], [] if not slice_paths or density <= 0: return [], []
rotation = np.radians(rotation_deg) 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_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() rg_max_x, rg_max_y = rotated_corners[:, 0].max(), rotated_corners[:, 1].max()
# Generate the global static grid lines in de-rotated space density_um = int(round(density * SCALE))
rg_min_x -= density if density_um <= 0:
rg_max_x += density return [], []
rg_min_y -= density
rg_max_y += density # 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 = [] vertical_lines = []
x = rg_min_x x_um = rg_min_x_um
while x <= rg_max_x: while x_um <= rg_max_x_um:
vertical_lines.append([ vertical_lines.append([
(int(x * SCALE), int(rg_min_y * SCALE)), (x_um, rg_min_y_um),
(int(x * SCALE), int(rg_max_y * SCALE)) (x_um, rg_max_y_um)
]) ])
x += density x_um += density_um
horizontal_lines = [] horizontal_lines = []
y = rg_min_y y_um = rg_min_y_um
while y <= rg_max_y: while y_um <= rg_max_y_um:
horizontal_lines.append([ horizontal_lines.append([
(int(rg_min_x * SCALE), int(y * SCALE)), (rg_min_x_um, y_um),
(int(rg_max_x * SCALE), int(y * SCALE)) (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]] = {} 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): for p, g in zip(slice_paths, slice_paths.groups):
groups.setdefault(g, []).append(p) groups.setdefault(g, []).append(p)
else: else:
# Fallback if it's just a list # Fallback if it's just a list
for p in slice_paths: 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) groups.setdefault(z_key, []).append(p)
output_paths: list[np.ndarray] = [] 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]) 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] 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] pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
for p in active_paths: for p in active_paths:
path_pts = p[:, :2] 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] path_pts = path_pts[:-1]
if len(path_pts) < 3: continue 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] pc.AddPath(scaled, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue]
if current_grid_paths: if current_grid_paths:
@ -1559,7 +1574,7 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float
# Rotate back # Rotate back
for path in clipped_lines: for path in clipped_lines:
if not path: continue 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)]) fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
rotated_fpath_3d = (fpath_3d - offset_2d) @ rot_mat.T + offset_2d rotated_fpath_3d = (fpath_3d - offset_2d) @ rot_mat.T + offset_2d
output_paths.append(rotated_fpath_3d) 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]: def group_segments_by_z(segments: np.ndarray) -> dict[int, np.ndarray]:
z_values: np.ndarray = segments[:, 0, 2] z_values: np.ndarray = segments[:, 0, 2]
unique_z: np.ndarray = np.unique(z_values) 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 return groups
############################################################ ############################################################

View file

@ -951,6 +951,43 @@ class TestSlice(unittest.TestCase):
mock_layout_missing = MagicMock() mock_layout_missing = MagicMock()
node_missing.draw_buttons(None, mock_layout_missing) 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__': if __name__ == '__main__':
unittest.main() unittest.main()