Spaces:
Running
Running
| """Vector toolpath generation: shapely layer polygons -> printable move lists. | |
| Replaces the pixel-raster core of the old TIFF pipeline. All geometry lives in | |
| world-XY millimetres; every motion segment is a 5-tuple | |
| ``(x0, y0, x1, y1, color)`` where color 255 means the dispensing valve is open | |
| and 0 means travel. `fil_width` is both the raster line spacing and the | |
| filament width. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| from dataclasses import dataclass | |
| from shapely import prepare | |
| from shapely.affinity import rotate, translate | |
| from shapely.geometry import LineString, MultiLineString, MultiPolygon, Point, box | |
| from shapely.geometry.polygon import orient | |
| from shapely.ops import linemerge, unary_union | |
| from stl_slicer import LayerStack, _as_multipolygon | |
| EPS = 1e-6 | |
| RASTER_PATTERN_SAME_DIRECTION = "X-direction raster" | |
| RASTER_PATTERN_Y_DIRECTION = "Y-direction raster" | |
| RASTER_PATTERN_WOODPILE = "90° Woodpile raster" | |
| RASTER_PATTERN_DIAGONAL_WOODPILE = "45° Woodpile raster" | |
| RASTER_PATTERN_RECTANGULAR_SPIRAL = "Rectangular Spiral raster" | |
| RASTER_PATTERN_CIRCLE_SPIRAL = "Circle Spiral raster" | |
| RASTER_PATTERN_CHOICES = ( | |
| RASTER_PATTERN_SAME_DIRECTION, | |
| RASTER_PATTERN_Y_DIRECTION, | |
| RASTER_PATTERN_WOODPILE, | |
| RASTER_PATTERN_DIAGONAL_WOODPILE, | |
| RASTER_PATTERN_RECTANGULAR_SPIRAL, | |
| RASTER_PATTERN_CIRCLE_SPIRAL, | |
| ) | |
| Seg = tuple[float, float, float, float, int] | |
| class ContourSource: | |
| owner_idx: int | |
| stack: LayerStack | |
| def _normalize_raster_pattern(pattern: str | None) -> str: | |
| if pattern in RASTER_PATTERN_CHOICES: | |
| return pattern | |
| return RASTER_PATTERN_SAME_DIRECTION | |
| def _raster_axis_for_pattern(pattern: str, layer_number: int) -> str: | |
| if pattern == RASTER_PATTERN_Y_DIRECTION: | |
| return "Y" | |
| if pattern == RASTER_PATTERN_WOODPILE and layer_number % 2 == 1: | |
| return "Y" | |
| if pattern == RASTER_PATTERN_DIAGONAL_WOODPILE and layer_number % 4 == 2: | |
| return "Y" | |
| return "X" | |
| def _diagonal_layer_angle(layer_number: int) -> float | None: | |
| """Diagonal-woodpile raster angle for a layer; None for the axis layers. | |
| The pattern cycles 0, 45, 90, 135 degrees: axis layers (0/90) go through | |
| the regular X/Y raster; only the odd layers need the rotated raster. | |
| """ | |
| phase = layer_number % 4 | |
| if phase == 1: | |
| return 45.0 | |
| if phase == 3: | |
| return 135.0 | |
| return None | |
| def _iter_linestrings(geometry: object): | |
| if geometry is None or getattr(geometry, "is_empty", True): | |
| return | |
| geom_type = geometry.geom_type | |
| if geom_type == "LineString": | |
| yield geometry | |
| elif geom_type in ("MultiLineString", "GeometryCollection"): | |
| for part in geometry.geoms: | |
| yield from _iter_linestrings(part) | |
| def _iter_coords(geometry: object): | |
| if geometry is None or getattr(geometry, "is_empty", True): | |
| return | |
| geom_type = geometry.geom_type | |
| if geom_type == "Point": | |
| yield geometry.x, geometry.y | |
| elif geom_type == "LineString": | |
| for coord in geometry.coords: | |
| yield coord[0], coord[1] | |
| elif geom_type in ("MultiPoint", "MultiLineString", "GeometryCollection"): | |
| for part in geometry.geoms: | |
| yield from _iter_coords(part) | |
| def _point_distance_sq(ax: float, ay: float, bx: float, by: float) -> float: | |
| return (ax - bx) ** 2 + (ay - by) ** 2 | |
| def _closest_point_on_segment( | |
| px: float, | |
| py: float, | |
| ax: float, | |
| ay: float, | |
| bx: float, | |
| by: float, | |
| ) -> tuple[float, float, float]: | |
| dx = bx - ax | |
| dy = by - ay | |
| length_sq = dx * dx + dy * dy | |
| if length_sq == 0: | |
| return ax, ay, 0.0 | |
| t = ((px - ax) * dx + (py - ay) * dy) / length_sq | |
| t = max(0.0, min(1.0, t)) | |
| return ax + t * dx, ay + t * dy, t | |
| def _append_colored_segment( | |
| segments: list[Seg], | |
| start_x: float, | |
| start_y: float, | |
| end_x: float, | |
| end_y: float, | |
| color: int, | |
| ) -> None: | |
| if start_x == end_x and start_y == end_y: | |
| return | |
| if segments: | |
| prev_start_x, prev_start_y, prev_end_x, prev_end_y, prev_color = segments[-1] | |
| if ( | |
| prev_color == color | |
| and prev_end_x == start_x | |
| and prev_end_y == start_y | |
| ): | |
| prev_dx = prev_end_x - prev_start_x | |
| prev_dy = prev_end_y - prev_start_y | |
| next_dx = end_x - start_x | |
| next_dy = end_y - start_y | |
| if abs((prev_dx * next_dy) - (prev_dy * next_dx)) < 1e-9: | |
| segments[-1] = ( | |
| prev_start_x, | |
| prev_start_y, | |
| end_x, | |
| end_y, | |
| color, | |
| ) | |
| return | |
| segments.append((start_x, start_y, end_x, end_y, color)) | |
| def _append_relative_move( | |
| output_list: list[dict], | |
| current_x: float, | |
| current_y: float, | |
| target_x: float, | |
| target_y: float, | |
| color: int, | |
| z_step: float | None = None, | |
| ) -> tuple[float, float]: | |
| # Round away GEOS float noise (sub-micron residues, -0.0) so the emitted | |
| # G-code stays clean; position tracking keeps the exact targets. Six | |
| # decimals (1 nm) is far below any printable resolution, and rounding here | |
| # also guarantees the writer can format every delta without scientific | |
| # notation (which G-code parsers misread: "X-5.1e-08" parses as X-5.1). | |
| dx = round(target_x - current_x, 6) + 0.0 | |
| dy = round(target_y - current_y, 6) + 0.0 | |
| if dx == 0 and dy == 0 and z_step is None: | |
| return current_x, current_y | |
| move = {"X": dx, "Y": dy, "Color": color} | |
| if z_step is not None: | |
| move["Z"] = z_step | |
| output_list.append(move) | |
| return target_x, target_y | |
| def _chord_runs( | |
| region: MultiPolygon, | |
| axis: str, | |
| coord: float, | |
| eps: float = EPS, | |
| ) -> list[tuple[float, float]]: | |
| """Sorted, merged (lo, hi) intervals where an axis-aligned scanline is inside `region`. | |
| Axis "X": horizontal scanline at y=coord, runs measured along X. | |
| Axis "Y": vertical scanline at x=coord, runs measured along Y. | |
| """ | |
| if region is None or region.is_empty: | |
| return [] | |
| min_x, min_y, max_x, max_y = region.bounds | |
| if axis == "Y": | |
| if coord < min_x - eps or coord > max_x + eps: | |
| return [] | |
| line = LineString([(coord, min_y - 1.0), (coord, max_y + 1.0)]) | |
| index = 1 | |
| else: | |
| if coord < min_y - eps or coord > max_y + eps: | |
| return [] | |
| line = LineString([(min_x - 1.0, coord), (max_x + 1.0, coord)]) | |
| index = 0 | |
| runs: list[tuple[float, float]] = [] | |
| for piece in _iter_linestrings(region.intersection(line)): | |
| if piece.length <= eps: | |
| continue | |
| values = [coords[index] for coords in piece.coords] | |
| runs.append((min(values), max(values))) | |
| runs.sort() | |
| merged: list[tuple[float, float]] = [] | |
| for lo, hi in runs: | |
| if merged and lo <= merged[-1][1] + eps: | |
| merged[-1] = (merged[-1][0], max(merged[-1][1], hi)) | |
| else: | |
| merged.append((lo, hi)) | |
| return merged | |
| def _point_along( | |
| t: float, | |
| x0: float, | |
| y0: float, | |
| x1: float, | |
| y1: float, | |
| ) -> tuple[float, float]: | |
| if t <= 0.0: | |
| return x0, y0 | |
| if t >= 1.0: | |
| return x1, y1 | |
| return x0 + (x1 - x0) * t, y0 + (y1 - y0) * t | |
| def _classify_polyline( | |
| points: list[tuple[float, float]], | |
| valve: MultiPolygon, | |
| eps: float = EPS, | |
| keep_point=None, | |
| keep_segment=None, | |
| ) -> list[Seg]: | |
| """Split a motion polyline at valve-boundary crossings and color the pieces. | |
| Preserves path order (a whole-polyline shapely intersection would not). | |
| Pieces on the boundary count as printing (`covers`), matching the raster | |
| convention that boundary-grazing sweeps dispense. `keep_point(x, y)` can | |
| additionally veto dispensing for a piece (evaluated at its midpoint) — | |
| used for partial infill. `keep_segment(x0, y0, x1, y1)` vetoes a whole | |
| source segment BEFORE it is split, so a vetoed transition move stays | |
| valve-off even where the material boundary cuts through it. The motion | |
| is unaffected by either gate. | |
| """ | |
| segments: list[Seg] = [] | |
| has_valve = valve is not None and not valve.is_empty | |
| if has_valve: | |
| prepare(valve) | |
| boundary = valve.boundary | |
| for (x0, y0), (x1, y1) in zip(points, points[1:]): | |
| dx = x1 - x0 | |
| dy = y1 - y0 | |
| seg_len = math.hypot(dx, dy) | |
| if seg_len <= eps: | |
| continue | |
| if not has_valve or ( | |
| keep_segment is not None and not keep_segment(x0, y0, x1, y1) | |
| ): | |
| _append_colored_segment(segments, x0, y0, x1, y1, 0) | |
| continue | |
| ts = {0.0, 1.0} | |
| crossings = LineString([(x0, y0), (x1, y1)]).intersection(boundary) | |
| for cx, cy in _iter_coords(crossings): | |
| t = ((cx - x0) * dx + (cy - y0) * dy) / (seg_len * seg_len) | |
| ts.add(max(0.0, min(1.0, t))) | |
| t_eps = eps / seg_len | |
| ordered = sorted(ts) | |
| deduped = [ordered[0]] | |
| for t in ordered[1:]: | |
| if t - deduped[-1] > t_eps: | |
| deduped.append(t) | |
| else: | |
| deduped[-1] = max(deduped[-1], t) | |
| for t0, t1 in zip(deduped, deduped[1:]): | |
| if (t1 - t0) * seg_len <= eps: | |
| continue | |
| mid_x, mid_y = _point_along((t0 + t1) / 2.0, x0, y0, x1, y1) | |
| dispensing = valve.covers(Point(mid_x, mid_y)) | |
| if dispensing and keep_point is not None: | |
| dispensing = keep_point(mid_x, mid_y) | |
| color = 255 if dispensing else 0 | |
| start = _point_along(t0, x0, y0, x1, y1) | |
| end = _point_along(t1, x0, y0, x1, y1) | |
| _append_colored_segment(segments, *start, *end, color) | |
| return segments | |
| def _infill_line_keep(fraction: float): | |
| """Line-selection gate for partial infill; None means print every line. | |
| Uses a Bresenham-style distribution so kept lines spread evenly: line k | |
| dispenses iff floor((k+1)*f) > floor(k*f). At f=0.5 every other line | |
| prints; at f=1 all do; at f=0 none. `k` is a global grid index, so the | |
| same lines are selected across layers, across split pieces sharing a | |
| parent frame, and across shapes sharing reference motion — the motion | |
| path itself is never affected. | |
| """ | |
| if fraction >= 1.0: | |
| return None | |
| fraction = max(0.0, float(fraction)) | |
| def keep(line_index: int) -> bool: | |
| return math.floor((line_index + 1) * fraction) - math.floor(line_index * fraction) >= 1 | |
| return keep | |
| def _combined_infill_keep(fractions): | |
| """Union infill gate over every shape sharing the motion; None = keep all. | |
| A grid line is traversed iff ANY shape's infill pattern dispenses on it — | |
| lines nobody prints are dropped from the MOTION entirely instead of being | |
| swept valve-off (e.g. every shape at 50% infill halves the path). Every | |
| shape must be given the same fraction list so the shared motion stays | |
| identical across heads. | |
| """ | |
| keeps = [] | |
| for fraction in fractions or []: | |
| keep = _infill_line_keep(fraction) | |
| if keep is None: | |
| return None # someone prints every line: no motion line can drop | |
| keeps.append(keep) | |
| if not keeps: | |
| return None | |
| def keep_any(line_index: int) -> bool: | |
| return any(keep(line_index) for keep in keeps) | |
| return keep_any | |
| def _scan_anchor(lo: float, hi: float, fil_width: float) -> float: | |
| """First scanline position of the centred grid spanning [lo, hi]. | |
| The grid is centred: when the extent is not an exact multiple of | |
| fil_width, the leftover slack is split evenly between both edges. | |
| """ | |
| extent = hi - lo | |
| if extent < fil_width: | |
| return (lo + hi) / 2.0 | |
| count = int(math.floor(extent / fil_width + 1e-9)) | |
| slack = max(0.0, extent - count * fil_width) | |
| return lo + slack / 2.0 + fil_width / 2.0 | |
| def _scan_coords( | |
| lo: float, | |
| hi: float, | |
| fil_width: float, | |
| anchor: float | None = None, | |
| ) -> list[float]: | |
| """Scanline positions in [lo, hi], fil_width apart; at least one line. | |
| Without `anchor` the set is centred in [lo, hi] (always at least one | |
| line). With `anchor` the lines lie on the global grid | |
| {anchor + k*fil_width}: grid-split pieces anchored to their parent's | |
| frame then raster on one continuous line grid, so the assembled seams | |
| keep an exact one-fil-width pitch instead of drifting by each piece's own | |
| quantization slack. The interval is half-open at `hi` so a line exactly | |
| on a cut belongs to exactly one piece, and a sliver too thin to contain a | |
| grid line gets none — the neighbouring grid line (at most one fil away) | |
| covers it with filament width. | |
| """ | |
| if anchor is None: | |
| anchor = _scan_anchor(lo, hi, fil_width) | |
| if hi - lo < fil_width: | |
| return [anchor] | |
| count = int(math.floor((hi - lo) / fil_width + 1e-9)) | |
| return [anchor + index * fil_width for index in range(count)] | |
| # The epsilon is in grid-cell units and must swamp float noise from the | |
| # anchor/edge arithmetic: a piece whose material starts EXACTLY on a grid | |
| # line (a split cut on the line) can compute (lo-anchor)/fil as | |
| # -1.0000000000000009, and a 1e-9 epsilon then ceils the boundary line | |
| # away — nobody prints it and every assembled seam gets a one-fil gap. | |
| k_lo = math.ceil((lo - anchor) / fil_width - 1e-6) | |
| k_hi = math.floor((hi - anchor) / fil_width - 1e-6) | |
| return [anchor + k * fil_width for k in range(int(k_lo), int(k_hi) + 1)] | |
| def _axis_raster_segments( | |
| motion: MultiPolygon, | |
| valve: MultiPolygon, | |
| fil_width: float, | |
| axis: str, | |
| reverse_order: bool = False, | |
| start_forward: bool = True, | |
| scan_anchor: float | None = None, | |
| infill_keep=None, | |
| motion_keep=None, | |
| ) -> list[Seg]: | |
| """Snake raster of `motion`, dispensing only inside `valve`. | |
| Axis "X" sweeps along X with rows stacked in Y; axis "Y" sweeps along Y | |
| with columns stacked in X. Each sweep spans from the first to the last | |
| motion chord (crossing interior gaps valve-off) and gets a fil_width | |
| valve-settle travel buffer before and after. `scan_anchor` pins the | |
| scanlines to a global grid (see `_scan_coords`). `infill_keep(k)` gates | |
| dispensing per grid line for partial infill: skipped lines are still | |
| swept with the valve closed. `motion_keep(k)` drops a grid line from the | |
| MOTION entirely — used when NO shape sharing the motion dispenses there | |
| (the union of every head's infill pattern), so nobody sweeps dead lines. | |
| """ | |
| if motion is None or motion.is_empty: | |
| return [] | |
| min_x, min_y, max_x, max_y = motion.bounds | |
| if axis == "Y": | |
| scan_lo, scan_hi = min_x, max_x | |
| else: | |
| scan_lo, scan_hi = min_y, max_y | |
| coords = _scan_coords(scan_lo, scan_hi, fil_width, anchor=scan_anchor) | |
| if reverse_order: | |
| coords = coords[::-1] | |
| # Grid index base: orientation-independent, and shared across pieces when | |
| # anchored to a common frame, so infill line selection lines up at seams. | |
| index_base = scan_anchor if scan_anchor is not None else _scan_anchor( | |
| scan_lo, scan_hi, fil_width | |
| ) | |
| # A grid line can graze the material boundary from OUTSIDE by float ulps | |
| # (split cuts sit exactly on grid lines, and the piece's material edge IS | |
| # the cut): probe the chords a hair inside the bounds so the boundary | |
| # sweep is still found, while emitting at the true grid coordinate. | |
| # A dropped boundary sweep prints a one-fil gap at every assembled seam. | |
| probe_eps = fil_width * 1e-6 | |
| probe_lo = min(scan_lo + probe_eps, (scan_lo + scan_hi) / 2.0) | |
| probe_hi = max(scan_hi - probe_eps, (scan_lo + scan_hi) / 2.0) | |
| segments: list[Seg] = [] | |
| sweep_number = 0 | |
| for coord in coords: | |
| line_index = int(round((coord - index_base) / fil_width)) | |
| if motion_keep is not None and not motion_keep(line_index): | |
| continue # no head prints this line: drop it from the motion | |
| probe = min(max(coord, probe_lo), probe_hi) | |
| motion_runs = _chord_runs(motion, axis, probe) | |
| if not motion_runs: | |
| continue | |
| sweep_lo = motion_runs[0][0] | |
| sweep_hi = motion_runs[-1][1] | |
| if infill_keep is not None and not infill_keep(line_index): | |
| valve_runs: list[tuple[float, float]] = [] | |
| else: | |
| valve_runs = [] | |
| for lo, hi in _chord_runs(valve, axis, probe): | |
| lo = max(lo, sweep_lo) | |
| hi = min(hi, sweep_hi) | |
| if hi - lo > EPS: | |
| valve_runs.append((lo, hi)) | |
| def emit(a: float, b: float, color: int) -> None: | |
| if axis == "Y": | |
| _append_colored_segment(segments, coord, a, coord, b, color) | |
| else: | |
| _append_colored_segment(segments, a, coord, b, coord, color) | |
| forward = (sweep_number % 2 == 0) == start_forward | |
| sweep_number += 1 | |
| if forward: | |
| emit(sweep_lo - fil_width, sweep_lo, 0) | |
| current = sweep_lo | |
| for lo, hi in valve_runs: | |
| if lo - current > EPS: | |
| emit(current, lo, 0) | |
| current = lo | |
| start = max(lo, current) | |
| if hi - start > EPS: | |
| emit(start, hi, 255) | |
| current = hi | |
| if sweep_hi - current > EPS: | |
| emit(current, sweep_hi, 0) | |
| current = sweep_hi | |
| emit(current, current + fil_width, 0) | |
| else: | |
| emit(sweep_hi + fil_width, sweep_hi, 0) | |
| current = sweep_hi | |
| for lo, hi in reversed(valve_runs): | |
| if current - hi > EPS: | |
| emit(current, hi, 0) | |
| current = hi | |
| start = min(hi, current) | |
| if start - lo > EPS: | |
| emit(start, lo, 255) | |
| current = lo | |
| if current - sweep_lo > EPS: | |
| emit(current, sweep_lo, 0) | |
| current = sweep_lo | |
| emit(current, current - fil_width, 0) | |
| return segments | |
| def _oriented_axis_raster_segments( | |
| motion: MultiPolygon, | |
| valve: MultiPolygon, | |
| fil_width: float, | |
| axis: str, | |
| current_x: float, | |
| current_y: float, | |
| prefer_default: bool = False, | |
| scan_anchor: float | None = None, | |
| infill_keep=None, | |
| motion_keep=None, | |
| ) -> list[Seg]: | |
| """Pick the raster orientation whose start is nearest the current position.""" | |
| default_segments = _axis_raster_segments( | |
| motion, | |
| valve, | |
| fil_width, | |
| axis, | |
| scan_anchor=scan_anchor, | |
| infill_keep=infill_keep, | |
| motion_keep=motion_keep, | |
| ) | |
| if prefer_default or not default_segments: | |
| return default_segments | |
| candidates: list[list[Seg]] = [default_segments] | |
| for reverse_order in (False, True): | |
| for start_forward in (False, True): | |
| segments = _axis_raster_segments( | |
| motion, | |
| valve, | |
| fil_width, | |
| axis, | |
| reverse_order=reverse_order, | |
| start_forward=start_forward, | |
| scan_anchor=scan_anchor, | |
| infill_keep=infill_keep, | |
| motion_keep=motion_keep, | |
| ) | |
| if segments and segments not in candidates: | |
| candidates.append(segments) | |
| return min( | |
| candidates, | |
| key=lambda segments: _point_distance_sq( | |
| current_x, | |
| current_y, | |
| segments[0][0], | |
| segments[0][1], | |
| ), | |
| ) | |
| def _rotated_raster_segments( | |
| motion: MultiPolygon, | |
| valve: MultiPolygon, | |
| fil_width: float, | |
| angle_degrees: float, | |
| current_x: float, | |
| current_y: float, | |
| prefer_default: bool, | |
| frame: tuple[float, float, float, float], | |
| infill_keep=None, | |
| motion_keep=None, | |
| ) -> list[Seg]: | |
| """Snake raster at an arbitrary angle, reusing the axis raster machinery. | |
| Rotates motion/valve by -angle about the scan frame's centre, rasters | |
| with the standard X-axis sweep (snake, buffers, valve gating, scan grid, | |
| infill selection all identical), then rotates the segments back. The | |
| pivot and the rotated scan anchor both derive from the shared frame, so | |
| split pieces and reference-motion shapes keep one continuous diagonal | |
| line grid across seams. | |
| """ | |
| if motion is None or motion.is_empty: | |
| return [] | |
| pivot_x = (frame[0] + frame[2]) / 2.0 | |
| pivot_y = (frame[1] + frame[3]) / 2.0 | |
| pivot = (pivot_x, pivot_y) | |
| rotated_motion = _as_multipolygon(rotate(motion, -angle_degrees, origin=pivot)) | |
| if valve is motion: | |
| rotated_valve = rotated_motion | |
| elif valve is None or valve.is_empty: | |
| rotated_valve = MultiPolygon() | |
| else: | |
| rotated_valve = _as_multipolygon(rotate(valve, -angle_degrees, origin=pivot)) | |
| rotated_frame_bounds = rotate(box(*frame), -angle_degrees, origin=pivot).bounds | |
| anchor = _scan_anchor(rotated_frame_bounds[1], rotated_frame_bounds[3], fil_width) | |
| theta = math.radians(-angle_degrees) | |
| cos_f, sin_f = math.cos(theta), math.sin(theta) | |
| rotated_current_x = pivot_x + (current_x - pivot_x) * cos_f - (current_y - pivot_y) * sin_f | |
| rotated_current_y = pivot_y + (current_x - pivot_x) * sin_f + (current_y - pivot_y) * cos_f | |
| segments = _oriented_axis_raster_segments( | |
| rotated_motion, | |
| rotated_valve, | |
| fil_width, | |
| "X", | |
| rotated_current_x, | |
| rotated_current_y, | |
| prefer_default=prefer_default, | |
| scan_anchor=anchor, | |
| infill_keep=infill_keep, | |
| motion_keep=motion_keep, | |
| ) | |
| theta_back = math.radians(angle_degrees) | |
| cos_b, sin_b = math.cos(theta_back), math.sin(theta_back) | |
| def back(x: float, y: float) -> tuple[float, float]: | |
| return ( | |
| pivot_x + (x - pivot_x) * cos_b - (y - pivot_y) * sin_b, | |
| pivot_y + (x - pivot_x) * sin_b + (y - pivot_y) * cos_b, | |
| ) | |
| return [ | |
| (*back(x0, y0), *back(x1, y1), color) | |
| for x0, y0, x1, y1, color in segments | |
| ] | |
| def _extend_polyline_ends( | |
| points: list[tuple[float, float]], | |
| length: float, | |
| ) -> list[tuple[float, float]]: | |
| """Extend both polyline ends by `length` along their local directions.""" | |
| if len(points) < 2: | |
| return points | |
| (x0, y0), (x1, y1) = points[0], points[1] | |
| distance = math.hypot(x1 - x0, y1 - y0) | |
| if distance > 0: | |
| points = [ | |
| (x0 - (x1 - x0) / distance * length, y0 - (y1 - y0) / distance * length), | |
| *points, | |
| ] | |
| (xa, ya), (xb, yb) = points[-2], points[-1] | |
| distance = math.hypot(xb - xa, yb - ya) | |
| if distance > 0: | |
| points = [ | |
| *points, | |
| (xb + (xb - xa) / distance * length, yb + (yb - ya) / distance * length), | |
| ] | |
| return points | |
| def _rectangular_spiral_polyline( | |
| bounds: tuple[float, float, float, float], | |
| fil_width: float, | |
| reverse: bool = False, | |
| ) -> list[tuple[float, float]]: | |
| """Corner polyline spiralling from the bounds inward (or outward if reversed).""" | |
| min_x, min_y, max_x, max_y = bounds | |
| half = fil_width / 2.0 | |
| left = min_x + half | |
| right = max_x - half | |
| bottom = min_y + half | |
| top = max_y - half | |
| if right < left: | |
| left = right = (min_x + max_x) / 2.0 | |
| if top < bottom: | |
| bottom = top = (min_y + max_y) / 2.0 | |
| eps = 1e-9 | |
| points: list[tuple[float, float]] = [] | |
| def add(x: float, y: float) -> None: | |
| if not points or _point_distance_sq(points[-1][0], points[-1][1], x, y) > eps: | |
| points.append((x, y)) | |
| while left <= right + eps and bottom <= top + eps: | |
| add(left, top) | |
| add(right, top) | |
| top -= fil_width | |
| if bottom <= top + eps: | |
| add(right, bottom) | |
| right -= fil_width | |
| if bottom <= top + eps: | |
| add(left, bottom) | |
| bottom += fil_width | |
| if left <= right + eps: | |
| if bottom <= top + eps: | |
| add(left, top) | |
| left += fil_width | |
| if len(points) == 1: | |
| center_x, center_y = points[0] | |
| points = [(center_x - half, center_y), (center_x + half, center_y)] | |
| if reverse: | |
| points.reverse() | |
| return points | |
| if reverse: | |
| points.reverse() | |
| return _extend_polyline_ends(points, half) | |
| def _drop_short_print_runs(segments: list[Seg], min_length: float) -> list[Seg]: | |
| """Close the valve over isolated dispensing runs shorter than min_length. | |
| Motion is untouched — only the color flips to travel, so shared-motion | |
| parallel heads stay in sync. Used against boundary-grazing flicker, | |
| which no valve can deposit cleanly anyway. | |
| """ | |
| result = list(segments) | |
| index = 0 | |
| while index < len(result): | |
| if result[index][4] != 255: | |
| index += 1 | |
| continue | |
| end = index | |
| run_length = 0.0 | |
| while end < len(result) and result[end][4] == 255: | |
| x0, y0, x1, y1, _color = result[end] | |
| run_length += math.hypot(x1 - x0, y1 - y0) | |
| end += 1 | |
| if run_length < min_length: | |
| for position in range(index, end): | |
| x0, y0, x1, y1, _color = result[position] | |
| result[position] = (x0, y0, x1, y1, 0) | |
| index = end | |
| return result | |
| def circle_wall_radius( | |
| layer: MultiPolygon | None, | |
| center_x: float, | |
| center_y: float, | |
| fil_width: float, | |
| ) -> float | None: | |
| """A layer's outermost COMPLETE ring radius about the ring centre. | |
| Uses the minimum OUTER-boundary distance (holes ignored; the material | |
| may sit slightly off the ring centre). When a global grid ring already | |
| lies close under the boundary it IS the outer ring (no extra wall — a | |
| custom wall a fraction of a bead away would just double-deposit); | |
| otherwise the wall sits half a bead inside the closest boundary point. | |
| Only meaningful when the material surrounds the centre. | |
| """ | |
| if layer is None or layer.is_empty: | |
| return None | |
| center = Point(center_x, center_y) | |
| for polygon in layer.geoms: | |
| if not polygon.covers(center): | |
| continue | |
| d_min = float(polygon.exterior.distance(center)) | |
| grid_j = int(math.floor(d_min / fil_width - 0.5 + 1e-9)) | |
| if grid_j >= 0: | |
| # ALWAYS the outermost grid ring that fits: an off-grid | |
| # edge-hugging wall would land within half a bead of the grid | |
| # ring below it (visibly "too close"), and suppressing that | |
| # ring instead leaves a visibly skipped line. Staying on the | |
| # grid keeps ring spacing uniform; the rim sits at most half a | |
| # bead further in at unlucky dimensions. | |
| return (grid_j + 0.5) * fil_width | |
| wall = d_min - fil_width / 2.0 | |
| return wall if wall > fil_width * 0.25 else None | |
| return None | |
| def _frame_spiral_bounds( | |
| frame: tuple[float, float, float, float], | |
| material_bounds: tuple[float, float, float, float], | |
| fil_width: float, | |
| ) -> tuple[float, float, float, float]: | |
| """Outer bounds for a rectangular spiral, on the FRAME's loop family. | |
| The spiral's loops live on the family "frame inset by half + k*fil per | |
| side" so every layer (and every split sibling) walks the same | |
| rectangles and walls stack. Loops that enclose this layer's material | |
| with more than half a bead of margin on EVERY side are pure travel: | |
| skip them by starting k0 loops in. Returns the frame shrunk by k0*fil | |
| per side (still on the family). | |
| """ | |
| frame_left, frame_bottom, frame_right, frame_top = frame | |
| mat_left, mat_bottom, mat_right, mat_top = material_bounds | |
| half = fil_width / 2.0 | |
| # Margin between the base loop (frame inset by half) and the material. | |
| min_margin = min( | |
| mat_left - (frame_left + half), | |
| mat_bottom - (frame_bottom + half), | |
| (frame_right - half) - mat_right, | |
| (frame_top - half) - mat_top, | |
| ) | |
| skip = max(0, int(math.ceil((min_margin - half) / fil_width - 1e-9))) | |
| # Never shrink past the material's own footprint. | |
| max_skip_x = (frame_right - frame_left - (mat_right - mat_left)) / (2.0 * fil_width) | |
| max_skip_y = (frame_top - frame_bottom - (mat_top - mat_bottom)) / (2.0 * fil_width) | |
| skip = min(skip, max(0, int(min(max_skip_x, max_skip_y)))) | |
| inset = skip * fil_width | |
| return ( | |
| frame_left + inset, | |
| frame_bottom + inset, | |
| frame_right - inset, | |
| frame_top - inset, | |
| ) | |
| def _circle_ring_radii( | |
| motion: MultiPolygon, | |
| center_x: float, | |
| center_y: float, | |
| fil_width: float, | |
| ) -> tuple[list[float], tuple[float, ...]]: | |
| """Ring radii for one layer, outermost first: perimeter walls + grid fill. | |
| The outermost revolution is a PERIMETER WALL hugging the layer's material | |
| edge (its farthest boundary distance minus half a bead), so the printed | |
| silhouette follows the shape smoothly instead of staircasing by whole | |
| grid steps. If the layer has a central hole, a matching inner wall hugs | |
| the hole edge. The fill between the walls comes from a global grid — | |
| ring j at (j + 1/2) * fil_width from the frame centre, the same grid on | |
| every layer (and every split sibling) so interior rings stack vertically. | |
| Grid rings that would overlap a wall bead, or whose circle cannot cross | |
| the layer's material at all, are skipped instead of traveled. | |
| Returns (radii outermost-first, wall radii). Walls always dispense even | |
| under partial infill, like contour tracing. | |
| """ | |
| if motion is None or motion.is_empty: | |
| return [], () | |
| max_dist = 0.0 | |
| for polygon in motion.geoms: | |
| for ring in (polygon.exterior, *polygon.interiors): | |
| for x, y in ring.coords: | |
| max_dist = max(max_dist, math.hypot(x - center_x, y - center_y)) | |
| if max_dist <= 0.0: | |
| return [], () | |
| min_dist = float(motion.distance(Point(center_x, center_y))) | |
| pitch = max(float(fil_width), 1e-9) | |
| half = pitch / 2.0 | |
| outer_wall = max_dist - half | |
| if outer_wall <= EPS: | |
| # Material thinner than one bead (e.g. the dome cap): one tiny ring | |
| # through the middle of it so the layer still gets motion. | |
| radius = max(max_dist / 2.0, EPS) | |
| return [radius], (radius,) | |
| walls = [outer_wall] | |
| inner_wall: float | None = None | |
| if min_dist > pitch / 4.0: | |
| candidate = min_dist + half | |
| if candidate <= outer_wall - half: | |
| inner_wall = candidate | |
| walls.append(candidate) | |
| grid_hi = outer_wall - half | |
| grid_lo = (inner_wall + half) if inner_wall is not None else max(min_dist - half, 0.0) | |
| j_hi = int(math.floor(grid_hi / pitch - 0.5 + 1e-9)) | |
| j_lo = max(0, int(math.ceil(grid_lo / pitch - 0.5 - 1e-9))) | |
| radii = [outer_wall] | |
| if j_hi >= j_lo: | |
| radii.extend((j + 0.5) * pitch for j in range(j_hi, j_lo - 1, -1)) | |
| if inner_wall is not None: | |
| radii.append(inner_wall) | |
| return radii, tuple(walls) | |
| def _circle_rings_polyline( | |
| center_x: float, | |
| center_y: float, | |
| radii: list[float], | |
| pitch: float, | |
| ) -> list[tuple[float, float]]: | |
| """Concentric-ring "spiral": one full circle per radius, in list order. | |
| Each revolution stays at a CONSTANT radius (so the printed walls are true | |
| smooth circles); consecutive rings are joined by a radial jump at theta 0, | |
| which the caller classifies as valve-off travel. | |
| """ | |
| pitch = max(float(pitch), 1e-9) | |
| points: list[tuple[float, float]] = [] | |
| for radius in radii: | |
| if radius <= 0.0: | |
| continue | |
| # Sample roughly one pitch of arc length per step, at least 20/ring. | |
| d_theta = min(math.pi / 10.0, pitch / max(radius, pitch)) | |
| steps = max(8, int(math.ceil((2.0 * math.pi) / d_theta))) | |
| for index in range(steps + 1): | |
| theta = (2.0 * math.pi) * index / steps | |
| points.append( | |
| ( | |
| center_x + (radius * math.cos(theta)), | |
| center_y + (radius * math.sin(theta)), | |
| ) | |
| ) | |
| return points | |
| def _contour_area2(points: list[tuple[float, float]]) -> float: | |
| return sum( | |
| x0 * y1 - x1 * y0 | |
| for (x0, y0), (x1, y1) in zip(points, points[1:]) | |
| ) | |
| def _contour_sort_key(points: list[tuple[float, float]]) -> tuple[float, float, float]: | |
| xs = [point[0] for point in points] | |
| ys = [point[1] for point in points] | |
| return (-abs(_contour_area2(points)), min(ys), min(xs)) | |
| def _layer_contour_loops(layer: MultiPolygon) -> list[list[tuple[float, float]]]: | |
| """Closed contour loops of a layer: exterior rings first, holes after.""" | |
| loops: list[list[tuple[float, float]]] = [] | |
| for polygon in layer.geoms: | |
| oriented = orient(polygon) | |
| for ring in (oriented.exterior, *oriented.interiors): | |
| simplified = ring.simplify(0) | |
| coords = [(float(x), float(y)) for x, y in simplified.coords] | |
| if len(coords) >= 4: | |
| loops.append(coords) | |
| loops.sort(key=_contour_sort_key) | |
| return loops | |
| def _rotate_closed_contour_to_nearest_border( | |
| contour: list[tuple[float, float]], | |
| current_x: float, | |
| current_y: float, | |
| approach_dx: float = 0.0, | |
| approach_dy: float = 0.0, | |
| ) -> list[tuple[float, float]]: | |
| if len(contour) < 3: | |
| return contour | |
| ring = contour[:-1] if contour[0] == contour[-1] else contour | |
| if len(ring) < 2: | |
| return contour | |
| best_idx = 0 | |
| best_t = 0.0 | |
| best_point = ring[0] | |
| best_dist = float("inf") | |
| for idx, (ax, ay) in enumerate(ring): | |
| bx, by = ring[(idx + 1) % len(ring)] | |
| point_x, point_y, t = _closest_point_on_segment( | |
| current_x, | |
| current_y, | |
| ax, | |
| ay, | |
| bx, | |
| by, | |
| ) | |
| distance = _point_distance_sq(current_x, current_y, point_x, point_y) | |
| if distance < best_dist: | |
| best_dist = distance | |
| best_idx = idx | |
| best_t = t | |
| best_point = (point_x, point_y) | |
| eps = 1e-9 | |
| def choose_direction( | |
| forward: list[tuple[float, float]], | |
| reverse: list[tuple[float, float]], | |
| ) -> list[tuple[float, float]]: | |
| if ( | |
| len(forward) < 2 | |
| or len(reverse) < 2 | |
| or (approach_dx == 0 and approach_dy == 0) | |
| ): | |
| return forward | |
| def score(candidate: list[tuple[float, float]]) -> float: | |
| tx = candidate[1][0] - candidate[0][0] | |
| ty = candidate[1][1] - candidate[0][1] | |
| # Positive when the shape interior, opposite the approach vector, | |
| # is to the left of the contour's first move. | |
| return (ty * approach_dx) - (tx * approach_dy) | |
| return reverse if score(reverse) > score(forward) else forward | |
| if best_t <= eps: | |
| forward = ring[best_idx:] + ring[:best_idx] + [ring[best_idx]] | |
| reverse_ring = list(reversed(ring)) | |
| reverse_idx = reverse_ring.index(ring[best_idx]) | |
| reverse = ( | |
| reverse_ring[reverse_idx:] | |
| + reverse_ring[:reverse_idx] | |
| + [reverse_ring[reverse_idx]] | |
| ) | |
| return choose_direction(forward, reverse) | |
| next_idx = (best_idx + 1) % len(ring) | |
| if best_t >= 1.0 - eps: | |
| forward = ring[next_idx:] + ring[:next_idx] + [ring[next_idx]] | |
| reverse_ring = list(reversed(ring)) | |
| reverse_idx = reverse_ring.index(ring[next_idx]) | |
| reverse = ( | |
| reverse_ring[reverse_idx:] | |
| + reverse_ring[:reverse_idx] | |
| + [reverse_ring[reverse_idx]] | |
| ) | |
| return choose_direction(forward, reverse) | |
| forward = [best_point] | |
| for step in range(1, len(ring) + 1): | |
| forward.append(ring[(best_idx + step) % len(ring)]) | |
| forward.append(best_point) | |
| reverse = [best_point] | |
| for step in range(0, len(ring)): | |
| reverse.append(ring[(best_idx - step) % len(ring)]) | |
| reverse.append(best_point) | |
| return choose_direction(forward, reverse) | |
| def _last_print_reference(output_list: list[dict]) -> tuple[float, float, float, float]: | |
| x = y = 0.0 | |
| last_x = last_y = 0.0 | |
| last_dx = last_dy = 0.0 | |
| for move in output_list: | |
| dx = float(move.get("X", 0.0)) | |
| dy = float(move.get("Y", 0.0)) | |
| x += dx | |
| y += dy | |
| if move.get("Color") == 255 and "Z" not in move: | |
| last_x = x | |
| last_y = y | |
| last_dx = dx | |
| last_dy = dy | |
| return last_x, last_y, last_dx, last_dy | |
| def _last_motion_reference(output_list: list[dict]) -> tuple[float, float, float, float]: | |
| """Endpoint and direction of the last XY move, regardless of valve state. | |
| Used instead of `_last_print_reference` when shapes share a reference | |
| motion path: anchoring contours off the valve state would give every | |
| shape a different contour tour, breaking the shared path. | |
| """ | |
| x = y = 0.0 | |
| last_x = last_y = 0.0 | |
| last_dx = last_dy = 0.0 | |
| for move in output_list: | |
| dx = float(move.get("X", 0.0)) | |
| dy = float(move.get("Y", 0.0)) | |
| x += dx | |
| y += dy | |
| if "Z" not in move and (dx != 0.0 or dy != 0.0): | |
| last_x = x | |
| last_y = y | |
| last_dx = dx | |
| last_dy = dy | |
| return last_x, last_y, last_dx, last_dy | |
| def _rewind_trailing_travel( | |
| output_list: list[dict], | |
| current_x: float, | |
| current_y: float, | |
| ) -> tuple[float, float]: | |
| if not output_list: | |
| return current_x, current_y | |
| last_move = output_list[-1] | |
| if last_move.get("Color") != 0 or "Z" in last_move: | |
| return current_x, current_y | |
| has_layer_print = any( | |
| move.get("Color") == 255 and "Z" not in move | |
| for move in reversed(output_list[:-1]) | |
| ) | |
| if not has_layer_print: | |
| return current_x, current_y | |
| output_list.pop() | |
| return ( | |
| current_x - float(last_move.get("X", 0.0)), | |
| current_y - float(last_move.get("Y", 0.0)), | |
| ) | |
| def build_contour_layers( | |
| sources: list[ContourSource] | None, | |
| n_layers: int, | |
| reference: LayerStack | None = None, | |
| ) -> list[list[dict]]: | |
| """Per-layer contour loops per owning shape. | |
| When `reference` is given (reference-motion mode) each source stack is | |
| translated by its centering delta so contours land in the shared frame. | |
| """ | |
| contour_layers: list[list[dict]] = [[] for _ in range(n_layers)] | |
| for source in sources or []: | |
| stack = source.stack | |
| if stack is None or not stack.layers: | |
| continue | |
| if reference is not None: | |
| delta_x, delta_y = _centering_delta(stack, reference) | |
| else: | |
| delta_x = delta_y = 0.0 | |
| if stack.contour_paths is not None: | |
| # Split pieces carry seam-free contour paths computed from the | |
| # parent shape's outline; use them directly. | |
| for layer_number in range(min(n_layers, len(stack.contour_paths))): | |
| contours = [ | |
| [(x + delta_x, y + delta_y) for x, y in path] | |
| for path in stack.contour_paths[layer_number] | |
| if len(path) >= 2 | |
| ] | |
| if contours: | |
| contour_layers[layer_number].append( | |
| { | |
| "owner_idx": source.owner_idx, | |
| "contours": contours, | |
| } | |
| ) | |
| continue | |
| if reference is not None: | |
| layers = align_stack_to(stack, reference, n_layers) | |
| else: | |
| layers = stack.layers | |
| for layer_number in range(min(n_layers, len(layers))): | |
| layer = layers[layer_number] | |
| if layer is None or layer.is_empty: | |
| continue | |
| contours = _layer_contour_loops(layer) | |
| if contours: | |
| contour_layers[layer_number].append( | |
| { | |
| "owner_idx": source.owner_idx, | |
| "contours": contours, | |
| } | |
| ) | |
| return contour_layers | |
| def _append_layer_contours( | |
| output_list: list[dict], | |
| current_x: float, | |
| current_y: float, | |
| contour_layers: list[list[dict]], | |
| layer_number: int, | |
| active_owner_idx: int | None, | |
| origin_x: float = 0.0, | |
| origin_y: float = 0.0, | |
| shared_motion: bool = False, | |
| ) -> tuple[float, float]: | |
| # `origin_x/origin_y` is the world position of the move list's start: | |
| # _last_print_reference sums relative moves from zero, so its result must | |
| # be shifted back into the world frame the contours live in. | |
| # | |
| # With `shared_motion` (reference-stack printing) every shape must trace | |
| # EVERY source's contours in the same order so all parallel heads follow | |
| # one identical path; the valve opens only on the active owner's contours. | |
| # Anchoring then uses the last motion move instead of the last print, and | |
| # the trailing-travel rewind is skipped — both depend on how the raster | |
| # moves were split at valve boundaries, which differs per shape and would | |
| # desynchronise the shared path. | |
| if layer_number >= len(contour_layers): | |
| return current_x, current_y | |
| active_sources = [ | |
| source | |
| for source in contour_layers[layer_number] | |
| if (shared_motion or source.get("owner_idx") == active_owner_idx) | |
| and any(len(contour) >= 2 for contour in source.get("contours", [])) | |
| ] | |
| if not active_sources: | |
| return current_x, current_y | |
| if not shared_motion: | |
| current_x, current_y = _rewind_trailing_travel( | |
| output_list, | |
| current_x, | |
| current_y, | |
| ) | |
| use_infill_reference = True | |
| for source in active_sources: | |
| if shared_motion and source.get("owner_idx") != active_owner_idx: | |
| color = 0 | |
| else: | |
| color = 255 | |
| for contour in source.get("contours", []): | |
| if len(contour) < 2: | |
| continue | |
| if use_infill_reference: | |
| reference = ( | |
| _last_motion_reference(output_list) | |
| if shared_motion | |
| else _last_print_reference(output_list) | |
| ) | |
| nearest_x, nearest_y, approach_dx, approach_dy = reference | |
| nearest_x += origin_x | |
| nearest_y += origin_y | |
| else: | |
| nearest_x, nearest_y = current_x, current_y | |
| approach_dx, approach_dy = 0.0, 0.0 | |
| is_closed = len(contour) >= 4 and contour[0] == contour[-1] | |
| if is_closed: | |
| contour = _rotate_closed_contour_to_nearest_border( | |
| contour, | |
| nearest_x, | |
| nearest_y, | |
| approach_dx, | |
| approach_dy, | |
| ) | |
| if contour[0] != contour[-1]: | |
| contour = [*contour, contour[0]] | |
| else: | |
| # Open arc (a split piece's seam-free outline): approach the | |
| # nearer end and print to the other. Never close the loop — | |
| # the closing chord would run along the cut seam this path | |
| # deliberately excludes. | |
| if _point_distance_sq( | |
| nearest_x, nearest_y, contour[-1][0], contour[-1][1] | |
| ) < _point_distance_sq( | |
| nearest_x, nearest_y, contour[0][0], contour[0][1] | |
| ): | |
| contour = list(reversed(contour)) | |
| start_x, start_y = contour[0] | |
| use_infill_reference = False | |
| current_x, current_y = _append_relative_move( | |
| output_list, | |
| current_x, | |
| current_y, | |
| start_x, | |
| start_y, | |
| 0, | |
| ) | |
| for target_x, target_y in contour[1:]: | |
| current_x, current_y = _append_relative_move( | |
| output_list, | |
| current_x, | |
| current_y, | |
| target_x, | |
| target_y, | |
| color, | |
| ) | |
| return current_x, current_y | |
| LEAD_IN_DIRECTION_LEFT = "Left" | |
| LEAD_IN_DIRECTION_RIGHT = "Right" | |
| LEAD_IN_DIRECTION_UP = "Up" | |
| LEAD_IN_DIRECTION_DOWN = "Down" | |
| LEAD_IN_DIRECTION_CHOICES = ( | |
| LEAD_IN_DIRECTION_LEFT, | |
| LEAD_IN_DIRECTION_RIGHT, | |
| LEAD_IN_DIRECTION_UP, | |
| LEAD_IN_DIRECTION_DOWN, | |
| ) | |
| # Away-from-the-part axis and the lateral line-stepping axis per direction. | |
| _LEAD_IN_AXES = { | |
| LEAD_IN_DIRECTION_LEFT: ((-1.0, 0.0), (0.0, 1.0)), | |
| LEAD_IN_DIRECTION_RIGHT: ((1.0, 0.0), (0.0, 1.0)), | |
| LEAD_IN_DIRECTION_UP: ((0.0, 1.0), (1.0, 0.0)), | |
| LEAD_IN_DIRECTION_DOWN: ((0.0, -1.0), (1.0, 0.0)), | |
| } | |
| def _normalize_lead_in_direction(direction: str | None) -> str: | |
| if direction in _LEAD_IN_AXES: | |
| return direction | |
| return LEAD_IN_DIRECTION_LEFT | |
| def _lead_in_moves( | |
| enabled: bool, | |
| length: float, | |
| clearance: float, | |
| line_count: int, | |
| line_spacing: float, | |
| print_color: int, | |
| off_color: int, | |
| direction: str | None = LEAD_IN_DIRECTION_LEFT, | |
| ) -> list[dict]: | |
| """Purge patch printed before layer 1, in the chosen direction. | |
| The patch sits `clearance` away from the toolpath start along the purge | |
| direction and snakes `line_count` strokes of `length`, stepping one | |
| `line_spacing` laterally between strokes. The return route exits the | |
| patch one lateral step to the OUTSIDE and comes home through virgin | |
| ground, so the freshly primed nozzle never drags back across the wet | |
| purge lines. | |
| """ | |
| if not enabled: | |
| return [] | |
| lead_length = max(0.0, float(length)) | |
| if lead_length <= 0.0: | |
| return [] | |
| lead_clearance = max(0.0, float(clearance)) | |
| pass_count = max(1, int(line_count)) | |
| spacing = max(0.0, float(line_spacing)) | |
| away, lateral = _LEAD_IN_AXES[_normalize_lead_in_direction(direction)] | |
| moves: list[dict] = [] | |
| # Patch-local frame: `a` runs along the away axis, `v` along the lateral. | |
| current_a = 0.0 | |
| current_v = 0.0 | |
| def append_move(delta_a: float, delta_v: float, color: int) -> None: | |
| nonlocal current_a, current_v | |
| if delta_a == 0.0 and delta_v == 0.0: | |
| return | |
| moves.append( | |
| { | |
| "X": delta_a * away[0] + delta_v * lateral[0], | |
| "Y": delta_a * away[1] + delta_v * lateral[1], | |
| "Color": color, | |
| } | |
| ) | |
| current_a += delta_a | |
| current_v += delta_v | |
| append_move(lead_clearance + lead_length, 0.0, off_color) | |
| stroke = -1.0 # first stroke prints back toward the part | |
| for pass_index in range(pass_count): | |
| append_move(stroke * lead_length, 0.0, print_color) | |
| stroke *= -1.0 | |
| if pass_index < pass_count - 1: | |
| append_move(0.0, spacing, off_color) | |
| # Return: step one spacing outside the patch laterally, travel home | |
| # through the clearance lane, then step back onto the start point. | |
| if spacing > 0.0: | |
| append_move(0.0, -(current_v + spacing), off_color) | |
| append_move(-current_a, 0.0, off_color) | |
| append_move(0.0, -current_v, off_color) | |
| else: | |
| append_move(-current_a, -current_v, off_color) | |
| return moves | |
| def plan_layer_moves( | |
| motion_layers: list[MultiPolygon], | |
| valve_layers: list[MultiPolygon], | |
| fil_width: float, | |
| layer_height: float, | |
| raster_pattern: str, | |
| contour_layers: list[list[dict]] | None = None, | |
| active_contour_owner: int | None = None, | |
| shared_motion: bool = False, | |
| scan_frame: tuple[float, float, float, float] | None = None, | |
| infill_fraction: float = 1.0, | |
| extra_wall_radii: list[list[float]] | None = None, | |
| ring_center: tuple[float, float] | None = None, | |
| motion_infill_fractions: list[float] | None = None, | |
| ) -> tuple[list[dict], tuple[float, float]]: | |
| """Assemble per-layer segments into a relative move list for all patterns. | |
| `scan_frame` (an XY box) pins the rasters' scanlines to a global grid so | |
| lines stack across layers and across split pieces, and provides the | |
| rotation pivot for diagonal layers. | |
| `infill_fraction` < 1 skips dispensing on evenly-distributed lines (grid | |
| lines for axis rasters, rings for the circle spiral). Lines that NO head | |
| prints are dropped from the motion entirely: `motion_infill_fractions` | |
| lists every shape sharing the motion (pass the SAME list to every shape, | |
| or the shared paths diverge). When omitted, a SOLO shape's own fraction | |
| bounds its motion, while shared motion is never restricted (the other | |
| heads' infill is unknown). The rectangular spiral keeps its full | |
| continuous walk (a skipped loop would break the spiral into | |
| disconnected rectangles). | |
| Returns the move list and the toolpath origin — the world position the | |
| relative moves start from (the first segment start of the first non-empty | |
| layer). Callers need it to map relative G-code back into world space. | |
| """ | |
| raster_pattern = _normalize_raster_pattern(raster_pattern) | |
| infill_keep = _infill_line_keep(infill_fraction) | |
| if motion_infill_fractions is not None: | |
| motion_keep = _combined_infill_keep(motion_infill_fractions) | |
| elif shared_motion: | |
| motion_keep = None | |
| else: | |
| motion_keep = infill_keep | |
| if scan_frame is not None: | |
| anchor_x = _scan_anchor(scan_frame[0], scan_frame[2], fil_width) | |
| anchor_y = _scan_anchor(scan_frame[1], scan_frame[3], fil_width) | |
| else: | |
| anchor_x = anchor_y = None | |
| gcode_list: list[dict] = [] | |
| current_x = 0.0 | |
| current_y = 0.0 | |
| origin_x = 0.0 | |
| origin_y = 0.0 | |
| raster_origin_initialized = False | |
| contour_layers = contour_layers or [] | |
| for layer_number, (motion, valve) in enumerate(zip(motion_layers, valve_layers)): | |
| if motion is None or motion.is_empty: | |
| segments: list[Seg] = [] | |
| elif raster_pattern == RASTER_PATTERN_CIRCLE_SPIRAL: | |
| # Rings live on one global radii grid anchored at the frame | |
| # centre (ring j at (j + 1/2) * fil): every layer draws from the | |
| # same radii, so walls stack across layers instead of aliasing, | |
| # and rings that never touch this layer's material are skipped | |
| # instead of swept as full travel circles. | |
| frame = scan_frame if scan_frame is not None else motion.bounds | |
| if ring_center is not None: | |
| # Shared-motion whole shapes: rings centred where every | |
| # shape's centre was ALIGNED to (the reference align | |
| # centre), so each shape is concentric with the ring set — | |
| # a bbox-union centre can sit off it when shapes differ in | |
| # size, making rings graze boundaries. | |
| center_x, center_y = ring_center | |
| else: | |
| center_x = (frame[0] + frame[2]) / 2.0 | |
| center_y = (frame[1] + frame[3]) / 2.0 | |
| radii, wall_radii = _circle_ring_radii(motion, center_x, center_y, fil_width) | |
| # Shared-motion parallel printing: every shape's own wall radius | |
| # joins the ONE ring set (all heads travel all walls; each | |
| # dispenses only on its own), so every shape keeps a smooth, | |
| # complete outer circle instead of whatever grid ring happens to | |
| # graze its boundary. | |
| extra_walls = ( | |
| extra_wall_radii[layer_number] | |
| if extra_wall_radii is not None and layer_number < len(extra_wall_radii) | |
| else [] | |
| ) | |
| if extra_walls: | |
| merged = sorted(set(radii) | set(extra_walls), reverse=True) | |
| radii = [] | |
| for radius in merged: | |
| if radius <= EPS: | |
| continue | |
| if radii and radii[-1] - radius < fil_width * 0.05: | |
| continue # near-duplicate wall/ring | |
| radii.append(radius) | |
| wall_radii = tuple(sorted(set(wall_radii) | set(extra_walls), reverse=True)) | |
| if motion_keep is not None: | |
| # Rings NO head dispenses on drop out of the motion (walls | |
| # always print, so they always stay). | |
| radii = [ | |
| radius | |
| for radius in radii | |
| if any(abs(radius - wall) <= fil_width * 0.25 for wall in wall_radii) | |
| or motion_keep(max(0, int(round(radius / fil_width - 0.5)))) | |
| ] | |
| points = _circle_rings_polyline(center_x, center_y, radii, fil_width) | |
| if layer_number % 2 == 1: | |
| points.reverse() | |
| # Dispense only ON a ring: the radial jump between revolutions | |
| # travels with the valve shut — otherwise every jump would | |
| # extrude a radial seam, worst on the outer wall. Vetoing whole | |
| # source segments by their radial change also kills the step | |
| # pieces the material boundary would otherwise split off. | |
| def keep_segment(x0: float, y0: float, x1: float, y1: float) -> bool: | |
| radius_0 = math.hypot(x0 - center_x, y0 - center_y) | |
| radius_1 = math.hypot(x1 - center_x, y1 - center_y) | |
| if abs(radius_1 - radius_0) > fil_width * 0.25: | |
| return False # radial jump between rings: travel only | |
| radius_mid = (radius_0 + radius_1) / 2.0 | |
| if infill_keep is None: | |
| return True | |
| if any(abs(radius_mid - wall) <= fil_width * 0.25 for wall in wall_radii): | |
| return True # perimeter walls always print, like contours | |
| ring = round(radius_mid / fil_width - 0.5) | |
| return infill_keep(max(0, int(ring))) | |
| segments = _classify_polyline(points, valve, keep_segment=keep_segment) | |
| # A ring grazing a faceted boundary flickers in/out of material, | |
| # printing sub-bead dashes (the spotty outer ring); real fill | |
| # arcs — a square's corner fill, a triangle's lobes — are far | |
| # longer and must stay. Close the valve over runs too short to | |
| # deposit cleanly; the motion is untouched. | |
| segments = _drop_short_print_runs(segments, fil_width * 1.5) | |
| elif raster_pattern == RASTER_PATTERN_RECTANGULAR_SPIRAL: | |
| # Anchor the loop family to the FRAME (constant across layers, | |
| # shapes, and split pieces), not each layer's own material | |
| # bounds: layers with smaller footprints would otherwise spiral | |
| # at their own offsets and the walls would not stack. Outer | |
| # loops that enclose this layer's material with more than half a | |
| # bead to spare are skipped instead of traveled. | |
| frame = scan_frame if scan_frame is not None else motion.bounds | |
| spiral_bounds = _frame_spiral_bounds(frame, motion.bounds, fil_width) | |
| points = _rectangular_spiral_polyline( | |
| spiral_bounds, | |
| fil_width, | |
| reverse=layer_number % 2 == 1, | |
| ) | |
| keep_point = None | |
| if infill_keep is not None: | |
| # A spiral "line" is one ring; index rings from the FRAME's | |
| # base loop so the selection lines up across layers. | |
| min_x, min_y, max_x, max_y = frame | |
| half = fil_width / 2.0 | |
| left, right = min_x + half, max_x - half | |
| bottom, top = min_y + half, max_y - half | |
| def keep_point(x: float, y: float) -> bool: | |
| inset = min(x - left, right - x, y - bottom, top - y) | |
| return infill_keep(max(0, int(round(inset / fil_width)))) | |
| segments = _classify_polyline(points, valve, keep_point=keep_point) | |
| elif ( | |
| raster_pattern == RASTER_PATTERN_DIAGONAL_WOODPILE | |
| and _diagonal_layer_angle(layer_number) is not None | |
| ): | |
| angle = _diagonal_layer_angle(layer_number) | |
| frame = scan_frame if scan_frame is not None else motion.bounds | |
| segments = _rotated_raster_segments( | |
| motion, | |
| valve, | |
| fil_width, | |
| angle, | |
| current_x, | |
| current_y, | |
| prefer_default=not raster_origin_initialized, | |
| frame=frame, | |
| infill_keep=infill_keep, | |
| motion_keep=motion_keep, | |
| ) | |
| else: | |
| raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number) | |
| # Axis "X" sweeps along X with rows stacked in Y, so its | |
| # scanlines use the Y anchor (and vice versa). | |
| axis_anchor = anchor_x if raster_axis == "Y" else anchor_y | |
| segments = _oriented_axis_raster_segments( | |
| motion, | |
| valve, | |
| fil_width, | |
| raster_axis, | |
| current_x, | |
| current_y, | |
| prefer_default=not raster_origin_initialized, | |
| scan_anchor=axis_anchor, | |
| infill_keep=infill_keep, | |
| motion_keep=motion_keep, | |
| ) | |
| if not segments: | |
| if layer_number > 0: | |
| gcode_list.append({"X": 0.0, "Y": 0.0, "Z": layer_height, "Color": 0}) | |
| layer_end_x, layer_end_y = current_x, current_y | |
| current_x, current_y = _append_layer_contours( | |
| gcode_list, | |
| current_x, | |
| current_y, | |
| contour_layers, | |
| layer_number, | |
| active_contour_owner, | |
| origin_x, | |
| origin_y, | |
| shared_motion, | |
| ) | |
| current_x, current_y = _append_relative_move( | |
| gcode_list, | |
| current_x, | |
| current_y, | |
| layer_end_x, | |
| layer_end_y, | |
| 0, | |
| ) | |
| continue | |
| first_x, first_y = segments[0][0], segments[0][1] | |
| if not raster_origin_initialized: | |
| if layer_number > 0: | |
| current_x, current_y = _append_relative_move( | |
| gcode_list, | |
| current_x, | |
| current_y, | |
| current_x, | |
| current_y, | |
| 0, | |
| z_step=layer_height, | |
| ) | |
| current_x, current_y = first_x, first_y | |
| origin_x, origin_y = first_x, first_y | |
| raster_origin_initialized = True | |
| elif layer_number > 0: | |
| current_x, current_y = _append_relative_move( | |
| gcode_list, | |
| current_x, | |
| current_y, | |
| first_x, | |
| first_y, | |
| 0, | |
| z_step=layer_height, | |
| ) | |
| else: | |
| current_x, current_y = _append_relative_move( | |
| gcode_list, | |
| current_x, | |
| current_y, | |
| first_x, | |
| first_y, | |
| 0, | |
| ) | |
| for start_x, start_y, end_x, end_y, color in segments: | |
| current_x, current_y = _append_relative_move( | |
| gcode_list, | |
| current_x, | |
| current_y, | |
| start_x, | |
| start_y, | |
| 0, | |
| ) | |
| current_x, current_y = _append_relative_move( | |
| gcode_list, | |
| current_x, | |
| current_y, | |
| end_x, | |
| end_y, | |
| color, | |
| ) | |
| layer_end_x, layer_end_y = current_x, current_y | |
| current_x, current_y = _append_layer_contours( | |
| gcode_list, | |
| current_x, | |
| current_y, | |
| contour_layers, | |
| layer_number, | |
| active_contour_owner, | |
| origin_x, | |
| origin_y, | |
| shared_motion, | |
| ) | |
| current_x, current_y = _append_relative_move( | |
| gcode_list, | |
| current_x, | |
| current_y, | |
| layer_end_x, | |
| layer_end_y, | |
| 0, | |
| ) | |
| return gcode_list, (origin_x, origin_y) | |
| def _stack_center(stack: LayerStack) -> tuple[float, float]: | |
| (x_min, y_min, _z_min), (x_max, y_max, _z_max) = stack.bounds | |
| return ((x_min + x_max) / 2.0, (y_min + y_max) / 2.0) | |
| def _alignment_center(stack: LayerStack) -> tuple[float, float]: | |
| """The point a stack is centred by: its multi-material group frame's | |
| centre when it belongs to a group, else its own bbox centre. Group | |
| members share one frame, so they all get the same delta and keep their | |
| modelled positions relative to each other.""" | |
| if stack.align_frame is not None: | |
| x_min, y_min, x_max, y_max = stack.align_frame | |
| return ((x_min + x_max) / 2.0, (y_min + y_max) / 2.0) | |
| return _stack_center(stack) | |
| def _snap_to_grid(value: float, grid: float | None) -> float: | |
| if not grid or grid <= 0.0: | |
| return value | |
| # Half-up, not Python's banker's rounding: values that differ by exact | |
| # grid multiples must snap to results that differ by the same multiples | |
| # (banker's rounds exact halves toward even, breaking that translation | |
| # invariance and with it uniform split-piece spacing). | |
| return math.floor(value / grid + 0.5 + 1e-9) * grid | |
| def _centering_delta(stack: LayerStack, reference: LayerStack) -> tuple[float, float]: | |
| """Translation that aligns `stack` into `reference`'s shared frame. | |
| Uses the alignment target and snap grid stamped on the reference by | |
| `build_reference_stack`, so re-deriving the delta later reproduces the | |
| exact translation the reference was built with. Snapping the delta to the | |
| fil grid keeps every shape's world scan-grid phase intact, so split | |
| pieces printed with shared reference motion still tile at an exact | |
| one-fil-width line pitch. | |
| Split siblings (stacks sharing the reference's scan frame) are aligned by | |
| their cell corner within that frame instead of their centre: with cells | |
| sized in whole grid multiples the deltas — and with them the required | |
| nozzle spacing — come out uniform across all pieces, whereas snapping the | |
| centres would wobble by up to one fil where the last cell's width (and so | |
| its centre phase) differs. | |
| Multi-material group members (stacks carrying a shared `align_frame`) | |
| are aligned by the group frame's centre instead of their own bbox | |
| centre: every member gets the same delta, so the group moves as one | |
| rigid unit and parts keep their modelled relative positions. | |
| """ | |
| grid = reference.align_grid | |
| if ( | |
| grid | |
| and stack.scan_frame is not None | |
| and stack.scan_frame == reference.scan_frame | |
| ): | |
| (stack_min_x, stack_min_y, _sz), _stack_max = ( | |
| stack.bounds[0], | |
| stack.bounds[1], | |
| ) | |
| frame_min_x, frame_min_y = stack.scan_frame[0], stack.scan_frame[1] | |
| return ( | |
| _snap_to_grid(frame_min_x - stack_min_x, grid), | |
| _snap_to_grid(frame_min_y - stack_min_y, grid), | |
| ) | |
| if reference.align_center is not None: | |
| reference_x, reference_y = reference.align_center | |
| else: | |
| reference_x, reference_y = _stack_center(reference) | |
| center_x, center_y = _alignment_center(stack) | |
| # No grid snap here: whole shapes centre EXACTLY on the align centre | |
| # (circle-spiral rings are centred there, and a snapped residue of up | |
| # to half a fil would make rings graze the shape's boundary). The snap | |
| # only ever mattered for split pieces, which take the scan-frame corner | |
| # rule above. | |
| return (reference_x - center_x, reference_y - center_y) | |
| def align_stack_to( | |
| stack: LayerStack, | |
| reference: LayerStack, | |
| n_layers: int, | |
| ) -> list[MultiPolygon]: | |
| """Translate a stack's layers into the reference frame, padded with empties.""" | |
| delta_x, delta_y = _centering_delta(stack, reference) | |
| layers: list[MultiPolygon] = [] | |
| for index in range(n_layers): | |
| if index < len(stack.layers) and not stack.layers[index].is_empty: | |
| layers.append( | |
| _as_multipolygon( | |
| translate(stack.layers[index], xoff=delta_x, yoff=delta_y) | |
| ) | |
| ) | |
| else: | |
| layers.append(MultiPolygon()) | |
| return layers | |
| def build_reference_stack( | |
| stacks: list[LayerStack | None], | |
| grid: float | None = None, | |
| ) -> LayerStack | None: | |
| """Union all shapes into one shared motion stack, alignment-centres aligned. | |
| Vector analog of the old centered "black wins" TIFF merge: every stack is | |
| translated so its alignment centre (its own XY bbox centre, or its | |
| multi-material group frame's centre — see `_alignment_center`) lands on | |
| the first stack's alignment centre, then each layer is the union of the | |
| translated layers. Multi-material group members share one frame, so the | |
| group translates as a rigid unit and its parts keep their modelled | |
| relative positions; the group holding the first stack does not move at | |
| all. Group members should be sliced on one common Z grid so layer indices | |
| line up; a part that starts higher simply contributes nothing to the | |
| lower layers. | |
| `grid` (the fil width) snaps each translation to grid multiples so every | |
| shape's world scan-grid phase survives the alignment — required for split | |
| pieces to keep an exact one-fil line pitch under shared reference motion. | |
| The alignment target and grid are stamped on the result so later | |
| `_centering_delta` calls reproduce the exact same translations. | |
| """ | |
| valid = [stack for stack in stacks if stack is not None and stack.layers] | |
| if not valid: | |
| return None | |
| n_layers = max(len(stack.layers) for stack in valid) | |
| reference_x, reference_y = _alignment_center(valid[0]) | |
| target = LayerStack( | |
| layers=[], | |
| z_values=[], | |
| bounds=((0.0, 0.0, 0.0), (0.0, 0.0, 0.0)), | |
| layer_height=valid[0].layer_height, | |
| scan_frame=valid[0].scan_frame, | |
| align_center=(reference_x, reference_y), | |
| align_grid=grid, | |
| ) | |
| layer_parts: list[list[MultiPolygon]] = [[] for _ in range(n_layers)] | |
| x_min = y_min = z_min = math.inf | |
| x_max = y_max = z_max = -math.inf | |
| for stack in valid: | |
| delta_x, delta_y = _centering_delta(stack, target) | |
| (sx_min, sy_min, sz_min), (sx_max, sy_max, sz_max) = stack.bounds | |
| x_min = min(x_min, sx_min + delta_x) | |
| x_max = max(x_max, sx_max + delta_x) | |
| y_min = min(y_min, sy_min + delta_y) | |
| y_max = max(y_max, sy_max + delta_y) | |
| z_min = min(z_min, sz_min) | |
| z_max = max(z_max, sz_max) | |
| for index, layer in enumerate(stack.layers): | |
| if layer is None or layer.is_empty: | |
| continue | |
| layer_parts[index].append(translate(layer, xoff=delta_x, yoff=delta_y)) | |
| layers = [ | |
| _as_multipolygon(unary_union(parts)) if parts else MultiPolygon() | |
| for parts in layer_parts | |
| ] | |
| z_values: list[float] = [] | |
| for index in range(n_layers): | |
| z_value = 0.0 | |
| for stack in valid: | |
| if index < len(stack.z_values): | |
| z_value = stack.z_values[index] | |
| break | |
| z_values.append(z_value) | |
| # The first stack's delta is zero (snap(0) == 0), so its scan frame is | |
| # already in the shared frame and anchors the reference's scan grid. | |
| return LayerStack( | |
| layers=layers, | |
| z_values=z_values, | |
| bounds=((x_min, y_min, z_min), (x_max, y_max, z_max)), | |
| layer_height=valid[0].layer_height, | |
| name="reference", | |
| scan_frame=valid[0].scan_frame, | |
| align_center=(reference_x, reference_y), | |
| align_grid=grid, | |
| ) | |
| def _base_split_edges( | |
| lo: float, | |
| hi: float, | |
| count: int, | |
| grid: float | None = None, | |
| ) -> list[float]: | |
| """Cell edges for one axis: equal cells, padded to whole grid multiples. | |
| With `grid` (the fil width), every cell gets the SAME width — the extent | |
| divided by `count`, rounded UP to a whole number of grid units — and the | |
| leftover becomes blank margin split evenly outside the shape's outer | |
| edges. This is the vector analog of the old pixel splitter's padded | |
| canvas: identical piece sizes, so the required nozzle spacing is one cell | |
| everywhere, including under shared reference motion. | |
| """ | |
| extent = hi - lo | |
| if grid and grid > 0.0 and count > 1 and extent > 0.0: | |
| units = max(1, math.ceil(extent / (count * grid) - 1e-9)) | |
| cell = units * grid | |
| pad = cell * count - extent | |
| start = lo - pad / 2.0 | |
| return [start + index * cell for index in range(count + 1)] | |
| return [lo + index * extent / count for index in range(count + 1)] | |
| def _shifted_split_edges( | |
| edges: list[float], | |
| layer_index: int, | |
| overlap: float, | |
| ) -> list[float]: | |
| """Per-layer cell edges; interior ones alternate by ±overlap per layer.""" | |
| count = len(edges) - 1 | |
| if overlap <= 0.0 or count <= 1: | |
| return list(edges) | |
| adjusted = list(edges) | |
| for boundary_index in range(1, count): | |
| direction = 1 if (layer_index + boundary_index) % 2 == 1 else -1 | |
| lower = adjusted[boundary_index - 1] + overlap | |
| upper = edges[boundary_index + 1] - overlap | |
| shifted = edges[boundary_index] + direction * overlap | |
| if lower <= upper: | |
| shifted = max(lower, min(upper, shifted)) | |
| else: | |
| shifted = edges[boundary_index] | |
| adjusted[boundary_index] = shifted | |
| return adjusted | |
| def _linework_to_paths(geometry: object) -> list[list[tuple[float, float]]]: | |
| """Merge linework into maximal polylines, ordered/oriented | |
| deterministically so shapes sharing reference motion trace them | |
| identically. Open paths allowed; closed rings keep first == last.""" | |
| pieces = list(_iter_linestrings(geometry)) | |
| if not pieces: | |
| return [] | |
| merged = linemerge(MultiLineString(pieces)) if len(pieces) > 1 else pieces[0] | |
| paths: list[list[tuple[float, float]]] = [] | |
| for line in _iter_linestrings(merged): | |
| coords = [(float(x), float(y)) for x, y in line.coords] | |
| if len(coords) < 2 or LineString(coords).length <= EPS: | |
| continue | |
| # Normalize open-path orientation (closed rings keep first == last). | |
| if coords[0] != coords[-1] and coords[-1] < coords[0]: | |
| coords.reverse() | |
| paths.append(coords) | |
| paths.sort( | |
| key=lambda path: ( | |
| min(point[1] for point in path), | |
| min(point[0] for point in path), | |
| -len(path), | |
| ) | |
| ) | |
| return paths | |
| def _clip_contour_paths( | |
| source_lines: object, | |
| cell: object, | |
| ) -> list[list[tuple[float, float]]]: | |
| """Contour polylines of a split piece: the parent outline inside the cell. | |
| Clipping the PARENT's boundary linework (instead of taking the piece | |
| polygon's own boundary) excludes the cut seams between sibling pieces — | |
| only the true outer surface remains. | |
| """ | |
| return _linework_to_paths(source_lines.intersection(cell)) | |
| def group_contour_paths( | |
| member: LayerStack, | |
| siblings: list[LayerStack], | |
| tolerance: float, | |
| ) -> list[list[list[tuple[float, float]]]]: | |
| """Per-layer seam-free contour polylines for one multi-material member. | |
| Parts sharing a nozzle assemble into ONE shape, so where a member's | |
| boundary meets a sibling material — or comes within `tolerance` of it | |
| (fit gaps between materials) — that edge is an internal interface, not a | |
| printable surface. Only the member boundary on the assembly's true | |
| outside is kept; a member fully embedded in the assembly gets no | |
| contours at all. Members should share one Z grid so layer indices align. | |
| """ | |
| tolerance = max(float(tolerance), EPS) | |
| contour_paths: list[list[list[tuple[float, float]]]] = [] | |
| for layer_number, layer in enumerate(member.layers): | |
| if layer is None or layer.is_empty: | |
| contour_paths.append([]) | |
| continue | |
| sibling_parts = [ | |
| sibling.layers[layer_number] | |
| for sibling in siblings | |
| if layer_number < len(sibling.layers) | |
| and sibling.layers[layer_number] is not None | |
| and not sibling.layers[layer_number].is_empty | |
| ] | |
| boundary = layer.boundary | |
| if sibling_parts: | |
| boundary = boundary.difference(unary_union(sibling_parts).buffer(tolerance)) | |
| contour_paths.append(_linework_to_paths(boundary)) | |
| return contour_paths | |
| def split_layer_stack_grid( | |
| stack: LayerStack, | |
| columns: int, | |
| rows: int, | |
| overlapping_layers: bool = False, | |
| overlap: float = 0.0, | |
| grid: float | None = None, | |
| frame: tuple[float, float, float, float] | None = None, | |
| ) -> list[LayerStack]: | |
| """Split a sliced shape into a rows x columns grid of piece stacks. | |
| Pieces are returned row-major with row 1 the top strip (max-Y side), | |
| matching the legacy image-grid ordering. With `overlapping_layers`, the | |
| interior cut lines alternate by ±overlap between layers so neighbouring | |
| pieces interlock. `grid` (the fil width) sizes the cells in whole grid | |
| multiples (see `_base_split_edges`). Piece `bounds` are the nominal | |
| (un-shifted) cell boxes. | |
| `frame` overrides the XY box the cell grid is computed over. Splitting | |
| every member of a multi-material group with the group's combined bounds | |
| as the frame clips all materials by the SAME cells (and one shared scan | |
| frame), so cell-mates assemble exactly. | |
| """ | |
| columns = max(1, int(columns)) | |
| rows = max(1, int(rows)) | |
| (x_min, y_min, z_min), (x_max, y_max, z_max) = stack.bounds | |
| if frame is not None: | |
| x_min, y_min, x_max, y_max = (float(value) for value in frame) | |
| overlap_x = overlap if (overlapping_layers and columns > 1) else 0.0 | |
| overlap_y = overlap if (overlapping_layers and rows > 1) else 0.0 | |
| base_x_edges = _base_split_edges(x_min, x_max, columns, grid) | |
| base_y_edges = _base_split_edges(y_min, y_max, rows, grid) | |
| layer_x_edges = [ | |
| _shifted_split_edges(base_x_edges, index, overlap_x) | |
| for index in range(len(stack.layers)) | |
| ] | |
| layer_y_edges = [ | |
| _shifted_split_edges(base_y_edges, index, overlap_y) | |
| for index in range(len(stack.layers)) | |
| ] | |
| # Pieces inherit the parent's scan frame so they all raster on ONE | |
| # continuous line grid: the assembled seams then keep an exact | |
| # one-fil-width pitch instead of each piece re-centring its own lines. | |
| scan_frame = stack.scan_frame or (x_min, y_min, x_max, y_max) | |
| base_name = stack.name or "shape" | |
| pieces: list[LayerStack] = [] | |
| for row_index in range(1, rows + 1): | |
| # Row 1 is the top strip: count y-cells down from the max edge. | |
| y_cell = rows - row_index | |
| for col_index in range(1, columns + 1): | |
| x_cell = col_index - 1 | |
| layers: list[MultiPolygon] = [] | |
| contour_paths: list[list[list[tuple[float, float]]]] = [] | |
| for layer_number, layer in enumerate(stack.layers): | |
| if layer is None or layer.is_empty: | |
| layers.append(MultiPolygon()) | |
| contour_paths.append([]) | |
| continue | |
| x_edges = layer_x_edges[layer_number] | |
| y_edges = layer_y_edges[layer_number] | |
| cell = box( | |
| x_edges[x_cell], | |
| y_edges[y_cell], | |
| x_edges[x_cell + 1], | |
| y_edges[y_cell + 1], | |
| ) | |
| layers.append(_as_multipolygon(layer.intersection(cell))) | |
| # Contours come from the parent's outline (or, when | |
| # re-splitting a piece, its already-seam-free paths) so the | |
| # cut lines between siblings are never traced. | |
| if stack.contour_paths is not None: | |
| parent_paths = ( | |
| stack.contour_paths[layer_number] | |
| if layer_number < len(stack.contour_paths) | |
| else [] | |
| ) | |
| source_lines = MultiLineString( | |
| [path for path in parent_paths if len(path) >= 2] | |
| ) | |
| else: | |
| source_lines = layer.boundary | |
| contour_paths.append(_clip_contour_paths(source_lines, cell)) | |
| pieces.append( | |
| LayerStack( | |
| layers=layers, | |
| z_values=list(stack.z_values), | |
| bounds=( | |
| (base_x_edges[x_cell], base_y_edges[y_cell], z_min), | |
| (base_x_edges[x_cell + 1], base_y_edges[y_cell + 1], z_max), | |
| ), | |
| layer_height=stack.layer_height, | |
| name=f"{base_name}_r{row_index:02d}_c{col_index:02d}", | |
| scan_frame=scan_frame, | |
| contour_paths=contour_paths, | |
| ) | |
| ) | |
| return pieces | |