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.
- **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.

View file

@ -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.

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.
- **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**:

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-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",

View file

@ -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:

View file

@ -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
############################################################

View file

@ -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()