decimal
This commit is contained in:
parent
da83351d98
commit
5c5fa1c589
7 changed files with 218 additions and 159 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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**:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
291
core/slice.py
291
core/slice.py
|
|
@ -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
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue