ParallelPrint / vector_toolpath.py
CyGuy8's picture
Drop raster lines no head prints from the shared motion; {preset} markers
67c2359
Raw
History Blame Contribute Delete
77.4 kB
"""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]
@dataclass(slots=True)
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