3476 lines
No EOL
138 KiB
Python
3476 lines
No EOL
138 KiB
Python
from __future__ import annotations
|
|
import math
|
|
from numpy import float64
|
|
import numpy as np
|
|
import pyclipper
|
|
from typing import Any, Literal, cast
|
|
import copy
|
|
from shapely.geometry import Polygon, MultiPolygon, LineString, LinearRing, MultiLineString, GeometryCollection, Point
|
|
import manifold3d
|
|
import trimesh
|
|
|
|
from ..infra.blender.blender_mesh_capture import PdfProperties
|
|
|
|
from .geometry import ndarray_lru_cache, update_transform
|
|
|
|
# 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 (int(round(float(path[0, 0]) * SCALE)) == int(round(float(path[-1, 0]) * SCALE)) and
|
|
int(round(float(path[0, 1]) * SCALE)) == int(round(float(path[-1, 1]) * SCALE)))
|
|
|
|
def _transfer_path_attr(src_attr: np.ndarray, orig_pts: np.ndarray, new_pts: np.ndarray) -> np.ndarray:
|
|
"""
|
|
Transfers 1D per-vertex attribute from orig_pts (shape N, 3) to new_pts (shape M, 3).
|
|
If src_attr is uniform (all values equal), returns array of shape (M,) filled with that value.
|
|
Otherwise, interpolates along normalized cumulative length.
|
|
"""
|
|
N = len(orig_pts)
|
|
M = len(new_pts)
|
|
if M == 0:
|
|
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 len(src_attr) > 0 and (len(src_attr) == 1 or 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)
|
|
if N == M and is_closed_path(orig_pts) == is_closed_path(new_pts) and np.all(np.rint(orig_pts * SCALE) == np.rint(new_pts * SCALE)):
|
|
return src_attr.copy()
|
|
|
|
orig_diffs = np.diff(orig_pts[:, :3], axis=0)
|
|
orig_dists = np.sqrt((orig_diffs ** 2).sum(axis=1))
|
|
orig_cum = np.concatenate(([0.0], np.cumsum(orig_dists)))
|
|
total_orig = orig_cum[-1]
|
|
if total_orig > 1e-7:
|
|
orig_t = orig_cum / total_orig
|
|
else:
|
|
orig_t = np.linspace(0.0, 1.0, N)
|
|
|
|
new_diffs = np.diff(new_pts[:, :3], axis=0)
|
|
new_dists = np.sqrt((new_diffs ** 2).sum(axis=1))
|
|
new_cum = np.concatenate(([0.0], np.cumsum(new_dists)))
|
|
total_new = new_cum[-1]
|
|
if total_new > 1e-7:
|
|
new_t = new_cum / total_new
|
|
else:
|
|
new_t = np.linspace(0.0, 1.0, M)
|
|
|
|
return np.interp(new_t, orig_t, src_attr)
|
|
|
|
|
|
def _transfer_group_attributes(src_sp: SlicePaths, res_paths: list[np.ndarray], res_groups: list[int]) -> tuple[list[np.ndarray], list[np.ndarray], dict[str, list[np.ndarray]]]:
|
|
"""
|
|
Given input SlicePaths src_sp and output res_paths/res_groups,
|
|
constructs extrusion_rate, feedrate, and extra_attributes for res_paths
|
|
by matching groups or nearest paths in src_sp.
|
|
"""
|
|
out_ext: list[np.ndarray] = []
|
|
out_feed: list[np.ndarray] = []
|
|
out_extra: dict[str, list[np.ndarray]] = {k: [] for k in getattr(src_sp, "extra_attributes", {})}
|
|
|
|
src_groups = getattr(src_sp, "groups", [])
|
|
src_group_map: dict[int, list[int]] = {}
|
|
for idx, g in enumerate(src_groups):
|
|
src_group_map.setdefault(g, []).append(idx)
|
|
|
|
src_z_keys: list[int] | None = None
|
|
if len(src_sp) > 0:
|
|
src_z_keys = [int(round(float(src_sp[i][0, 2]) * SCALE)) if len(src_sp[i]) > 0 and src_sp[i].shape[1] > 2 else 0 for i in range(len(src_sp))]
|
|
|
|
for idx, (p, g) in enumerate(zip(res_paths, res_groups)):
|
|
M = len(p)
|
|
src_indices = src_group_map.get(g, [])
|
|
if not src_indices and len(src_sp) > 0 and src_z_keys is not None:
|
|
z_um = int(round(float(p[0, 2]) * SCALE)) if len(p) > 0 and p.shape[1] > 2 else 0
|
|
src_indices = [min(range(len(src_sp)), key=lambda i: abs(src_z_keys[i] - z_um))]
|
|
|
|
if src_indices:
|
|
src_i = src_indices[0]
|
|
ext = _transfer_path_attr(src_sp.extrusion_rate[src_i], src_sp[src_i], p)
|
|
feed = _transfer_path_attr(src_sp.feedrate[src_i], src_sp[src_i], p)
|
|
out_ext.append(ext)
|
|
out_feed.append(feed)
|
|
for k in src_sp.extra_attributes:
|
|
attr_arr = _transfer_path_attr(src_sp.extra_attributes[k][src_i], src_sp[src_i], p)
|
|
out_extra[k].append(attr_arr)
|
|
else:
|
|
out_ext.append(np.ones(M))
|
|
out_feed.append(np.zeros(M))
|
|
for k in src_sp.extra_attributes:
|
|
out_extra[k].append(np.zeros(M))
|
|
|
|
return out_ext, out_feed, out_extra
|
|
|
|
|
|
import ctypes
|
|
import os
|
|
|
|
_c_seam_lib = None
|
|
try:
|
|
_so_dir = os.path.dirname(os.path.abspath(__file__))
|
|
_so_path = os.path.join(_so_dir, "libseam_opt.so")
|
|
_c_src = os.path.join(_so_dir, "c_seam.c")
|
|
rebuild = False
|
|
if os.path.exists(_c_src):
|
|
if not os.path.exists(_so_path) or os.path.getmtime(_so_path) < os.path.getmtime(_c_src):
|
|
rebuild = True
|
|
if rebuild:
|
|
import subprocess
|
|
for compiler in ("gcc", "clang"):
|
|
try:
|
|
subprocess.run([compiler, "-O3", "-fPIC", "-shared", _c_src, "-o", _so_path, "-lm"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
if os.path.exists(_so_path):
|
|
break
|
|
except Exception:
|
|
pass
|
|
if os.path.exists(_so_path):
|
|
_c_seam_lib = ctypes.CDLL(_so_path)
|
|
_c_seam_lib.find_sharpest_seam_c.argtypes = [
|
|
ctypes.POINTER(ctypes.c_double),
|
|
ctypes.c_int,
|
|
ctypes.c_int,
|
|
ctypes.c_double
|
|
]
|
|
_c_seam_lib.find_sharpest_seam_c.restype = ctypes.c_int
|
|
|
|
if hasattr(_c_seam_lib, "weld_vertices_c"):
|
|
_c_seam_lib.weld_vertices_c.argtypes = [
|
|
ctypes.POINTER(ctypes.c_float),
|
|
ctypes.c_int,
|
|
ctypes.POINTER(ctypes.c_float),
|
|
ctypes.POINTER(ctypes.c_uint32)
|
|
]
|
|
_c_seam_lib.weld_vertices_c.restype = ctypes.c_int
|
|
|
|
if hasattr(_c_seam_lib, "polygon_signed_area_2d_c"):
|
|
_c_seam_lib.polygon_signed_area_2d_c.argtypes = [
|
|
ctypes.POINTER(ctypes.c_double),
|
|
ctypes.c_int,
|
|
ctypes.c_int
|
|
]
|
|
_c_seam_lib.polygon_signed_area_2d_c.restype = ctypes.c_double
|
|
|
|
if hasattr(_c_seam_lib, "find_path_mesh_intersection_c"):
|
|
_c_seam_lib.find_path_mesh_intersection_c.argtypes = [
|
|
ctypes.POINTER(ctypes.c_double),
|
|
ctypes.c_int,
|
|
ctypes.c_int,
|
|
ctypes.POINTER(ctypes.c_double),
|
|
ctypes.c_int,
|
|
ctypes.c_int,
|
|
ctypes.POINTER(ctypes.c_double),
|
|
ctypes.POINTER(ctypes.c_int),
|
|
ctypes.POINTER(ctypes.c_double),
|
|
ctypes.POINTER(ctypes.c_double)
|
|
]
|
|
_c_seam_lib.find_path_mesh_intersection_c.restype = ctypes.c_int
|
|
|
|
if hasattr(_c_seam_lib, "reposition_path_seam_c"):
|
|
_c_seam_lib.reposition_path_seam_c.argtypes = [
|
|
ctypes.POINTER(ctypes.c_double),
|
|
ctypes.c_int,
|
|
ctypes.c_int,
|
|
ctypes.c_int,
|
|
ctypes.c_double,
|
|
ctypes.POINTER(ctypes.c_double),
|
|
ctypes.POINTER(ctypes.c_double)
|
|
]
|
|
_c_seam_lib.reposition_path_seam_c.restype = ctypes.c_int
|
|
|
|
if hasattr(_c_seam_lib, "slice_triangles_at_z_c"):
|
|
_c_seam_lib.slice_triangles_at_z_c.argtypes = [
|
|
ctypes.POINTER(ctypes.c_double),
|
|
ctypes.c_int,
|
|
ctypes.c_double,
|
|
ctypes.POINTER(ctypes.c_double)
|
|
]
|
|
_c_seam_lib.slice_triangles_at_z_c.restype = ctypes.c_int
|
|
|
|
if hasattr(_c_seam_lib, "intersect_path_with_2d_segments_c"):
|
|
_c_seam_lib.intersect_path_with_2d_segments_c.argtypes = [
|
|
ctypes.POINTER(ctypes.c_double),
|
|
ctypes.c_int,
|
|
ctypes.c_int,
|
|
ctypes.POINTER(ctypes.c_double),
|
|
ctypes.c_int,
|
|
ctypes.c_double,
|
|
ctypes.c_int,
|
|
ctypes.POINTER(ctypes.c_double),
|
|
ctypes.POINTER(ctypes.c_int),
|
|
ctypes.POINTER(ctypes.c_double),
|
|
ctypes.POINTER(ctypes.c_double)
|
|
]
|
|
_c_seam_lib.intersect_path_with_2d_segments_c.restype = ctypes.c_int
|
|
|
|
if hasattr(_c_seam_lib, "shift_path_seam_by_distance_c"):
|
|
_c_seam_lib.shift_path_seam_by_distance_c.argtypes = [
|
|
ctypes.POINTER(ctypes.c_double),
|
|
ctypes.c_int,
|
|
ctypes.c_int,
|
|
ctypes.c_double,
|
|
ctypes.POINTER(ctypes.c_double)
|
|
]
|
|
_c_seam_lib.shift_path_seam_by_distance_c.restype = ctypes.c_int
|
|
except Exception:
|
|
_c_seam_lib = None
|
|
|
|
|
|
def weld_vertices(tris: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
"""
|
|
Given an array of triangles of shape (F, 3, 3) or (F, 4, 3), welds identical vertices.
|
|
Returns (unique_verts, faces) where unique_verts is (V, 3) float32 and faces is (F, 3) uint32.
|
|
"""
|
|
flat = np.ascontiguousarray(tris[:, :3, :].reshape(-1, 3), dtype=np.float32)
|
|
num_verts = len(flat)
|
|
if num_verts == 0:
|
|
return np.empty((0, 3), dtype=np.float32), np.empty((0, 3), dtype=np.uint32)
|
|
|
|
if _c_seam_lib is not None and hasattr(_c_seam_lib, "weld_vertices_c"):
|
|
try:
|
|
out_unique = np.empty((num_verts, 3), dtype=np.float32)
|
|
out_inv = np.empty(num_verts, dtype=np.uint32)
|
|
unique_count = int(_c_seam_lib.weld_vertices_c(
|
|
flat.ctypes.data_as(ctypes.POINTER(ctypes.c_float)),
|
|
num_verts,
|
|
out_unique.ctypes.data_as(ctypes.POINTER(ctypes.c_float)),
|
|
out_inv.ctypes.data_as(ctypes.POINTER(ctypes.c_uint32))
|
|
))
|
|
if unique_count >= 0:
|
|
return out_unique[:unique_count], out_inv.reshape(-1, 3)
|
|
except Exception:
|
|
pass
|
|
|
|
# NumPy fallback
|
|
void_dtype = np.dtype((np.void, flat.dtype.itemsize * flat.shape[1]))
|
|
flat_void = flat.view(void_dtype).ravel()
|
|
unique_voids, inv = np.unique(flat_void, return_inverse=True)
|
|
unique_verts = unique_voids.view(flat.dtype).reshape(-1, 3)
|
|
faces = inv.reshape(-1, 3).astype(np.uint32)
|
|
return unique_verts, faces
|
|
|
|
|
|
def find_path_seam(path: np.ndarray) -> int:
|
|
"""
|
|
Finds the optimal starting vertex index (seam) positioned at the back of the object (+Y).
|
|
Uses a weighted heuristic prioritizing maximum Y with a sharpness bonus for corners,
|
|
with deterministic tie-breaking for consistent vertical layer alignment.
|
|
"""
|
|
if len(path) < 4 or not is_closed_path(path):
|
|
return 0
|
|
|
|
pts = path[:-1]
|
|
M = len(pts)
|
|
if M < 3:
|
|
return 0
|
|
|
|
if _c_seam_lib is not None:
|
|
try:
|
|
c_pts = np.ascontiguousarray(pts, dtype=np.float64)
|
|
stride = int(c_pts.shape[1])
|
|
ptr = c_pts.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
|
|
return int(_c_seam_lib.find_sharpest_seam_c(ptr, M, stride, float(SCALE)))
|
|
except Exception:
|
|
pass
|
|
|
|
# NumPy fallback
|
|
x = pts[:, 0]
|
|
y = pts[:, 1]
|
|
y_min, y_max = float(np.min(y)), float(np.max(y))
|
|
y_range = y_max - y_min
|
|
bonus = min(2.0, 0.1 * y_range) if y_range > 0 else 2.0
|
|
|
|
prev_pts = np.roll(pts[:, :2], 1, axis=0)
|
|
next_pts = np.roll(pts[:, :2], -1, axis=0)
|
|
|
|
v_in = pts[:, :2] - prev_pts
|
|
v_out = next_pts - pts[:, :2]
|
|
|
|
l_in = np.linalg.norm(v_in, axis=1)
|
|
l_out = np.linalg.norm(v_out, axis=1)
|
|
|
|
valid = (l_in > 1e-7) & (l_out > 1e-7)
|
|
cos_ang = np.ones(M, dtype=np.float64)
|
|
cos_ang[valid] = np.sum(v_in[valid] * v_out[valid], axis=1) / (l_in[valid] * l_out[valid])
|
|
cos_ang = np.clip(cos_ang, -1.0, 1.0)
|
|
sharpness = 1.0 - cos_ang
|
|
|
|
scores = y + sharpness * bonus
|
|
# Deterministic tie-break: highest score, smallest x
|
|
return int(np.lexsort((x, -scores))[0])
|
|
|
|
|
|
def align_path_seam_sharpest(path: np.ndarray) -> np.ndarray:
|
|
"""
|
|
Reorders a closed 2D/3D polygon path (N, 3) or (N, 2) so that its starting vertex (seam)
|
|
is located at the back of the object (highest Y with corner preference).
|
|
|
|
If path is open or has fewer than 3 distinct vertices, returns path unchanged.
|
|
"""
|
|
if len(path) < 4 or not is_closed_path(path):
|
|
return path
|
|
|
|
M = len(path) - 1
|
|
best_idx = find_path_seam(path)
|
|
if best_idx == 0:
|
|
return path
|
|
|
|
res = np.empty_like(path)
|
|
split = M - best_idx
|
|
res[:split] = path[best_idx:-1]
|
|
res[split:] = path[:best_idx + 1]
|
|
return res
|
|
|
|
|
|
def extract_mesh_triangles(mesh_input: Any) -> np.ndarray:
|
|
"""
|
|
Extracts 3D triangle vertices (F, 3, 3) from a SlicingObject, SlicingCollection,
|
|
SlicingGroup, list of geometries, bpy Object, or raw numpy array.
|
|
"""
|
|
if mesh_input is None:
|
|
return np.empty((0, 3, 3), dtype=np.float64)
|
|
|
|
if isinstance(mesh_input, np.ndarray):
|
|
if mesh_input.ndim == 3 and mesh_input.shape[1] >= 3 and mesh_input.shape[2] == 3:
|
|
return np.ascontiguousarray(mesh_input[:, :3, :], dtype=np.float64)
|
|
return np.empty((0, 3, 3), dtype=np.float64)
|
|
|
|
objects = []
|
|
if hasattr(mesh_input, "collections"): # SlicingGroup
|
|
for c in mesh_input.collections.values():
|
|
objects.extend(getattr(c, "objects", []))
|
|
elif hasattr(mesh_input, "objects"): # SlicingCollection
|
|
objects.extend(mesh_input.objects)
|
|
elif hasattr(mesh_input, "mesh"): # SlicingObject
|
|
objects.append(mesh_input)
|
|
elif isinstance(mesh_input, list):
|
|
for item in mesh_input:
|
|
if hasattr(item, "collections"):
|
|
for c in item.collections.values():
|
|
objects.extend(getattr(c, "objects", []))
|
|
elif hasattr(item, "objects"):
|
|
objects.extend(item.objects)
|
|
elif hasattr(item, "mesh"):
|
|
objects.append(item)
|
|
elif hasattr(mesh_input, "type") and getattr(mesh_input, "type") == 'MESH':
|
|
from ..infra.blender.blender_mesh_capture import SlicingObject
|
|
objects.append(SlicingObject(mesh_input))
|
|
|
|
if not objects:
|
|
return np.empty((0, 3, 3), dtype=np.float64)
|
|
|
|
valid_meshes = [o.mesh[:, :3, :] for o in objects if hasattr(o, "mesh") and o.mesh is not None and o.mesh.size > 0]
|
|
if not valid_meshes:
|
|
return np.empty((0, 3, 3), dtype=np.float64)
|
|
|
|
return np.ascontiguousarray(np.concatenate(valid_meshes, axis=0), dtype=np.float64)
|
|
|
|
|
|
def extract_mesh_transforms(mesh_input: Any) -> list[dict[str, Any]]:
|
|
"""
|
|
Extracts accumulated transform dicts from a SlicingObject, SlicingCollection,
|
|
SlicingGroup, or list of objects.
|
|
"""
|
|
if mesh_input is None:
|
|
return []
|
|
if hasattr(mesh_input, "transforms") and isinstance(mesh_input.transforms, list) and mesh_input.transforms:
|
|
return [dict(t) for t in mesh_input.transforms if isinstance(t, dict)]
|
|
if hasattr(mesh_input, "collections"):
|
|
for c in mesh_input.collections.values():
|
|
if hasattr(c, "transforms") and c.transforms:
|
|
return [dict(t) for t in c.transforms if isinstance(t, dict)]
|
|
for o in getattr(c, "objects", []):
|
|
if hasattr(o, "transforms") and o.transforms:
|
|
return [dict(t) for t in o.transforms if isinstance(t, dict)]
|
|
if hasattr(mesh_input, "objects"):
|
|
for o in mesh_input.objects:
|
|
if hasattr(o, "transforms") and o.transforms:
|
|
return [dict(t) for t in o.transforms if isinstance(t, dict)]
|
|
if isinstance(mesh_input, list):
|
|
for item in mesh_input:
|
|
trans = extract_mesh_transforms(item)
|
|
if trans:
|
|
return trans
|
|
return []
|
|
|
|
|
|
def compute_delta_transforms(
|
|
path_transforms: list[dict[str, Any]] | None,
|
|
mesh_transforms: list[dict[str, Any]] | None
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Computes the difference between path_transforms and mesh_transforms,
|
|
so that only missing transforms are applied to the cutting mesh.
|
|
Prevents double-transformation when the mesh was already transformed upstream.
|
|
"""
|
|
if not path_transforms:
|
|
return []
|
|
if not mesh_transforms:
|
|
return path_transforms
|
|
|
|
# Consolidate path transforms
|
|
p_t = [0.0, 0.0, 0.0]
|
|
p_r = [0.0, 0.0, 0.0]
|
|
p_s = [1.0, 1.0, 1.0]
|
|
p_angle_deg = 0.0
|
|
p_center = [0.0, 0.0, 0.0]
|
|
|
|
for t in path_transforms:
|
|
if not isinstance(t, dict):
|
|
continue
|
|
tr = t.get("translation", t.get("offset", (0.0, 0.0, 0.0)))
|
|
rot = t.get("rotation", (0.0, 0.0, 0.0))
|
|
sc = t.get("scale", (1.0, 1.0, 1.0))
|
|
p_t[0] += float(tr[0])
|
|
p_t[1] += float(tr[1])
|
|
p_t[2] += float(tr[2])
|
|
p_r[0] += float(rot[0])
|
|
p_r[1] += float(rot[1])
|
|
p_r[2] += float(rot[2])
|
|
p_s[0] *= float(sc[0])
|
|
p_s[1] *= float(sc[1])
|
|
p_s[2] *= float(sc[2])
|
|
if "angle_deg" in t:
|
|
p_angle_deg += float(t["angle_deg"])
|
|
if "center" in t:
|
|
p_center = [float(v) for v in t["center"]]
|
|
|
|
# Consolidate mesh transforms
|
|
m_t = [0.0, 0.0, 0.0]
|
|
m_r = [0.0, 0.0, 0.0]
|
|
m_s = [1.0, 1.0, 1.0]
|
|
m_angle_deg = 0.0
|
|
m_center = [0.0, 0.0, 0.0]
|
|
|
|
for t in mesh_transforms:
|
|
if not isinstance(t, dict):
|
|
continue
|
|
tr = t.get("translation", t.get("offset", (0.0, 0.0, 0.0)))
|
|
rot = t.get("rotation", (0.0, 0.0, 0.0))
|
|
sc = t.get("scale", (1.0, 1.0, 1.0))
|
|
m_t[0] += float(tr[0])
|
|
m_t[1] += float(tr[1])
|
|
m_t[2] += float(tr[2])
|
|
m_r[0] += float(rot[0])
|
|
m_r[1] += float(rot[1])
|
|
m_r[2] += float(rot[2])
|
|
m_s[0] *= float(sc[0])
|
|
m_s[1] *= float(sc[1])
|
|
m_s[2] *= float(sc[2])
|
|
if "angle_deg" in t:
|
|
m_angle_deg += float(t["angle_deg"])
|
|
if "center" in t:
|
|
m_center = [float(v) for v in t["center"]]
|
|
|
|
# Compute deltas
|
|
d_t = (p_t[0] - m_t[0], p_t[1] - m_t[1], p_t[2] - m_t[2])
|
|
d_r = (p_r[0] - m_r[0], p_r[1] - m_r[1], p_r[2] - m_r[2])
|
|
d_s = (
|
|
p_s[0] / m_s[0] if abs(m_s[0]) > 1e-12 else 1.0,
|
|
p_s[1] / m_s[1] if abs(m_s[1]) > 1e-12 else 1.0,
|
|
p_s[2] / m_s[2] if abs(m_s[2]) > 1e-12 else 1.0,
|
|
)
|
|
d_angle = p_angle_deg - m_angle_deg
|
|
|
|
delta_transforms: list[dict[str, Any]] = []
|
|
|
|
has_scale = any(abs(v - 1.0) > 1e-6 for v in d_s)
|
|
has_rot = any(abs(v) > 1e-6 for v in d_r)
|
|
has_trans = any(abs(v) > 1e-6 for v in d_t)
|
|
has_angle = abs(d_angle) > 1e-6
|
|
|
|
if has_scale or has_rot or has_trans or has_angle:
|
|
dt: dict[str, Any] = {"type": "transform"}
|
|
if has_scale:
|
|
dt["scale"] = d_s
|
|
if has_rot:
|
|
dt["rotation"] = d_r
|
|
if has_trans:
|
|
dt["translation"] = d_t
|
|
if has_angle:
|
|
dt["angle_deg"] = d_angle
|
|
dt["center"] = p_center
|
|
delta_transforms.append(dt)
|
|
|
|
return delta_transforms
|
|
|
|
|
|
def apply_transforms_to_triangles(
|
|
tris: np.ndarray,
|
|
transforms: list[dict[str, Any]] | None
|
|
) -> np.ndarray:
|
|
"""
|
|
Applies a list of transform dicts (translation, rotation, scale, angle_deg)
|
|
to a 3D triangle array of shape (F, 3, 3) or (F, 4, 3).
|
|
"""
|
|
if tris.size == 0 or not transforms:
|
|
return tris
|
|
|
|
out_tris = tris.copy()
|
|
for t in transforms:
|
|
if not isinstance(t, dict):
|
|
continue
|
|
|
|
# 1. Scale
|
|
if "scale" in t:
|
|
sc = t["scale"]
|
|
if any(float(v) != 1.0 for v in sc):
|
|
s_arr = np.array(sc, dtype=np.float64)
|
|
out_tris[:, :3, :] *= s_arr
|
|
|
|
# 2. Rotation
|
|
if "angle_deg" in t:
|
|
ang_deg = float(t["angle_deg"])
|
|
if ang_deg != 0.0:
|
|
rad = math.radians(ang_deg)
|
|
cos_a = math.cos(rad)
|
|
sin_a = math.sin(rad)
|
|
center = t.get("center", (0.0, 0.0, 0.0))
|
|
c_arr = np.array(center, dtype=np.float64)[:2]
|
|
|
|
pts_2d = out_tris[:, :3, :2] - c_arr
|
|
rot_mat = np.array([[cos_a, -sin_a], [sin_a, cos_a]], dtype=np.float64)
|
|
out_tris[:, :3, :2] = (pts_2d @ rot_mat.T) + c_arr
|
|
elif "rotation" in t:
|
|
rot = t["rotation"]
|
|
if any(float(v) != 0.0 for v in rot):
|
|
from .geometry import euler_rotation_matrix
|
|
R = euler_rotation_matrix(float(rot[0]), float(rot[1]), float(rot[2]))
|
|
center = t.get("center", None)
|
|
if center is not None:
|
|
c_arr = np.array(center, dtype=np.float64)
|
|
out_tris[:, :3, :] = (out_tris[:, :3, :] - c_arr) @ R.T + c_arr
|
|
else:
|
|
out_tris[:, :3, :] = out_tris[:, :3, :] @ R.T
|
|
|
|
# 3. Translation / Offset
|
|
tr = t.get("translation", t.get("offset", None))
|
|
if tr is not None and any(float(v) != 0.0 for v in tr):
|
|
t_arr = np.array(tr, dtype=np.float64)
|
|
out_tris[:, :3, :] += t_arr
|
|
|
|
return out_tris
|
|
|
|
|
|
def _intersect_segments_2d_py(a: tuple[float, float], b: tuple[float, float], c: tuple[float, float], d: tuple[float, float]) -> float | None:
|
|
denom = (b[0] - a[0]) * (d[1] - c[1]) - (b[1] - a[1]) * (d[0] - c[0])
|
|
if abs(denom) < 1e-12:
|
|
return None
|
|
t = ((c[0] - a[0]) * (d[1] - c[1]) - (c[1] - a[1]) * (d[0] - c[0])) / denom
|
|
u = ((c[0] - a[0]) * (b[1] - a[1]) - (c[1] - a[1]) * (b[0] - a[0])) / denom
|
|
if -1e-6 <= t <= 1.0 + 1e-6 and -1e-6 <= u <= 1.0 + 1e-6:
|
|
return max(0.0, min(1.0, t))
|
|
return None
|
|
|
|
|
|
def _intersect_segment_triangle_py(p0: np.ndarray, p1: np.ndarray, v0: np.ndarray, v1: np.ndarray, v2: np.ndarray) -> tuple[float, np.ndarray] | None:
|
|
e1 = v1 - v0
|
|
e2 = v2 - v0
|
|
n = np.cross(e1, e2)
|
|
n_len_sq = float(np.dot(n, n))
|
|
if n_len_sq < 1e-14:
|
|
return None
|
|
|
|
d0 = float(np.dot(p0 - v0, n))
|
|
d1 = float(np.dot(p1 - v0, n))
|
|
|
|
if (d0 > 1e-6 and d1 > 1e-6) or (d0 < -1e-6 and d1 < -1e-6):
|
|
return None
|
|
|
|
denom = d0 - d1
|
|
if abs(denom) > 1e-12:
|
|
t = d0 / denom
|
|
if t < -1e-6 or t > 1.0 + 1e-6:
|
|
return None
|
|
t = max(0.0, min(1.0, t))
|
|
ipt = p0 + t * (p1 - p0)
|
|
|
|
c0 = np.cross(v1 - v0, ipt - v0)
|
|
c1 = np.cross(v2 - v1, ipt - v1)
|
|
c2 = np.cross(v0 - v2, ipt - v2)
|
|
|
|
tol = -1e-6 * n_len_sq
|
|
if np.dot(c0, n) >= tol and np.dot(c1, n) >= tol and np.dot(c2, n) >= tol:
|
|
return t, ipt
|
|
elif abs(d0) < 1e-6:
|
|
# Coplanar segment - project along dominant normal axis
|
|
ax0, ax1 = 0, 1
|
|
if abs(n[0]) >= abs(n[1]) and abs(n[0]) >= abs(n[2]):
|
|
ax0, ax1 = 1, 2
|
|
elif abs(n[1]) >= abs(n[0]) and abs(n[1]) >= abs(n[2]):
|
|
ax0, ax1 = 0, 2
|
|
verts = [v0, v1, v2]
|
|
best_t = None
|
|
for e in range(3):
|
|
va = verts[e]
|
|
vb = verts[(e + 1) % 3]
|
|
t_cand = _intersect_segments_2d_py(
|
|
(float(p0[ax0]), float(p0[ax1])), (float(p1[ax0]), float(p1[ax1])),
|
|
(float(va[ax0]), float(va[ax1])), (float(vb[ax0]), float(vb[ax1]))
|
|
)
|
|
if t_cand is not None:
|
|
if best_t is None or t_cand < best_t:
|
|
best_t = t_cand
|
|
if best_t is not None:
|
|
return best_t, p0 + best_t * (p1 - p0)
|
|
return None
|
|
|
|
|
|
def _slice_triangles_at_z_np(active_tris: np.ndarray, pz: float) -> np.ndarray:
|
|
if len(active_tris) == 0:
|
|
return np.empty((0, 4), dtype=np.float64)
|
|
|
|
v0 = active_tris[:, 0, :]
|
|
v1 = active_tris[:, 1, :]
|
|
v2 = active_tris[:, 2, :]
|
|
|
|
edges = [(v0, v1), (v1, v2), (v2, v0)]
|
|
pts_list = []
|
|
for va, vb in edges:
|
|
dza = va[:, 2] - pz
|
|
dzb = vb[:, 2] - pz
|
|
cross = (dza * dzb <= 0) & (np.abs(vb[:, 2] - va[:, 2]) > 1e-12)
|
|
denom = np.where(np.abs(vb[:, 2] - va[:, 2]) > 1e-12, vb[:, 2] - va[:, 2], 1.0)
|
|
u = np.clip((pz - va[:, 2]) / denom, 0.0, 1.0)
|
|
qx = va[:, 0] + u * (vb[:, 0] - va[:, 0])
|
|
qy = va[:, 1] + u * (vb[:, 1] - va[:, 1])
|
|
q = np.stack([qx, qy], axis=-1)
|
|
pts_list.append((cross, q))
|
|
|
|
c0, q0 = pts_list[0]
|
|
c1, q1 = pts_list[1]
|
|
c2, q2 = pts_list[2]
|
|
|
|
segs = []
|
|
for idx in range(len(active_tris)):
|
|
p_cand = []
|
|
if c0[idx]:
|
|
p_cand.append(q0[idx])
|
|
if c1[idx] and (len(p_cand) == 0 or np.linalg.norm(q1[idx] - p_cand[0]) > 1e-6):
|
|
p_cand.append(q1[idx])
|
|
if c2[idx] and (len(p_cand) == 0 or (np.linalg.norm(q2[idx] - p_cand[0]) > 1e-6 and (len(p_cand) < 2 or np.linalg.norm(q2[idx] - p_cand[1]) > 1e-6))):
|
|
p_cand.append(q2[idx])
|
|
if len(p_cand) >= 2:
|
|
segs.append([p_cand[0][0], p_cand[0][1], p_cand[1][0], p_cand[1][1]])
|
|
|
|
if not segs:
|
|
return np.empty((0, 4), dtype=np.float64)
|
|
return np.array(segs, dtype=np.float64)
|
|
|
|
|
|
def _intersect_path_2d_segs_np(
|
|
path: np.ndarray,
|
|
mesh_segs: np.ndarray,
|
|
pz: float,
|
|
mode_int: int,
|
|
ref_pt: np.ndarray
|
|
) -> tuple[int, float, np.ndarray] | None:
|
|
if len(path) < 4 or len(mesh_segs) == 0:
|
|
return None
|
|
pts = path[:, :3]
|
|
P_start = pts[:-1, :2] # (M, 2)
|
|
P_end = pts[1:, :2] # (M, 2)
|
|
|
|
Q_start = mesh_segs[:, :2] # (K, 2)
|
|
Q_end = mesh_segs[:, 2:] # (K, 2)
|
|
|
|
A = P_start[:, np.newaxis, :] # (M, 1, 2)
|
|
B = P_end[:, np.newaxis, :] # (M, 1, 2)
|
|
C = Q_start[np.newaxis, :, :] # (1, K, 2)
|
|
D = Q_end[np.newaxis, :, :] # (1, K, 2)
|
|
|
|
dx_AB = B[..., 0] - A[..., 0]
|
|
dy_AB = B[..., 1] - A[..., 1]
|
|
dx_CD = D[..., 0] - C[..., 0]
|
|
dy_CD = D[..., 1] - C[..., 1]
|
|
dx_AC = C[..., 0] - A[..., 0]
|
|
dy_AC = C[..., 1] - A[..., 1]
|
|
|
|
denom = dx_AB * dy_CD - dy_AB * dx_CD
|
|
valid = np.abs(denom) > 1e-12
|
|
|
|
denom_safe = np.where(valid, denom, 1.0)
|
|
t_mat = (dx_AC * dy_CD - dy_AC * dx_CD) / denom_safe
|
|
u_mat = (dx_AC * dy_AB - dy_AC * dx_AB) / denom_safe
|
|
|
|
hit_mask = valid & (t_mat >= -1e-6) & (t_mat <= 1.0 + 1e-6) & (u_mat >= -1e-6) & (u_mat <= 1.0 + 1e-6)
|
|
hit_m, hit_k = np.where(hit_mask)
|
|
|
|
if len(hit_m) > 0:
|
|
t_vals = np.clip(t_mat[hit_m, hit_k], 0.0, 1.0)
|
|
p0_hits = pts[hit_m]
|
|
p1_hits = pts[hit_m + 1]
|
|
ipts = p0_hits + t_vals[:, np.newaxis] * (p1_hits - p0_hits)
|
|
|
|
if mode_int == 1: # FIRST
|
|
return int(hit_m[0]), float(t_vals[0]), ipts[0]
|
|
|
|
dists_sq = np.sum((ipts - ref_pt) ** 2, axis=1)
|
|
best_idx = int(np.argmin(dists_sq))
|
|
return int(hit_m[best_idx]), float(t_vals[best_idx]), ipts[best_idx]
|
|
|
|
return None
|
|
|
|
|
|
def slice_triangles_at_z(tris: np.ndarray, pz: float) -> np.ndarray:
|
|
"""
|
|
Slices a 3D triangle array (F, 3, 3) or (F, 4, 3) at a specific Z height.
|
|
Returns 2D line segments of shape (K, 4) where each segment is [x0, y0, x1, y1].
|
|
"""
|
|
if len(tris) == 0:
|
|
return np.empty((0, 4), dtype=np.float64)
|
|
|
|
if _c_seam_lib is not None and hasattr(_c_seam_lib, "slice_triangles_at_z_c"):
|
|
try:
|
|
tris_c = np.ascontiguousarray(tris[:, :3, :], dtype=np.float64)
|
|
num_tris = len(tris_c)
|
|
out_segs = np.empty((num_tris, 4), dtype=np.float64)
|
|
num_segs = _c_seam_lib.slice_triangles_at_z_c(
|
|
tris_c.ctypes.data_as(ctypes.POINTER(ctypes.c_double)),
|
|
num_tris,
|
|
ctypes.c_double(pz),
|
|
out_segs.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
|
|
)
|
|
return out_segs[:num_segs]
|
|
except Exception:
|
|
pass
|
|
|
|
return _slice_triangles_at_z_np(tris, pz)
|
|
|
|
|
|
def intersect_path_with_2d_segments(
|
|
path: np.ndarray,
|
|
mesh_segs: np.ndarray,
|
|
pz: float,
|
|
mode: str = "closest",
|
|
mesh_center: np.ndarray | None = None
|
|
) -> tuple[int, float, np.ndarray] | None:
|
|
"""
|
|
Intersects a closed planar path with pre-computed 2D line segments at height pz.
|
|
Returns (segment_index, t_parameter, intersection_point) or None.
|
|
"""
|
|
if len(path) < 4 or len(mesh_segs) == 0:
|
|
return None
|
|
|
|
pts = np.ascontiguousarray(path[:, :3], dtype=np.float64)
|
|
segs = np.ascontiguousarray(mesh_segs, dtype=np.float64)
|
|
num_pts = len(pts)
|
|
num_segs = len(segs)
|
|
|
|
mode_int = 0
|
|
if mode.lower() == "first":
|
|
mode_int = 1
|
|
elif mode.lower() == "centroid":
|
|
mode_int = 2
|
|
|
|
ref_pt = pts[0]
|
|
if mode_int == 2:
|
|
ref_pt = mesh_center if mesh_center is not None else np.array([0.0, 0.0, pz], dtype=np.float64)
|
|
ref_pt_c = np.ascontiguousarray(ref_pt, dtype=np.float64)
|
|
|
|
if _c_seam_lib is not None and hasattr(_c_seam_lib, "intersect_path_with_2d_segments_c"):
|
|
try:
|
|
out_seg = ctypes.c_int(-1)
|
|
out_t = ctypes.c_double(0.0)
|
|
out_pt = (ctypes.c_double * 3)()
|
|
|
|
found = _c_seam_lib.intersect_path_with_2d_segments_c(
|
|
pts.ctypes.data_as(ctypes.POINTER(ctypes.c_double)),
|
|
num_pts,
|
|
3,
|
|
segs.ctypes.data_as(ctypes.POINTER(ctypes.c_double)),
|
|
num_segs,
|
|
ctypes.c_double(pz),
|
|
mode_int,
|
|
ref_pt_c.ctypes.data_as(ctypes.POINTER(ctypes.c_double)),
|
|
ctypes.byref(out_seg),
|
|
ctypes.byref(out_t),
|
|
out_pt
|
|
)
|
|
if found:
|
|
return int(out_seg.value), float(out_t.value), np.array([out_pt[0], out_pt[1], out_pt[2]], dtype=np.float64)
|
|
return None
|
|
except Exception:
|
|
pass
|
|
|
|
return _intersect_path_2d_segs_np(pts, segs, pz, mode_int, ref_pt_c)
|
|
|
|
|
|
def find_path_mesh_intersection(
|
|
path: np.ndarray,
|
|
tri_verts: np.ndarray,
|
|
mode: str = "closest",
|
|
mesh_center: np.ndarray | None = None
|
|
) -> tuple[int, float, np.ndarray] | None:
|
|
"""
|
|
Finds intersection between a closed polygon path and a mesh of triangles.
|
|
Returns (segment_index, t_parameter, intersection_point) or None.
|
|
"""
|
|
if len(path) < 4 or not is_closed_path(path) or len(tri_verts) == 0:
|
|
return None
|
|
|
|
pts = np.ascontiguousarray(path[:, :3], dtype=np.float64)
|
|
tris = np.ascontiguousarray(tri_verts[:, :3, :], dtype=np.float64)
|
|
num_pts = len(pts)
|
|
num_tris = len(tris)
|
|
stride = 3
|
|
|
|
mode_int = 0
|
|
if mode.lower() == "first":
|
|
mode_int = 1
|
|
elif mode.lower() == "centroid":
|
|
mode_int = 2
|
|
|
|
ref_pt = pts[0]
|
|
if mode_int == 2:
|
|
ref_pt = mesh_center if mesh_center is not None else np.mean(tris.reshape(-1, 3), axis=0)
|
|
ref_pt_c = np.ascontiguousarray(ref_pt, dtype=np.float64)
|
|
|
|
if _c_seam_lib is not None and hasattr(_c_seam_lib, "find_path_mesh_intersection_c"):
|
|
try:
|
|
out_seg = ctypes.c_int(-1)
|
|
out_t = ctypes.c_double(0.0)
|
|
out_pt = (ctypes.c_double * 3)()
|
|
|
|
found = _c_seam_lib.find_path_mesh_intersection_c(
|
|
pts.ctypes.data_as(ctypes.POINTER(ctypes.c_double)),
|
|
num_pts,
|
|
stride,
|
|
tris.ctypes.data_as(ctypes.POINTER(ctypes.c_double)),
|
|
num_tris,
|
|
mode_int,
|
|
ref_pt_c.ctypes.data_as(ctypes.POINTER(ctypes.c_double)),
|
|
ctypes.byref(out_seg),
|
|
ctypes.byref(out_t),
|
|
out_pt
|
|
)
|
|
if found:
|
|
return int(out_seg.value), float(out_t.value), np.array([out_pt[0], out_pt[1], out_pt[2]], dtype=np.float64)
|
|
return None
|
|
except Exception:
|
|
pass
|
|
|
|
# 2. Vectorized / Planar Fallback
|
|
p_min = np.min(pts, axis=0) - 1e-4
|
|
p_max = np.max(pts, axis=0) + 1e-4
|
|
|
|
t_min = np.min(tris, axis=1)
|
|
t_max = np.max(tris, axis=1)
|
|
|
|
active_mask = np.all(t_max >= p_min, axis=1) & np.all(t_min <= p_max, axis=1)
|
|
if not np.any(active_mask):
|
|
return None
|
|
|
|
active_tris = tris[active_mask]
|
|
pz = pts[0, 2]
|
|
is_planar = (p_max[2] - p_min[2]) < 1e-3
|
|
|
|
if is_planar:
|
|
segs = slice_triangles_at_z(active_tris, pz)
|
|
if len(segs) > 0:
|
|
return intersect_path_with_2d_segments(path, segs, pz, mode=mode, mesh_center=mesh_center)
|
|
return None
|
|
|
|
# 3D fallback for non-planar path
|
|
best_hit = None
|
|
best_dist = 1e30
|
|
M = len(pts) - 1
|
|
for i in range(M):
|
|
p0 = pts[i]
|
|
p1 = pts[i + 1]
|
|
for tr in active_tris:
|
|
hit = _intersect_segment_triangle_py(p0, p1, tr[0], tr[1], tr[2])
|
|
if hit is not None:
|
|
t_cand, ipt = hit
|
|
if mode_int == 1:
|
|
return i, t_cand, ipt
|
|
d_sq = float(np.sum((ipt - ref_pt) ** 2))
|
|
if d_sq < best_dist:
|
|
best_dist = d_sq
|
|
best_hit = (i, t_cand, ipt)
|
|
return best_hit
|
|
|
|
|
|
def reposition_path_seam(
|
|
path: np.ndarray,
|
|
seg_idx: int,
|
|
t: float,
|
|
intersect_pt: np.ndarray,
|
|
extrusion_rate: np.ndarray | None = None,
|
|
feedrate: np.ndarray | None = None,
|
|
extra_attributes: dict[str, np.ndarray] | None = None
|
|
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray | None, dict[str, np.ndarray] | None]:
|
|
"""
|
|
Reposition the seam of a closed path at the given intersection (seg_idx, t, intersect_pt).
|
|
Properly rotates/interpolates per-vertex attributes (extrusion_rate, feedrate, extra_attributes).
|
|
"""
|
|
if len(path) < 4:
|
|
return path, extrusion_rate, feedrate, extra_attributes
|
|
|
|
M = len(path) - 1
|
|
k = seg_idx
|
|
|
|
# Check snapping
|
|
if t <= 1e-6:
|
|
if k == 0:
|
|
return path, extrusion_rate, feedrate, extra_attributes
|
|
new_p = np.concatenate([path[k:-1], path[:k+1]], axis=0)
|
|
new_ext = np.concatenate([extrusion_rate[k:-1], extrusion_rate[:k+1]]) if extrusion_rate is not None and len(extrusion_rate) == len(path) else extrusion_rate
|
|
new_feed = np.concatenate([feedrate[k:-1], feedrate[:k+1]]) if feedrate is not None and len(feedrate) == len(path) else feedrate
|
|
new_extra = {}
|
|
if extra_attributes:
|
|
for attr_name, attr_arr in extra_attributes.items():
|
|
if len(attr_arr) == len(path):
|
|
new_extra[attr_name] = np.concatenate([attr_arr[k:-1], attr_arr[:k+1]])
|
|
else:
|
|
new_extra[attr_name] = attr_arr
|
|
return new_p, new_ext, new_feed, new_extra if extra_attributes else extra_attributes
|
|
|
|
if 1.0 - t <= 1e-6:
|
|
next_k = (k + 1) % M
|
|
if next_k == 0:
|
|
return path, extrusion_rate, feedrate, extra_attributes
|
|
new_p = np.concatenate([path[next_k:-1], path[:next_k+1]], axis=0)
|
|
new_ext = np.concatenate([extrusion_rate[next_k:-1], extrusion_rate[:next_k+1]]) if extrusion_rate is not None and len(extrusion_rate) == len(path) else extrusion_rate
|
|
new_feed = np.concatenate([feedrate[next_k:-1], feedrate[:next_k+1]]) if feedrate is not None and len(feedrate) == len(path) else feedrate
|
|
new_extra = {}
|
|
if extra_attributes:
|
|
for attr_name, attr_arr in extra_attributes.items():
|
|
if len(attr_arr) == len(path):
|
|
new_extra[attr_name] = np.concatenate([attr_arr[next_k:-1], attr_arr[:next_k+1]])
|
|
else:
|
|
new_extra[attr_name] = attr_arr
|
|
return new_p, new_ext, new_feed, new_extra if extra_attributes else extra_attributes
|
|
|
|
# Insert intersection point I
|
|
I_pt = np.array(intersect_pt, dtype=path.dtype)
|
|
if I_pt.shape != path[0].shape:
|
|
I_full = np.zeros_like(path[0])
|
|
I_full[:min(len(I_full), len(I_pt))] = I_pt[:min(len(I_full), len(I_pt))]
|
|
I_pt = I_full
|
|
|
|
new_p = np.concatenate([[I_pt], path[k+1:-1], path[:k+1], [I_pt]], axis=0)
|
|
|
|
new_ext = None
|
|
if extrusion_rate is not None and len(extrusion_rate) == len(path):
|
|
ext_I = (1.0 - t) * extrusion_rate[k] + t * extrusion_rate[k + 1]
|
|
new_ext = np.concatenate([[ext_I], extrusion_rate[k+1:-1], extrusion_rate[:k+1], [ext_I]])
|
|
else:
|
|
new_ext = extrusion_rate
|
|
|
|
new_feed = None
|
|
if feedrate is not None and len(feedrate) == len(path):
|
|
feed_I = (1.0 - t) * feedrate[k] + t * feedrate[k + 1]
|
|
new_feed = np.concatenate([[feed_I], feedrate[k+1:-1], feedrate[:k+1], [feed_I]])
|
|
else:
|
|
new_feed = feedrate
|
|
|
|
new_extra = {}
|
|
if extra_attributes:
|
|
for attr_name, attr_arr in extra_attributes.items():
|
|
if len(attr_arr) == len(path):
|
|
attr_I = (1.0 - t) * attr_arr[k] + t * attr_arr[k + 1]
|
|
new_extra[attr_name] = np.concatenate([[attr_I], attr_arr[k+1:-1], attr_arr[:k+1], [attr_I]])
|
|
else:
|
|
new_extra[attr_name] = attr_arr
|
|
|
|
return new_p, new_ext, new_feed, new_extra if extra_attributes else extra_attributes
|
|
|
|
|
|
def shift_path_seam_by_distance(
|
|
path: np.ndarray,
|
|
dist_mm: float,
|
|
extrusion_rate: np.ndarray | None = None,
|
|
feedrate: np.ndarray | None = None,
|
|
extra_attributes: dict[str, np.ndarray] | None = None
|
|
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray | None, dict[str, np.ndarray] | None]:
|
|
"""
|
|
Shifts the starting point (seam) of a closed path along its perimeter by dist_mm.
|
|
Positive dist_mm moves forward along the path direction, negative moves backward.
|
|
Preserves and interpolates per-vertex attributes.
|
|
"""
|
|
if len(path) < 4 or not is_closed_path(path) or abs(dist_mm) < 1e-6:
|
|
return path, extrusion_rate, feedrate, extra_attributes
|
|
|
|
pts = path[:, :3]
|
|
diffs = np.diff(pts, axis=0)
|
|
seg_lens = np.sqrt(np.sum(diffs ** 2, axis=1))
|
|
total_len = float(np.sum(seg_lens))
|
|
|
|
if total_len <= 1e-6:
|
|
return path, extrusion_rate, feedrate, extra_attributes
|
|
|
|
target_d = dist_mm % total_len
|
|
if target_d < 0:
|
|
target_d += total_len
|
|
|
|
if target_d <= 1e-6 or abs(target_d - total_len) <= 1e-6:
|
|
return path, extrusion_rate, feedrate, extra_attributes
|
|
|
|
cum = np.concatenate(([0.0], np.cumsum(seg_lens)))
|
|
seg_idx = int(np.searchsorted(cum, target_d, side='right') - 1)
|
|
seg_idx = max(0, min(len(seg_lens) - 1, seg_idx))
|
|
|
|
l_seg = seg_lens[seg_idx]
|
|
if l_seg > 1e-7:
|
|
t = float((target_d - cum[seg_idx]) / l_seg)
|
|
else:
|
|
t = 0.0
|
|
t = max(0.0, min(1.0, t))
|
|
|
|
p0 = path[seg_idx]
|
|
p1 = path[seg_idx + 1]
|
|
ipt = (1.0 - t) * p0 + t * p1
|
|
|
|
return reposition_path_seam(
|
|
path, seg_idx, t, ipt,
|
|
extrusion_rate=extrusion_rate,
|
|
feedrate=feedrate,
|
|
extra_attributes=extra_attributes
|
|
)
|
|
|
|
|
|
class SlicePaths(list[np.ndarray]):
|
|
groups: list[int]
|
|
extrusion_rate: list[np.ndarray]
|
|
feedrate: list[np.ndarray]
|
|
extra_attributes: dict[str, list[np.ndarray]]
|
|
layer_height: float
|
|
first_layer_height: float | None
|
|
transforms: list[dict[str, Any]]
|
|
pdf_object_properties: list[tuple[str, str]]
|
|
pdf_properties: PdfProperties
|
|
name: str
|
|
objects_info: dict[str, Any] | None
|
|
|
|
def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = [], extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None, layer_height: float = 0.2, first_layer_height: float | None = None, transforms: list[dict[str, Any]] | None = None, name: str = "", objects_info: dict[str, Any] | None = None):
|
|
super().__init__(paths)
|
|
|
|
self.groups = []
|
|
if groups: self.groups = list(groups)
|
|
else: self.groups = list(range(0,len(paths)))
|
|
|
|
self.extrusion_rate = []
|
|
if extrusion_rate:
|
|
self.extrusion_rate = list(extrusion_rate)
|
|
else:
|
|
self.extrusion_rate = [np.ones(len(p)) for p in paths]
|
|
|
|
self.feedrate = []
|
|
if feedrate:
|
|
self.feedrate = list(feedrate)
|
|
else:
|
|
self.feedrate = [np.zeros(len(p)) for p in paths]
|
|
|
|
self.extra_attributes = extra_attributes or {}
|
|
self.layer_height = layer_height
|
|
self.first_layer_height = first_layer_height
|
|
self.transforms = list(transforms) if transforms is not None else []
|
|
self.name = name
|
|
self.objects_info = copy.deepcopy(objects_info) if objects_info is not None else None
|
|
|
|
def copy_with(self, paths: list[np.ndarray], groups: list[int] | None = None, extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None) -> SlicePaths:
|
|
"""Returns a new SlicePaths object with new paths/groups, preserving all metadata and instance attributes."""
|
|
target_groups = groups if groups is not None else (self.groups if len(self.groups) == len(paths) else list(range(len(paths))))
|
|
|
|
if extrusion_rate is None or feedrate is None or extra_attributes is None:
|
|
calc_ext, calc_feed, calc_extra = _transfer_group_attributes(self, paths, target_groups)
|
|
if extrusion_rate is None:
|
|
extrusion_rate = calc_ext
|
|
if feedrate is None:
|
|
feedrate = calc_feed
|
|
if extra_attributes is None:
|
|
extra_attributes = calc_extra
|
|
|
|
new_obj = SlicePaths(paths, target_groups, extrusion_rate, feedrate, extra_attributes)
|
|
for k, v in self.__dict__.items():
|
|
if k not in ["groups", "extrusion_rate", "feedrate", "extra_attributes"]:
|
|
setattr(new_obj, k, v)
|
|
return new_obj
|
|
|
|
def offset(self, offset_vec: np.ndarray) -> SlicePaths:
|
|
"""Returns a new SlicePaths object shifted by the given vector."""
|
|
res = self.copy_with([p + offset_vec for p in self])
|
|
res.transforms = update_transform(getattr(self, "transforms", []), t_arr=offset_vec)
|
|
return res
|
|
|
|
def transform(self, matrix: np.ndarray) -> SlicePaths:
|
|
"""Applies a transformation matrix to all paths."""
|
|
res = self.copy_with([p @ matrix.T for p in self])
|
|
res.transforms = [dict(t) for t in getattr(self, "transforms", [])]
|
|
return res
|
|
|
|
def buffer(self, distance: float, miter: str = 'round', rotation: Any = None) -> SlicePaths:
|
|
"""Buffers (offsets) the paths. Handles optional rotation for non-planar buffering."""
|
|
p = self
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat = Euler(rotation).to_matrix().inverted()
|
|
p = self.transform(np.array(mat))
|
|
|
|
res_paths, res_groups = buffer_slice(p, distance, miter=miter)
|
|
new_paths = self.copy_with(res_paths, res_groups)
|
|
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat_inv = Euler(rotation).to_matrix()
|
|
new_paths = new_paths.transform(np.array(mat_inv))
|
|
|
|
return new_paths
|
|
|
|
def buffer_multiple(self, base_distance: float, count: int, width_step: float, miter: str = 'round', rotation: Any = None) -> SlicePaths:
|
|
"""Generates multiple concentric buffers and interleaves them by group (Z-layer), preserving all metadata."""
|
|
if not self or count <= 0:
|
|
return self.copy_with([])
|
|
|
|
p = self
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat = Euler(rotation).to_matrix().inverted()
|
|
p = self.transform(np.array(mat))
|
|
|
|
res_paths, res_groups = buffer_multiple_slice(p, base_distance, count, width_step, miter=miter)
|
|
new_paths = self.copy_with(res_paths, res_groups)
|
|
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat_inv = Euler(rotation).to_matrix()
|
|
new_paths = new_paths.transform(np.array(mat_inv))
|
|
|
|
return new_paths
|
|
|
|
def buffer_curve(self, distance: float, cap_style: str = 'round', join_style: str = 'round', single_sided: bool = False, rotation: Any = None) -> SlicePaths:
|
|
"""Buffers open curves using shapely. Handles optional rotation for non-planar buffering."""
|
|
p = self
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat = Euler(rotation).to_matrix().inverted()
|
|
p = self.transform(np.array(mat))
|
|
|
|
res_paths, res_groups = buffer_curve_slice(p, distance, cap_style=cap_style, join_style=join_style, single_sided=single_sided)
|
|
new_paths = self.copy_with(res_paths, res_groups)
|
|
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat_inv = Euler(rotation).to_matrix()
|
|
new_paths = new_paths.transform(np.array(mat_inv))
|
|
|
|
return new_paths
|
|
|
|
def offset_curve(self, distance: float, join_style: Literal['round', 'mitre'] = 'round', rotation: Any = None) -> SlicePaths:
|
|
"""Offsets open/closed curves by a distance using shapely. Handles optional rotation."""
|
|
p = self
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat = Euler(rotation).to_matrix().inverted()
|
|
p = self.transform(np.array(mat))
|
|
|
|
res_paths, res_groups = offset_curve_slice(p, distance, join_style=join_style)
|
|
new_paths = self.copy_with(res_paths, res_groups)
|
|
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat_inv = Euler(rotation).to_matrix()
|
|
new_paths = new_paths.transform(np.array(mat_inv))
|
|
|
|
return new_paths
|
|
|
|
def simplify(self, tolerance: float = 0.1, preserve_topology: bool = True) -> SlicePaths:
|
|
"""Simplifies paths using Shapely while conserving all per-path metadata and attributes."""
|
|
if not self or tolerance <= 0:
|
|
return self
|
|
|
|
from shapely.geometry import LineString
|
|
|
|
simplified_paths = []
|
|
for p in self:
|
|
if len(p) < 3:
|
|
simplified_paths.append(p)
|
|
continue
|
|
line = LineString(p)
|
|
simplified_line = line.simplify(tolerance, preserve_topology=preserve_topology)
|
|
if simplified_line.geom_type == 'LineString' and len(simplified_line.coords) >= 2:
|
|
simplified_paths.append(np.array(simplified_line.coords, dtype=p.dtype))
|
|
else:
|
|
simplified_paths.append(p)
|
|
|
|
return self.copy_with(simplified_paths)
|
|
|
|
@classmethod
|
|
def extend_paths(cls, paths_list: list[SlicePaths], primary_source: SlicePaths | None = None) -> SlicePaths:
|
|
"""Combines multiple SlicePaths sequentially into one SlicePaths, conserving all metadata and attributes."""
|
|
if not paths_list:
|
|
return SlicePaths()
|
|
|
|
base = primary_source if primary_source is not None else next((sp for sp in paths_list if len(sp) > 0), paths_list[0])
|
|
merged = base.copy_with([])
|
|
|
|
for sp in paths_list:
|
|
if not sp:
|
|
continue
|
|
merged.extend_with_groups(sp)
|
|
merged.transforms = [dict(t) for t in getattr(base, "transforms", [])]
|
|
|
|
return merged
|
|
|
|
@classmethod
|
|
def zip_paths(cls, paths_list: list[SlicePaths], primary_source: SlicePaths | None = None) -> SlicePaths:
|
|
"""Interleaves multiple SlicePaths layer by layer (by group ID), conserving all metadata and attributes."""
|
|
if not paths_list:
|
|
return SlicePaths()
|
|
|
|
non_empty = [sp for sp in paths_list if len(sp) > 0]
|
|
if not non_empty:
|
|
return SlicePaths()
|
|
|
|
base = primary_source if primary_source is not None else non_empty[0]
|
|
|
|
all_groups = set()
|
|
grouped_inputs = []
|
|
group_z: dict[int, int] = {}
|
|
for p_list in non_empty:
|
|
d = {}
|
|
for i, g in enumerate(p_list.groups):
|
|
all_groups.add(g)
|
|
d.setdefault(g, []).append((p_list[i], p_list.extrusion_rate[i], p_list.feedrate[i], {k: v[i] for k, v in p_list.extra_attributes.items()}))
|
|
if g not in group_z and len(p_list[i]) > 0:
|
|
group_z[g] = int(round(p_list[i][0, 2] * SCALE))
|
|
grouped_inputs.append(d)
|
|
|
|
sorted_groups = sorted(list(all_groups), key=lambda g: (group_z.get(g, 0), g))
|
|
|
|
merged = base.copy_with([])
|
|
for g in sorted_groups:
|
|
for d in grouped_inputs:
|
|
items = d.get(g, [])
|
|
for path, ext, feed, extra in items:
|
|
merged.append_with_group(path, group=g, extrusion_rate=ext, feedrate=feed, extra_attributes=extra)
|
|
|
|
merged_names = []
|
|
merged_objs = []
|
|
for sp in non_empty:
|
|
obj_info = getattr(sp, "objects_info", None)
|
|
if obj_info:
|
|
for obj_entry in obj_info.get("objects", []):
|
|
if obj_entry not in merged_objs:
|
|
merged_objs.append(dict(obj_entry))
|
|
n_str = obj_entry.get("name", "")
|
|
if n_str and n_str not in merged_names:
|
|
merged_names.append(n_str)
|
|
elif hasattr(sp, "name") and getattr(sp, "name"):
|
|
n_str = str(getattr(sp, "name"))
|
|
if n_str and n_str not in merged_names:
|
|
merged_names.append(n_str)
|
|
merged_objs.append({"name": n_str, "polygon": []})
|
|
|
|
merged.transforms = [dict(t) for t in getattr(base, "transforms", [])]
|
|
if merged_objs:
|
|
merged.objects_info = {"objects": merged_objs}
|
|
merged.name = merged_names[0] if merged_names else ""
|
|
|
|
return merged
|
|
|
|
def get_infill_mask(self) -> SlicePaths:
|
|
"""Computes the 2D solid infill mask for each layer, resolving exterior boundaries and interior holes."""
|
|
res_paths, res_groups = get_infill_mask_slice(self)
|
|
return self.copy_with(res_paths, res_groups)
|
|
|
|
def get_overhang_mask(self, tolerance: float = 0.05) -> SlicePaths:
|
|
"""Computes the overhang mask for each layer, propagating from top to bottom."""
|
|
res_paths, res_groups = get_overhang_mask_slice(self, tuple(self.groups), tolerance=tolerance)
|
|
return self.copy_with(res_paths, res_groups)
|
|
|
|
def infill(self, density: float, rotation_deg: float = 0.0, infill_type: str = 'GRID', layer_rotation: Any = None) -> SlicePaths:
|
|
"""Generates infill for the paths."""
|
|
p = self
|
|
if layer_rotation and any(layer_rotation):
|
|
from mathutils import Euler
|
|
mat = Euler(layer_rotation).to_matrix().inverted()
|
|
p = self.transform(np.array(mat))
|
|
|
|
res_paths, res_groups = infill_slice(p, density, rotation_deg=rotation_deg, infill_type=infill_type)
|
|
new_paths = self.copy_with(res_paths, res_groups)
|
|
|
|
if layer_rotation and any(layer_rotation):
|
|
from mathutils import Euler
|
|
mat_inv = Euler(layer_rotation).to_matrix()
|
|
new_paths = new_paths.transform(np.array(mat_inv))
|
|
|
|
return new_paths
|
|
|
|
def rotate(self, angle_deg: float, center: np.ndarray | None = None) -> SlicePaths:
|
|
"""Rotates the paths around Z-axis."""
|
|
if angle_deg == 0:
|
|
return self
|
|
|
|
if center is None:
|
|
center = self.get_first_layer_midpoint()
|
|
|
|
angle_rad = np.radians(angle_deg)
|
|
c, s = np.cos(angle_rad), np.sin(angle_rad)
|
|
rot_mat = np.array([
|
|
[c, -s, 0.0],
|
|
[s, c, 0.0],
|
|
[0.0, 0.0, 1.0]
|
|
], dtype=np.float64)
|
|
|
|
new_paths = []
|
|
for p in self:
|
|
# Shift to center, rotate, shift back, preserving exact original Z
|
|
new_p = (p - center) @ rot_mat.T + center
|
|
if len(p) > 0 and p.shape[1] >= 3:
|
|
new_p[:, 2] = p[:, 2]
|
|
new_paths.append(new_p)
|
|
|
|
res = self.copy_with(new_paths)
|
|
r_arr = np.array([0.0, 0.0, float(angle_rad)], dtype=np.float64)
|
|
res.transforms = update_transform(getattr(self, "transforms", []), r_arr=r_arr)
|
|
if res.transforms:
|
|
res.transforms[0]["angle_deg"] = float(angle_deg)
|
|
if center is not None:
|
|
res.transforms[0]["center"] = center.tolist() if isinstance(center, np.ndarray) else list(center)
|
|
return res
|
|
|
|
def get_first_layer_midpoint(self) -> np.ndarray:
|
|
if not self:
|
|
return np.zeros(3)
|
|
|
|
# Find true minimum Z layer in integer microns across non-empty paths
|
|
min_z_um = min((int(round(p[0, 2] * SCALE)) for p in self if len(p) > 0), default=None)
|
|
if min_z_um is None:
|
|
return np.zeros(3)
|
|
|
|
first_layer_pts = [p for p in self if len(p) > 0 and int(round(p[0, 2] * SCALE)) == min_z_um]
|
|
if not first_layer_pts:
|
|
return np.zeros(3)
|
|
|
|
pts = np.concatenate(first_layer_pts)
|
|
min_pts = np.min(pts, axis=0)
|
|
max_pts = np.max(pts, axis=0)
|
|
return (min_pts + max_pts) / 2.0
|
|
|
|
def boolean(self, other: SlicePaths, operation: str = 'UNION', connect_along_boundary: bool = False) -> SlicePaths:
|
|
return boolean_slice(self, other, operation, connect_along_boundary=connect_along_boundary)
|
|
|
|
@classmethod
|
|
def path_operations(cls, paths_list: list[SlicePaths], operation: str = 'ZIP', connect_along_boundary: bool = False) -> SlicePaths:
|
|
"""Executes Path Operations across multiple SlicePaths inputs."""
|
|
return path_operations_slice(paths_list, operation=operation, connect_along_boundary=connect_along_boundary)
|
|
|
|
@classmethod
|
|
def minimum_coverage(cls, paths_list: list[SlicePaths], connect_along_boundary: bool = False) -> SlicePaths:
|
|
"""Computes Minimum Coverage across multiple SlicePaths inputs."""
|
|
return minimum_coverage_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
|
|
@classmethod
|
|
def maximum_coverage(cls, paths_list: list[SlicePaths], connect_along_boundary: bool = False) -> SlicePaths:
|
|
"""Computes Maximum Coverage across multiple SlicePaths inputs."""
|
|
return maximum_coverage_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
|
|
@classmethod
|
|
def intersection(cls, paths_list: list[SlicePaths], connect_along_boundary: bool = False) -> SlicePaths:
|
|
"""Computes Intersection across multiple SlicePaths inputs."""
|
|
return intersection_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
|
|
@classmethod
|
|
def subtract(cls, paths_list: list[SlicePaths], connect_along_boundary: bool = False) -> SlicePaths:
|
|
"""Computes Difference (Subtract) across multiple SlicePaths inputs."""
|
|
return subtract_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
|
|
@classmethod
|
|
def xor(cls, paths_list: list[SlicePaths], connect_along_boundary: bool = False) -> SlicePaths:
|
|
"""Computes Exclusive Or (XOR) across multiple SlicePaths inputs."""
|
|
return xor_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
|
|
def extend_with_groups(self, other: SlicePaths | list[np.ndarray]):
|
|
if isinstance(other, SlicePaths):
|
|
self.extend(other)
|
|
self.groups.extend(other.groups)
|
|
self.extrusion_rate.extend(other.extrusion_rate)
|
|
self.feedrate.extend(other.feedrate)
|
|
for k, v in other.extra_attributes.items():
|
|
if k in self.extra_attributes:
|
|
self.extra_attributes[k].extend(v)
|
|
else:
|
|
# Pad with zeros for existing paths if we didn't have this attribute
|
|
self.extra_attributes[k] = [np.zeros(len(p)) for p in self[:-len(other)]] + list(v)
|
|
# Pad existing attributes if 'other' didn't have them
|
|
for k in self.extra_attributes:
|
|
if k not in other.extra_attributes:
|
|
self.extra_attributes[k].extend([np.zeros(len(p)) for p in other])
|
|
else:
|
|
self.extend(other)
|
|
self.groups.extend(list(range(0,len(other))))
|
|
self.extrusion_rate.extend([np.ones(len(p)) for p in other])
|
|
self.feedrate.extend([np.zeros(len(p)) for p in other])
|
|
for k in self.extra_attributes:
|
|
self.extra_attributes[k].extend([np.zeros(len(p)) for p in other])
|
|
|
|
def append_with_group(self, path: np.ndarray, group: int = 0, extrusion_rate: np.ndarray | None = None, feedrate: np.ndarray | None = None, extra_attributes: dict[str, np.ndarray] | None = None):
|
|
self.append(path)
|
|
self.groups.append(group)
|
|
self.extrusion_rate.append(extrusion_rate if extrusion_rate is not None else np.ones(len(path)))
|
|
self.feedrate.append(feedrate if feedrate is not None else np.zeros(len(path)))
|
|
|
|
extra_attributes = extra_attributes or {}
|
|
for k, v in extra_attributes.items():
|
|
if k in self.extra_attributes:
|
|
self.extra_attributes[k].append(v)
|
|
else:
|
|
self.extra_attributes[k] = [np.zeros(len(p)) for p in self[:-1]] + [v]
|
|
|
|
for k in self.extra_attributes:
|
|
if k not in extra_attributes:
|
|
self.extra_attributes[k].append(np.zeros(len(path)))
|
|
|
|
def normalize_groups(self) -> None:
|
|
self.groups = [m.setdefault(x, len(m)) for m in [{}] for x in self.groups]
|
|
|
|
def align_seams(self, mode: str = "sharpest") -> SlicePaths:
|
|
"""
|
|
Aligns the seams (starting point of closed paths) in SlicePaths to the back (+Y).
|
|
Preserves all per-path and per-vertex attributes.
|
|
"""
|
|
if not self:
|
|
return self
|
|
|
|
new_paths: list[np.ndarray] = []
|
|
new_ext: list[np.ndarray] = []
|
|
new_feed: 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 is_closed_path(p):
|
|
new_paths.append(p)
|
|
new_ext.append(self.extrusion_rate[i])
|
|
new_feed.append(self.feedrate[i])
|
|
for k in self.extra_attributes:
|
|
new_extra[k].append(self.extra_attributes[k][i])
|
|
continue
|
|
|
|
M = len(p) - 1
|
|
best_idx = find_path_seam(p)
|
|
if best_idx == 0:
|
|
new_paths.append(p)
|
|
new_ext.append(self.extrusion_rate[i])
|
|
new_feed.append(self.feedrate[i])
|
|
for k in self.extra_attributes:
|
|
new_extra[k].append(self.extra_attributes[k][i])
|
|
continue
|
|
|
|
else:
|
|
new_p = np.empty_like(p)
|
|
split = M - best_idx
|
|
new_p[:split] = p[best_idx:-1]
|
|
new_p[split:] = p[:best_idx + 1]
|
|
new_paths.append(new_p)
|
|
|
|
ext = self.extrusion_rate[i]
|
|
if len(ext) == len(p):
|
|
new_e = np.empty_like(ext)
|
|
new_e[:split] = ext[best_idx:-1]
|
|
new_e[split:] = ext[:best_idx + 1]
|
|
new_ext.append(new_e)
|
|
else:
|
|
new_ext.append(ext)
|
|
|
|
feed = self.feedrate[i]
|
|
if len(feed) == len(p):
|
|
new_f = np.empty_like(feed)
|
|
new_f[:split] = feed[best_idx:-1]
|
|
new_f[split:] = feed[:best_idx + 1]
|
|
new_feed.append(new_f)
|
|
else:
|
|
new_feed.append(feed)
|
|
|
|
for k in self.extra_attributes:
|
|
attr = self.extra_attributes[k][i]
|
|
if len(attr) == len(p):
|
|
new_a = np.empty_like(attr)
|
|
new_a[:split] = attr[best_idx:-1]
|
|
new_a[split:] = attr[:best_idx + 1]
|
|
new_extra[k].append(new_a)
|
|
else:
|
|
new_extra[k].append(attr)
|
|
|
|
return self.copy_with(new_paths, extrusion_rate=new_ext, feedrate=new_feed, extra_attributes=new_extra)
|
|
|
|
def stagger_seams(self, stagger_mm: float) -> SlicePaths:
|
|
"""
|
|
Shifts the seams of closed paths layer-by-layer, alternating between +stagger_mm (even layers)
|
|
and -stagger_mm (odd layers) along the perimeter to avoid vertical seam alignment weak spots.
|
|
"""
|
|
return self.place_seams(mesh_input=None, stagger_mm=stagger_mm)
|
|
|
|
from ..infra.debug import debug
|
|
@debug
|
|
def place_seams(self, mesh_input: Any = None, mode: str = "closest", acquire_transforms: bool = True, stagger_mm: float = 0.0) -> SlicePaths:
|
|
"""
|
|
Repositions the starting point (seam) of closed slice paths (including outer contours
|
|
and interior holes) to the intersection points between the slice paths and the mesh.
|
|
If acquire_transforms is True, applies upstream transforms from slicepaths metadata to the cutting mesh.
|
|
If stagger_mm is non-zero, offsets the seam along the path perimeter (+stagger_mm on even layers, -stagger_mm on odd layers).
|
|
If there is no intersection for a given path, its seam remains unchanged (aside from any staggering).
|
|
"""
|
|
if not self:
|
|
return self
|
|
|
|
tris = extract_mesh_triangles(mesh_input) if mesh_input is not None else np.empty((0, 3, 3), dtype=np.float64)
|
|
if len(tris) == 0 and abs(stagger_mm) < 1e-6:
|
|
return self
|
|
|
|
if len(tris) > 0:
|
|
if acquire_transforms and getattr(self, "transforms", None):
|
|
mesh_transforms = extract_mesh_transforms(mesh_input)
|
|
delta_transforms = compute_delta_transforms(self.transforms, mesh_transforms)
|
|
if delta_transforms:
|
|
tris = apply_transforms_to_triangles(tris, delta_transforms)
|
|
|
|
mesh_center = np.mean(tris[:, :3, :].reshape(-1, 3), axis=0)
|
|
|
|
# Precompute triangle Z bounds
|
|
tz_min = np.min(tris[:, :3, 2], axis=1) - 1e-4
|
|
tz_max = np.max(tris[:, :3, 2], axis=1) + 1e-4
|
|
mesh_z_min = float(np.min(tz_min)) if len(tz_min) > 0 else 0.0
|
|
mesh_z_max = float(np.max(tz_max)) if len(tz_max) > 0 else 0.0
|
|
else:
|
|
mesh_center = np.zeros(3)
|
|
tz_min = np.empty(0, dtype=np.float64)
|
|
tz_max = np.empty(0, dtype=np.float64)
|
|
mesh_z_min = 0.0
|
|
mesh_z_max = 0.0
|
|
|
|
n_paths = len(self)
|
|
res_paths: list[np.ndarray | None] = [None] * n_paths
|
|
res_ext: list[np.ndarray | None] = [None] * n_paths
|
|
res_feed: list[np.ndarray | None] = [None] * n_paths
|
|
res_extra: dict[str, list[np.ndarray | None]] = {k: [None] * n_paths for k in self.extra_attributes}
|
|
|
|
# Group planar paths by layer integer micron key
|
|
z_to_indices: dict[int, list[int]] = {}
|
|
non_planar_indices: list[int] = []
|
|
|
|
for i, p in enumerate(self):
|
|
if len(p) < 4 or not is_closed_path(p):
|
|
res_paths[i] = p
|
|
res_ext[i] = self.extrusion_rate[i]
|
|
res_feed[i] = self.feedrate[i]
|
|
for k in self.extra_attributes:
|
|
res_extra[k][i] = self.extra_attributes[k][i]
|
|
continue
|
|
|
|
pz_min = float(np.min(p[:, 2]))
|
|
pz_max = float(np.max(p[:, 2]))
|
|
|
|
if (pz_max - pz_min) < 1e-3:
|
|
z_um = int(round(float(p[0, 2]) * SCALE))
|
|
z_to_indices.setdefault(z_um, []).append(i)
|
|
else:
|
|
non_planar_indices.append(i)
|
|
|
|
# Process planar paths layer-by-layer in sorted Z order
|
|
sorted_z_keys = sorted(z_to_indices.keys())
|
|
for layer_idx, z_um in enumerate(sorted_z_keys):
|
|
layer_stagger = float(stagger_mm) if (layer_idx % 2 == 0) else -float(stagger_mm)
|
|
indices = z_to_indices[z_um]
|
|
first_p = self[indices[0]]
|
|
pz = float(first_p[0, 2])
|
|
|
|
mesh_segs = np.empty((0, 2, 2), dtype=np.float64)
|
|
if len(tris) > 0 and (mesh_z_min <= pz <= mesh_z_max):
|
|
t_mask = (tz_min <= pz) & (tz_max >= pz)
|
|
active_tris = tris[t_mask]
|
|
if len(active_tris) > 0:
|
|
mesh_segs = slice_triangles_at_z(active_tris, pz)
|
|
|
|
for idx in indices:
|
|
p = self[idx]
|
|
curr_ext = self.extrusion_rate[idx]
|
|
curr_feed = self.feedrate[idx]
|
|
curr_extra = {k: self.extra_attributes[k][idx] for k in self.extra_attributes} if self.extra_attributes else None
|
|
|
|
# 1. Reposition to cutting mesh intersection if available
|
|
if len(mesh_segs) > 0:
|
|
inter = intersect_path_with_2d_segments(p, mesh_segs, pz, mode=mode, mesh_center=mesh_center)
|
|
if inter is not None:
|
|
seg_idx, t, ipt = inter
|
|
p, curr_ext, curr_feed, curr_extra = reposition_path_seam(
|
|
p, seg_idx, t, ipt,
|
|
extrusion_rate=curr_ext,
|
|
feedrate=curr_feed,
|
|
extra_attributes=curr_extra
|
|
)
|
|
|
|
# 2. Shift seam along path if seam staggering is requested
|
|
if abs(layer_stagger) > 1e-6 and is_closed_path(p):
|
|
p, curr_ext, curr_feed, curr_extra = shift_path_seam_by_distance(
|
|
p, layer_stagger,
|
|
extrusion_rate=curr_ext,
|
|
feedrate=curr_feed,
|
|
extra_attributes=curr_extra
|
|
)
|
|
|
|
res_paths[idx] = p
|
|
res_ext[idx] = curr_ext if curr_ext is not None else self.extrusion_rate[idx]
|
|
res_feed[idx] = curr_feed if curr_feed is not None else self.feedrate[idx]
|
|
if curr_extra:
|
|
for k in self.extra_attributes:
|
|
res_extra[k][idx] = curr_extra.get(k, self.extra_attributes[k][idx])
|
|
else:
|
|
for k in self.extra_attributes:
|
|
res_extra[k][idx] = self.extra_attributes[k][idx]
|
|
|
|
# Process any non-planar paths
|
|
for np_idx, idx in enumerate(non_planar_indices):
|
|
layer_idx = len(sorted_z_keys) + np_idx
|
|
layer_stagger = float(stagger_mm) if (layer_idx % 2 == 0) else -float(stagger_mm)
|
|
p = self[idx]
|
|
curr_ext = self.extrusion_rate[idx]
|
|
curr_feed = self.feedrate[idx]
|
|
curr_extra = {k: self.extra_attributes[k][idx] for k in self.extra_attributes} if self.extra_attributes else None
|
|
|
|
if len(tris) > 0:
|
|
inter = find_path_mesh_intersection(p, tris, mode=mode, mesh_center=mesh_center)
|
|
if inter is not None:
|
|
seg_idx, t, ipt = inter
|
|
p, curr_ext, curr_feed, curr_extra = reposition_path_seam(
|
|
p, seg_idx, t, ipt,
|
|
extrusion_rate=curr_ext,
|
|
feedrate=curr_feed,
|
|
extra_attributes=curr_extra
|
|
)
|
|
|
|
if abs(layer_stagger) > 1e-6 and is_closed_path(p):
|
|
p, curr_ext, curr_feed, curr_extra = shift_path_seam_by_distance(
|
|
p, layer_stagger,
|
|
extrusion_rate=curr_ext,
|
|
feedrate=curr_feed,
|
|
extra_attributes=curr_extra
|
|
)
|
|
|
|
res_paths[idx] = p
|
|
res_ext[idx] = curr_ext if curr_ext is not None else self.extrusion_rate[idx]
|
|
res_feed[idx] = curr_feed if curr_feed is not None else self.feedrate[idx]
|
|
if curr_extra:
|
|
for k in self.extra_attributes:
|
|
res_extra[k][idx] = curr_extra.get(k, self.extra_attributes[k][idx])
|
|
else:
|
|
for k in self.extra_attributes:
|
|
res_extra[k][idx] = self.extra_attributes[k][idx]
|
|
|
|
return self.copy_with(
|
|
res_paths, # type: ignore[arg-type]
|
|
extrusion_rate=res_ext, # type: ignore[arg-type]
|
|
feedrate=res_feed, # type: ignore[arg-type]
|
|
extra_attributes=res_extra # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
def _slice_single_mesh(mesh_array: np.ndarray, z_values: np.ndarray | list[float], ignore_holes: bool = False) -> list[np.ndarray]:
|
|
"""
|
|
Slices a single 3D triangle mesh (shape (F, 4, 3) or (F, 3, 3)) across multiple Z heights.
|
|
Uses manifold3d for robust solid slicing with exact coplanar/topological resolution,
|
|
and falls back to trimesh for open/non-manifold surface meshes.
|
|
"""
|
|
if len(mesh_array) == 0:
|
|
return []
|
|
|
|
z_arr = np.asarray(z_values, dtype=np.float64)
|
|
if len(z_arr) == 0:
|
|
return []
|
|
|
|
unique_verts, faces = weld_vertices(mesh_array)
|
|
if len(unique_verts) == 0:
|
|
return []
|
|
|
|
all_paths: list[np.ndarray] = []
|
|
|
|
# 1. Primary: manifold3d for solid meshes (fastest, guaranteed watertight topological output)
|
|
try:
|
|
mesh_m = manifold3d.Mesh(
|
|
vert_properties=np.ascontiguousarray(unique_verts),
|
|
tri_verts=np.ascontiguousarray(faces)
|
|
)
|
|
m = manifold3d.Manifold(mesh_m)
|
|
# If mesh has inverted normals (negative volume), flip triangle winding
|
|
if m.status() == manifold3d.Error.NoError and m.volume() < 0:
|
|
faces_flipped = faces[:, [0, 2, 1]]
|
|
mesh_m_flipped = manifold3d.Mesh(
|
|
vert_properties=np.ascontiguousarray(unique_verts),
|
|
tri_verts=np.ascontiguousarray(faces_flipped)
|
|
)
|
|
m = manifold3d.Manifold(mesh_m_flipped)
|
|
|
|
if m.status() == manifold3d.Error.NoError and not m.is_empty():
|
|
min_z = float(np.min(unique_verts[:, 2]))
|
|
max_z = float(np.max(unique_verts[:, 2]))
|
|
|
|
for z in z_arr:
|
|
zf = float(z)
|
|
if zf < min_z - 1e-4 or zf > max_z + 1e-4:
|
|
continue
|
|
# If slicing at boundary face, apply micro-epsilon so boundary is cleanly captured
|
|
slice_z = zf - 1e-5 if abs(zf - max_z) < 1e-5 else (zf + 1e-5 if abs(zf - min_z) < 1e-5 else zf)
|
|
cs = m.slice(slice_z)
|
|
polys = cs.to_polygons()
|
|
for poly in polys:
|
|
N = len(poly)
|
|
if N < 3:
|
|
continue
|
|
if ignore_holes:
|
|
if _c_seam_lib is not None and hasattr(_c_seam_lib, "polygon_signed_area_2d_c"):
|
|
poly_c = np.ascontiguousarray(poly, dtype=np.float64)
|
|
area = _c_seam_lib.polygon_signed_area_2d_c(poly_c.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), N, 2)
|
|
if area <= 0:
|
|
continue
|
|
else:
|
|
p_x = poly[:, 0]
|
|
p_y = poly[:, 1]
|
|
signed_area = np.sum(p_x[:-1] * p_y[1:] - p_x[1:] * p_y[:-1]) + (p_x[-1] * p_y[0] - p_x[0] * p_y[-1])
|
|
if signed_area <= 0:
|
|
continue
|
|
p3d = np.empty((N + 1, 3), dtype=np.float64)
|
|
p3d[:N, :2] = poly
|
|
p3d[:N, 2] = zf
|
|
p3d[N] = p3d[0]
|
|
p3d = align_path_seam_sharpest(p3d)
|
|
all_paths.append(p3d)
|
|
return all_paths
|
|
except Exception:
|
|
pass
|
|
|
|
# 2. Fallback: trimesh multiplane slicing for open sheets / non-manifold meshes
|
|
try:
|
|
min_z = float(np.min(unique_verts[:, 2]))
|
|
max_z = float(np.max(unique_verts[:, 2]))
|
|
slice_heights = np.where(
|
|
np.abs(z_arr - max_z) < 1e-5,
|
|
z_arr - 1e-5,
|
|
np.where(np.abs(z_arr - min_z) < 1e-5, z_arr + 1e-5, z_arr)
|
|
)
|
|
tm = trimesh.Trimesh(vertices=unique_verts, faces=faces, process=False)
|
|
lines, _, _ = trimesh.intersections.mesh_multiplane(
|
|
mesh=tm,
|
|
plane_origin=[0.0, 0.0, 0.0],
|
|
plane_normal=[0.0, 0.0, 1.0],
|
|
heights=slice_heights
|
|
)
|
|
for z, line_segs in zip(z_arr, lines):
|
|
if line_segs is None or len(line_segs) == 0:
|
|
continue
|
|
zf = float(z)
|
|
seg2d = np.asarray(line_segs, dtype=np.float64)
|
|
z_col = np.full((len(seg2d), 2, 1), zf, dtype=np.float64)
|
|
seg3d = np.concatenate([seg2d, z_col], axis=-1)
|
|
slice_paths = segments_to_slice(seg3d, ignore_holes=ignore_holes)
|
|
all_paths.extend(slice_paths)
|
|
except Exception:
|
|
pass
|
|
|
|
return all_paths
|
|
|
|
|
|
def slice_at_range(models: list[np.ndarray], z_values: np.ndarray | list[float], ignore_holes: bool = False) -> list[list[np.ndarray]]:
|
|
"""
|
|
Top-level slicing function that slices multiple model meshes across given Z heights.
|
|
Returns a list of path lists (one per model).
|
|
"""
|
|
if not models:
|
|
return []
|
|
|
|
mmps: list[list[np.ndarray]] = []
|
|
for model in models:
|
|
model_paths = _slice_single_mesh(model, z_values, ignore_holes=ignore_holes)
|
|
mmps.append(model_paths)
|
|
return mmps
|
|
|
|
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]
|
|
|
|
# Quantize to integer micron: (x_um, y_um) packed into 64-bit scalar key
|
|
pts_um = np.round(pts * SCALE).astype(np.int64)
|
|
k1_arr = (pts_um[:, 0, 0] << 32) | (pts_um[:, 0, 1] & 0xFFFFFFFF)
|
|
k2_arr = (pts_um[:, 1, 0] << 32) | (pts_um[:, 1, 1] & 0xFFFFFFFF)
|
|
|
|
adj: dict[int, list[tuple[int, int]]] = {}
|
|
for i in range(len(segments)):
|
|
k1 = int(k1_arr[i])
|
|
k2 = int(k2_arr[i])
|
|
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
|
|
k1 = int(k1_arr[i])
|
|
k2 = int(k2_arr[i])
|
|
if k1 == k2:
|
|
continue
|
|
|
|
fwd_pts = [pts[i, 0], pts[i, 1]]
|
|
bwd_pts = []
|
|
used.add(i)
|
|
|
|
# Forward
|
|
curr_k = k2
|
|
while True:
|
|
found = False
|
|
for next_k, idx in adj.get(curr_k, []):
|
|
if idx not in used:
|
|
pk1 = int(k1_arr[idx])
|
|
other_pt = pts[idx, 1] if pk1 == curr_k else pts[idx, 0]
|
|
fwd_pts.append(other_pt)
|
|
used.add(idx)
|
|
curr_k = next_k
|
|
found = True
|
|
break
|
|
if not found:
|
|
break
|
|
|
|
# Backward (O(1) append to bwd_pts)
|
|
curr_k = k1
|
|
while True:
|
|
found = False
|
|
for next_k, idx in adj.get(curr_k, []):
|
|
if idx not in used:
|
|
pk1 = int(k1_arr[idx])
|
|
other_pt = pts[idx, 1] if pk1 == curr_k else pts[idx, 0]
|
|
bwd_pts.append(other_pt)
|
|
used.add(idx)
|
|
curr_k = next_k
|
|
found = True
|
|
break
|
|
if not found:
|
|
break
|
|
|
|
all_pts = bwd_pts[::-1] + fwd_pts if bwd_pts else fwd_pts
|
|
p_arr = np.empty((len(all_pts), 3), dtype=np.float64)
|
|
p_arr[:, :2] = all_pts
|
|
p_arr[:, 2] = z
|
|
|
|
if len(p_arr) > 2 and is_closed_path(p_arr):
|
|
p_arr[-1] = p_arr[0]
|
|
|
|
paths.append(p_arr)
|
|
return paths
|
|
|
|
@ndarray_lru_cache(maxsize=128)
|
|
def segments_to_slice(segments: np.ndarray, ignore_holes: bool = False) -> list[np.ndarray]:
|
|
|
|
linked = link_segments(segments)
|
|
if not linked: return []
|
|
|
|
closed_paths = []
|
|
open_paths = []
|
|
for p in linked:
|
|
path_pts = p[:, :2]
|
|
if len(path_pts) > 3 and is_closed_path(p):
|
|
closed_paths.append(p)
|
|
else:
|
|
open_paths.append(p)
|
|
|
|
res = []
|
|
z = segments[0, 0, 2]
|
|
|
|
if closed_paths:
|
|
pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
|
|
for p in closed_paths:
|
|
path_pts = p[:, :2]
|
|
path_pts = path_pts[:-1]
|
|
scaled = (path_pts * SCALE).round().astype(np.int64).tolist()
|
|
if abs(pyclipper.Area(scaled)) < 1:
|
|
continue
|
|
pc.AddPath(scaled, pyclipper.PT_SUBJECT, closed = True) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
if ignore_holes:
|
|
tree = pc.Execute2(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
|
|
def collect_outer_contours(node) -> list[list[int]]:
|
|
contours = []
|
|
if hasattr(node, 'Contour') and node.Contour and not getattr(node, 'IsHole', False):
|
|
contours.append(node.Contour)
|
|
if hasattr(node, 'Childs') and node.Childs:
|
|
for child in node.Childs:
|
|
contours.extend(collect_outer_contours(child))
|
|
return contours
|
|
|
|
solution = collect_outer_contours(tree)
|
|
else:
|
|
# Use EVENODD for robust hole detection.
|
|
solution = pc.Execute(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
for path in solution:
|
|
if not path: continue
|
|
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]])
|
|
fpath_3d = align_path_seam_sharpest(fpath_3d)
|
|
res.append(fpath_3d)
|
|
|
|
res.extend(open_paths)
|
|
return res
|
|
|
|
def find_point_on_loop(pt: np.ndarray, loop: np.ndarray, tol: float = 1.0) -> tuple[int, float]:
|
|
"""Finds the edge index and parameter u [0,1] for a point on a loop. loop is (N,2) and closed."""
|
|
p1 = loop[:-1]
|
|
p2 = loop[1:]
|
|
v = p2 - p1
|
|
w = pt - p1
|
|
|
|
l2 = np.sum(v**2, axis=1)
|
|
mask = l2 > 0
|
|
u = np.zeros(len(l2))
|
|
u[mask] = np.sum(w[mask] * v[mask], axis=1) / l2[mask]
|
|
u = np.clip(u, 0, 1)
|
|
|
|
dist_sq = np.sum((p1 + u[:, np.newaxis] * v - pt)**2, axis=1)
|
|
min_idx = np.argmin(dist_sq)
|
|
if dist_sq[min_idx] < tol**2:
|
|
return int(min_idx), float(u[min_idx])
|
|
return -1, 0.0
|
|
|
|
def get_boundary_path(pt1: np.ndarray, pt2: np.ndarray, loops: list[np.ndarray]) -> np.ndarray | None:
|
|
"""Returns the shortest path along one of the loops between pt1 and pt2."""
|
|
best_path = None
|
|
min_len = float('inf')
|
|
|
|
for loop in loops:
|
|
# Ensure it's closed for logic.
|
|
working_loop = loop
|
|
if not is_closed_path(loop):
|
|
working_loop = np.vstack([loop, loop[0]])
|
|
|
|
idx1, u1 = find_point_on_loop(pt1, working_loop)
|
|
if idx1 == -1: continue
|
|
idx2, u2 = find_point_on_loop(pt2, working_loop)
|
|
if idx2 == -1: continue
|
|
|
|
# Found on same loop. Try forward and backward.
|
|
num_verts = len(working_loop) - 1
|
|
|
|
# Forward path
|
|
fwd_pts = [pt1]
|
|
curr = idx1
|
|
while curr != idx2:
|
|
curr = (curr + 1) % num_verts
|
|
fwd_pts.append(working_loop[curr])
|
|
fwd_pts.append(pt2)
|
|
fwd_path = np.array(fwd_pts)
|
|
fwd_len = np.sum(np.sqrt(np.sum(np.diff(fwd_path, axis=0)**2, axis=1)))
|
|
|
|
# Backward path
|
|
bwd_pts = [pt1]
|
|
curr = idx1
|
|
while curr != (idx2 + 1) % num_verts:
|
|
bwd_pts.append(working_loop[curr])
|
|
curr = (curr - 1 + num_verts) % num_verts
|
|
bwd_pts.append(pt2)
|
|
bwd_path = np.array(bwd_pts)
|
|
bwd_len = np.sum(np.sqrt(np.sum(np.diff(bwd_path, axis=0)**2, axis=1)))
|
|
|
|
if fwd_len < bwd_len:
|
|
if fwd_len < min_len:
|
|
min_len = fwd_len
|
|
best_path = fwd_path
|
|
else:
|
|
if bwd_len < min_len:
|
|
min_len = bwd_len
|
|
best_path = bwd_path
|
|
|
|
return best_path
|
|
|
|
def _group_closed_paths_into_shapes(closed_paths: list[np.ndarray]) -> list[list[np.ndarray]]:
|
|
"""
|
|
Groups a list of closed paths on a layer into distinct polygon shapes,
|
|
where each shape consists of one outer boundary and zero or more inner holes.
|
|
Uses topological containment with representative points:
|
|
- Paths contained in an even number of enclosing paths (0, 2, ...) are Outer boundaries.
|
|
- Paths contained in an odd number of enclosing paths (1, 3, ...) are Holes belonging
|
|
to the immediately enclosing Outer boundary.
|
|
"""
|
|
if not closed_paths:
|
|
return []
|
|
if len(closed_paths) == 1:
|
|
return [[closed_paths[0]]]
|
|
|
|
polys: list[tuple[int, np.ndarray, Polygon, float]] = []
|
|
for i, p in enumerate(closed_paths):
|
|
pts = p[:, :2]
|
|
if len(pts) < 3:
|
|
continue
|
|
try:
|
|
poly = Polygon(pts)
|
|
if not poly.is_valid:
|
|
poly = poly.buffer(0)
|
|
if not poly.is_empty:
|
|
polys.append((i, p, poly, float(poly.area)))
|
|
except Exception:
|
|
continue
|
|
|
|
if not polys:
|
|
return [[p] for p in closed_paths]
|
|
if len(polys) == 1:
|
|
return [[polys[0][1]]]
|
|
|
|
# Sort descending by area so enclosing paths come before enclosed ones
|
|
polys.sort(key=lambda x: x[3], reverse=True)
|
|
|
|
# For each polygon, find all strictly larger polygons that contain its representative point
|
|
shapes_map: dict[int, list[np.ndarray]] = {}
|
|
outer_entries: list[tuple[int, Polygon, float]] = []
|
|
|
|
for idx, p, poly, area in polys:
|
|
rep_pt = poly.representative_point()
|
|
# Find all larger polygons containing this representative point
|
|
containers = [
|
|
(c_idx, c_poly, c_area)
|
|
for (c_idx, c_p, c_poly, c_area) in polys
|
|
if c_area > area and (c_poly.contains(rep_pt) or c_poly.buffer(1e-5).contains(rep_pt))
|
|
]
|
|
|
|
if len(containers) % 2 == 0:
|
|
# Even enclosing count -> Outer boundary
|
|
shapes_map[idx] = [p]
|
|
outer_entries.append((idx, poly, area))
|
|
else:
|
|
# Odd enclosing count -> Hole belonging to the smallest enclosing outer
|
|
# Find the enclosing outer with the smallest area
|
|
enclosing_outers = [
|
|
(o_idx, o_poly, o_area)
|
|
for (o_idx, o_poly, o_area) in outer_entries
|
|
if o_area > area and (o_poly.contains(rep_pt) or o_poly.buffer(1e-5).contains(rep_pt))
|
|
]
|
|
if enclosing_outers:
|
|
# Smallest area is the last one because outer_entries is in descending order
|
|
parent_idx = enclosing_outers[-1][0]
|
|
shapes_map[parent_idx].append(p)
|
|
elif outer_entries:
|
|
shapes_map[outer_entries[-1][0]].append(p)
|
|
else:
|
|
shapes_map[idx] = [p]
|
|
|
|
return list(shapes_map.values())
|
|
|
|
@ndarray_lru_cache(maxsize=128)
|
|
def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNION', connect_along_boundary: bool = False) -> SlicePaths:
|
|
all_z_keys: set[int] = set()
|
|
subject_dict: dict[int, list[np.ndarray]] = {}
|
|
object_dict: dict[int, list[np.ndarray]] = {}
|
|
|
|
for s in subject:
|
|
if len(s) < 2:
|
|
continue
|
|
z = int(round(s[0, 2] * SCALE))
|
|
all_z_keys.add(z)
|
|
subject_dict.setdefault(z, []).append(s)
|
|
|
|
for s in object:
|
|
if len(s) < 2:
|
|
continue
|
|
z = int(round(s[0, 2] * SCALE))
|
|
all_z_keys.add(z)
|
|
object_dict.setdefault(z, []).append(s)
|
|
|
|
results: SlicePaths = SlicePaths()
|
|
|
|
group = 0
|
|
for k in sorted(all_z_keys):
|
|
subject_slices = subject_dict.get(k, [])
|
|
object_slices = object_dict.get(k, [])
|
|
|
|
if not subject_slices:
|
|
if object_slices and operation == 'UNION':
|
|
results.extend(object_slices)
|
|
results.groups.extend([group] * len(object_slices))
|
|
group += 1
|
|
continue
|
|
|
|
if not object_slices:
|
|
if operation != 'INTERSECTION':
|
|
results.extend(subject_slices)
|
|
results.groups.extend([group] * len(subject_slices))
|
|
group += 1
|
|
continue
|
|
|
|
z = float(k) / SCALE
|
|
|
|
pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
layer_results = []
|
|
|
|
sub_closed = [s for s in subject_slices if is_closed_path(s)]
|
|
sub_open = [s for s in subject_slices if not is_closed_path(s)]
|
|
obj_closed = [s for s in object_slices if is_closed_path(s)]
|
|
obj_open = [s for s in object_slices if not is_closed_path(s)]
|
|
|
|
sub_closed_scaled = [(s[:, :2][:-1] * SCALE).round().astype(np.int64).tolist() for s in sub_closed]
|
|
sub_closed_pts = [s[:, :2] for s in sub_closed]
|
|
obj_closed_scaled = [(s[:, :2][:-1] * SCALE).round().astype(np.int64).tolist() for s in obj_closed]
|
|
obj_closed_pts = [s[:, :2] for s in obj_closed]
|
|
|
|
def process_open_op(open_paths, clips_scaled, clips_pts, op_type):
|
|
for s in open_paths:
|
|
path_pts = s[:, :2]
|
|
pc.Clear()
|
|
if not connect_along_boundary:
|
|
pc.AddPath((path_pts * SCALE).round().astype(np.int64).tolist(), pyclipper.PT_SUBJECT, False)
|
|
for c in clips_scaled:
|
|
pc.AddPath(c, pyclipper.PT_CLIP, True)
|
|
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
|
|
for path in pyclipper.OpenPathsFromPolyTree(solution):
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)]))
|
|
else:
|
|
pc.AddPath((path_pts * SCALE).round().astype(np.int64).tolist(), pyclipper.PT_SUBJECT, False)
|
|
for c in clips_scaled:
|
|
pc.AddPath(c, pyclipper.PT_CLIP, True)
|
|
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
|
|
open_results = pyclipper.OpenPathsFromPolyTree(solution)
|
|
if not open_results:
|
|
continue
|
|
|
|
def get_p(pt):
|
|
p1 = path_pts[:-1]
|
|
p2 = path_pts[1:]
|
|
v = p2 - p1
|
|
w = pt - p1
|
|
l2 = np.sum(v**2, axis=1)
|
|
mask = l2 > 0
|
|
u = np.zeros(len(l2))
|
|
u[mask] = np.sum(w[mask] * v[mask], axis=1) / l2[mask]
|
|
u = np.clip(u, 0, 1)
|
|
dist_sq = np.sum((p1 + u[:, np.newaxis] * v - pt)**2, axis=1)
|
|
i = np.argmin(dist_sq)
|
|
return i + u[i]
|
|
|
|
segments = []
|
|
for path in open_results:
|
|
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:
|
|
fpath = fpath[::-1]
|
|
p_start, p_end = p_end, p_start
|
|
segments.append((p_start, fpath))
|
|
|
|
segments.sort(key=lambda x: x[0])
|
|
current_group = [segments[0][1]]
|
|
for i in range(1, len(segments)):
|
|
prev_end = current_group[-1][-1] * SCALE
|
|
curr_start = segments[i][1][0] * SCALE
|
|
scaled_loops = [(pts * SCALE) for pts in clips_pts]
|
|
b_path = get_boundary_path(prev_end, curr_start, scaled_loops)
|
|
|
|
if b_path is not None:
|
|
if len(b_path) > 2:
|
|
current_group.append(b_path[1:-1] / SCALE)
|
|
current_group.append(segments[i][1])
|
|
else:
|
|
path_to_add = np.vstack(current_group)
|
|
layer_results.append(np.column_stack([path_to_add, np.full(len(path_to_add), z)]))
|
|
current_group = [segments[i][1]]
|
|
|
|
path_to_add = np.vstack(current_group)
|
|
layer_results.append(np.column_stack([path_to_add, np.full(len(path_to_add), z)]))
|
|
|
|
if operation == 'INTERSECTION':
|
|
if sub_closed and obj_closed:
|
|
pc.Clear()
|
|
for c in sub_closed_scaled:
|
|
pc.AddPath(c, pyclipper.PT_SUBJECT, True)
|
|
for c in obj_closed_scaled:
|
|
pc.AddPath(c, pyclipper.PT_CLIP, True)
|
|
solution = pc.Execute2(pyclipper.CT_INTERSECTION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
|
|
for path in pyclipper.ClosedPathsFromPolyTree(solution):
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
|
|
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]])
|
|
layer_results.append(align_path_seam_sharpest(fpath_3d))
|
|
for path in pyclipper.OpenPathsFromPolyTree(solution):
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)]))
|
|
|
|
if sub_open and obj_closed_scaled:
|
|
process_open_op(sub_open, obj_closed_scaled, obj_closed_pts, pyclipper.CT_INTERSECTION)
|
|
if obj_open and sub_closed_scaled:
|
|
process_open_op(obj_open, sub_closed_scaled, sub_closed_pts, pyclipper.CT_INTERSECTION)
|
|
|
|
elif operation == 'DIFFERENCE':
|
|
if sub_closed and obj_closed:
|
|
pc.Clear()
|
|
for c in sub_closed_scaled:
|
|
pc.AddPath(c, pyclipper.PT_SUBJECT, True)
|
|
for c in obj_closed_scaled:
|
|
pc.AddPath(c, pyclipper.PT_CLIP, True)
|
|
solution = pc.Execute2(pyclipper.CT_DIFFERENCE, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
|
|
for path in pyclipper.ClosedPathsFromPolyTree(solution):
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
|
|
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]])
|
|
layer_results.append(align_path_seam_sharpest(fpath_3d))
|
|
for path in pyclipper.OpenPathsFromPolyTree(solution):
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)]))
|
|
elif sub_closed and not obj_closed:
|
|
layer_results.extend(sub_closed)
|
|
|
|
if sub_open and obj_closed_scaled:
|
|
process_open_op(sub_open, obj_closed_scaled, obj_closed_pts, pyclipper.CT_DIFFERENCE)
|
|
elif sub_open and not obj_closed:
|
|
layer_results.extend(sub_open)
|
|
|
|
else:
|
|
# UNION, XOR
|
|
pc.Clear()
|
|
for c in sub_closed_scaled:
|
|
pc.AddPath(c, pyclipper.PT_SUBJECT, True)
|
|
for s in sub_open:
|
|
path_pts = s[:, :2]
|
|
scaled = (path_pts * SCALE).round().astype(np.int64).tolist()
|
|
pc.AddPath(scaled, pyclipper.PT_SUBJECT, False)
|
|
|
|
for c in obj_closed_scaled:
|
|
pc.AddPath(c, pyclipper.PT_CLIP, True)
|
|
for s in obj_open:
|
|
path_pts = s[:, :2]
|
|
scaled = (path_pts * SCALE).round().astype(np.int64).tolist()
|
|
pc.AddPath(scaled, pyclipper.PT_SUBJECT, False)
|
|
|
|
op_type = pyclipper.CT_UNION if operation == 'UNION' else pyclipper.CT_XOR
|
|
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
|
|
for path in pyclipper.ClosedPathsFromPolyTree(solution):
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
|
|
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]])
|
|
layer_results.append(align_path_seam_sharpest(fpath_3d))
|
|
for path in pyclipper.OpenPathsFromPolyTree(solution):
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)]))
|
|
|
|
results.extend(layer_results)
|
|
results.groups.extend([group] * len(layer_results))
|
|
group += 1
|
|
|
|
# Transfer per-group/path metadata (extrusion_rate, feedrate, extra_attributes) from subject
|
|
calc_ext, calc_feed, calc_extra = _transfer_group_attributes(subject, results, results.groups)
|
|
results.extrusion_rate = calc_ext
|
|
results.feedrate = calc_feed
|
|
results.extra_attributes = calc_extra
|
|
|
|
for attr, val in subject.__dict__.items():
|
|
if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes", "transforms", "objects_info", "name"]:
|
|
setattr(results, attr, copy.deepcopy(val) if isinstance(val, (list, dict)) else val)
|
|
for attr, val in object.__dict__.items():
|
|
if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes", "transforms", "objects_info", "name"]:
|
|
if not hasattr(results, attr) or getattr(results, attr) is None or getattr(results, attr) == "" or getattr(results, attr) == []:
|
|
setattr(results, attr, copy.deepcopy(val) if isinstance(val, (list, dict)) else val)
|
|
|
|
merged_transforms = []
|
|
merged_names = []
|
|
merged_objs = []
|
|
for sp in [subject, object]:
|
|
obj_info = getattr(sp, "objects_info", None)
|
|
if obj_info:
|
|
for obj_entry in obj_info.get("objects", []):
|
|
if obj_entry not in merged_objs:
|
|
merged_objs.append(dict(obj_entry))
|
|
n_str = obj_entry.get("name", "")
|
|
if n_str and n_str not in merged_names:
|
|
merged_names.append(n_str)
|
|
elif hasattr(sp, "name") and getattr(sp, "name"):
|
|
n_str = str(getattr(sp, "name"))
|
|
if n_str and n_str not in merged_names:
|
|
merged_names.append(n_str)
|
|
merged_objs.append({"name": n_str, "polygon": []})
|
|
|
|
subj_trans = getattr(subject, "transforms", None)
|
|
obj_trans = getattr(object, "transforms", None)
|
|
results.transforms = [dict(t) for t in subj_trans] if subj_trans else ([dict(t) for t in obj_trans] if obj_trans else [])
|
|
if merged_objs:
|
|
results.objects_info = {"objects": merged_objs}
|
|
results.name = merged_names[0] if merged_names else ""
|
|
|
|
return results
|
|
|
|
def minimum_coverage_slice(
|
|
paths_list: list[SlicePaths],
|
|
connect_along_boundary: bool = False
|
|
) -> SlicePaths:
|
|
"""
|
|
Computes the Minimum Coverage across multiple SlicePaths inputs (or within a single SlicePaths input).
|
|
For each layer (grouped by integer micron Z), it calculates the intersection of all objects having paths on that layer.
|
|
In layers where some objects have no paths, the empty paths are ignored (so the result doesn't end up being zero for that layer).
|
|
"""
|
|
valid_paths = [sp for sp in paths_list if isinstance(sp, SlicePaths) and len(sp) > 0]
|
|
if not valid_paths:
|
|
return SlicePaths()
|
|
|
|
primary = valid_paths[0]
|
|
|
|
all_z_keys: set[int] = set()
|
|
input_layer_maps: list[dict[int, list[np.ndarray]]] = []
|
|
|
|
for sp in valid_paths:
|
|
layer_map: dict[int, list[np.ndarray]] = {}
|
|
for p in sp:
|
|
if len(p) < 2:
|
|
continue
|
|
z_key = int(round(p[0, 2] * SCALE))
|
|
all_z_keys.add(z_key)
|
|
layer_map.setdefault(z_key, []).append(p)
|
|
input_layer_maps.append(layer_map)
|
|
|
|
results = SlicePaths()
|
|
group = 0
|
|
|
|
for z_key in sorted(all_z_keys):
|
|
active_indices = [
|
|
i for i, lmap in enumerate(input_layer_maps) if z_key in lmap and len(lmap[z_key]) > 0
|
|
]
|
|
|
|
if not active_indices:
|
|
continue
|
|
|
|
if len(active_indices) == 1:
|
|
# Only one object has paths on this layer -> empty paths of other objects ignored
|
|
idx = active_indices[0]
|
|
layer_paths = input_layer_maps[idx][z_key]
|
|
|
|
# If this single object has multiple closed paths on this layer (e.g. concentric perimeters):
|
|
closed = [p for p in layer_paths if is_closed_path(p)]
|
|
open_p = [p for p in layer_paths if not is_closed_path(p)]
|
|
|
|
if len(closed) > 1 and len(valid_paths) == 1:
|
|
# Intersect concentric perimeters to extract the innermost perimeter
|
|
curr_sp = valid_paths[idx].copy_with([closed[0]])
|
|
for c in closed[1:]:
|
|
inter_res = boolean_slice(
|
|
curr_sp,
|
|
valid_paths[idx].copy_with([c]),
|
|
operation='INTERSECTION',
|
|
connect_along_boundary=connect_along_boundary
|
|
)
|
|
if inter_res:
|
|
curr_sp = inter_res
|
|
else:
|
|
# If disjoint islands, preserve both!
|
|
curr_sp.extend([c])
|
|
if open_p:
|
|
curr_sp.extend(open_p)
|
|
if curr_sp:
|
|
results.extend(curr_sp)
|
|
results.groups.extend([group] * len(curr_sp))
|
|
group += 1
|
|
else:
|
|
results.extend(layer_paths)
|
|
results.groups.extend([group] * len(layer_paths))
|
|
group += 1
|
|
continue
|
|
|
|
# Multiple active inputs on this layer -> intersect across active inputs
|
|
idx0 = active_indices[0]
|
|
curr_sp = valid_paths[idx0].copy_with(input_layer_maps[idx0][z_key])
|
|
|
|
for next_idx in active_indices[1:]:
|
|
next_sp = valid_paths[next_idx].copy_with(input_layer_maps[next_idx][z_key])
|
|
curr_sp = boolean_slice(
|
|
curr_sp,
|
|
next_sp,
|
|
operation='INTERSECTION',
|
|
connect_along_boundary=connect_along_boundary
|
|
)
|
|
if not curr_sp:
|
|
break
|
|
|
|
if curr_sp:
|
|
results.extend(curr_sp)
|
|
results.groups.extend([group] * len(curr_sp))
|
|
group += 1
|
|
|
|
calc_ext, calc_feed, calc_extra = _transfer_group_attributes(primary, results, results.groups)
|
|
results.extrusion_rate = calc_ext
|
|
results.feedrate = calc_feed
|
|
results.extra_attributes = calc_extra
|
|
|
|
for attr, val in primary.__dict__.items():
|
|
if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes", "transforms", "objects_info", "name"]:
|
|
setattr(results, attr, copy.deepcopy(val) if isinstance(val, (list, dict)) else val)
|
|
|
|
merged_names = []
|
|
merged_objs = []
|
|
for sp in valid_paths:
|
|
obj_info = getattr(sp, "objects_info", None)
|
|
if obj_info:
|
|
for obj_entry in obj_info.get("objects", []):
|
|
if obj_entry not in merged_objs:
|
|
merged_objs.append(dict(obj_entry))
|
|
n_str = obj_entry.get("name", "")
|
|
if n_str and n_str not in merged_names:
|
|
merged_names.append(n_str)
|
|
elif hasattr(sp, "name") and getattr(sp, "name"):
|
|
n_str = str(getattr(sp, "name"))
|
|
if n_str and n_str not in merged_names:
|
|
merged_names.append(n_str)
|
|
merged_objs.append({"name": n_str, "polygon": []})
|
|
|
|
results.transforms = [dict(t) for t in getattr(primary, "transforms", [])]
|
|
if merged_objs:
|
|
results.objects_info = {"objects": merged_objs}
|
|
results.name = merged_names[0] if merged_names else ""
|
|
|
|
return results
|
|
|
|
def maximum_coverage_slice(
|
|
paths_list: list[SlicePaths],
|
|
connect_along_boundary: bool = False
|
|
) -> SlicePaths:
|
|
"""
|
|
Computes the Maximum Coverage across multiple SlicePaths inputs (or within a single SlicePaths input).
|
|
For each layer (grouped by integer micron Z), it calculates the boolean union of all polygons.
|
|
"""
|
|
valid_paths = [sp for sp in paths_list if isinstance(sp, SlicePaths) and len(sp) > 0]
|
|
if not valid_paths:
|
|
return SlicePaths()
|
|
|
|
if len(valid_paths) == 1:
|
|
# Single input: if it has multiple perimeters/shapes on a layer, union them
|
|
sp = valid_paths[0]
|
|
layer_map: dict[int, list[np.ndarray]] = {}
|
|
for p in sp:
|
|
if len(p) < 2:
|
|
continue
|
|
z_key = int(round(p[0, 2] * SCALE))
|
|
layer_map.setdefault(z_key, []).append(p)
|
|
|
|
results = SlicePaths()
|
|
group = 0
|
|
for z_key in sorted(layer_map.keys()):
|
|
l_paths = layer_map[z_key]
|
|
if len(l_paths) <= 1:
|
|
results.extend(l_paths)
|
|
results.groups.extend([group] * len(l_paths))
|
|
group += 1
|
|
else:
|
|
closed = [p for p in l_paths if is_closed_path(p)]
|
|
open_p = [p for p in l_paths if not is_closed_path(p)]
|
|
if len(closed) <= 1:
|
|
results.extend(l_paths)
|
|
results.groups.extend([group] * len(l_paths))
|
|
group += 1
|
|
else:
|
|
# Union all closed paths on this layer
|
|
curr_sp = sp.copy_with([closed[0]])
|
|
for c in closed[1:]:
|
|
curr_sp = boolean_slice(
|
|
curr_sp,
|
|
sp.copy_with([c]),
|
|
operation='UNION',
|
|
connect_along_boundary=connect_along_boundary
|
|
)
|
|
if open_p:
|
|
curr_sp.extend(open_p)
|
|
curr_sp.groups.extend([group] * len(open_p))
|
|
if curr_sp:
|
|
results.extend(curr_sp)
|
|
results.groups.extend([group] * len(curr_sp))
|
|
group += 1
|
|
|
|
calc_ext, calc_feed, calc_extra = _transfer_group_attributes(sp, results, results.groups)
|
|
results.extrusion_rate = calc_ext
|
|
results.feedrate = calc_feed
|
|
results.extra_attributes = calc_extra
|
|
for attr, val in sp.__dict__.items():
|
|
if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes", "transforms", "objects_info", "name"]:
|
|
setattr(results, attr, copy.deepcopy(val) if isinstance(val, (list, dict)) else val)
|
|
|
|
results.transforms = [dict(t) for t in getattr(sp, "transforms", [])]
|
|
results.name = getattr(sp, "name", "")
|
|
results.objects_info = copy.deepcopy(getattr(sp, "objects_info", None))
|
|
return results
|
|
|
|
# Multiple inputs: sequentially union all inputs with boolean_slice
|
|
accumulated = valid_paths[0]
|
|
for sp in valid_paths[1:]:
|
|
accumulated = boolean_slice(
|
|
accumulated,
|
|
sp,
|
|
operation='UNION',
|
|
connect_along_boundary=connect_along_boundary
|
|
)
|
|
return accumulated
|
|
|
|
def intersection_slice(
|
|
paths_list: list[SlicePaths],
|
|
connect_along_boundary: bool = False
|
|
) -> SlicePaths:
|
|
"""Computes the Intersection across multiple SlicePaths inputs (same behavior as Boolean Paths)."""
|
|
if not paths_list:
|
|
return SlicePaths()
|
|
if len(paths_list) == 1:
|
|
return paths_list[0]
|
|
|
|
accumulated = paths_list[0]
|
|
for sp in paths_list[1:]:
|
|
if not sp or not accumulated:
|
|
return SlicePaths()
|
|
accumulated = boolean_slice(
|
|
accumulated,
|
|
sp,
|
|
operation='INTERSECTION',
|
|
connect_along_boundary=connect_along_boundary
|
|
)
|
|
if not accumulated:
|
|
return SlicePaths()
|
|
return accumulated
|
|
|
|
def subtract_slice(
|
|
paths_list: list[SlicePaths],
|
|
connect_along_boundary: bool = False
|
|
) -> SlicePaths:
|
|
"""Computes the Difference (Subtract) of subsequent SlicePaths inputs from the first input."""
|
|
if not paths_list:
|
|
return SlicePaths()
|
|
if len(paths_list) == 1:
|
|
return paths_list[0]
|
|
|
|
accumulated = paths_list[0]
|
|
for sp in paths_list[1:]:
|
|
if not accumulated:
|
|
return SlicePaths()
|
|
if not sp:
|
|
continue
|
|
accumulated = boolean_slice(
|
|
accumulated,
|
|
sp,
|
|
operation='DIFFERENCE',
|
|
connect_along_boundary=connect_along_boundary
|
|
)
|
|
return accumulated
|
|
|
|
def xor_slice(
|
|
paths_list: list[SlicePaths],
|
|
connect_along_boundary: bool = False
|
|
) -> SlicePaths:
|
|
"""Computes the Symmetric Difference (XOR) across multiple SlicePaths inputs."""
|
|
if not paths_list:
|
|
return SlicePaths()
|
|
if len(paths_list) == 1:
|
|
return paths_list[0]
|
|
|
|
accumulated = paths_list[0]
|
|
for sp in paths_list[1:]:
|
|
if not sp:
|
|
continue
|
|
if not accumulated:
|
|
accumulated = sp
|
|
continue
|
|
accumulated = boolean_slice(
|
|
accumulated,
|
|
sp,
|
|
operation='XOR',
|
|
connect_along_boundary=connect_along_boundary
|
|
)
|
|
return accumulated
|
|
|
|
def path_operations_slice(
|
|
paths_list: list[SlicePaths],
|
|
operation: str = 'ZIP',
|
|
connect_along_boundary: bool = False
|
|
) -> SlicePaths:
|
|
"""Dispatches Path Operations across multiple SlicePaths inputs."""
|
|
if not paths_list:
|
|
return SlicePaths()
|
|
|
|
op = str(operation).upper()
|
|
if op in ('ZIP', 'ZIP_PATHS'):
|
|
return SlicePaths.zip_paths(paths_list)
|
|
elif op in ('EXTEND', 'EXTEND_PATHS'):
|
|
return SlicePaths.extend_paths(paths_list)
|
|
elif op in ('MAX_COVERAGE', 'MAXIMUM_COVERAGE', 'UNION'):
|
|
return maximum_coverage_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
elif op in ('MIN_COVERAGE', 'MINIMUM_COVERAGE'):
|
|
return minimum_coverage_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
elif op in ('INTERSECTION', 'INTERSECT'):
|
|
return intersection_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
elif op in ('SUBTRACT', 'DIFFERENCE'):
|
|
return subtract_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
elif op == 'XOR':
|
|
return xor_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
else:
|
|
return SlicePaths.zip_paths(paths_list)
|
|
|
|
def geometry_to_paths(geom: Any, z: float) -> list[np.ndarray]:
|
|
"""Helper to convert shapely geometries to a list of 3D numpy arrays with a given Z height."""
|
|
paths = []
|
|
if geom.is_empty:
|
|
return paths
|
|
|
|
if isinstance(geom, Polygon):
|
|
ext_coords = np.asarray(geom.exterior.coords, dtype=np.float64)
|
|
N = len(ext_coords)
|
|
ext_coords_3d = np.empty((N, 3), dtype=np.float64)
|
|
ext_coords_3d[:, :2] = ext_coords
|
|
ext_coords_3d[:, 2] = z
|
|
paths.append(ext_coords_3d)
|
|
for interior in geom.interiors:
|
|
int_coords = np.asarray(interior.coords, dtype=np.float64)
|
|
M = len(int_coords)
|
|
int_coords_3d = np.empty((M, 3), dtype=np.float64)
|
|
int_coords_3d[:, :2] = int_coords
|
|
int_coords_3d[:, 2] = z
|
|
paths.append(int_coords_3d)
|
|
elif isinstance(geom, (MultiPolygon, GeometryCollection)):
|
|
for poly in geom.geoms:
|
|
paths.extend(geometry_to_paths(poly, z))
|
|
|
|
return paths
|
|
|
|
@ndarray_lru_cache(maxsize=64)
|
|
def buffer_curve_slice(
|
|
slice_paths: list[np.ndarray],
|
|
distance: float,
|
|
cap_style: str = 'round',
|
|
join_style: str = 'round',
|
|
single_sided: bool = False
|
|
) -> tuple[list[np.ndarray], list[int]]:
|
|
if not slice_paths or distance <= 0:
|
|
return [], []
|
|
|
|
from shapely.geometry import LineString
|
|
|
|
# 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 * SCALE))
|
|
groups.setdefault(z_key, []).append(p)
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
|
|
group_id = 0
|
|
for z_key in sorted(groups.keys()):
|
|
layer_paths = groups[z_key]
|
|
z = float(z_key) / SCALE
|
|
|
|
for p in layer_paths:
|
|
if len(p) < 2:
|
|
continue
|
|
|
|
pts_2d = p[:, :2]
|
|
line = LineString(pts_2d)
|
|
|
|
poly = line.buffer(
|
|
distance,
|
|
cap_style=cast(Literal['round', 'square', 'flat'], cap_style),
|
|
join_style=cast(Literal['round', 'mitre', 'bevel'], join_style),
|
|
single_sided=single_sided,
|
|
quad_segs=16
|
|
)
|
|
|
|
layer_result_paths = geometry_to_paths(poly, z)
|
|
|
|
for path in layer_result_paths:
|
|
paths_list.append(path)
|
|
groups_list.append(group_id)
|
|
|
|
group_id += 1
|
|
|
|
return paths_list, groups_list
|
|
|
|
def offset_geometry_to_paths(geom: Any, z: float) -> list[np.ndarray]:
|
|
"""Helper to convert shapely line geometries to a list of 3D numpy arrays with a given Z height."""
|
|
paths = []
|
|
if geom.is_empty:
|
|
return paths
|
|
|
|
if isinstance(geom, (LineString, LinearRing)):
|
|
coords = np.asarray(geom.coords, dtype=np.float64)
|
|
N = len(coords)
|
|
coords_3d = np.empty((N, 3), dtype=np.float64)
|
|
coords_3d[:, :2] = coords
|
|
coords_3d[:, 2] = z
|
|
paths.append(coords_3d)
|
|
elif isinstance(geom, MultiLineString):
|
|
for line in geom.geoms:
|
|
coords = np.asarray(line.coords, dtype=np.float64)
|
|
N = len(coords)
|
|
coords_3d = np.empty((N, 3), dtype=np.float64)
|
|
coords_3d[:, :2] = coords
|
|
coords_3d[:, 2] = z
|
|
paths.append(coords_3d)
|
|
elif isinstance(geom, GeometryCollection):
|
|
for sub_geom in geom.geoms:
|
|
paths.extend(offset_geometry_to_paths(sub_geom, z))
|
|
elif isinstance(geom, (Polygon, MultiPolygon)):
|
|
boundary = geom.boundary
|
|
paths.extend(offset_geometry_to_paths(boundary, z))
|
|
return paths
|
|
|
|
@ndarray_lru_cache(maxsize=64)
|
|
def offset_curve_slice(
|
|
slice_paths: list[np.ndarray],
|
|
distance: float,
|
|
join_style: Literal['round', 'mitre'] = 'round'
|
|
) -> tuple[list[np.ndarray], list[int]]:
|
|
if not slice_paths:
|
|
return [], []
|
|
|
|
from shapely.geometry import LineString, Polygon, LinearRing
|
|
|
|
# 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 * SCALE))
|
|
groups.setdefault(z_key, []).append(p)
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
|
|
group_id = 0
|
|
for z_key in sorted(groups.keys()):
|
|
layer_paths = groups[z_key]
|
|
z = float(z_key) / SCALE
|
|
|
|
for p in layer_paths:
|
|
if len(p) < 2:
|
|
continue
|
|
|
|
pts_2d = p[:, :2]
|
|
is_closed = is_closed_path(p)
|
|
|
|
if is_closed:
|
|
try:
|
|
# Check direction/orientation
|
|
ccw = LinearRing(pts_2d).is_ccw
|
|
poly = Polygon(pts_2d)
|
|
if poly.is_valid:
|
|
# Use Polygon buffer which keeps boundary closed
|
|
buf_dist = distance if ccw else -distance
|
|
offset_geom = poly.buffer(
|
|
buf_dist,
|
|
join_style=join_style,
|
|
quad_segs=16
|
|
)
|
|
else:
|
|
line = LineString(pts_2d)
|
|
offset_geom = line.offset_curve(
|
|
distance,
|
|
join_style=join_style,
|
|
quad_segs=16
|
|
)
|
|
except Exception:
|
|
line = LineString(pts_2d)
|
|
offset_geom = line.offset_curve(
|
|
distance,
|
|
join_style=join_style,
|
|
quad_segs=16
|
|
)
|
|
else:
|
|
line = LineString(pts_2d)
|
|
offset_geom = line.offset_curve(
|
|
distance,
|
|
join_style=join_style,
|
|
quad_segs=16
|
|
)
|
|
|
|
layer_result_paths = offset_geometry_to_paths(offset_geom, z)
|
|
|
|
for path in layer_result_paths:
|
|
paths_list.append(path)
|
|
groups_list.append(group_id)
|
|
|
|
group_id += 1
|
|
|
|
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 in 1-micron space."""
|
|
if not paths:
|
|
return Polygon()
|
|
|
|
import pyclipper
|
|
from shapely.ops import unary_union
|
|
|
|
pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
|
|
for p in paths:
|
|
if len(p) < 3:
|
|
continue
|
|
pts_2d = p[:, :2]
|
|
if is_closed_path(p):
|
|
pts_2d = pts_2d[:-1]
|
|
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]
|
|
|
|
def polytree_to_shapely(node) -> list[Polygon]:
|
|
polygons = []
|
|
if hasattr(node, 'Contour') and node.Contour and not node.IsHole:
|
|
if len(node.Contour) >= 3:
|
|
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, dtype=np.float64) / SCALE)
|
|
for grandchild in child.Childs:
|
|
child_islands.append(grandchild)
|
|
else:
|
|
child_islands.append(child)
|
|
poly = Polygon(exterior, interiors)
|
|
if not poly.is_valid:
|
|
poly = poly.buffer(0)
|
|
if not poly.is_empty:
|
|
polygons.append(poly)
|
|
for island in child_islands:
|
|
polygons.extend(polytree_to_shapely(island))
|
|
else:
|
|
for child in node.Childs:
|
|
polygons.extend(polytree_to_shapely(child))
|
|
return polygons
|
|
|
|
polys = polytree_to_shapely(tree)
|
|
return unary_union(polys)
|
|
|
|
@ndarray_lru_cache(maxsize=64)
|
|
def get_infill_mask_slice(slice_paths: list[np.ndarray]) -> tuple[list[np.ndarray], list[int]]:
|
|
if not slice_paths:
|
|
return [], []
|
|
|
|
# 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 * SCALE))
|
|
groups.setdefault(z_key, []).append(p)
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
|
|
group_id = 0
|
|
for z_key in sorted(groups.keys()):
|
|
layer_paths = groups[z_key]
|
|
if not layer_paths:
|
|
continue
|
|
|
|
z = float(z_key) / SCALE
|
|
|
|
# Convert layer paths to a single Shapely geometry (handling holes and multiple islands)
|
|
geom = paths_to_shapely_polygon(layer_paths)
|
|
if geom.is_empty:
|
|
continue
|
|
|
|
# Convert resulting polygon back to 3D paths
|
|
result_paths = geometry_to_paths(geom, z)
|
|
|
|
for path in result_paths:
|
|
paths_list.append(path)
|
|
groups_list.append(group_id)
|
|
|
|
group_id += 1
|
|
|
|
return paths_list, groups_list
|
|
|
|
@ndarray_lru_cache(maxsize=32)
|
|
def get_overhang_mask_slice(slice_paths: list[np.ndarray], groups: tuple[int, ...], tolerance: float = 0.05) -> tuple[list[np.ndarray], list[int]]:
|
|
if not slice_paths or not groups:
|
|
return [], []
|
|
|
|
from shapely.geometry import Polygon
|
|
|
|
# Group paths by their group index
|
|
group_map: dict[int, list[np.ndarray]] = {}
|
|
for p, g in zip(slice_paths, groups):
|
|
group_map.setdefault(g, []).append(p)
|
|
|
|
sorted_groups = sorted(
|
|
group_map.keys(),
|
|
key=lambda g: int(round(group_map[g][0][0, 2] * SCALE)) if group_map[g] and len(group_map[g][0]) > 0 else g
|
|
)
|
|
if len(sorted_groups) < 2:
|
|
# If there's only 1 layer, there are no overhangs under any layer
|
|
return [], []
|
|
|
|
# Pre-build solid geometry for each group
|
|
layer_geoms = {g: paths_to_shapely_polygon(group_map[g]) for g in sorted_groups}
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
|
|
accumulated = Polygon()
|
|
|
|
# Iterate from top group down to bottom group
|
|
for i in range(len(sorted_groups) - 1, -1, -1):
|
|
g = sorted_groups[i]
|
|
z = float(int(round(group_map[g][0][0, 2] * SCALE))) / SCALE if group_map[g] and len(group_map[g][0]) > 0 else 0.0
|
|
geom_i = layer_geoms[g]
|
|
|
|
# Calculate raw support needed at layer g: union of all layers above g, minus geom_i.
|
|
# This prevents boundary mismatches/slivers caused by subtracting and then unioning back the same layers.
|
|
if i < len(sorted_groups) - 1:
|
|
raw_support = accumulated.difference(geom_i)
|
|
if not raw_support.is_empty:
|
|
# Apply tolerance clearance only to the output of the current layer.
|
|
geom_i_sub = geom_i.buffer(tolerance) if tolerance > 0 else geom_i
|
|
output_geom = raw_support.difference(geom_i_sub)
|
|
if not output_geom.is_empty:
|
|
result_paths = geometry_to_paths(output_geom, z)
|
|
for path in result_paths:
|
|
paths_list.append(path)
|
|
groups_list.append(g)
|
|
|
|
# Accumulate the union of model layers for the layers below
|
|
accumulated = accumulated.union(geom_i)
|
|
|
|
# Sort output by group index so that paths are ordered bottom-to-top
|
|
sorted_indices = np.argsort(groups_list)
|
|
paths_list = [paths_list[idx] for idx in sorted_indices]
|
|
groups_list = [int(groups_list[idx]) for idx in sorted_indices]
|
|
|
|
return paths_list, groups_list
|
|
|
|
@ndarray_lru_cache(maxsize=64)
|
|
def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', tolerance=.01) -> tuple[list[np.ndarray], list[int]]:
|
|
|
|
if not slice_paths: return [], []
|
|
|
|
# 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 * SCALE))
|
|
groups.setdefault(z_key, []).append(p)
|
|
|
|
join_types = {
|
|
'round': pyclipper.JT_ROUND, # pyright: ignore[reportAttributeAccessIssue]
|
|
'square': pyclipper.JT_SQUARE, # pyright: ignore[reportAttributeAccessIssue]
|
|
'flat': pyclipper.JT_SQUARE, # pyright: ignore[reportAttributeAccessIssue]
|
|
'miter': pyclipper.JT_MITER # pyright: ignore[reportAttributeAccessIssue]
|
|
}
|
|
jt = join_types.get(miter, pyclipper.JT_ROUND) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
pco = pyclipper.PyclipperOffset(arc_tolerance=int(round(tolerance * SCALE))) # pyright: ignore[reportAttributeAccessIssue]
|
|
scale_inv = 1.0 / SCALE
|
|
delta_scaled = int(round(distance * SCALE))
|
|
|
|
# Buffer each layer independently
|
|
group_id = 0
|
|
for z_key in sorted(groups.keys()):
|
|
layer_paths = groups[z_key]
|
|
z = float(z_key) * scale_inv
|
|
|
|
pco.Clear()
|
|
scaled_paths = []
|
|
for p in layer_paths:
|
|
if len(p) < 3:
|
|
continue
|
|
path_pts = p[:-1, :2] if is_closed_path(p) else p[:, :2]
|
|
if len(path_pts) < 3:
|
|
continue
|
|
scaled_paths.append(np.rint(path_pts * SCALE).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(delta_scaled)
|
|
|
|
for path in solution:
|
|
if not path:
|
|
continue
|
|
N = len(path)
|
|
fpath_3d = np.empty((N + 1, 3), dtype=np.float64)
|
|
arr = np.asarray(path, dtype=np.float64)
|
|
fpath_3d[:N, :2] = arr * scale_inv
|
|
fpath_3d[:N, 2] = z
|
|
fpath_3d[N] = fpath_3d[0]
|
|
fpath_3d = align_path_seam_sharpest(fpath_3d)
|
|
paths_list.append(fpath_3d)
|
|
groups_list.append(group_id)
|
|
|
|
group_id += 1
|
|
|
|
return paths_list, groups_list
|
|
|
|
|
|
@ndarray_lru_cache(maxsize=64)
|
|
def buffer_multiple_slice(
|
|
slice_paths: list[np.ndarray],
|
|
base_distance: float,
|
|
count: int,
|
|
width_step: float,
|
|
miter: str = 'round',
|
|
tolerance: float = 0.01
|
|
) -> tuple[list[np.ndarray], list[int]]:
|
|
"""Generates multiple concentric buffers per layer in a single pass."""
|
|
if not slice_paths or count <= 0:
|
|
return [], []
|
|
|
|
groups: dict[int, list[np.ndarray]] = {}
|
|
for p in slice_paths:
|
|
z = p[0, 2]
|
|
z_key = int(round(z * SCALE))
|
|
groups.setdefault(z_key, []).append(p)
|
|
|
|
join_types = {
|
|
'round': pyclipper.JT_ROUND, # pyright: ignore[reportAttributeAccessIssue]
|
|
'square': pyclipper.JT_SQUARE, # pyright: ignore[reportAttributeAccessIssue]
|
|
'flat': pyclipper.JT_SQUARE, # pyright: ignore[reportAttributeAccessIssue]
|
|
'miter': pyclipper.JT_MITER # pyright: ignore[reportAttributeAccessIssue]
|
|
}
|
|
jt = join_types.get(miter, pyclipper.JT_ROUND) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
pco = pyclipper.PyclipperOffset(arc_tolerance=int(round(tolerance * SCALE))) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
scaled_dists = [int(round((base_distance + i * width_step) * SCALE)) for i in range(count)]
|
|
scale_inv = 1.0 / SCALE
|
|
|
|
group_id = 0
|
|
for z_key in sorted(groups.keys()):
|
|
layer_paths = groups[z_key]
|
|
z = float(z_key) * scale_inv
|
|
|
|
pco.Clear()
|
|
scaled_paths = []
|
|
for p in layer_paths:
|
|
if len(p) < 3:
|
|
continue
|
|
path_pts = p[:-1, :2] if is_closed_path(p) else p[:, :2]
|
|
if len(path_pts) < 3:
|
|
continue
|
|
scaled_paths.append(np.rint(path_pts * SCALE).astype(np.int64).tolist())
|
|
|
|
if not scaled_paths:
|
|
continue
|
|
|
|
pco.AddPaths(scaled_paths, jt, pyclipper.ET_CLOSEDPOLYGON) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
for d_scaled in scaled_dists:
|
|
solution = pco.Execute(d_scaled)
|
|
for path in solution:
|
|
if not path:
|
|
continue
|
|
N = len(path)
|
|
fpath_3d = np.empty((N + 1, 3), dtype=np.float64)
|
|
arr = np.asarray(path, dtype=np.float64)
|
|
fpath_3d[:N, :2] = arr * scale_inv
|
|
fpath_3d[:N, 2] = z
|
|
fpath_3d[N] = fpath_3d[0]
|
|
fpath_3d = align_path_seam_sharpest(fpath_3d)
|
|
paths_list.append(fpath_3d)
|
|
groups_list.append(group_id)
|
|
|
|
group_id += 1
|
|
|
|
return paths_list, groups_list
|
|
|
|
@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 using 1-micron integer grid."""
|
|
if not slice_paths or density <= 0: return [], []
|
|
|
|
rotation = np.radians(rotation_deg)
|
|
|
|
# Calculate global bounding box for the entire model to ensure grid alignment
|
|
all_pts_model = np.concatenate(slice_paths)
|
|
g_min_x, g_min_y = all_pts_model[:, 0].min(), all_pts_model[:, 1].min()
|
|
g_max_x, g_max_y = all_pts_model[:, 0].max(), all_pts_model[:, 1].max()
|
|
global_center = np.array([(g_min_x + g_max_x) / 2, (g_min_y + g_max_y) / 2, 0])
|
|
|
|
# Pre-calculate rotation matrices
|
|
c_inv, s_inv = np.cos(-rotation), np.sin(-rotation)
|
|
rot_mat_inv = np.array([[c_inv, -s_inv, 0], [s_inv, c_inv, 0], [0, 0, 1]])
|
|
|
|
c, s = np.cos(rotation), np.sin(rotation)
|
|
rot_mat = np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])
|
|
|
|
# Determine grid bounds in the rotated space to ensure full coverage
|
|
corners = np.array([
|
|
[g_min_x, g_min_y, 0], [g_max_x, g_min_y, 0],
|
|
[g_min_x, g_max_y, 0], [g_max_x, g_max_y, 0]
|
|
])
|
|
rotated_corners = (corners - global_center) @ rot_mat_inv.T + global_center
|
|
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()
|
|
|
|
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_um = rg_min_x_um
|
|
while x_um <= rg_max_x_um:
|
|
vertical_lines.append([
|
|
(x_um, rg_min_y_um),
|
|
(x_um, rg_max_y_um)
|
|
])
|
|
x_um += density_um
|
|
|
|
horizontal_lines = []
|
|
y_um = rg_min_y_um
|
|
while y_um <= rg_max_y_um:
|
|
horizontal_lines.append([
|
|
(rg_min_x_um, y_um),
|
|
(rg_max_x_um, y_um)
|
|
])
|
|
y_um += density_um
|
|
|
|
# Group by Z in integer microns
|
|
groups: dict[int, list[np.ndarray]] = {}
|
|
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] * SCALE))
|
|
groups.setdefault(z_key, []).append(p)
|
|
|
|
output_paths: list[np.ndarray] = []
|
|
output_groups: list[int] = []
|
|
|
|
# Sort groups by Z in integer microns to determine layer index for alternating rectilinear infill
|
|
unique_groups = sorted(
|
|
groups.keys(),
|
|
key=lambda g: int(round(groups[g][0][0, 2] * SCALE)) if groups[g] and len(groups[g][0]) > 0 else g
|
|
)
|
|
group_to_layer_index = {g: i for i, g in enumerate(unique_groups)}
|
|
|
|
for g in unique_groups:
|
|
layer_idx = group_to_layer_index[g]
|
|
|
|
if infill_type == 'GRID':
|
|
current_grid_paths = vertical_lines + horizontal_lines
|
|
elif infill_type == 'RECTILINEAR':
|
|
current_grid_paths = vertical_lines if layer_idx % 2 == 0 else horizontal_lines
|
|
elif infill_type == 'LINES':
|
|
current_grid_paths = vertical_lines
|
|
else:
|
|
current_grid_paths = []
|
|
|
|
layer_paths = groups[g]
|
|
z = float(int(round(layer_paths[0][0, 2] * SCALE))) / SCALE if layer_paths and len(layer_paths[0]) > 0 else 0.0
|
|
|
|
# De-rotate layer paths
|
|
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 in integer micron space
|
|
pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
for p in active_paths:
|
|
path_pts = p[:, :2]
|
|
if is_closed_path(p):
|
|
path_pts = path_pts[:-1]
|
|
if len(path_pts) < 3: continue
|
|
scaled = (path_pts * SCALE).round().astype(np.int64).tolist()
|
|
pc.AddPath(scaled, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
if current_grid_paths:
|
|
pc.AddPaths(current_grid_paths, pyclipper.PT_SUBJECT, False) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
solution = pc.Execute2(pyclipper.CT_INTERSECTION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
|
|
clipped_lines = pyclipper.OpenPathsFromPolyTree(solution) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
# Rotate back
|
|
for path in clipped_lines:
|
|
if not path: continue
|
|
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)
|
|
output_groups.append(g)
|
|
|
|
return output_paths, output_groups
|
|
|
|
def group_segments_by_z(segments: np.ndarray) -> dict[int, np.ndarray]:
|
|
if len(segments) == 0:
|
|
return {}
|
|
z_um = np.round(segments[:, 0, 2] * SCALE).astype(np.int64)
|
|
unique_z_um = np.unique(z_um)
|
|
return {int(z): segments[z_um == z] for z in unique_z_um}
|
|
|
|
############################################################
|
|
|
|
class Pather(object):
|
|
|
|
def __init__(self, paths: list[np.ndarray], **kwargs) -> None:
|
|
self.paths = paths
|
|
|
|
# Robust initialization: ensure metadata lists match paths length
|
|
e_rate = getattr(paths, "extrusion_rate", None)
|
|
if e_rate is not None and len(e_rate) == len(paths):
|
|
self.extrusion_rates = e_rate
|
|
else:
|
|
self.extrusion_rates = [np.ones(len(p)) for p in paths]
|
|
|
|
f_rate = getattr(paths, "feedrate", None)
|
|
if f_rate is not None and len(f_rate) == len(paths):
|
|
self.feedrates = f_rate
|
|
else:
|
|
self.feedrates = [np.zeros(len(p)) for p in paths]
|
|
|
|
extra = getattr(paths, "extra_attributes", None)
|
|
if extra is not None and all(len(v) == len(paths) for v in extra.values()):
|
|
self.extra_attributes = extra
|
|
else:
|
|
self.extra_attributes = {}
|
|
|
|
self.bed_center_x: float = 250.
|
|
self.bed_center_y: float = 250.
|
|
self.feedrate: float = 1200.
|
|
self.travel_feedrate: float = 2400.
|
|
self.extrusion_rate: float = 0.017
|
|
self.layer_height: float = 0.2
|
|
self.first_layer_height: float | None = None
|
|
self._layer_width: float | None = None
|
|
self.retraction: float = 0.0
|
|
self.retraction_feedrate: float = 0.0
|
|
self.lift: float = 0.0
|
|
self.transforms: list[dict[str, Any]] = list(getattr(paths, "transforms", []))
|
|
self.objects_info: dict[str, Any] | None = getattr(paths, "objects_info", None)
|
|
self.name: str = str(getattr(paths, "name", ""))
|
|
|
|
for k, v in kwargs.items():
|
|
setattr(self, k, v)
|
|
|
|
@property
|
|
def layer_width(self) -> float:
|
|
if self._layer_width is not None:
|
|
return self._layer_width
|
|
if self.layer_height > 0:
|
|
import math
|
|
filament_area = math.pi * (1.75 / 2.0) ** 2
|
|
return (self.extrusion_rate * filament_area) / self.layer_height
|
|
return 0.4
|
|
|
|
@layer_width.setter
|
|
def layer_width(self, val: float | None):
|
|
self._layer_width = val
|
|
|
|
def to_gcode(self, include_header: bool = True) -> str:
|
|
if not self.paths:
|
|
return ""
|
|
|
|
# Centering logic using NumPy
|
|
all_pts = np.concatenate(self.paths)
|
|
min_xy = all_pts[:, :2].min(axis=0)
|
|
max_xy = all_pts[:, :2].max(axis=0)
|
|
center_xy = (min_xy + max_xy) / 2.0
|
|
|
|
offset_x = self.bed_center_x - center_xy[0]
|
|
offset_y = self.bed_center_y - center_xy[1]
|
|
|
|
# Apply offset to all paths
|
|
centered_paths = []
|
|
for path in self.paths:
|
|
new_path = path.copy()
|
|
new_path[:, 0] += offset_x
|
|
new_path[:, 1] += offset_y
|
|
centered_paths.append(new_path)
|
|
|
|
try:
|
|
retraction_fr_val = float(self.retraction_feedrate) if self.retraction_feedrate is not None else 0.0
|
|
except (TypeError, ValueError):
|
|
retraction_fr_val = 0.0
|
|
|
|
if retraction_fr_val <= 0.0:
|
|
retraction_fr_val = self.feedrate
|
|
|
|
gcode: list[str] = []
|
|
|
|
if include_header:
|
|
header: list[str] = [
|
|
"; Generated from NodeSlicer",
|
|
f";TYPE:Custom",
|
|
f";DEFAULT_FEEDRATE={self.feedrate}",
|
|
f";DEFAULT_TRAVEL_FEEDRATE={self.travel_feedrate}",
|
|
f";DEFAULT_RETRACTION_FEEDRATE={retraction_fr_val}",
|
|
f";DEFAULT_EXTRUSION_RATE={self.extrusion_rate}",
|
|
f";DEFAULT_LAYER_HEIGHT={self.layer_height:.6f}",
|
|
]
|
|
if self.first_layer_height is not None:
|
|
header.append(f";DEFAULT_FIRST_LAYER_HEIGHT={self.first_layer_height:.6f}")
|
|
header.extend([
|
|
f";DEFAULT_LAYER_WIDTH={self.layer_width:.6f}",
|
|
f";OFFSET_X={offset_x:.6f}",
|
|
f";OFFSET_Y={offset_y:.6f}",
|
|
])
|
|
|
|
if self.objects_info:
|
|
import json
|
|
header.append(f"; objects_info = {json.dumps(self.objects_info)}")
|
|
elif self.name:
|
|
import json
|
|
header.append(f'; objects_info = {json.dumps({"objects": [{"name": self.name, "polygon": []}]})}')
|
|
|
|
if hasattr(self, "transforms") and self.transforms:
|
|
import json
|
|
header.append(f";PREPARATION_TRANSFORMS={json.dumps(self.transforms)}")
|
|
|
|
total_tx, total_ty, total_tz = 0.0, 0.0, 0.0
|
|
total_rx, total_ry, total_rz = 0.0, 0.0, 0.0
|
|
total_sx, total_sy, total_sz = 1.0, 1.0, 1.0
|
|
|
|
for t in self.transforms:
|
|
if isinstance(t, dict):
|
|
tr = t.get("translation", (0.0, 0.0, 0.0))
|
|
rot = t.get("rotation", (0.0, 0.0, 0.0))
|
|
sc = t.get("scale", (1.0, 1.0, 1.0))
|
|
if "offset" in t:
|
|
tr = t["offset"]
|
|
|
|
total_tx += float(tr[0])
|
|
total_ty += float(tr[1])
|
|
total_tz += float(tr[2])
|
|
total_rx += float(rot[0])
|
|
total_ry += float(rot[1])
|
|
total_rz += float(rot[2])
|
|
total_sx *= float(sc[0])
|
|
total_sy *= float(sc[1])
|
|
total_sz *= float(sc[2])
|
|
|
|
header.append(f";PREPARATION_TOTAL_TRANSLATION={total_tx:.6f},{total_ty:.6f},{total_tz:.6f}")
|
|
header.append(f";PREPARATION_TOTAL_ROTATION={total_rx:.6f},{total_ry:.6f},{total_rz:.6f}")
|
|
header.append(f";PREPARATION_TOTAL_SCALE={total_sx:.6f},{total_sy:.6f},{total_sz:.6f}")
|
|
|
|
header.extend([
|
|
"G21 ; set units to mm",
|
|
"G90 ; absolute XYZ positioning",
|
|
"M83 ; relative extrusion mode",
|
|
"G92 E0",
|
|
])
|
|
gcode += header
|
|
|
|
# Safely parse retraction and lift fields.
|
|
# If they cannot be parsed or value is zero, we skip them.
|
|
try:
|
|
retraction_val = float(self.retraction) if self.retraction is not None else 0.0
|
|
except (TypeError, ValueError):
|
|
retraction_val = 0.0
|
|
|
|
try:
|
|
lift_val = float(self.lift) if self.lift is not None else 0.0
|
|
except (TypeError, ValueError):
|
|
lift_val = 0.0
|
|
|
|
last_z_um: int | None = None
|
|
max_z = 0
|
|
last_nozzle_z = 0.0
|
|
|
|
for i, line in enumerate(centered_paths):
|
|
coords = line.tolist()
|
|
if len(coords) < 2:
|
|
continue
|
|
|
|
e_rate = self.extrusion_rates[i]
|
|
f_rate = self.feedrates[i]
|
|
extras = {prefix: vals[i] for prefix, vals in self.extra_attributes.items()}
|
|
|
|
x0, y0, z0 = coords[0]
|
|
z0_um = int(round(z0 * SCALE))
|
|
|
|
active_feedrate = self.feedrate
|
|
if i > 0:
|
|
# 1. Retract if retraction is enabled
|
|
if retraction_val > 0.0:
|
|
gcode.append(f"G1 E-{retraction_val:.5f} F{retraction_fr_val:.3f}")
|
|
active_feedrate = retraction_fr_val
|
|
|
|
# 2. Lift Z if lift is enabled
|
|
if lift_val > 0.0:
|
|
gcode.append(f"G0 Z{last_nozzle_z + lift_val:.3f} F720")
|
|
active_feedrate = 720.0
|
|
|
|
# 3. Travel to the start of path `i` (x0, y0) in XY
|
|
travel_fr = f_rate[0] if f_rate[0] > 0 else self.travel_feedrate
|
|
gcode.append(f"G0 X{x0:.3f} Y{y0:.3f} F{travel_fr}")
|
|
active_feedrate = travel_fr
|
|
|
|
# 4. Handle Z alignment (layer change and/or un-lift)
|
|
if last_z_um is None or z0_um != last_z_um:
|
|
gcode.append(f";BEFORE_LAYER_CHANGE")
|
|
gcode.append(f"G0 Z{z0:.3f} F720")
|
|
gcode.append(f";AFTER_LAYER_CHANGE")
|
|
last_z_um = z0_um
|
|
active_feedrate = 720.0
|
|
elif lift_val > 0.0:
|
|
gcode.append(f"G0 Z{z0:.3f} F720")
|
|
active_feedrate = 720.0
|
|
|
|
# 5. Un-retract if retraction was enabled
|
|
if retraction_val > 0.0:
|
|
gcode.append(f"G1 E{retraction_val:.5f} F{retraction_fr_val:.3f}")
|
|
active_feedrate = retraction_fr_val
|
|
else:
|
|
if last_z_um is None or z0_um != last_z_um:
|
|
gcode.append(f";BEFORE_LAYER_CHANGE")
|
|
gcode.append(f"G0 Z{z0:.3f} F720")
|
|
gcode.append(f";AFTER_LAYER_CHANGE")
|
|
last_z_um = z0_um
|
|
active_feedrate = 720.0
|
|
|
|
old_fr = f_rate[0] if f_rate[0] > 0 else self.travel_feedrate
|
|
gcode.append(f"G0 X{x0:.3f} Y{y0:.3f} Z{z0:.3f} F{old_fr}")
|
|
active_feedrate = old_fr
|
|
|
|
old_fr = active_feedrate
|
|
old_x, old_y, old_z = x0, y0, z0
|
|
old_extras = {prefix: None for prefix in extras}
|
|
for j, (p1, p2) in enumerate(zip(coords, coords[1:])):
|
|
seg_len: float = math.dist(p1, p2)
|
|
e_segment = seg_len * self.extrusion_rate * e_rate[j]
|
|
|
|
# Per-segment feedrate value (not multiplier)
|
|
fr = f_rate[j] if f_rate[j] > 0 else self.feedrate
|
|
|
|
old_x, old_y, old_z = p1
|
|
x, y, z = p2
|
|
|
|
cur_gcode = "G1"
|
|
|
|
if not old_x == x: cur_gcode += f" X{x:.3f}"
|
|
if not old_y == y: cur_gcode += f" Y{y:.3f}"
|
|
if not old_z == z: cur_gcode += f" Z{z:.3f}"
|
|
if e_segment: cur_gcode += f" E{e_segment:.5f}"
|
|
if not old_fr == fr: cur_gcode += f" F{fr:.3f}"
|
|
|
|
for prefix, evals in extras.items():
|
|
val = evals[j]
|
|
if not old_extras[prefix] == val:
|
|
cur_gcode += f" {prefix}{val:.3f}"
|
|
old_extras[prefix] = val
|
|
|
|
gcode += [cur_gcode]
|
|
|
|
max_z = max(max_z, z)
|
|
old_fr = fr
|
|
|
|
last_nozzle_z = coords[-1][2]
|
|
|
|
return "\n".join(gcode) |