CyGuy8 Claude Fable 5 commited on
Commit
c7f0b44
Β·
1 Parent(s): ff58e99

Add multi-material assemblies, fix slicing of multi-body STLs, improve circle spiral

Browse files

Multi-material printing (STLs sharing a nozzle number):
- Shapes on one nozzle are detected as one assembly: sliced on a shared
Z grid (empty lower layers for parts that start higher) and aligned as
one rigid unit at their modeled positions via a shared group frame
- Contour tracing skips material-to-material interfaces (within half a
bead, covering fit-tolerance gaps) - only the assembly's true outer
surface is traced, like split-piece seam exclusion
- Multi-Nozzle Split treats a group as one shape: all materials clipped
by the same cell grid, cell-mates share a nozzle, each piece keeps its
own valve; split source dropdown warns before a group split

Slicer fixes for multi-body STLs (Maryland flag, checkerboard cubes):
- Watertight bodies slice per-body and union (interpenetrating stripe
prisms no longer XOR into holes); inverted bodies subtract as cavities
- Non-watertight fragments section together as one remainder mesh, so
shredded topology (T-vertices) still reconstructs whole rings
- Ring composition uses containment depth (polygons_full, new rtree dep)

Circle spiral raster:
- Rings live on one global radii grid anchored at the frame centre, so
walls stack across layers instead of aliasing wider on higher layers
- Outermost revolution is a perimeter wall hugging the material edge
(smooth silhouette); rings that cannot touch material are skipped
(halfsphere travel dropped to ~3% of path length)

UI:
- In-table color dropdown works: Color cells swallow pointer events
before the dataframe opens its raw-HTML cell editor (which was killing
the native popup); picks relay through a hidden sink/apply pair
- Yellow and White added to the palette
- G-code visualization filament widths follow the slicer's Filament/Line
Width instead of the layer height

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

README.md CHANGED
@@ -45,6 +45,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
45
  - Scales loaded STLs from editable target X/Y/Z dimensions in the Shape Settings table; new rows default to the STL's original dimensions, **Reset Dimensions** restores them, and **Keep Proportions** updates the other target sides from the edited side
46
  - Lets you choose layer height and filament/line width
47
  - Slices each shape into per-layer vector outlines held in memory (no intermediate image files)
 
48
  - Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
49
  - Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
50
  - Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
@@ -57,6 +58,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
57
  - Visualizes generated or uploaded G-code tool paths, with the source selectable from any active generated shape or an uploaded file
58
  - Renders the tool path as a fast line plot or an animated 3D tube plot (play/pause, speed, scrub, frame-step, nozzle marker)
59
  - Plots the generated shapes using the configured nozzle spacing and animates them printing in parallel, with a server-side GIF export of that animation
 
60
 
61
  ## Behavior and Implementation Notes
62
 
@@ -71,6 +73,18 @@ When you click **Slice Shapes**, the app automatically unions the sliced shapes
71
  - Shapes are aligned by centering each shape's XY bounding box on a common center before the union.
72
  - Alignment is centered placement (in exact millimetres), not bottom-left anchoring.
73
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  ### Multi-Nozzle Split
75
 
76
  The **Multi-Nozzle Split** accordion on the **Shapes & Slicing** tab can split one sliced shape into a grid of print-ready piece stacks. Choose a source shape that has been sliced, set the number of columns and rows, choose the starting nozzle and valve numbers, then click **Split Selected Shape into Grid Pieces**.
@@ -79,6 +93,7 @@ The **Multi-Nozzle Split** accordion on the **Shapes & Slicing** tab can split o
79
  - The selected shape is replaced in Shape Settings by one generated record per grid cell, named by row and column.
80
  - Nozzle and valve numbers are assigned sequentially from the starting values, and the **Generate G-Code** tab can generate separate G-code for each piece.
81
  - **Overlapping Layers** alternates the interior cut lines by one filament width per layer so neighbouring pieces interlock.
 
82
 
83
  ### G-code XY Step Size
84
 
@@ -95,9 +110,9 @@ The **Multi-Nozzle Split** accordion on the **Shapes & Slicing** tab can split o
95
  - **Lead In**: enabled per shape via the **Lead In** column in Shape Settings; the Lead In Options accordion on the Generate G-Code tab sets the patch geometry. Prints a purge patch before layer 1, in a selectable direction (Left/Right/Up/Down) at the configured clearance from the start point. The return route exits the patch laterally and comes home through the clearance lane, so the primed nozzle never drags back across the wet purge lines. For grid-split pieces the clearance is automatically extended by the assembly's remaining extent along the purge axis (reported in the G-code status), so under shared reference motion every nozzle's purge patch lands clear of the whole assembled part instead of on a neighbor's print area. The **Lead In** column in Shape Settings controls dispensing per shape: an opted-out head still travels the shared patch (keeping parallel heads in sync) but keeps its valve shut, and skips the lead-in moves entirely when printing without shared motion.
96
  - **Use combined reference outline for motion**: when enabled, every shape's *motion* is taken from the combined reference layer union while each shape's *valve/dispensing* comes from its own layer polygons β€” so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. The reference union is rebuilt automatically when shapes are sliced. Contour tracing stays synchronized too: every shape traces every traced shape's contour, opening its valve only on its own outline.
97
  - Every generated file starts with a `; PathOrigin X.. Y..` comment: the world position (in the shape's own frame) that the relative toolpath starts from. Tools use it to place parallel parts so split pieces reassemble.
98
- - **Raster Pattern**: `X-direction raster` sweeps every layer back-and-forth in X. `Y-direction raster` rasters every layer in Y. `90Β° Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps. `45Β° Woodpile raster` rotates the sweep 45 degrees per layer, cycling 0, 45, 90, 135 degrees. `Rectangular Spiral raster` walks each layer from the outer layer bounds toward the center, then reverses from center to edge on the next layer. `Circle Spiral raster` prints concentric circles from the layer bounds toward the center β€” each revolution stays at a constant radius and steps inward by one line width between revolutions, so the walls are smooth true circles β€” then reverses outward on the next layer. Spiral motion covers the layer bounds; the valve opens only where the path is inside material.
99
  - **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (`PathOrigin` anchors + toolpath bounds), sets the grid columns/rows from the split, and fills the Advanced Grid Spacing table. Works for every raster pattern, filament width, reference-motion setting, and overlapping-layer split. Requires the pieces' G-code to be generated first.
100
- - **Contour Tracing**: enabled per row in Shape Settings. The app traces the layer polygon's boundary rings (holes traced separately), travels from the layer raster end to the nearest contour point, prints the contour, then returns to the raster endpoint before the next layer. For grid-split pieces only the parent shape's true outer surface is traced β€” the cut seams between sibling pieces are excluded (open arcs are printed end-to-end without closing the loop; fully interior pieces get no contour).
101
 
102
  ### Print vs Travel Classification
103
 
@@ -113,7 +128,7 @@ The parser also handles standard slicer G-code: single-axis and Z-only moves, ax
113
  The G-code visualization tab renders generated shape G-code or an uploaded `.txt`, `.gcode`, or `.nc` file. It parses `G0`/`G1` movement lines, supports relative (`G91`) and absolute (`G90`) positioning, and offers two render modes:
114
 
115
  - **Line Plot** β€” fast thin scatter lines (print and travel), with color/opacity controls.
116
- - **Tube Plot with Animation** β€” mm-width filament tubes (circular, capped, lit) with a client-side build animation (play/pause, speed, scrub, frame-step) and a moving nozzle marker. Filament/travel widths default to the layer height and its quarter.
117
 
118
  ### Parallel Printing Visualization
119
 
 
45
  - Scales loaded STLs from editable target X/Y/Z dimensions in the Shape Settings table; new rows default to the STL's original dimensions, **Reset Dimensions** restores them, and **Keep Proportions** updates the other target sides from the edited side
46
  - Lets you choose layer height and filament/line width
47
  - Slices each shape into per-layer vector outlines held in memory (no intermediate image files)
48
+ - Shapes that share a **nozzle number** are treated automatically as one multi-material assembly: sliced on one shared Z grid and kept exactly where they were modeled, while shapes alone on their nozzle behave as ordinary independent parts
49
  - Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
50
  - Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
51
  - Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
 
58
  - Visualizes generated or uploaded G-code tool paths, with the source selectable from any active generated shape or an uploaded file
59
  - Renders the tool path as a fast line plot or an animated 3D tube plot (play/pause, speed, scrub, frame-step, nozzle marker)
60
  - Plots the generated shapes using the configured nozzle spacing and animates them printing in parallel, with a server-side GIF export of that animation
61
+ - Each shape's plot color is set with one click on a palette chip embedded in the Shape Settings **Color** column (Orange, Blue, Green, Red, Purple, Pink, Teal, Yellow, White, Black) β€” the cell shows the current color's name and highlights its chip
62
 
63
  ## Behavior and Implementation Notes
64
 
 
73
  - Shapes are aligned by centering each shape's XY bounding box on a common center before the union.
74
  - Alignment is centered placement (in exact millimetres), not bottom-left anchoring.
75
 
76
+ ### Multi-Material Assemblies (shared nozzle numbers)
77
+
78
+ For a multi-material object exported as separate STLs (one per material), give every part the **same nozzle number** in Shape Settings β€” parts sharing a nozzle print from the same physical position, so the app treats them automatically as one assembly:
79
+
80
+ - Group members are sliced on **one shared Z grid** spanning the whole assembly, so a part that starts higher in the model simply has empty lower layers β€” it travels the shared path but dispenses nothing until the print reaches its height.
81
+ - The group is aligned into the reference union as **one rigid unit**: each part keeps exactly the position it was modeled at relative to the others, so asymmetric assemblies line up the way they do in the 3D preview.
82
+ - Shapes alone on their nozzle keep the normal behavior (centered onto the common reference), so regular shapes and multi-material assemblies can print together in one job.
83
+ - **Contour Tracing** on assembly parts outlines only the assembly's true outer surface: edges where one material meets (or nearly meets, within half a bead β€” fit tolerances included) another material are internal interfaces and are skipped, exactly like the cut seams of grid-split pieces.
84
+ - Combine with **Use combined reference outline for motion** so all heads share one synchronized path while each dispenses only its own part.
85
+ - Nozzle renumbering in the table takes effect on the next slice or G-code generation β€” groups are re-detected automatically.
86
+ - Leave assembly parts' target dimensions at their defaults (or scale every part identically); parts are scaled about their own corners, so unequal scaling would misalign an assembly.
87
+
88
  ### Multi-Nozzle Split
89
 
90
  The **Multi-Nozzle Split** accordion on the **Shapes & Slicing** tab can split one sliced shape into a grid of print-ready piece stacks. Choose a source shape that has been sliced, set the number of columns and rows, choose the starting nozzle and valve numbers, then click **Split Selected Shape into Grid Pieces**.
 
93
  - The selected shape is replaced in Shape Settings by one generated record per grid cell, named by row and column.
94
  - Nozzle and valve numbers are assigned sequentially from the starting values, and the **Generate G-Code** tab can generate separate G-code for each piece.
95
  - **Overlapping Layers** alternates the interior cut lines by one filament width per layer so neighbouring pieces interlock.
96
+ - **Multi-material assemblies split as one shape**: if the selected shape shares its nozzle with other shapes, the whole group is split together β€” every material is clipped by the same cell grid over the group's combined bounds. Pieces are emitted cell by cell: each cell's pieces share a nozzle (so every cell is itself a multi-material group, keeping the alignment and seam-free contour behavior), each piece gets its own valve, and cells where a material has no geometry are skipped. Auto Align works on the result like any other split.
97
 
98
  ### G-code XY Step Size
99
 
 
110
  - **Lead In**: enabled per shape via the **Lead In** column in Shape Settings; the Lead In Options accordion on the Generate G-Code tab sets the patch geometry. Prints a purge patch before layer 1, in a selectable direction (Left/Right/Up/Down) at the configured clearance from the start point. The return route exits the patch laterally and comes home through the clearance lane, so the primed nozzle never drags back across the wet purge lines. For grid-split pieces the clearance is automatically extended by the assembly's remaining extent along the purge axis (reported in the G-code status), so under shared reference motion every nozzle's purge patch lands clear of the whole assembled part instead of on a neighbor's print area. The **Lead In** column in Shape Settings controls dispensing per shape: an opted-out head still travels the shared patch (keeping parallel heads in sync) but keeps its valve shut, and skips the lead-in moves entirely when printing without shared motion.
111
  - **Use combined reference outline for motion**: when enabled, every shape's *motion* is taken from the combined reference layer union while each shape's *valve/dispensing* comes from its own layer polygons β€” so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. The reference union is rebuilt automatically when shapes are sliced. Contour tracing stays synchronized too: every shape traces every traced shape's contour, opening its valve only on its own outline.
112
  - Every generated file starts with a `; PathOrigin X.. Y..` comment: the world position (in the shape's own frame) that the relative toolpath starts from. Tools use it to place parallel parts so split pieces reassemble.
113
+ - **Raster Pattern**: `X-direction raster` sweeps every layer back-and-forth in X. `Y-direction raster` rasters every layer in Y. `90Β° Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps. `45Β° Woodpile raster` rotates the sweep 45 degrees per layer, cycling 0, 45, 90, 135 degrees. `Rectangular Spiral raster` walks each layer from the outer layer bounds toward the center, then reverses from center to edge on the next layer. `Circle Spiral raster` prints concentric circles stepping inward by one line width per revolution β€” each revolution stays at a constant radius, so the walls are smooth true circles β€” then reverses outward on the next layer. The outermost revolution is a perimeter wall hugging the layer's material edge (half a bead inside its farthest boundary point), so the printed silhouette follows the shape smoothly; a matching inner wall hugs a central hole when there is one. The fill rings between the walls come from one global radii grid anchored at the shape frame's center, so interior rings stack exactly across layers, and rings that cannot touch a layer's material (or would overlap a wall bead) are skipped instead of traveled. Walls always dispense even under partial infill, like contour tracing; elsewhere the valve opens only where the path is inside material.
114
  - **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (`PathOrigin` anchors + toolpath bounds), sets the grid columns/rows from the split, and fills the Advanced Grid Spacing table. Works for every raster pattern, filament width, reference-motion setting, and overlapping-layer split. Requires the pieces' G-code to be generated first.
115
+ - **Contour Tracing**: enabled per row in Shape Settings. The app traces the layer polygon's boundary rings (holes traced separately), travels from the layer raster end to the nearest contour point, prints the contour, then returns to the raster endpoint before the next layer. For grid-split pieces only the parent shape's true outer surface is traced β€” the cut seams between sibling pieces are excluded (open arcs are printed end-to-end without closing the loop; fully interior pieces get no contour). Multi-material assembly parts (shapes sharing a nozzle) work the same way: boundary within half a bead of a sibling material counts as an internal interface and is not contoured β€” only the assembled shape's true outer surface is traced, and a part fully embedded in the assembly gets no contour at all.
116
 
117
  ### Print vs Travel Classification
118
 
 
128
  The G-code visualization tab renders generated shape G-code or an uploaded `.txt`, `.gcode`, or `.nc` file. It parses `G0`/`G1` movement lines, supports relative (`G91`) and absolute (`G90`) positioning, and offers two render modes:
129
 
130
  - **Line Plot** β€” fast thin scatter lines (print and travel), with color/opacity controls.
131
+ - **Tube Plot with Animation** β€” mm-width filament tubes (circular, capped, lit) with a client-side build animation (play/pause, speed, scrub, frame-step) and a moving nozzle marker. Filament/travel widths automatically follow the slicer's Filament/Line Width and its quarter.
132
 
133
  ### Parallel Printing Visualization
134
 
app.py CHANGED
@@ -30,6 +30,7 @@ from gcode_viewer import (
30
  )
31
  from stl_slicer import (
32
  LayerStack,
 
33
  load_mesh,
34
  scale_factors_for_target_extents,
35
  scale_mesh,
@@ -44,6 +45,7 @@ from vector_toolpath import (
44
  RASTER_PATTERN_Y_DIRECTION,
45
  ContourSource,
46
  build_reference_stack,
 
47
  split_layer_stack_grid,
48
  )
49
 
@@ -69,6 +71,56 @@ NOZZLE_LAYOUT_PRESETS = [
69
  "5 x 2",
70
  ]
71
  APP_CSS = """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  .gradio-container {
73
  font-size: 90%;
74
  padding-top: 0.5rem !important;
@@ -319,6 +371,39 @@ APP_CSS = """
319
  APP_HEAD = """
320
  <script>
321
  (function () {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  function enableUndoButtons(root) {
323
  (root || document).querySelectorAll('.model3D button[aria-label="Undo"]').forEach(function (btn) {
324
  if (btn.disabled) {
@@ -335,9 +420,29 @@ APP_HEAD = """
335
  }
336
  }, 0);
337
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  function start() {
339
  enableUndoButtons();
340
  document.addEventListener('focusin', suppressDeleteCellEditor);
 
 
 
 
 
 
 
341
  var observer = new MutationObserver(function (mutations) {
342
  for (var i = 0; i < mutations.length; i++) {
343
  var m = mutations[i];
@@ -1154,7 +1259,8 @@ GCODE_SOURCE_UPLOAD = "Upload G-Code file"
1154
  PARALLEL_COLOR_CHOICES = [
1155
  ("Orange", "#ff7f0e"), ("Blue", "#1f77b4"), ("Green", "#2ca02c"),
1156
  ("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"),
1157
- ("Teal", "#17becf"), ("Black", "#000000"),
 
1158
  ]
1159
  DEFAULT_PARALLEL_COLORS = ("#ff7f0e", "#1f77b4", "#2ca02c")
1160
  SHAPE_COLOR_NAMES = [name for name, _hex in PARALLEL_COLOR_CHOICES]
@@ -1168,6 +1274,62 @@ def _color_display(value: str | None) -> str:
1168
  return _COLOR_NAME_BY_HEX.get(text.lower(), text)
1169
 
1170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1171
  def _color_from_cell(cell, fallback: str) -> str:
1172
  """Parse a Color cell: palette name (case-insensitive) or a hex value.
1173
 
@@ -1403,7 +1565,7 @@ SHAPE_SETTINGS_DATATYPES = [
1403
  "number",
1404
  "number",
1405
  "number",
1406
- "str",
1407
  "number",
1408
  "bool",
1409
  "bool",
@@ -1584,7 +1746,7 @@ def _shape_settings_rows(records: list[dict]) -> list[list[Any]]:
1584
  record.get("valve", 4),
1585
  _record_nozzle_number(record, int(record["idx"])),
1586
  record.get("port", 1),
1587
- _color_display(record.get("color", _default_color(record["idx"]))),
1588
  _coerce_float(record.get("infill", 100.0), 100.0),
1589
  bool(record.get("contour_tracing", False)),
1590
  bool(record.get("lead_in", False)),
@@ -2249,21 +2411,129 @@ def show_selected_model(
2249
  )
2250
 
2251
 
2252
- def _slice_params_snapshot(record: dict, layer_height: float, scale_mode: str | None) -> dict:
 
 
 
 
 
2253
  return {
2254
  "layer_height": float(layer_height),
2255
  "scale_mode": _normalize_scale_mode(scale_mode),
2256
  "target_x": record.get("target_x"),
2257
  "target_y": record.get("target_y"),
2258
  "target_z": record.get("target_z"),
 
 
 
 
2259
  }
2260
 
2261
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2262
  def _slice_record(
2263
  record: dict,
2264
  layer_height: float,
2265
  scale_mode: str | None,
2266
  progress_callback=None,
 
2267
  ) -> LayerStack:
2268
  stl_path = record["stl_path"]
2269
  mesh = load_mesh(stl_path)
@@ -2281,12 +2551,36 @@ def _slice_record(
2281
  progress_callback=progress_callback,
2282
  scale_factors=scale_factors,
2283
  name=str(record.get("name") or Path(stl_path).stem),
 
2284
  )
2285
  record["layer_stack"] = stack
2286
- record["slice_params"] = _slice_params_snapshot(record, layer_height, scale_mode)
2287
  return stack
2288
 
2289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2290
  def generate_dynamic_layer_stacks(
2291
  records: list[dict] | None,
2292
  settings_table: Any,
@@ -2300,6 +2594,7 @@ def generate_dynamic_layer_stacks(
2300
  return records, "Upload at least one STL first.", None
2301
  total = len(records)
2302
  messages: list[str] = []
 
2303
  for pos, record in enumerate(records):
2304
  stl_path = record.get("stl_path")
2305
  if not stl_path:
@@ -2313,7 +2608,9 @@ def generate_dynamic_layer_stacks(
2313
  progress((offset + cur / tot) / total, desc=f"Slicing shape {offset + 1} of {total}...")
2314
 
2315
  try:
2316
- stack = _slice_record(record, layer_height, scale_mode, report_progress)
 
 
2317
  (x_min, y_min, _z_min), (x_max, y_max, _z_max) = stack.bounds
2318
  messages.append(
2319
  f"Shape {record['idx']}: sliced {len(stack.layers)} layers "
@@ -2335,12 +2632,184 @@ def generate_dynamic_reference_stack(
2335
  ) -> LayerStack | None:
2336
  # Snapping the alignment to the fil grid keeps split pieces' scan-grid
2337
  # phase intact under shared reference motion (exact one-fil seam pitch).
 
 
 
 
2338
  return build_reference_stack(
2339
- [record.get("layer_stack") for record in (records or [])],
2340
  grid=float(fil_width) if fil_width else None,
2341
  )
2342
 
2343
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2344
  def split_selected_shape_for_grid(
2345
  records: list[dict] | None,
2346
  selected: str | None,
@@ -2383,6 +2852,33 @@ def split_selected_shape_for_grid(
2383
 
2384
  split_column_count = max(1, _coerce_int(columns, 2))
2385
  split_row_count = max(1, _coerce_int(rows, 1))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2386
  try:
2387
  pieces = split_layer_stack_grid(
2388
  stack,
@@ -2464,16 +2960,18 @@ def _ensure_records_sliced(
2464
  messages: list[str],
2465
  ) -> bool:
2466
  """Re-slice records whose layers are missing or stale for the current settings."""
 
2467
  resliced = False
2468
  for record in records:
2469
  stl_path = record.get("stl_path")
2470
  if not stl_path:
2471
  continue # Split pieces carry their clipped layers; nothing to re-slice.
2472
- current = _slice_params_snapshot(record, layer_height, scale_mode)
 
2473
  if record.get("layer_stack") is not None and record.get("slice_params") == current:
2474
  continue
2475
  try:
2476
- stack = _slice_record(record, layer_height, scale_mode)
2477
  messages.append(
2478
  f"Shape {record['idx']}: sliced automatically ({len(stack.layers)} layers)."
2479
  )
@@ -2532,12 +3030,14 @@ def generate_dynamic_gcode(
2532
  records = _apply_shape_settings(records or [], settings_table)
2533
  messages: list[str] = []
2534
  resliced = _ensure_records_sliced(records, layer_height, scale_mode, messages)
2535
- if use_reference_motion:
2536
  # Always rebuild with the CURRENT fil width: the reference stack's
2537
  # alignment snap grid must match the fil the G-code is generated with.
2538
  ref_layers = generate_dynamic_reference_stack(records, fil_width)
2539
- elif resliced:
2540
- ref_layers = generate_dynamic_reference_stack(records, fil_width)
 
 
2541
  contour_sources = _contour_tracing_sources(records)
2542
  if contour_sources:
2543
  enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
@@ -2834,7 +3334,7 @@ def build_dynamic_demo() -> gr.Blocks:
2834
  """
2835
  # Shapes & Slicing
2836
  Upload any number of STL files, edit per-shape dimensions and print settings in the table, then slice each shape into per-layer outlines.
2837
- Color accepts: Orange, Blue, Green, Red, Purple, Pink, Teal, Black.
2838
  """
2839
  )
2840
  with gr.Row():
@@ -2856,6 +3356,19 @@ def build_dynamic_demo() -> gr.Blocks:
2856
  label="Scaling Mode",
2857
  )
2858
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2859
  shape_settings = gr.Dataframe(
2860
  headers=SHAPE_SETTINGS_HEADERS,
2861
  value=[],
@@ -2863,6 +3376,7 @@ def build_dynamic_demo() -> gr.Blocks:
2863
  column_count=(len(SHAPE_SETTINGS_HEADERS), "fixed"),
2864
  datatype=SHAPE_SETTINGS_DATATYPES,
2865
  interactive=True,
 
2866
  label="Shape Settings",
2867
  elem_id="shape-settings-table",
2868
  )
@@ -2882,7 +3396,7 @@ def build_dynamic_demo() -> gr.Blocks:
2882
  split_start_valve = gr.Number(label="Starting Valve", value=4, minimum=1, step=1)
2883
  split_overlapping_layers = gr.Checkbox(label="Overlapping Layers", value=False)
2884
  split_button = gr.Button("Split Selected Shape into Grid Pieces", variant="primary")
2885
- split_status = gr.Markdown("Slice a shape, then split it for multi-nozzle printing.")
2886
 
2887
  with gr.Accordion("Selected Shape Preview", open=False, elem_classes=["settings-accordion"]):
2888
  with gr.Row():
@@ -3099,6 +3613,12 @@ def build_dynamic_demo() -> gr.Blocks:
3099
  outputs=[nozzle_grid_spacing_table],
3100
  queue=False,
3101
  )
 
 
 
 
 
 
3102
  shape_settings.select(
3103
  fn=delete_shape_from_settings,
3104
  inputs=[shape_records, shape_settings, last_shape_delete_at],
@@ -3162,6 +3682,14 @@ def build_dynamic_demo() -> gr.Blocks:
3162
  )
3163
 
3164
  split_refresh_sources.click(fn=lambda records: _dropdown_update(records), inputs=[shape_records], outputs=[split_source], queue=False)
 
 
 
 
 
 
 
 
3165
  split_button.click(
3166
  fn=split_selected_shape_for_grid,
3167
  inputs=[
@@ -3311,15 +3839,25 @@ def build_dynamic_demo() -> gr.Blocks:
3311
  travel_width_slider.release(fn=rerender_dynamic_toolpath_current_mode, inputs=[render_mode] + render_inputs, outputs=[toolpath_plot, toolpath_status, parsed_state])
3312
  print_width_slider.release(fn=rerender_dynamic_toolpath_current_mode, inputs=[render_mode] + render_inputs, outputs=[toolpath_plot, toolpath_status, parsed_state])
3313
 
3314
- def sync_width_sliders(v: float):
3315
- height = float(v or 0.8)
3316
- travel = height / 4
 
 
 
3317
  return (
3318
- gr.update(value=height, minimum=min(0.1, height), maximum=height * 1.5),
3319
- gr.update(value=travel, minimum=min(0.05, travel), maximum=height * 1.5),
 
 
3320
  )
3321
 
3322
- layer_height.change(fn=sync_width_sliders, inputs=[layer_height], outputs=[print_width_slider, travel_width_slider], queue=False)
 
 
 
 
 
3323
 
3324
  parallel_render_inputs = [
3325
  shape_records,
 
30
  )
31
  from stl_slicer import (
32
  LayerStack,
33
+ calculate_z_levels,
34
  load_mesh,
35
  scale_factors_for_target_extents,
36
  scale_mesh,
 
45
  RASTER_PATTERN_Y_DIRECTION,
46
  ContourSource,
47
  build_reference_stack,
48
+ group_contour_paths,
49
  split_layer_stack_grid,
50
  )
51
 
 
71
  "5 x 2",
72
  ]
73
  APP_CSS = """
74
+ .pp-visually-hidden {
75
+ position: absolute !important;
76
+ width: 1px !important;
77
+ height: 1px !important;
78
+ min-width: 0 !important;
79
+ min-height: 0 !important;
80
+ padding: 0 !important;
81
+ margin: -1px !important;
82
+ border: 0 !important;
83
+ overflow: hidden !important;
84
+ clip-path: inset(50%);
85
+ }
86
+ .pp-color-cell {
87
+ display: flex;
88
+ align-items: center;
89
+ gap: 6px;
90
+ min-width: 170px;
91
+ }
92
+ .pp-color-current {
93
+ display: inline-block;
94
+ min-width: 46px;
95
+ padding: 1px 5px;
96
+ border: 1px solid rgba(0, 0, 0, 0.25);
97
+ border-radius: 4px;
98
+ font-size: 0.75em;
99
+ text-align: center;
100
+ white-space: nowrap;
101
+ }
102
+ .pp-swatches {
103
+ display: inline-flex;
104
+ gap: 3px;
105
+ align-items: center;
106
+ }
107
+ .pp-swatch {
108
+ display: inline-block;
109
+ width: 13px;
110
+ height: 13px;
111
+ border: 1px solid rgba(0, 0, 0, 0.35);
112
+ border-radius: 3px;
113
+ cursor: pointer;
114
+ box-sizing: border-box;
115
+ }
116
+ .pp-swatch:hover {
117
+ transform: scale(1.3);
118
+ }
119
+ .pp-swatch.pp-current {
120
+ outline: 2px solid var(--color-accent, #f97316);
121
+ outline-offset: 1px;
122
+ }
123
+
124
  .gradio-container {
125
  font-size: 90%;
126
  padding-top: 0.5rem !important;
 
371
  APP_HEAD = """
372
  <script>
373
  (function () {
374
+ function relayColorChoice(rowIdx, hex) {
375
+ var sink = document.querySelector('#pp-color-sink textarea, #pp-color-sink input');
376
+ var apply = document.querySelector('#pp-color-apply button, button#pp-color-apply, #pp-color-apply');
377
+ if (!sink || !apply) return;
378
+ sink.value = rowIdx + '|' + hex;
379
+ sink.dispatchEvent(new Event('input', { bubbles: true }));
380
+ if (apply.tagName !== 'BUTTON') { apply = apply.querySelector('button') || apply; }
381
+ apply.click();
382
+ }
383
+ // In-table color dropdowns: relay each select's pick to the hidden sink
384
+ // textbox + apply button so the backend updates the shape record. The
385
+ // native popup survives because Color cells' pointer events are
386
+ // swallowed before the dataframe can open its cell editor (see
387
+ // isolateColorCell below).
388
+ document.addEventListener('change', function (event) {
389
+ var el = event.target;
390
+ if (!el || !el.classList || !el.classList.contains('pp-color-select')) return;
391
+ var match = (el.className || '').match(/pp-idx-([0-9]+)/);
392
+ if (!match) return;
393
+ relayColorChoice(match[1], el.value);
394
+ }, true);
395
+ // In-table color swatches (if a cell renders palette chips instead):
396
+ // clicking a chip relays "rowIdx|#hex" the same way.
397
+ document.addEventListener('click', function (event) {
398
+ var el = event.target;
399
+ if (!el || !el.classList || !el.classList.contains('pp-swatch')) return;
400
+ var idxMatch = (el.className || '').match(/pp-idx-([0-9]+)/);
401
+ var hexMatch = (el.className || '').match(/pp-hex-([0-9a-fA-F]{6})/);
402
+ if (!idxMatch || !hexMatch) return;
403
+ event.preventDefault();
404
+ event.stopPropagation();
405
+ relayColorChoice(idxMatch[1], '#' + hexMatch[1].toLowerCase());
406
+ }, true);
407
  function enableUndoButtons(root) {
408
  (root || document).querySelectorAll('.model3D button[aria-label="Undo"]').forEach(function (btn) {
409
  if (btn.disabled) {
 
420
  }
421
  }, 0);
422
  }
423
+ // Color cells host clickable palette chips: swallow pointer events before
424
+ // the dataframe's own handlers run, or a click would select the cell and
425
+ // open the raw-HTML cell editor on top of the chips.
426
+ function isolateColorCell(event) {
427
+ var el = event.target;
428
+ if (!el || !el.closest) return;
429
+ var cell = el.closest('td, [role="gridcell"]');
430
+ if (!cell || !cell.querySelector('.pp-color-cell')) return;
431
+ event.stopPropagation();
432
+ if (event.type === 'dblclick') {
433
+ event.preventDefault();
434
+ }
435
+ }
436
  function start() {
437
  enableUndoButtons();
438
  document.addEventListener('focusin', suppressDeleteCellEditor);
439
+ document.addEventListener('pointerdown', isolateColorCell, true);
440
+ document.addEventListener('mousedown', isolateColorCell, true);
441
+ document.addEventListener('touchstart', isolateColorCell, true);
442
+ document.addEventListener('dblclick', isolateColorCell, true);
443
+ // 'click' too: registered AFTER the swatch relay above, so the chip
444
+ // click is applied first, then the dataframe never sees the event.
445
+ document.addEventListener('click', isolateColorCell, true);
446
  var observer = new MutationObserver(function (mutations) {
447
  for (var i = 0; i < mutations.length; i++) {
448
  var m = mutations[i];
 
1259
  PARALLEL_COLOR_CHOICES = [
1260
  ("Orange", "#ff7f0e"), ("Blue", "#1f77b4"), ("Green", "#2ca02c"),
1261
  ("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"),
1262
+ ("Teal", "#17becf"), ("Yellow", "#ffe119"), ("White", "#ffffff"),
1263
+ ("Black", "#000000"),
1264
  ]
1265
  DEFAULT_PARALLEL_COLORS = ("#ff7f0e", "#1f77b4", "#2ca02c")
1266
  SHAPE_COLOR_NAMES = [name for name, _hex in PARALLEL_COLOR_CHOICES]
 
1274
  return _COLOR_NAME_BY_HEX.get(text.lower(), text)
1275
 
1276
 
1277
+ def apply_color_selection(
1278
+ records: list[dict] | None,
1279
+ settings_table: Any,
1280
+ payload: str | None,
1281
+ ) -> tuple[list[dict], list[list[Any]]]:
1282
+ """Apply an in-table color dropdown change ("idx|#hex" from the sink)."""
1283
+ records = _apply_shape_settings(records or [], settings_table)
1284
+ parts = str(payload or "").split("|")
1285
+ if len(parts) >= 2:
1286
+ try:
1287
+ idx = int(parts[0])
1288
+ except (TypeError, ValueError):
1289
+ idx = None
1290
+ hex_value = parts[1].strip().lower()
1291
+ palette = {value.lower() for _name, value in PARALLEL_COLOR_CHOICES}
1292
+ if idx is not None and hex_value in palette:
1293
+ records = [
1294
+ dict(record, color=hex_value)
1295
+ if int(record.get("idx", 0) or 0) == idx
1296
+ else record
1297
+ for record in records
1298
+ ]
1299
+ return records, _shape_settings_rows(records)
1300
+
1301
+
1302
+ def _color_select_cell(record: dict) -> str:
1303
+ """HTML dropdown for a shape's Color cell (markdown-rendered).
1304
+
1305
+ The select carries the record idx as a sanitizer-safe class token; a
1306
+ delegated head-script change listener relays picks to the hidden
1307
+ sink/apply pair, which updates the record and re-renders the table. The
1308
+ closed select shows the current color as its background. Works because
1309
+ the head script also swallows pointer events on Color cells before the
1310
+ dataframe's handlers run β€” otherwise the cell editor would open on
1311
+ mousedown and kill the native dropdown popup.
1312
+ """
1313
+ idx = int(record.get("idx", 0) or 0)
1314
+ current = str(record.get("color", _default_color(idx))).strip().lower()
1315
+ options = "".join(
1316
+ '<option value="{hex}"{sel}>{name}</option>'.format(
1317
+ hex=hex_value,
1318
+ sel=" selected" if hex_value.lower() == current else "",
1319
+ name=name,
1320
+ )
1321
+ for name, hex_value in PARALLEL_COLOR_CHOICES
1322
+ )
1323
+ text_color = "#ffffff" if current in ("#000000",) else "#000000"
1324
+ return (
1325
+ '<span class="pp-color-cell">'
1326
+ '<select class="pp-color-select pp-idx-{idx}" '
1327
+ 'style="background-color:{bg};color:{fg}">{options}</select>'
1328
+ "</span>"
1329
+ ).format(idx=idx, bg=current if current.startswith("#") else "#ffffff",
1330
+ fg=text_color, options=options)
1331
+
1332
+
1333
  def _color_from_cell(cell, fallback: str) -> str:
1334
  """Parse a Color cell: palette name (case-insensitive) or a hex value.
1335
 
 
1565
  "number",
1566
  "number",
1567
  "number",
1568
+ "markdown",
1569
  "number",
1570
  "bool",
1571
  "bool",
 
1746
  record.get("valve", 4),
1747
  _record_nozzle_number(record, int(record["idx"])),
1748
  record.get("port", 1),
1749
+ _color_select_cell(record),
1750
  _coerce_float(record.get("infill", 100.0), 100.0),
1751
  bool(record.get("contour_tracing", False)),
1752
  bool(record.get("lead_in", False)),
 
2411
  )
2412
 
2413
 
2414
+ def _slice_params_snapshot(
2415
+ record: dict,
2416
+ layer_height: float,
2417
+ scale_mode: str | None,
2418
+ z_levels: list[float] | None = None,
2419
+ ) -> dict:
2420
  return {
2421
  "layer_height": float(layer_height),
2422
  "scale_mode": _normalize_scale_mode(scale_mode),
2423
  "target_x": record.get("target_x"),
2424
  "target_y": record.get("target_y"),
2425
  "target_z": record.get("target_z"),
2426
+ # Multi-material mode: the shared Z grid fingerprint. Adding/removing
2427
+ # an assembly part changes the grid, which correctly marks every
2428
+ # part's slices stale.
2429
+ "z_grid": (round(z_levels[0], 6), len(z_levels)) if z_levels else None,
2430
  }
2431
 
2432
 
2433
+ def _multi_material_groups(records: list[dict]) -> dict[int, list[dict]]:
2434
+ """Multi-material groups: uploaded shapes that share a nozzle number.
2435
+
2436
+ Shapes on the same nozzle print from the same physical position, so
2437
+ their STLs are parts of one assembly (one STL per material). They must
2438
+ slice on one shared Z grid and keep their modelled positions relative to
2439
+ each other. Shapes alone on their nozzle stay ordinary. Split pieces
2440
+ (no stl_path) are excluded β€” they carry their own frame alignment.
2441
+ """
2442
+ by_nozzle: dict[int, list[dict]] = {}
2443
+ for record in records:
2444
+ if not record.get("stl_path"):
2445
+ continue
2446
+ nozzle = _record_nozzle_number(record, int(record.get("idx", 1) or 1))
2447
+ by_nozzle.setdefault(nozzle, []).append(record)
2448
+ return {nozzle: members for nozzle, members in by_nozzle.items() if len(members) > 1}
2449
+
2450
+
2451
+ def _stamp_multi_material_frames(records: list[dict], fil_width: float = 0.8) -> None:
2452
+ """Stamp each sliced stack's multi-material group frame and seam-free
2453
+ contour paths (or clear them).
2454
+
2455
+ Group members get the group's combined XY bbox as `align_frame`, so the
2456
+ reference alignment moves them as one rigid unit, and `contour_paths`
2457
+ that exclude material-to-material interfaces β€” the assembled parts form
2458
+ ONE shape, so only its true outer surface is contoured (boundary within
2459
+ half a bead of a sibling material counts as an interface, covering both
2460
+ exact contact and fit-tolerance gaps). Idempotent β€” safe to call before
2461
+ every reference build so nozzle renumbering in the table takes effect
2462
+ without re-slicing.
2463
+ """
2464
+ grouped_ids: set[int] = set()
2465
+ for members in _multi_material_groups(records).values():
2466
+ stacks = [
2467
+ member.get("layer_stack")
2468
+ for member in members
2469
+ if member.get("layer_stack") is not None
2470
+ ]
2471
+ if len(stacks) < 2:
2472
+ continue
2473
+ frame = (
2474
+ min(stack.bounds[0][0] for stack in stacks),
2475
+ min(stack.bounds[0][1] for stack in stacks),
2476
+ max(stack.bounds[1][0] for stack in stacks),
2477
+ max(stack.bounds[1][1] for stack in stacks),
2478
+ )
2479
+ for stack in stacks:
2480
+ stack.align_frame = frame
2481
+ stack.contour_paths = group_contour_paths(
2482
+ stack,
2483
+ [other for other in stacks if other is not stack],
2484
+ tolerance=float(fil_width or 0.8) / 2.0,
2485
+ )
2486
+ grouped_ids.add(id(stack))
2487
+ for record in records:
2488
+ stack = record.get("layer_stack")
2489
+ if stack is not None and id(stack) not in grouped_ids and record.get("stl_path"):
2490
+ stack.align_frame = None
2491
+ stack.contour_paths = None
2492
+
2493
+
2494
+ def _multi_material_z_levels(
2495
+ records: list[dict],
2496
+ layer_height: float,
2497
+ scale_mode: str | None,
2498
+ ) -> list[float] | None:
2499
+ """One shared Z grid spanning the given parts' scaled extents.
2500
+
2501
+ Multi-material group members must slice on the SAME planes so a part
2502
+ that starts higher gets empty lower layers instead of having its first
2503
+ material layer treated as layer 0.
2504
+ """
2505
+ z_lo = math.inf
2506
+ z_hi = -math.inf
2507
+ for record in records:
2508
+ stl_path = record.get("stl_path")
2509
+ if not stl_path:
2510
+ continue
2511
+ try:
2512
+ mesh = load_mesh(stl_path)
2513
+ scale_factors = _resolve_mesh_scale_factors(
2514
+ mesh,
2515
+ True,
2516
+ scale_mode,
2517
+ record.get("target_x"),
2518
+ record.get("target_y"),
2519
+ record.get("target_z"),
2520
+ )
2521
+ scaled = scale_mesh(mesh, scale_factors)
2522
+ except Exception:
2523
+ continue
2524
+ z_lo = min(z_lo, float(scaled.bounds[0][2]))
2525
+ z_hi = max(z_hi, float(scaled.bounds[1][2]))
2526
+ if not math.isfinite(z_lo) or not math.isfinite(z_hi):
2527
+ return None
2528
+ return calculate_z_levels(z_lo, z_hi, float(layer_height))
2529
+
2530
+
2531
  def _slice_record(
2532
  record: dict,
2533
  layer_height: float,
2534
  scale_mode: str | None,
2535
  progress_callback=None,
2536
+ z_levels: list[float] | None = None,
2537
  ) -> LayerStack:
2538
  stl_path = record["stl_path"]
2539
  mesh = load_mesh(stl_path)
 
2551
  progress_callback=progress_callback,
2552
  scale_factors=scale_factors,
2553
  name=str(record.get("name") or Path(stl_path).stem),
2554
+ z_levels=z_levels,
2555
  )
2556
  record["layer_stack"] = stack
2557
+ record["slice_params"] = _slice_params_snapshot(record, layer_height, scale_mode, z_levels)
2558
  return stack
2559
 
2560
 
2561
+ def _group_z_levels_by_record(
2562
+ records: list[dict],
2563
+ layer_height: float,
2564
+ scale_mode: str | None,
2565
+ messages: list[str] | None = None,
2566
+ ) -> dict[int, list[float]]:
2567
+ """Shared Z grid per multi-material group, keyed by member record id."""
2568
+ z_by_record: dict[int, list[float]] = {}
2569
+ for nozzle, members in sorted(_multi_material_groups(records).items()):
2570
+ z_levels = _multi_material_z_levels(members, layer_height, scale_mode)
2571
+ if z_levels is None:
2572
+ continue
2573
+ for member in members:
2574
+ z_by_record[id(member)] = z_levels
2575
+ if messages is not None:
2576
+ names = ", ".join(str(m.get("name") or f"Shape {m['idx']}") for m in members)
2577
+ messages.append(
2578
+ f"Multi-material group (nozzle {nozzle}): {names} β€” sliced on one "
2579
+ f"shared Z grid ({len(z_levels)} layers), positions locked together."
2580
+ )
2581
+ return z_by_record
2582
+
2583
+
2584
  def generate_dynamic_layer_stacks(
2585
  records: list[dict] | None,
2586
  settings_table: Any,
 
2594
  return records, "Upload at least one STL first.", None
2595
  total = len(records)
2596
  messages: list[str] = []
2597
+ z_by_record = _group_z_levels_by_record(records, layer_height, scale_mode, messages)
2598
  for pos, record in enumerate(records):
2599
  stl_path = record.get("stl_path")
2600
  if not stl_path:
 
2608
  progress((offset + cur / tot) / total, desc=f"Slicing shape {offset + 1} of {total}...")
2609
 
2610
  try:
2611
+ stack = _slice_record(
2612
+ record, layer_height, scale_mode, report_progress, z_by_record.get(id(record))
2613
+ )
2614
  (x_min, y_min, _z_min), (x_max, y_max, _z_max) = stack.bounds
2615
  messages.append(
2616
  f"Shape {record['idx']}: sliced {len(stack.layers)} layers "
 
2632
  ) -> LayerStack | None:
2633
  # Snapping the alignment to the fil grid keeps split pieces' scan-grid
2634
  # phase intact under shared reference motion (exact one-fil seam pitch).
2635
+ # Shapes sharing a nozzle are multi-material assembly parts: their group
2636
+ # frame makes them align as one rigid unit at their modeled positions.
2637
+ records = records or []
2638
+ _stamp_multi_material_frames(records, fil_width)
2639
  return build_reference_stack(
2640
+ [record.get("layer_stack") for record in records],
2641
  grid=float(fil_width) if fil_width else None,
2642
  )
2643
 
2644
 
2645
+ SPLIT_STATUS_DEFAULT = (
2646
+ "Slice a shape, then split it for multi-nozzle printing. Shapes that share "
2647
+ "a nozzle number are one multi-material assembly: selecting any of them "
2648
+ "splits the **whole group** together as one shape."
2649
+ )
2650
+
2651
+
2652
+ def describe_split_source(records: list[dict] | None, selected: str | None) -> str:
2653
+ """Split-source note: warn up front when the selection splits a whole group."""
2654
+ records = records or []
2655
+ pos = _selected_record_index(records, selected)
2656
+ if pos < 0 or pos >= len(records):
2657
+ return SPLIT_STATUS_DEFAULT
2658
+ source = records[pos]
2659
+ if not source.get("stl_path"):
2660
+ return SPLIT_STATUS_DEFAULT
2661
+ nozzle = _record_nozzle_number(source, int(source.get("idx", pos + 1) or (pos + 1)))
2662
+ members = _multi_material_groups(records).get(nozzle, [])
2663
+ if len(members) <= 1:
2664
+ return SPLIT_STATUS_DEFAULT
2665
+ names = ", ".join(
2666
+ "**{}**".format(member.get("name") or f"Shape {member.get('idx')}")
2667
+ for member in members
2668
+ )
2669
+ return (
2670
+ f"⚠️ This shape is part of the multi-material group on nozzle {nozzle} "
2671
+ f"({names}). Splitting it splits the **whole group as one shape**: every "
2672
+ "material is clipped by the same cells, each cell's pieces share a nozzle, "
2673
+ "and every piece gets its own valve."
2674
+ )
2675
+
2676
+
2677
+ def _split_group_records(
2678
+ records: list[dict],
2679
+ group_members: list[dict],
2680
+ group_nozzle: int,
2681
+ split_column_count: int,
2682
+ split_row_count: int,
2683
+ overlapping_layers: bool,
2684
+ starting_nozzle: Any,
2685
+ starting_valve: Any,
2686
+ fil_width: float,
2687
+ selected: str | None,
2688
+ _outputs,
2689
+ ) -> tuple:
2690
+ """Split a whole multi-material group as one shape.
2691
+
2692
+ Every material is clipped by the same cell grid over the group's
2693
+ combined bounds and one shared scan frame, so cell-mates assemble
2694
+ exactly. Pieces are emitted cell-major: each cell's pieces share a
2695
+ nozzle (making the cell a multi-material group again, with the same
2696
+ alignment and seam-free-contour behavior), and every piece gets its own
2697
+ valve. Cells where a material has no geometry are skipped.
2698
+ """
2699
+ unsliced = [
2700
+ member
2701
+ for member in group_members
2702
+ if member.get("layer_stack") is None or not member["layer_stack"].layers
2703
+ ]
2704
+ if unsliced:
2705
+ return _outputs(
2706
+ records,
2707
+ selected,
2708
+ f"Split failed: shape(s) on nozzle {group_nozzle} have no sliced layers yet - "
2709
+ "press Slice Shapes first so the group shares one Z grid.",
2710
+ )
2711
+ stacks = [member["layer_stack"] for member in group_members]
2712
+ layer_counts = {len(stack.layers) for stack in stacks}
2713
+ z_starts = {round(stack.z_values[0], 6) for stack in stacks if stack.z_values}
2714
+ if len(layer_counts) != 1 or len(z_starts) > 1:
2715
+ return _outputs(
2716
+ records,
2717
+ selected,
2718
+ f"Split failed: the shapes on nozzle {group_nozzle} were sliced on different "
2719
+ "Z grids - press Slice Shapes to re-slice the group together.",
2720
+ )
2721
+
2722
+ # Stamp group frames + seam-free contours so pieces inherit contour
2723
+ # linework that already excludes material-to-material interfaces.
2724
+ _stamp_multi_material_frames(records, float(fil_width))
2725
+ frame = stacks[0].align_frame
2726
+
2727
+ try:
2728
+ pieces_by_member = [
2729
+ (
2730
+ member,
2731
+ split_layer_stack_grid(
2732
+ member["layer_stack"],
2733
+ columns=split_column_count,
2734
+ rows=split_row_count,
2735
+ overlapping_layers=bool(overlapping_layers),
2736
+ overlap=float(fil_width) if overlapping_layers else 0.0,
2737
+ grid=float(fil_width),
2738
+ frame=frame,
2739
+ ),
2740
+ )
2741
+ for member in group_members
2742
+ ]
2743
+ except Exception as exc:
2744
+ return _outputs(records, selected, f"Split failed: {exc}")
2745
+
2746
+ first_member = group_members[0]
2747
+ first_nozzle = max(1, _coerce_int(starting_nozzle, 1))
2748
+ first_valve = max(
2749
+ 1, _coerce_int(starting_valve, _coerce_int(first_member.get("valve", 4), 4))
2750
+ )
2751
+ split_group_id = f"split-{int(time.time() * 1_000_000)}-{first_member.get('idx', 1)}"
2752
+ cell_count = split_column_count * split_row_count
2753
+ split_records: list[dict] = []
2754
+ valve_cursor = first_valve
2755
+ for cell in range(cell_count):
2756
+ row_index = cell // split_column_count + 1
2757
+ col_index = cell % split_column_count + 1
2758
+ for member, pieces in pieces_by_member:
2759
+ piece = pieces[cell]
2760
+ if all(layer.is_empty for layer in piece.layers):
2761
+ continue # this material has nothing in this cell
2762
+ (piece_x_min, piece_y_min, _z_min), (piece_x_max, piece_y_max, _z_max) = piece.bounds
2763
+ member_name = str(member.get("name") or f"Shape {member.get('idx')}")
2764
+ piece_record = dict(member)
2765
+ piece_record.update({
2766
+ "name": f"{member_name} - R{row_index}C{col_index}",
2767
+ "stl_path": None,
2768
+ "target_x": (piece_x_max - piece_x_min) or member.get("target_x", DEFAULT_TARGET_EXTENTS[0]),
2769
+ "target_y": (piece_y_max - piece_y_min) or member.get("target_y", DEFAULT_TARGET_EXTENTS[1]),
2770
+ "nozzle": first_nozzle + cell,
2771
+ "valve": valve_cursor,
2772
+ "split_group_id": split_group_id,
2773
+ "split_index": cell,
2774
+ "split_row": row_index,
2775
+ "split_col": col_index,
2776
+ "split_rows": split_row_count,
2777
+ "split_columns": split_column_count,
2778
+ "layer_stack": piece,
2779
+ "slice_params": member.get("slice_params"),
2780
+ "gcode_path": None,
2781
+ })
2782
+ valve_cursor += 1
2783
+ split_records.append(piece_record)
2784
+
2785
+ member_ids = {id(member) for member in group_members}
2786
+ first_pos = min(
2787
+ index for index, record in enumerate(records) if id(record) in member_ids
2788
+ )
2789
+ kept = [record for record in records if id(record) not in member_ids]
2790
+ insert_at = sum(1 for record in records[:first_pos] if id(record) not in member_ids)
2791
+ next_records = _reindex_shape_records(
2792
+ [*kept[:insert_at], *split_records, *kept[insert_at:]]
2793
+ )
2794
+ split_selected = (
2795
+ _shape_choice(next_records[insert_at]) if insert_at < len(next_records) else None
2796
+ )
2797
+ status = (
2798
+ f"Split the multi-material group on nozzle {group_nozzle} "
2799
+ f"({len(group_members)} materials) as one shape into {cell_count} cells "
2800
+ f"({split_column_count} columns x {split_row_count} rows) - "
2801
+ f"{len(split_records)} piece(s); material-empty cells skipped. \n"
2802
+ f"Each cell's pieces share a nozzle (nozzles {first_nozzle}-"
2803
+ f"{first_nozzle + cell_count - 1}); valves {first_valve}-{valve_cursor - 1}."
2804
+ )
2805
+ if overlapping_layers:
2806
+ status += (
2807
+ " \nOverlapping Layers is enabled: split boundaries alternate by one "
2808
+ "filament width per layer so neighbouring pieces interlock."
2809
+ )
2810
+ return _outputs(next_records, split_selected, status)
2811
+
2812
+
2813
  def split_selected_shape_for_grid(
2814
  records: list[dict] | None,
2815
  selected: str | None,
 
2852
 
2853
  split_column_count = max(1, _coerce_int(columns, 2))
2854
  split_row_count = max(1, _coerce_int(rows, 1))
2855
+
2856
+ # A shape sharing its nozzle with others is one material of a
2857
+ # multi-material assembly: the whole group splits together as ONE shape,
2858
+ # every material clipped by the same cell grid over the group's combined
2859
+ # bounds. Cell-mates share a nozzle (so each cell is itself a
2860
+ # multi-material group); every piece keeps its own valve.
2861
+ group_nozzle = _record_nozzle_number(source, int(source.get("idx", pos + 1) or (pos + 1)))
2862
+ group_members = (
2863
+ _multi_material_groups(records).get(group_nozzle, [])
2864
+ if source.get("stl_path")
2865
+ else []
2866
+ )
2867
+ if len(group_members) > 1:
2868
+ return _split_group_records(
2869
+ records,
2870
+ group_members,
2871
+ group_nozzle,
2872
+ split_column_count,
2873
+ split_row_count,
2874
+ overlapping_layers,
2875
+ starting_nozzle,
2876
+ starting_valve,
2877
+ fil_width,
2878
+ selected,
2879
+ _outputs,
2880
+ )
2881
+
2882
  try:
2883
  pieces = split_layer_stack_grid(
2884
  stack,
 
2960
  messages: list[str],
2961
  ) -> bool:
2962
  """Re-slice records whose layers are missing or stale for the current settings."""
2963
+ z_by_record = _group_z_levels_by_record(records, layer_height, scale_mode)
2964
  resliced = False
2965
  for record in records:
2966
  stl_path = record.get("stl_path")
2967
  if not stl_path:
2968
  continue # Split pieces carry their clipped layers; nothing to re-slice.
2969
+ z_levels = z_by_record.get(id(record))
2970
+ current = _slice_params_snapshot(record, layer_height, scale_mode, z_levels)
2971
  if record.get("layer_stack") is not None and record.get("slice_params") == current:
2972
  continue
2973
  try:
2974
+ stack = _slice_record(record, layer_height, scale_mode, None, z_levels)
2975
  messages.append(
2976
  f"Shape {record['idx']}: sliced automatically ({len(stack.layers)} layers)."
2977
  )
 
3030
  records = _apply_shape_settings(records or [], settings_table)
3031
  messages: list[str] = []
3032
  resliced = _ensure_records_sliced(records, layer_height, scale_mode, messages)
3033
+ if use_reference_motion or resliced:
3034
  # Always rebuild with the CURRENT fil width: the reference stack's
3035
  # alignment snap grid must match the fil the G-code is generated with.
3036
  ref_layers = generate_dynamic_reference_stack(records, fil_width)
3037
+ else:
3038
+ # Still refresh group frames + seam-free contours: nozzle numbers may
3039
+ # have been edited in the table since the last slice.
3040
+ _stamp_multi_material_frames(records, fil_width)
3041
  contour_sources = _contour_tracing_sources(records)
3042
  if contour_sources:
3043
  enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
 
3334
  """
3335
  # Shapes & Slicing
3336
  Upload any number of STL files, edit per-shape dimensions and print settings in the table, then slice each shape into per-layer outlines.
3337
+ Pick each shape's plot color straight from the dropdown in the **Color** column.
3338
  """
3339
  )
3340
  with gr.Row():
 
3356
  label="Scaling Mode",
3357
  )
3358
 
3359
+ # Visually hidden (not visible=False: Gradio would omit them
3360
+ # from the DOM entirely and the color-select relay needs them).
3361
+ color_sink = gr.Textbox(
3362
+ label="color sink",
3363
+ container=False,
3364
+ elem_id="pp-color-sink",
3365
+ elem_classes=["pp-visually-hidden"],
3366
+ )
3367
+ color_apply = gr.Button(
3368
+ "apply color",
3369
+ elem_id="pp-color-apply",
3370
+ elem_classes=["pp-visually-hidden"],
3371
+ )
3372
  shape_settings = gr.Dataframe(
3373
  headers=SHAPE_SETTINGS_HEADERS,
3374
  value=[],
 
3376
  column_count=(len(SHAPE_SETTINGS_HEADERS), "fixed"),
3377
  datatype=SHAPE_SETTINGS_DATATYPES,
3378
  interactive=True,
3379
+ static_columns=[SHAPE_SETTINGS_HEADERS.index("Color")],
3380
  label="Shape Settings",
3381
  elem_id="shape-settings-table",
3382
  )
 
3396
  split_start_valve = gr.Number(label="Starting Valve", value=4, minimum=1, step=1)
3397
  split_overlapping_layers = gr.Checkbox(label="Overlapping Layers", value=False)
3398
  split_button = gr.Button("Split Selected Shape into Grid Pieces", variant="primary")
3399
+ split_status = gr.Markdown(SPLIT_STATUS_DEFAULT)
3400
 
3401
  with gr.Accordion("Selected Shape Preview", open=False, elem_classes=["settings-accordion"]):
3402
  with gr.Row():
 
3613
  outputs=[nozzle_grid_spacing_table],
3614
  queue=False,
3615
  )
3616
+ color_apply.click(
3617
+ fn=apply_color_selection,
3618
+ inputs=[shape_records, shape_settings, color_sink],
3619
+ outputs=[shape_records, shape_settings],
3620
+ queue=False,
3621
+ )
3622
  shape_settings.select(
3623
  fn=delete_shape_from_settings,
3624
  inputs=[shape_records, shape_settings, last_shape_delete_at],
 
3682
  )
3683
 
3684
  split_refresh_sources.click(fn=lambda records: _dropdown_update(records), inputs=[shape_records], outputs=[split_source], queue=False)
3685
+ # .input (user selections only): programmatic dropdown refills after a
3686
+ # split must not overwrite the split result message.
3687
+ split_source.input(
3688
+ fn=describe_split_source,
3689
+ inputs=[shape_records, split_source],
3690
+ outputs=[split_status],
3691
+ queue=False,
3692
+ )
3693
  split_button.click(
3694
  fn=split_selected_shape_for_grid,
3695
  inputs=[
 
3839
  travel_width_slider.release(fn=rerender_dynamic_toolpath_current_mode, inputs=[render_mode] + render_inputs, outputs=[toolpath_plot, toolpath_status, parsed_state])
3840
  print_width_slider.release(fn=rerender_dynamic_toolpath_current_mode, inputs=[render_mode] + render_inputs, outputs=[toolpath_plot, toolpath_status, parsed_state])
3841
 
3842
+ def sync_width_sliders(value: float):
3843
+ # Visualization filament widths track the slicer's Filament/Line
3844
+ # Width (what the G-code is actually generated with); travel
3845
+ # lines render at a quarter of it.
3846
+ width = float(value or 0.8)
3847
+ travel = width / 4
3848
  return (
3849
+ gr.update(value=width, minimum=min(0.1, width), maximum=width * 1.5),
3850
+ gr.update(value=travel, minimum=min(0.05, travel), maximum=width * 1.5),
3851
+ gr.update(value=width, minimum=min(0.1, width), maximum=max(3.0, width * 1.5)),
3852
+ gr.update(value=travel, minimum=min(0.05, travel), maximum=max(3.0, width * 1.5)),
3853
  )
3854
 
3855
+ fil_width.change(
3856
+ fn=sync_width_sliders,
3857
+ inputs=[fil_width],
3858
+ outputs=[print_width_slider, travel_width_slider, pp_filament_width, pp_travel_width],
3859
+ queue=False,
3860
+ )
3861
 
3862
  parallel_render_inputs = [
3863
  shape_records,
pyproject.toml CHANGED
@@ -11,6 +11,7 @@ dependencies = [
11
  "numpy>=2.2.0",
12
  "pillow>=11.1.0",
13
  "plotly>=6.7.0",
 
14
  "scipy>=1.15.2",
15
  "shapely>=2.0.7",
16
  "trimesh>=4.6.5",
 
11
  "numpy>=2.2.0",
12
  "pillow>=11.1.0",
13
  "plotly>=6.7.0",
14
+ "rtree>=1.4.1",
15
  "scipy>=1.15.2",
16
  "shapely>=2.0.7",
17
  "trimesh>=4.6.5",
requirements.txt CHANGED
@@ -149,6 +149,8 @@ pyyaml==6.0.3
149
  # huggingface-hub
150
  rich==14.3.3
151
  # via typer
 
 
152
  safehttpx==0.1.7
153
  # via gradio
154
  scipy==1.17.1
 
149
  # huggingface-hub
150
  rich==14.3.3
151
  # via typer
152
+ rtree==1.4.1
153
+ # via stl-to-gcode
154
  safehttpx==0.1.7
155
  # via gradio
156
  scipy==1.17.1
stl_slicer.py CHANGED
@@ -7,6 +7,7 @@ from typing import Callable, Sequence
7
 
8
  import numpy as np
9
  from shapely.geometry import GeometryCollection, MultiPolygon, Polygon
 
10
  from shapely.validation import make_valid
11
  import trimesh
12
 
@@ -44,6 +45,12 @@ class LayerStack:
44
  scan_frame: tuple[float, float, float, float] | None = None
45
  align_center: tuple[float, float] | None = None
46
  align_grid: float | None = None
 
 
 
 
 
 
47
  # Grid-split pieces: per-layer contour polylines from the PARENT shape's
48
  # boundary clipped to this piece's cell β€” cut seams between sibling
49
  # pieces are excluded, so contour tracing only outlines the true outer
@@ -168,7 +175,20 @@ def _extract_world_polygons(section: trimesh.path.Path3D) -> list[tuple[np.ndarr
168
  else:
169
  planar, to_3d = section.to_planar()
170
 
171
- composed_polygons = _compose_even_odd_polygons(list(planar.polygons_closed))
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  polygons: list[tuple[np.ndarray, list[np.ndarray]]] = []
173
  for polygon in composed_polygons:
174
  exterior = _ring_to_world_xy(polygon.exterior.coords, to_3d)
@@ -206,13 +226,56 @@ def _section_to_multipolygon(section: trimesh.path.Path3D | None) -> MultiPolygo
206
  return MultiPolygon()
207
 
208
  polygons = [
209
- Polygon(exterior, holes)
210
  for exterior, holes in _extract_world_polygons(section)
211
  ]
212
  if not polygons:
213
  return MultiPolygon()
214
 
215
- return _as_multipolygon(make_valid(MultiPolygon(polygons)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
 
218
  def slice_stl_to_layers(
@@ -221,21 +284,41 @@ def slice_stl_to_layers(
221
  progress_callback: ProgressCallback = None,
222
  scale_factors: Sequence[float] | None = None,
223
  name: str | None = None,
 
224
  ) -> LayerStack:
225
- """Slice an STL into per-layer vector outlines (world-XY millimetres)."""
 
 
 
 
 
226
  stl_path = Path(stl_path)
227
  mesh = scale_mesh(load_mesh(stl_path), scale_factors)
228
  (x_min, y_min, z_min), (x_max, y_max, z_max) = mesh.bounds
229
 
230
- z_values = calculate_z_levels(float(z_min), float(z_max), layer_height)
 
 
 
 
 
 
 
 
 
 
 
 
 
231
 
232
  layers: list[MultiPolygon] = []
233
  for index, z_value in enumerate(z_values):
234
- section = mesh.section(
235
- plane_origin=np.array([0.0, 0.0, z_value], dtype=float),
236
- plane_normal=np.array([0.0, 0.0, 1.0], dtype=float),
237
- )
238
- layers.append(_section_to_multipolygon(section))
 
239
 
240
  if progress_callback is not None:
241
  progress_callback(index + 1, len(z_values))
 
7
 
8
  import numpy as np
9
  from shapely.geometry import GeometryCollection, MultiPolygon, Polygon
10
+ from shapely.ops import unary_union
11
  from shapely.validation import make_valid
12
  import trimesh
13
 
 
45
  scan_frame: tuple[float, float, float, float] | None = None
46
  align_center: tuple[float, float] | None = None
47
  align_grid: float | None = None
48
+ # Multi-material group frame: the combined XY bbox of every part sharing
49
+ # this shape's nozzle. Group members all carry the SAME frame and are
50
+ # aligned by its centre instead of their own bbox centre, so they move as
51
+ # one rigid unit and keep their modelled positions relative to each
52
+ # other. None = align by the shape's own bbox centre (normal shapes).
53
+ align_frame: tuple[float, float, float, float] | None = None
54
  # Grid-split pieces: per-layer contour polylines from the PARENT shape's
55
  # boundary clipped to this piece's cell β€” cut seams between sibling
56
  # pieces are excluded, so contour tracing only outlines the true outer
 
175
  else:
176
  planar, to_3d = section.to_planar()
177
 
178
+ # polygons_full composes rings by containment depth (ring in a ring is a
179
+ # hole, ring in a hole is an island), so rings from separate solids that
180
+ # merely OVERLAP each other stay separate solids β€” the caller unions
181
+ # them. A flat even-odd XOR across all rings would punch false holes
182
+ # wherever interpenetrating solids overlap (e.g. flag stripe prisms
183
+ # packed into one STL).
184
+ try:
185
+ composed_polygons = [
186
+ polygon
187
+ for polygon in planar.polygons_full
188
+ if polygon is not None and not polygon.is_empty
189
+ ]
190
+ except BaseException:
191
+ composed_polygons = _compose_even_odd_polygons(list(planar.polygons_closed))
192
  polygons: list[tuple[np.ndarray, list[np.ndarray]]] = []
193
  for polygon in composed_polygons:
194
  exterior = _ring_to_world_xy(polygon.exterior.coords, to_3d)
 
226
  return MultiPolygon()
227
 
228
  polygons = [
229
+ make_valid(Polygon(exterior, holes))
230
  for exterior, holes in _extract_world_polygons(section)
231
  ]
232
  if not polygons:
233
  return MultiPolygon()
234
 
235
+ # Union, not just collect: polygons from interpenetrating solids overlap.
236
+ return _as_multipolygon(make_valid(unary_union(polygons)))
237
+
238
+
239
+ def _split_solids_and_cavities(
240
+ mesh: trimesh.Trimesh,
241
+ ) -> tuple[list[trimesh.Trimesh], list[trimesh.Trimesh]]:
242
+ """Connected bodies of a mesh, split into solids and explicit cavities.
243
+
244
+ A single STL often packs several separate solids that touch or
245
+ interpenetrate (checkerboard cells, overlapping stripe prisms).
246
+ Sectioning such a mesh whole corrupts the ring reconstruction where
247
+ rings cross or meet, so each WATERTIGHT body is sliced separately and
248
+ the results unioned; a watertight body wound inside-out (negative
249
+ volume) is a modeller's cavity, subtracted instead of unioned.
250
+
251
+ Everything that is not watertight β€” stray internal quads, meshes whose
252
+ face connectivity shreds into open fragments (T-vertices, unstitched
253
+ fans) β€” is kept together as ONE remainder mesh and sectioned whole,
254
+ where ring reconstruction can stitch across fragment boundaries; open
255
+ slivers there simply produce no closed rings and drop out.
256
+ """
257
+ try:
258
+ bodies = [body for body in mesh.split(only_watertight=False) if len(body.faces)]
259
+ except Exception:
260
+ return [mesh], []
261
+ if len(bodies) <= 1:
262
+ return [mesh], []
263
+
264
+ solids: list[trimesh.Trimesh] = []
265
+ cavities: list[trimesh.Trimesh] = []
266
+ remainder: list[trimesh.Trimesh] = []
267
+ for body in bodies:
268
+ if not body.is_watertight:
269
+ remainder.append(body)
270
+ elif body.is_winding_consistent and float(body.volume) < 0.0:
271
+ cavities.append(body)
272
+ else:
273
+ solids.append(body)
274
+ if remainder:
275
+ solids.append(
276
+ trimesh.util.concatenate(remainder) if len(remainder) > 1 else remainder[0]
277
+ )
278
+ return solids, cavities
279
 
280
 
281
  def slice_stl_to_layers(
 
284
  progress_callback: ProgressCallback = None,
285
  scale_factors: Sequence[float] | None = None,
286
  name: str | None = None,
287
+ z_levels: Sequence[float] | None = None,
288
  ) -> LayerStack:
289
+ """Slice an STL into per-layer vector outlines (world-XY millimetres).
290
+
291
+ `z_levels` overrides the per-mesh Z planes with an explicit (world) grid β€”
292
+ used by multi-material assemblies so every part slices on ONE shared grid
293
+ and parts that start higher simply get empty lower layers.
294
+ """
295
  stl_path = Path(stl_path)
296
  mesh = scale_mesh(load_mesh(stl_path), scale_factors)
297
  (x_min, y_min, z_min), (x_max, y_max, z_max) = mesh.bounds
298
 
299
+ if z_levels is not None:
300
+ z_values = [float(z) for z in z_levels]
301
+ else:
302
+ z_values = calculate_z_levels(float(z_min), float(z_max), layer_height)
303
+
304
+ solids, cavities = _split_solids_and_cavities(mesh)
305
+
306
+ def section_at(body: trimesh.Trimesh, z_value: float) -> MultiPolygon:
307
+ return _section_to_multipolygon(
308
+ body.section(
309
+ plane_origin=np.array([0.0, 0.0, z_value], dtype=float),
310
+ plane_normal=np.array([0.0, 0.0, 1.0], dtype=float),
311
+ )
312
+ )
313
 
314
  layers: list[MultiPolygon] = []
315
  for index, z_value in enumerate(z_values):
316
+ layer = unary_union([section_at(body, z_value) for body in solids])
317
+ if cavities:
318
+ layer = layer.difference(
319
+ unary_union([section_at(body, z_value) for body in cavities])
320
+ )
321
+ layers.append(_as_multipolygon(make_valid(layer)))
322
 
323
  if progress_callback is not None:
324
  progress_callback(index + 1, len(z_values))
tests/test_nozzle_spacing.py CHANGED
@@ -164,7 +164,9 @@ def test_shape_settings_round_trip_infill_column() -> None:
164
  assert _apply_shape_settings(records, rows)[0]["infill"] == 0.0
165
 
166
 
167
- def test_shape_settings_color_column_uses_palette_names() -> None:
 
 
168
  records = [
169
  {
170
  "idx": 1,
@@ -184,18 +186,47 @@ def test_shape_settings_color_column_uses_palette_names() -> None:
184
 
185
  rows = _shape_settings_rows(records)
186
  color_pos = SHAPE_SETTINGS_HEADERS.index("Color")
187
- # Stored hex displays as its friendly name.
188
- assert rows[0][color_pos] == "Orange"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
- # Typing a palette name (any case) stores the matching hex.
191
- rows[0][color_pos] = "red"
192
- assert _apply_shape_settings(records, rows)[0]["color"] == "#d62728"
193
 
194
- # Raw hex still works; unknown text keeps the previous color.
195
- rows[0][color_pos] = "#123456"
196
- assert _apply_shape_settings(records, rows)[0]["color"] == "#123456"
197
- rows[0][color_pos] = "not-a-color"
198
- assert _apply_shape_settings(records, rows)[0]["color"] == "#ff7f0e"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
 
200
 
201
  def test_lead_in_assembly_extension_covers_the_split_extent() -> None:
@@ -668,3 +699,120 @@ def test_delete_shape_cooldown_blocks_immediate_second_delete() -> None:
668
  )
669
 
670
  assert [record["name"] for record in second_outputs[1]] == ["first", "last"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  assert _apply_shape_settings(records, rows)[0]["infill"] == 0.0
165
 
166
 
167
+ def test_color_cell_renders_an_in_table_dropdown() -> None:
168
+ from app import PARALLEL_COLOR_CHOICES
169
+
170
  records = [
171
  {
172
  "idx": 1,
 
186
 
187
  rows = _shape_settings_rows(records)
188
  color_pos = SHAPE_SETTINGS_HEADERS.index("Color")
189
+ cell = rows[0][color_pos]
190
+
191
+ # The cell is a select carrying the record idx (as a class token β€” data
192
+ # attributes get sanitized out of markdown cells), wrapped in the
193
+ # pp-color-cell span the head script's pointer-event isolation keys on,
194
+ # with every palette color as an option and the current one selected.
195
+ assert cell.startswith('<span class="pp-color-cell">')
196
+ assert '<select class="pp-color-select pp-idx-1"' in cell
197
+ for name, hex_value in PARALLEL_COLOR_CHOICES:
198
+ assert f'value="{hex_value}"' in cell
199
+ assert f">{name}<" in cell
200
+ assert '<option value="#ff7f0e" selected>Orange</option>' in cell
201
+ assert cell.count(" selected") == 1
202
+
203
+ # Round-tripping the table through _apply_shape_settings keeps the color
204
+ # (the html cell never parses as a color value).
205
+ assert _apply_shape_settings(records, rows)[0]["color"] == "#ff7f0e"
206
 
 
 
 
207
 
208
+ def test_apply_color_selection_updates_the_right_record() -> None:
209
+ from app import apply_color_selection
210
+
211
+ records = [
212
+ {"idx": 1, "name": "a", "stl_path": "a.stl", "color": "#ff7f0e",
213
+ "target_x": 1.0, "target_y": 1.0, "target_z": 1.0,
214
+ "pressure": 25.0, "valve": 4, "nozzle": 1, "port": 1},
215
+ {"idx": 2, "name": "b", "stl_path": "b.stl", "color": "#1f77b4",
216
+ "target_x": 1.0, "target_y": 1.0, "target_z": 1.0,
217
+ "pressure": 25.0, "valve": 5, "nozzle": 2, "port": 1},
218
+ ]
219
+
220
+ updated, rows = apply_color_selection(records, None, "2|#ffe119")
221
+ assert updated[0]["color"] == "#ff7f0e"
222
+ assert updated[1]["color"] == "#ffe119" # Yellow applied to shape 2
223
+ assert 'value="#ffe119" selected' in rows[1][SHAPE_SETTINGS_HEADERS.index("Color")]
224
+
225
+ # White is available; junk payloads change nothing.
226
+ assert apply_color_selection(records, None, "1|#ffffff")[0][0]["color"] == "#ffffff"
227
+ assert apply_color_selection(records, None, "1|#123456")[0][0]["color"] == "#ff7f0e"
228
+ assert apply_color_selection(records, None, "garbage")[0][0]["color"] == "#ff7f0e"
229
+ assert apply_color_selection(records, None, None)[0][0]["color"] == "#ff7f0e"
230
 
231
 
232
  def test_lead_in_assembly_extension_covers_the_split_extent() -> None:
 
699
  )
700
 
701
  assert [record["name"] for record in second_outputs[1]] == ["first", "last"]
702
+
703
+
704
+ def test_group_split_splits_all_materials_on_one_shared_grid() -> None:
705
+ from shapely.geometry import MultiPolygon, box
706
+
707
+ from app import split_selected_shape_for_grid
708
+ from stl_slicer import LayerStack
709
+
710
+ def _material(polygon, name: str) -> LayerStack:
711
+ layers = [MultiPolygon([polygon]), MultiPolygon([polygon])]
712
+ x_min, y_min, x_max, y_max = polygon.bounds
713
+ return LayerStack(
714
+ layers=layers,
715
+ z_values=[0.5, 1.5],
716
+ bounds=((x_min, y_min, 0.0), (x_max, y_max, 2.0)),
717
+ layer_height=1.0,
718
+ name=name,
719
+ )
720
+
721
+ def _record(idx: int, name: str, stack: LayerStack) -> dict:
722
+ return {
723
+ "idx": idx,
724
+ "name": name,
725
+ "stl_path": f"{name}.stl",
726
+ "target_x": 20.0,
727
+ "target_y": 5.0,
728
+ "target_z": 2.0,
729
+ "pressure": 25.0,
730
+ "valve": 4,
731
+ "nozzle": 1, # both materials on nozzle 1 -> one assembly
732
+ "port": 1,
733
+ "color": "#111111",
734
+ "layer_stack": stack,
735
+ }
736
+
737
+ # Two materials tiling one 20x10 shape as horizontal strips.
738
+ records = [
739
+ _record(1, "bottom", _material(box(0.0, 0.0, 20.0, 5.0), "bottom")),
740
+ _record(2, "top", _material(box(0.0, 5.0, 20.0, 10.0), "top")),
741
+ ]
742
+
743
+ outputs = split_selected_shape_for_grid(
744
+ records,
745
+ None, # selected -> defaults to the first record
746
+ None, # settings table
747
+ 2, # columns
748
+ 1, # rows
749
+ False, # overlapping layers
750
+ 5, # starting nozzle
751
+ 9, # starting valve
752
+ 1.0, # fil width
753
+ )
754
+ next_records = outputs[0]
755
+
756
+ pieces = [record for record in next_records if record.get("split_group_id")]
757
+ assert len(pieces) == 4 # 2 cells x 2 materials
758
+
759
+ # Cell-major: cell 1 pieces share nozzle 5, cell 2 pieces share nozzle 6;
760
+ # every piece gets its own valve.
761
+ assert [piece["nozzle"] for piece in pieces] == [5, 5, 6, 6]
762
+ assert [piece["valve"] for piece in pieces] == [9, 10, 11, 12]
763
+ assert [piece["name"] for piece in pieces] == [
764
+ "bottom - R1C1",
765
+ "top - R1C1",
766
+ "bottom - R1C2",
767
+ "top - R1C2",
768
+ ]
769
+
770
+ # Cell-mates carry IDENTICAL nominal cell bounds (the shared grid) and
771
+ # one shared scan frame covering the whole assembly.
772
+ for cell_first, cell_second in ((0, 1), (2, 3)):
773
+ assert pieces[cell_first]["layer_stack"].bounds == pieces[cell_second]["layer_stack"].bounds
774
+ frames = {piece["layer_stack"].scan_frame for piece in pieces}
775
+ assert frames == {(0.0, 0.0, 20.0, 10.0)}
776
+
777
+ # Geometry: each piece is its material clipped to its cell.
778
+ assert pieces[0]["layer_stack"].layers[0].bounds == (0.0, 0.0, 10.0, 5.0)
779
+ assert pieces[1]["layer_stack"].layers[0].bounds == (0.0, 5.0, 10.0, 10.0)
780
+ assert pieces[2]["layer_stack"].layers[0].bounds == (10.0, 0.0, 20.0, 5.0)
781
+
782
+ # Contours exclude BOTH the material interface (y=5) and the cut seam
783
+ # (x=10): piece R1C1 of `bottom` keeps only its west + south edges.
784
+ for path in pieces[0]["layer_stack"].contour_paths[0]:
785
+ for x, y in path:
786
+ assert x <= 10.0 - 0.5 + 1e-9 or y <= 5.0 - 0.5 + 1e-9
787
+
788
+
789
+ def test_describe_split_source_warns_about_group_splits() -> None:
790
+ from shapely.geometry import MultiPolygon, box
791
+
792
+ from app import SPLIT_STATUS_DEFAULT, describe_split_source
793
+ from stl_slicer import LayerStack
794
+
795
+ def _stack(name: str) -> LayerStack:
796
+ return LayerStack(
797
+ layers=[MultiPolygon([box(0.0, 0.0, 1.0, 1.0)])],
798
+ z_values=[0.5],
799
+ bounds=((0.0, 0.0, 0.0), (1.0, 1.0, 1.0)),
800
+ layer_height=1.0,
801
+ name=name,
802
+ )
803
+
804
+ records = [
805
+ {"idx": 1, "name": "black", "stl_path": "black.stl", "nozzle": 1, "layer_stack": _stack("black")},
806
+ {"idx": 2, "name": "gold", "stl_path": "gold.stl", "nozzle": 1, "layer_stack": _stack("gold")},
807
+ {"idx": 3, "name": "solo", "stl_path": "solo.stl", "nozzle": 2, "layer_stack": _stack("solo")},
808
+ ]
809
+
810
+ grouped_note = describe_split_source(records, "1: black")
811
+ assert "whole group as one shape" in grouped_note
812
+ assert "black" in grouped_note and "gold" in grouped_note
813
+ assert "nozzle 1" in grouped_note
814
+
815
+ assert describe_split_source(records, "3: solo") == SPLIT_STATUS_DEFAULT
816
+ # No selection defaults to the first shape - which is grouped here.
817
+ assert "whole group as one shape" in describe_split_source(records, None)
818
+ assert describe_split_source([], None) == SPLIT_STATUS_DEFAULT
tests/test_stl_slicer.py CHANGED
@@ -82,3 +82,61 @@ def test_compose_even_odd_polygons_preserves_holes() -> None:
82
  assert len(composed) == 1
83
  assert composed[0].area == outer.area - inner.area
84
  assert len(composed[0].interiors) == 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  assert len(composed) == 1
83
  assert composed[0].area == outer.area - inner.area
84
  assert len(composed[0].interiors) == 1
85
+
86
+
87
+ def test_slice_stl_unions_interpenetrating_bodies(tmp_path) -> None:
88
+ # One STL packing several separate solids that overlap (e.g. the stripe
89
+ # prisms of a flag part) must slice to their union β€” the whole-mesh
90
+ # even-odd rule would XOR the overlap into a false hole.
91
+ a = trimesh.creation.box(extents=(4.0, 2.0, 2.0))
92
+ b = trimesh.creation.box(extents=(2.0, 4.0, 2.0))
93
+ b.apply_translation((1.0, 0.0, 0.0)) # overlaps half of `a`
94
+ stl_path = tmp_path / "cross.stl"
95
+ trimesh.util.concatenate([a, b]).export(stl_path)
96
+
97
+ stack = slice_stl_to_layers(stl_path, layer_height=1.0)
98
+
99
+ # Union area: 8 + 8 - 2x2 overlap = 12 (XOR would give 8).
100
+ for layer in stack.layers:
101
+ assert layer.area == pytest.approx(12.0)
102
+ assert all(not polygon.interiors for polygon in layer.geoms)
103
+
104
+
105
+ def test_slice_stl_subtracts_inverted_cavity_bodies(tmp_path) -> None:
106
+ # A watertight body wound inside-out (negative volume) is a modeller's
107
+ # cavity: it must stay a hole, not be unioned as a solid.
108
+ outer = trimesh.creation.box(extents=(6.0, 6.0, 2.0))
109
+ cavity = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
110
+ cavity.invert()
111
+ stl_path = tmp_path / "hollow.stl"
112
+ trimesh.util.concatenate([outer, cavity]).export(stl_path)
113
+
114
+ stack = slice_stl_to_layers(stl_path, layer_height=1.0)
115
+
116
+ for layer in stack.layers:
117
+ assert layer.area == pytest.approx(36.0 - 4.0)
118
+ assert sum(len(polygon.interiors) for polygon in layer.geoms) == 1
119
+
120
+
121
+ def test_slice_stl_handles_abutting_cells_and_stray_open_quads(tmp_path) -> None:
122
+ # Checkerboard-style STL: watertight cells that touch at edges/corners,
123
+ # plus stray open quad fragments (internal walls). The cells must slice
124
+ # per body and union into the exact checker pattern; the open quads
125
+ # produce no closed rings and drop out.
126
+ cells = []
127
+ for cx, cy in ((0, 0), (1, 1), (2, 0), (0, 2), (2, 2)):
128
+ cell = trimesh.creation.box(extents=(10.0, 10.0, 10.0))
129
+ cell.apply_translation((cx * 10.0 + 5.0, cy * 10.0 + 5.0, 5.0))
130
+ cells.append(cell)
131
+ quad = trimesh.Trimesh(
132
+ vertices=[(10.0, 0.0, 0.0), (10.0, 10.0, 0.0), (10.0, 10.0, 10.0), (10.0, 0.0, 10.0)],
133
+ faces=[(0, 1, 2), (0, 2, 3)],
134
+ )
135
+ stl_path = tmp_path / "checker.stl"
136
+ trimesh.util.concatenate(cells + [quad]).export(stl_path)
137
+
138
+ stack = slice_stl_to_layers(stl_path, layer_height=1.0)
139
+
140
+ for layer in stack.layers:
141
+ assert layer.area == pytest.approx(500.0)
142
+ assert layer.bounds == pytest.approx((0.0, 0.0, 30.0, 30.0))
tests/test_vector_gcode.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
 
3
  import math
4
 
5
- from shapely.geometry import MultiPolygon, Polygon, box
6
 
7
  from stl_slicer import LayerStack
8
  from vector_gcode import generate_vector_gcode
@@ -13,7 +13,8 @@ from vector_toolpath import (
13
  RASTER_PATTERN_Y_DIRECTION,
14
  ContourSource,
15
  _append_layer_contours,
16
- _circle_spiral_points,
 
17
  _layer_contour_loops,
18
  _rectangular_spiral_polyline,
19
  build_reference_stack,
@@ -660,33 +661,106 @@ def test_rectangular_spiral_raster_reverses_between_layers(tmp_path) -> None:
660
  assert end_z == 1.0
661
 
662
 
663
- def test_circle_spiral_points_decrease_radius_to_center() -> None:
664
- points = _circle_spiral_points(2.0, 3.0, outer_radius=4.0, pitch=1.0)
665
- radii = [math.hypot(x - 2.0, y - 3.0) for x, y in points]
 
 
 
666
 
667
- assert radii[0] == 4.0
668
- assert radii[-1] == 0.0
669
- assert all(
670
- current <= previous + 1e-9
671
- for previous, current in zip(radii, radii[1:])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
672
  )
 
 
 
 
 
 
 
 
673
 
674
 
675
- def test_circle_spiral_steps_radius_by_whole_pitches() -> None:
676
  # Each revolution is a true circle at a constant radius; the radius drops
677
  # by exactly one pitch in a single radial jump between revolutions.
678
- points = _circle_spiral_points(2.0, 3.0, outer_radius=4.0, pitch=0.8)
 
679
  radii = [math.hypot(x - 2.0, y - 3.0) for x, y in points]
680
 
681
  distinct = sorted({round(radius, 6) for radius in radii})
682
- assert distinct == [0.0, 0.8, 1.6, 2.4, 3.2, 4.0]
683
 
684
  ring_transitions = sum(
685
  1
686
  for previous, current in zip(radii, radii[1:])
687
  if abs(current - previous) > 1e-9
688
  )
689
- assert ring_transitions == 5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
690
 
691
 
692
  def test_circle_spiral_ring_steps_travel_with_valve_shut(tmp_path) -> None:
@@ -1400,3 +1474,148 @@ def test_split_layer_stack_grid_overlap_alternates_between_layers() -> None:
1400
  # Nominal bounds stay the un-shifted cells.
1401
  assert left.bounds == ((0.0, 0.0, 0.0), (2.0, 2.0, 2.0))
1402
  assert right.bounds == ((2.0, 0.0, 0.0), (4.0, 2.0, 2.0))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import math
4
 
5
+ from shapely.geometry import MultiPolygon, Point, Polygon, box
6
 
7
  from stl_slicer import LayerStack
8
  from vector_gcode import generate_vector_gcode
 
13
  RASTER_PATTERN_Y_DIRECTION,
14
  ContourSource,
15
  _append_layer_contours,
16
+ _circle_ring_radii,
17
+ _circle_rings_polyline,
18
  _layer_contour_loops,
19
  _rectangular_spiral_polyline,
20
  build_reference_stack,
 
661
  assert end_z == 1.0
662
 
663
 
664
+ def test_circle_ring_radii_hug_the_wall_then_fill_from_a_global_grid() -> None:
665
+ # The outermost revolution follows the material edge (max distance minus
666
+ # half a bead); the fill rings inside it sit on the (j + 1/2) * fil grid
667
+ # shared by every layer, so interior rings stack instead of aliasing.
668
+ disc = MultiPolygon([Point(2.0, 3.0).buffer(4.2, quad_segs=64)])
669
+ radii, walls = _circle_ring_radii(disc, 2.0, 3.0, 0.8)
670
 
671
+ assert radii == sorted(radii, reverse=True)
672
+ assert walls == (radii[0],)
673
+ assert abs(radii[0] - (4.2 - 0.4)) < 1e-2 # wall hugs the material edge
674
+ for radius in radii[1:]:
675
+ ring = radius / 0.8 - 0.5
676
+ assert abs(ring - round(ring)) < 1e-9 # fill stays on the global grid
677
+ assert radius <= radii[0] - 0.4 + 1e-9 # no overlap with the wall bead
678
+ assert min(radii) == 0.4 # material at the centre keeps the innermost ring
679
+
680
+
681
+ def test_circle_ring_radii_skip_rings_outside_the_material() -> None:
682
+ # An annulus gets an outer wall, an inner wall hugging the hole, and fill
683
+ # rings only where circles can cross material.
684
+ annulus = MultiPolygon(
685
+ [
686
+ Point(0.0, 0.0)
687
+ .buffer(6.0, quad_segs=64)
688
+ .difference(Point(0.0, 0.0).buffer(3.0, quad_segs=64))
689
+ ]
690
  )
691
+ radii, walls = _circle_ring_radii(annulus, 0.0, 0.0, 0.8)
692
+
693
+ assert radii
694
+ assert len(walls) == 2
695
+ assert abs(max(walls) - (6.0 - 0.4)) < 1e-2 # outer wall at the edge
696
+ assert abs(min(walls) - (3.0 + 0.4)) < 1e-2 # inner wall at the hole
697
+ assert min(radii) >= 3.0 - 1e-2
698
+ assert max(radii) <= 6.0 + 1e-9
699
 
700
 
701
+ def test_circle_rings_polyline_steps_radius_by_whole_pitches() -> None:
702
  # Each revolution is a true circle at a constant radius; the radius drops
703
  # by exactly one pitch in a single radial jump between revolutions.
704
+ ring_radii = [3.6, 2.8, 2.0, 1.2, 0.4]
705
+ points = _circle_rings_polyline(2.0, 3.0, ring_radii, 0.8)
706
  radii = [math.hypot(x - 2.0, y - 3.0) for x, y in points]
707
 
708
  distinct = sorted({round(radius, 6) for radius in radii})
709
+ assert distinct == [0.4, 1.2, 2.0, 2.8, 3.6]
710
 
711
  ring_transitions = sum(
712
  1
713
  for previous, current in zip(radii, radii[1:])
714
  if abs(current - previous) > 1e-9
715
  )
716
+ assert ring_transitions == 4
717
+
718
+
719
+ def test_circle_spiral_dome_has_no_travel_rings_and_monotone_radii(tmp_path) -> None:
720
+ # A dome (shrinking discs): motion must stay near each layer's material
721
+ # instead of sweeping the full frame, and the outermost printed radius
722
+ # must never grow with height.
723
+ from gcode_viewer import parse_gcode_path
724
+
725
+ center = (5.0, 5.0)
726
+ layer_radii = [5.0, 4.3, 3.4, 2.2]
727
+ layers = [Point(*center).buffer(r, quad_segs=64) for r in layer_radii]
728
+ gcode_path = generate_vector_gcode(
729
+ _stack(*layers),
730
+ shape_name="dome",
731
+ pressure=25,
732
+ valve=7,
733
+ port=3,
734
+ fil_width=0.8,
735
+ layer_height=1.0,
736
+ raster_pattern=RASTER_PATTERN_CIRCLE_SPIRAL,
737
+ output_dir=tmp_path,
738
+ )
739
+
740
+ parsed = parse_gcode_path(gcode_path.read_text())
741
+ origin_x, origin_y = parsed["path_origin"]
742
+
743
+ def layer_of(z: float) -> int:
744
+ return max(0, min(len(layer_radii) - 1, int(round(z))))
745
+
746
+ motion_max = [0.0] * len(layer_radii)
747
+ print_max = [0.0] * len(layer_radii)
748
+ for kind in ("print_segments", "travel_segments"):
749
+ for segment in parsed[kind]:
750
+ for x, y, z in segment:
751
+ radius = math.hypot(x + origin_x - center[0], y + origin_y - center[1])
752
+ index = layer_of(z)
753
+ motion_max[index] = max(motion_max[index], radius)
754
+ if kind == "print_segments":
755
+ print_max[index] = max(print_max[index], radius)
756
+
757
+ for index, layer_radius in enumerate(layer_radii):
758
+ # No motion meaningfully beyond this layer's own material edge.
759
+ assert motion_max[index] <= layer_radius + 0.8, (index, motion_max[index])
760
+ assert print_max[index] <= layer_radius + 1e-6
761
+ # Outermost printed ring shrinks (or holds) as the dome narrows.
762
+ for lower, upper in zip(print_max, print_max[1:]):
763
+ assert upper <= lower + 1e-9
764
 
765
 
766
  def test_circle_spiral_ring_steps_travel_with_valve_shut(tmp_path) -> None:
 
1474
  # Nominal bounds stay the un-shifted cells.
1475
  assert left.bounds == ((0.0, 0.0, 0.0), (2.0, 2.0, 2.0))
1476
  assert right.bounds == ((2.0, 0.0, 0.0), (4.0, 2.0, 2.0))
1477
+
1478
+
1479
+ def test_group_frame_reference_keeps_modeled_positions(tmp_path) -> None:
1480
+ # Multi-material group (shapes sharing a nozzle): parts carry one shared
1481
+ # align_frame, so they are NOT centered individually β€” each keeps its
1482
+ # modeled position relative to the others, and a part that has no
1483
+ # material on the lower layers just travels there (empty valve layers).
1484
+ from gcode_viewer import parse_gcode_path
1485
+
1486
+ lower = _stack(box(0.0, 0.0, 4.0, 4.0), box(0.0, 0.0, 4.0, 4.0), name="lower")
1487
+ # `upper` sits 6 mm to the right and only exists on layer 1.
1488
+ upper = _stack(None, box(6.0, 0.0, 10.0, 4.0), name="upper")
1489
+ group_frame = (0.0, 0.0, 10.0, 4.0)
1490
+ lower.align_frame = group_frame
1491
+ upper.align_frame = group_frame
1492
+ # A regular shape (own nozzle, no frame) modeled far away prints in the
1493
+ # same job: it gets centered onto the reference like always.
1494
+ solo = _stack(box(100.0, 100.0, 104.0, 104.0), box(100.0, 100.0, 104.0, 104.0), name="solo")
1495
+ reference = build_reference_stack([lower, upper, solo], grid=1.0)
1496
+
1497
+ # The group holding the first stack anchors the reference (no
1498
+ # translation); solo lands centered on the frame centre (5, 2): x 3..7.
1499
+ assert reference.layers[0].bounds == (0.0, 0.0, 7.0, 4.0)
1500
+ assert reference.layers[1].bounds == (0.0, 0.0, 10.0, 4.0)
1501
+ # Layer 0 = lower box (0..4) union solo centered to (3..7): 16+16-4 overlap.
1502
+ assert abs(reference.layers[0].area - 28.0) < 1e-6
1503
+
1504
+ def _world_motion_polyline(text: str) -> list[tuple[float, float, float]]:
1505
+ # Ordered nozzle path in world coordinates, simplified so points that
1506
+ # only mark valve changes (collinear, same direction) drop out.
1507
+ origin_line = next(line for line in text.splitlines() if "PathOrigin" in line)
1508
+ tokens = origin_line.split()
1509
+ origin_x, origin_y = float(tokens[-2][1:]), float(tokens[-1][1:])
1510
+ moves = _moves_with_colors(text)
1511
+ points = [moves[0]["start"]] + [move["end"] for move in moves]
1512
+ world = [(x + origin_x, y + origin_y, z) for x, y, z in points]
1513
+ simplified = [world[0]]
1514
+ for point in world[1:]:
1515
+ if len(simplified) >= 2:
1516
+ ax, ay, az = simplified[-2]
1517
+ bx, by, bz = simplified[-1]
1518
+ d1 = (bx - ax, by - ay, bz - az)
1519
+ d2 = (point[0] - bx, point[1] - by, point[2] - bz)
1520
+ cross = (
1521
+ d1[1] * d2[2] - d1[2] * d2[1],
1522
+ d1[2] * d2[0] - d1[0] * d2[2],
1523
+ d1[0] * d2[1] - d1[1] * d2[0],
1524
+ )
1525
+ same_dir = all(abs(c) < 1e-9 for c in cross) and (
1526
+ d1[0] * d2[0] + d1[1] * d2[1] + d1[2] * d2[2] >= 0
1527
+ )
1528
+ if same_dir:
1529
+ simplified[-1] = point
1530
+ continue
1531
+ simplified.append(point)
1532
+ return [(round(x, 6), round(y, 6), round(z, 6)) for x, y, z in simplified]
1533
+
1534
+ prints: dict[str, list] = {}
1535
+ motions: dict[str, list] = {}
1536
+ for stack in (lower, upper):
1537
+ gcode_path = generate_vector_gcode(
1538
+ stack,
1539
+ shape_name=stack.name,
1540
+ pressure=25,
1541
+ valve=7,
1542
+ port=3,
1543
+ fil_width=1.0,
1544
+ layer_height=1.0,
1545
+ motion=reference,
1546
+ output_dir=tmp_path,
1547
+ )
1548
+ text = gcode_path.read_text()
1549
+ parsed = parse_gcode_path(text)
1550
+ origin_x, origin_y = parsed["path_origin"]
1551
+ prints[stack.name] = [
1552
+ [(x + origin_x, y + origin_y, z) for x, y, z in segment]
1553
+ for segment in parsed["print_segments"]
1554
+ ]
1555
+ motions[stack.name] = _world_motion_polyline(text)
1556
+
1557
+ # Shared motion: both heads trace exactly the same world path.
1558
+ assert motions["lower"] == motions["upper"]
1559
+
1560
+ # `upper` never dispenses on layer 0 and prints only inside x 6..10.
1561
+ for segment in prints["upper"]:
1562
+ for x, y, z in segment:
1563
+ assert z > 0.5
1564
+ assert 6.0 - 1e-6 <= x <= 10.0 + 1e-6
1565
+ # `lower` prints only inside x 0..4 (its modeled position, not recentered).
1566
+ for segment in prints["lower"]:
1567
+ for x, y, z in segment:
1568
+ assert 0.0 - 1e-6 <= x <= 4.0 + 1e-6
1569
+
1570
+
1571
+ def test_group_contour_paths_exclude_material_interfaces() -> None:
1572
+ from vector_toolpath import group_contour_paths
1573
+
1574
+ # Two materials abutting at x=4 assemble into one 8x4 shape: the shared
1575
+ # edge is an internal interface, so each member contours only its three
1576
+ # outer sides.
1577
+ left = _stack(box(0.0, 0.0, 4.0, 4.0), name="left")
1578
+ right = _stack(box(4.0, 0.0, 8.0, 4.0), name="right")
1579
+ paths = group_contour_paths(left, [right], tolerance=0.4)
1580
+
1581
+ assert len(paths) == 1
1582
+ total = sum(
1583
+ math.dist(a, b)
1584
+ for path in paths[0]
1585
+ for a, b in zip(path, path[1:])
1586
+ )
1587
+ # 3 outer sides of the 4x4 box; boundary within tolerance (0.4) of the
1588
+ # sibling also counts as interface, so the top/bottom edges stop 0.4
1589
+ # short of the seam: 12 - 2*0.4. The seam edge itself is gone entirely.
1590
+ assert abs(total - 11.2) < 1e-6
1591
+ for path in paths[0]:
1592
+ for x, _y in path:
1593
+ assert x <= 3.6 + 1e-9 # nothing at or past the seam
1594
+
1595
+ # A fit-tolerance gap smaller than the tolerance still counts as an
1596
+ # interface; a distant shape does not.
1597
+ gapped = _stack(box(4.2, 0.0, 8.0, 4.0), name="gapped")
1598
+ paths_gapped = group_contour_paths(left, [gapped], tolerance=0.4)
1599
+ total_gapped = sum(
1600
+ math.dist(a, b)
1601
+ for path in paths_gapped[0]
1602
+ for a, b in zip(path, path[1:])
1603
+ )
1604
+ # Seam edge excluded; top/bottom trimmed where within 0.4 of the sibling
1605
+ # (which starts at 4.2): 12 - 2*0.2.
1606
+ assert abs(total_gapped - 11.6) < 1e-6
1607
+
1608
+ far = _stack(box(9.0, 0.0, 12.0, 4.0), name="far")
1609
+ paths_far = group_contour_paths(left, [far], tolerance=0.4)
1610
+ total_far = sum(
1611
+ math.dist(a, b)
1612
+ for path in paths_far[0]
1613
+ for a, b in zip(path, path[1:])
1614
+ )
1615
+ assert abs(total_far - 16.0) < 1e-6 # full ring: nothing nearby
1616
+
1617
+ # A material fully embedded in the assembly has no outer surface at all.
1618
+ core = _stack(box(1.0, 1.0, 3.0, 3.0), name="core")
1619
+ shell_layer = box(0.0, 0.0, 4.0, 4.0).difference(box(1.0, 1.0, 3.0, 3.0))
1620
+ shell = _stack(shell_layer, name="shell")
1621
+ assert group_contour_paths(core, [shell], tolerance=0.4) == [[]]
uv.lock CHANGED
@@ -1429,6 +1429,22 @@ wheels = [
1429
  { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
1430
  ]
1431
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1432
  [[package]]
1433
  name = "safehttpx"
1434
  version = "0.1.7"
@@ -1622,6 +1638,7 @@ dependencies = [
1622
  { name = "numpy" },
1623
  { name = "pillow" },
1624
  { name = "plotly" },
 
1625
  { name = "scipy" },
1626
  { name = "shapely" },
1627
  { name = "trimesh" },
@@ -1640,6 +1657,7 @@ requires-dist = [
1640
  { name = "numpy", specifier = ">=2.2.0" },
1641
  { name = "pillow", specifier = ">=11.1.0" },
1642
  { name = "plotly", specifier = ">=6.7.0" },
 
1643
  { name = "scipy", specifier = ">=1.15.2" },
1644
  { name = "shapely", specifier = ">=2.0.7" },
1645
  { name = "trimesh", specifier = ">=4.6.5" },
 
1429
  { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
1430
  ]
1431
 
1432
+ [[package]]
1433
+ name = "rtree"
1434
+ version = "1.4.1"
1435
+ source = { registry = "https://pypi.org/simple" }
1436
+ sdist = { url = "https://files.pythonhosted.org/packages/95/09/7302695875a019514de9a5dd17b8320e7a19d6e7bc8f85dcfb79a4ce2da3/rtree-1.4.1.tar.gz", hash = "sha256:c6b1b3550881e57ebe530cc6cffefc87cd9bf49c30b37b894065a9f810875e46", size = 52425, upload-time = "2025-08-13T19:32:01.413Z" }
1437
+ wheels = [
1438
+ { url = "https://files.pythonhosted.org/packages/04/d9/108cd989a4c0954e60b3cdc86fd2826407702b5375f6dfdab2802e5fed98/rtree-1.4.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d672184298527522d4914d8ae53bf76982b86ca420b0acde9298a7a87d81d4a4", size = 468484, upload-time = "2025-08-13T19:31:50.593Z" },
1439
+ { url = "https://files.pythonhosted.org/packages/f3/cf/2710b6fd6b07ea0aef317b29f335790ba6adf06a28ac236078ed9bd8a91d/rtree-1.4.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a7e48d805e12011c2cf739a29d6a60ae852fb1de9fc84220bbcef67e6e595d7d", size = 436325, upload-time = "2025-08-13T19:31:52.367Z" },
1440
+ { url = "https://files.pythonhosted.org/packages/55/e1/4d075268a46e68db3cac51846eb6a3ab96ed481c585c5a1ad411b3c23aad/rtree-1.4.1-py3-none-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa8c4496e31e9ad58ff6c7df89abceac7022d906cb64a3e18e4fceae6b77f65", size = 459789, upload-time = "2025-08-13T19:31:53.926Z" },
1441
+ { url = "https://files.pythonhosted.org/packages/d1/75/e5d44be90525cd28503e7f836d077ae6663ec0687a13ba7810b4114b3668/rtree-1.4.1-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12de4578f1b3381a93a655846900be4e3d5f4cd5e306b8b00aa77c1121dc7e8c", size = 507644, upload-time = "2025-08-13T19:31:55.164Z" },
1442
+ { url = "https://files.pythonhosted.org/packages/fd/85/b8684f769a142163b52859a38a486493b05bafb4f2fb71d4f945de28ebf9/rtree-1.4.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b558edda52eca3e6d1ee629042192c65e6b7f2c150d6d6cd207ce82f85be3967", size = 1454478, upload-time = "2025-08-13T19:31:56.808Z" },
1443
+ { url = "https://files.pythonhosted.org/packages/e9/a4/c2292b95246b9165cc43a0c3757e80995d58bc9b43da5cb47ad6e3535213/rtree-1.4.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f155bc8d6bac9dcd383481dee8c130947a4866db1d16cb6dff442329a038a0dc", size = 1555140, upload-time = "2025-08-13T19:31:58.031Z" },
1444
+ { url = "https://files.pythonhosted.org/packages/74/25/5282c8270bfcd620d3e73beb35b40ac4ab00f0a898d98ebeb41ef0989ec8/rtree-1.4.1-py3-none-win_amd64.whl", hash = "sha256:efe125f416fd27150197ab8521158662943a40f87acab8028a1aac4ad667a489", size = 389358, upload-time = "2025-08-13T19:31:59.247Z" },
1445
+ { url = "https://files.pythonhosted.org/packages/3f/50/0a9e7e7afe7339bd5e36911f0ceb15fed51945836ed803ae5afd661057fd/rtree-1.4.1-py3-none-win_arm64.whl", hash = "sha256:3d46f55729b28138e897ffef32f7ce93ac335cb67f9120125ad3742a220800f0", size = 355253, upload-time = "2025-08-13T19:32:00.296Z" },
1446
+ ]
1447
+
1448
  [[package]]
1449
  name = "safehttpx"
1450
  version = "0.1.7"
 
1638
  { name = "numpy" },
1639
  { name = "pillow" },
1640
  { name = "plotly" },
1641
+ { name = "rtree" },
1642
  { name = "scipy" },
1643
  { name = "shapely" },
1644
  { name = "trimesh" },
 
1657
  { name = "numpy", specifier = ">=2.2.0" },
1658
  { name = "pillow", specifier = ">=11.1.0" },
1659
  { name = "plotly", specifier = ">=6.7.0" },
1660
+ { name = "rtree", specifier = ">=1.4.1" },
1661
  { name = "scipy", specifier = ">=1.15.2" },
1662
  { name = "shapely", specifier = ">=2.0.7" },
1663
  { name = "trimesh", specifier = ">=4.6.5" },
vector_toolpath.py CHANGED
@@ -670,27 +670,87 @@ def _rectangular_spiral_polyline(
670
  return _extend_polyline_ends(points, half)
671
 
672
 
673
- def _circle_spiral_points(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
674
  center_x: float,
675
  center_y: float,
676
- outer_radius: float,
677
  pitch: float,
678
  ) -> list[tuple[float, float]]:
679
- """Concentric-ring "spiral": full circles stepping inward by one pitch.
680
 
681
  Each revolution stays at a CONSTANT radius (so the printed walls are true
682
- smooth circles) and the radius then drops by exactly one pitch in a short
683
- radial jump before the next revolution, ending at the centre. Ring k thus
684
- sits exactly at radius outer - k*pitch, which is also what the partial
685
- infill revolution index assumes.
686
  """
687
- if outer_radius <= 0.0:
688
- return [(center_x, center_y)]
689
-
690
  pitch = max(float(pitch), 1e-9)
691
  points: list[tuple[float, float]] = []
692
- radius = outer_radius
693
- while radius > 1e-9:
 
694
  # Sample roughly one pitch of arc length per step, at least 20/ring.
695
  d_theta = min(math.pi / 10.0, pitch / max(radius, pitch))
696
  steps = max(8, int(math.ceil((2.0 * math.pi) / d_theta)))
@@ -702,34 +762,6 @@ def _circle_spiral_points(
702
  center_y + (radius * math.sin(theta)),
703
  )
704
  )
705
- radius -= pitch
706
-
707
- if not points or points[-1] != (center_x, center_y):
708
- points.append((center_x, center_y))
709
- return points
710
-
711
-
712
- def _circle_spiral_polyline(
713
- bounds: tuple[float, float, float, float],
714
- fil_width: float,
715
- reverse: bool = False,
716
- ) -> list[tuple[float, float]]:
717
- min_x, min_y, max_x, max_y = bounds
718
- center_x = (min_x + max_x) / 2.0
719
- center_y = (min_y + max_y) / 2.0
720
- outer_radius = max(
721
- math.hypot(corner_x - center_x, corner_y - center_y)
722
- for corner_x, corner_y in (
723
- (min_x, min_y),
724
- (max_x, min_y),
725
- (max_x, max_y),
726
- (min_x, max_y),
727
- )
728
- )
729
-
730
- points = _circle_spiral_points(center_x, center_y, outer_radius, fil_width)
731
- if reverse:
732
- points.reverse()
733
  return points
734
 
735
 
@@ -1224,38 +1256,35 @@ def plan_layer_moves(
1224
  if motion is None or motion.is_empty:
1225
  segments: list[Seg] = []
1226
  elif raster_pattern == RASTER_PATTERN_CIRCLE_SPIRAL:
1227
- points = _circle_spiral_polyline(
1228
- motion.bounds,
1229
- fil_width,
1230
- reverse=layer_number % 2 == 1,
1231
- )
1232
- # Rings sit exactly at radius outer - k*fil. Dispense only ON a
1233
- # ring: the radial step between revolutions (and the final dive
1234
- # to the centre) travels with the valve shut β€” otherwise every
1235
- # step would extrude a radial seam, worst on the outer wall.
1236
- # Vetoing whole source segments by their radial change also kills
1237
- # the step pieces the material boundary would otherwise split off.
1238
- min_x, min_y, max_x, max_y = motion.bounds
1239
- center_x = (min_x + max_x) / 2.0
1240
- center_y = (min_y + max_y) / 2.0
1241
- outer_radius = max(
1242
- math.hypot(cx - center_x, cy - center_y)
1243
- for cx, cy in (
1244
- (min_x, min_y),
1245
- (max_x, min_y),
1246
- (max_x, max_y),
1247
- (min_x, max_y),
1248
- )
1249
- )
1250
-
1251
  def keep_segment(x0: float, y0: float, x1: float, y1: float) -> bool:
1252
  radius_0 = math.hypot(x0 - center_x, y0 - center_y)
1253
  radius_1 = math.hypot(x1 - center_x, y1 - center_y)
1254
  if abs(radius_1 - radius_0) > fil_width * 0.25:
1255
- return False # radial step between rings: travel only
1256
  if infill_keep is None:
1257
  return True
1258
- ring = round((outer_radius - (radius_0 + radius_1) / 2.0) / fil_width)
 
 
 
1259
  return infill_keep(max(0, int(ring)))
1260
 
1261
  segments = _classify_polyline(points, valve, keep_segment=keep_segment)
@@ -1420,6 +1449,17 @@ def _stack_center(stack: LayerStack) -> tuple[float, float]:
1420
  return ((x_min + x_max) / 2.0, (y_min + y_max) / 2.0)
1421
 
1422
 
 
 
 
 
 
 
 
 
 
 
 
1423
  def _snap_to_grid(value: float, grid: float | None) -> float:
1424
  if not grid or grid <= 0.0:
1425
  return value
@@ -1446,6 +1486,11 @@ def _centering_delta(stack: LayerStack, reference: LayerStack) -> tuple[float, f
1446
  nozzle spacing β€” come out uniform across all pieces, whereas snapping the
1447
  centres would wobble by up to one fil where the last cell's width (and so
1448
  its centre phase) differs.
 
 
 
 
 
1449
  """
1450
  grid = reference.align_grid
1451
  if (
@@ -1467,7 +1512,7 @@ def _centering_delta(stack: LayerStack, reference: LayerStack) -> tuple[float, f
1467
  reference_x, reference_y = reference.align_center
1468
  else:
1469
  reference_x, reference_y = _stack_center(reference)
1470
- center_x, center_y = _stack_center(stack)
1471
  return (
1472
  _snap_to_grid(reference_x - center_x, grid),
1473
  _snap_to_grid(reference_y - center_y, grid),
@@ -1498,11 +1543,18 @@ def build_reference_stack(
1498
  stacks: list[LayerStack | None],
1499
  grid: float | None = None,
1500
  ) -> LayerStack | None:
1501
- """Union all shapes into one shared motion stack, bbox-centres aligned.
1502
 
1503
  Vector analog of the old centered "black wins" TIFF merge: every stack is
1504
- translated so its XY bbox centre lands on the first stack's centre, then
1505
- each layer is the union of the translated layers.
 
 
 
 
 
 
 
1506
 
1507
  `grid` (the fil width) snaps each translation to grid multiples so every
1508
  shape's world scan-grid phase survives the alignment β€” required for split
@@ -1515,7 +1567,7 @@ def build_reference_stack(
1515
  return None
1516
 
1517
  n_layers = max(len(stack.layers) for stack in valid)
1518
- reference_x, reference_y = _stack_center(valid[0])
1519
  target = LayerStack(
1520
  layers=[],
1521
  z_values=[],
@@ -1623,20 +1675,11 @@ def _shifted_split_edges(
1623
  return adjusted
1624
 
1625
 
1626
- def _clip_contour_paths(
1627
- source_lines: object,
1628
- cell: object,
1629
- ) -> list[list[tuple[float, float]]]:
1630
- """Contour polylines of a split piece: the parent outline inside the cell.
1631
-
1632
- Clipping the PARENT's boundary linework (instead of taking the piece
1633
- polygon's own boundary) excludes the cut seams between sibling pieces β€”
1634
- only the true outer surface remains. Results are merged into maximal
1635
- polylines and ordered/oriented deterministically so pieces sharing
1636
- reference motion trace them identically.
1637
- """
1638
- clipped = source_lines.intersection(cell)
1639
- pieces = list(_iter_linestrings(clipped))
1640
  if not pieces:
1641
  return []
1642
  merged = linemerge(MultiLineString(pieces)) if len(pieces) > 1 else pieces[0]
@@ -1661,6 +1704,53 @@ def _clip_contour_paths(
1661
  return paths
1662
 
1663
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1664
  def split_layer_stack_grid(
1665
  stack: LayerStack,
1666
  columns: int,
@@ -1668,6 +1758,7 @@ def split_layer_stack_grid(
1668
  overlapping_layers: bool = False,
1669
  overlap: float = 0.0,
1670
  grid: float | None = None,
 
1671
  ) -> list[LayerStack]:
1672
  """Split a sliced shape into a rows x columns grid of piece stacks.
1673
 
@@ -1677,10 +1768,17 @@ def split_layer_stack_grid(
1677
  pieces interlock. `grid` (the fil width) sizes the cells in whole grid
1678
  multiples (see `_base_split_edges`). Piece `bounds` are the nominal
1679
  (un-shifted) cell boxes.
 
 
 
 
 
1680
  """
1681
  columns = max(1, int(columns))
1682
  rows = max(1, int(rows))
1683
  (x_min, y_min, z_min), (x_max, y_max, z_max) = stack.bounds
 
 
1684
 
1685
  overlap_x = overlap if (overlapping_layers and columns > 1) else 0.0
1686
  overlap_y = overlap if (overlapping_layers and rows > 1) else 0.0
 
670
  return _extend_polyline_ends(points, half)
671
 
672
 
673
+ def _circle_ring_radii(
674
+ motion: MultiPolygon,
675
+ center_x: float,
676
+ center_y: float,
677
+ fil_width: float,
678
+ ) -> tuple[list[float], tuple[float, ...]]:
679
+ """Ring radii for one layer, outermost first: perimeter walls + grid fill.
680
+
681
+ The outermost revolution is a PERIMETER WALL hugging the layer's material
682
+ edge (its farthest boundary distance minus half a bead), so the printed
683
+ silhouette follows the shape smoothly instead of staircasing by whole
684
+ grid steps. If the layer has a central hole, a matching inner wall hugs
685
+ the hole edge. The fill between the walls comes from a global grid β€”
686
+ ring j at (j + 1/2) * fil_width from the frame centre, the same grid on
687
+ every layer (and every split sibling) so interior rings stack vertically.
688
+ Grid rings that would overlap a wall bead, or whose circle cannot cross
689
+ the layer's material at all, are skipped instead of traveled.
690
+
691
+ Returns (radii outermost-first, wall radii). Walls always dispense even
692
+ under partial infill, like contour tracing.
693
+ """
694
+ if motion is None or motion.is_empty:
695
+ return [], ()
696
+
697
+ max_dist = 0.0
698
+ for polygon in motion.geoms:
699
+ for ring in (polygon.exterior, *polygon.interiors):
700
+ for x, y in ring.coords:
701
+ max_dist = max(max_dist, math.hypot(x - center_x, y - center_y))
702
+ if max_dist <= 0.0:
703
+ return [], ()
704
+ min_dist = float(motion.distance(Point(center_x, center_y)))
705
+
706
+ pitch = max(float(fil_width), 1e-9)
707
+ half = pitch / 2.0
708
+
709
+ outer_wall = max_dist - half
710
+ if outer_wall <= EPS:
711
+ # Material thinner than one bead (e.g. the dome cap): one tiny ring
712
+ # through the middle of it so the layer still gets motion.
713
+ radius = max(max_dist / 2.0, EPS)
714
+ return [radius], (radius,)
715
+
716
+ walls = [outer_wall]
717
+ inner_wall: float | None = None
718
+ if min_dist > pitch / 4.0:
719
+ candidate = min_dist + half
720
+ if candidate <= outer_wall - half:
721
+ inner_wall = candidate
722
+ walls.append(candidate)
723
+
724
+ grid_hi = outer_wall - half
725
+ grid_lo = (inner_wall + half) if inner_wall is not None else max(min_dist - half, 0.0)
726
+ j_hi = int(math.floor(grid_hi / pitch - 0.5 + 1e-9))
727
+ j_lo = max(0, int(math.ceil(grid_lo / pitch - 0.5 - 1e-9)))
728
+
729
+ radii = [outer_wall]
730
+ if j_hi >= j_lo:
731
+ radii.extend((j + 0.5) * pitch for j in range(j_hi, j_lo - 1, -1))
732
+ if inner_wall is not None:
733
+ radii.append(inner_wall)
734
+ return radii, tuple(walls)
735
+
736
+
737
+ def _circle_rings_polyline(
738
  center_x: float,
739
  center_y: float,
740
+ radii: list[float],
741
  pitch: float,
742
  ) -> list[tuple[float, float]]:
743
+ """Concentric-ring "spiral": one full circle per radius, in list order.
744
 
745
  Each revolution stays at a CONSTANT radius (so the printed walls are true
746
+ smooth circles); consecutive rings are joined by a radial jump at theta 0,
747
+ which the caller classifies as valve-off travel.
 
 
748
  """
 
 
 
749
  pitch = max(float(pitch), 1e-9)
750
  points: list[tuple[float, float]] = []
751
+ for radius in radii:
752
+ if radius <= 0.0:
753
+ continue
754
  # Sample roughly one pitch of arc length per step, at least 20/ring.
755
  d_theta = min(math.pi / 10.0, pitch / max(radius, pitch))
756
  steps = max(8, int(math.ceil((2.0 * math.pi) / d_theta)))
 
762
  center_y + (radius * math.sin(theta)),
763
  )
764
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
765
  return points
766
 
767
 
 
1256
  if motion is None or motion.is_empty:
1257
  segments: list[Seg] = []
1258
  elif raster_pattern == RASTER_PATTERN_CIRCLE_SPIRAL:
1259
+ # Rings live on one global radii grid anchored at the frame
1260
+ # centre (ring j at (j + 1/2) * fil): every layer draws from the
1261
+ # same radii, so walls stack across layers instead of aliasing,
1262
+ # and rings that never touch this layer's material are skipped
1263
+ # instead of swept as full travel circles.
1264
+ frame = scan_frame if scan_frame is not None else motion.bounds
1265
+ center_x = (frame[0] + frame[2]) / 2.0
1266
+ center_y = (frame[1] + frame[3]) / 2.0
1267
+ radii, wall_radii = _circle_ring_radii(motion, center_x, center_y, fil_width)
1268
+ points = _circle_rings_polyline(center_x, center_y, radii, fil_width)
1269
+ if layer_number % 2 == 1:
1270
+ points.reverse()
1271
+
1272
+ # Dispense only ON a ring: the radial jump between revolutions
1273
+ # travels with the valve shut β€” otherwise every jump would
1274
+ # extrude a radial seam, worst on the outer wall. Vetoing whole
1275
+ # source segments by their radial change also kills the step
1276
+ # pieces the material boundary would otherwise split off.
 
 
 
 
 
 
1277
  def keep_segment(x0: float, y0: float, x1: float, y1: float) -> bool:
1278
  radius_0 = math.hypot(x0 - center_x, y0 - center_y)
1279
  radius_1 = math.hypot(x1 - center_x, y1 - center_y)
1280
  if abs(radius_1 - radius_0) > fil_width * 0.25:
1281
+ return False # radial jump between rings: travel only
1282
  if infill_keep is None:
1283
  return True
1284
+ radius_mid = (radius_0 + radius_1) / 2.0
1285
+ if any(abs(radius_mid - wall) <= fil_width * 0.25 for wall in wall_radii):
1286
+ return True # perimeter walls always print, like contours
1287
+ ring = round(radius_mid / fil_width - 0.5)
1288
  return infill_keep(max(0, int(ring)))
1289
 
1290
  segments = _classify_polyline(points, valve, keep_segment=keep_segment)
 
1449
  return ((x_min + x_max) / 2.0, (y_min + y_max) / 2.0)
1450
 
1451
 
1452
+ def _alignment_center(stack: LayerStack) -> tuple[float, float]:
1453
+ """The point a stack is centred by: its multi-material group frame's
1454
+ centre when it belongs to a group, else its own bbox centre. Group
1455
+ members share one frame, so they all get the same delta and keep their
1456
+ modelled positions relative to each other."""
1457
+ if stack.align_frame is not None:
1458
+ x_min, y_min, x_max, y_max = stack.align_frame
1459
+ return ((x_min + x_max) / 2.0, (y_min + y_max) / 2.0)
1460
+ return _stack_center(stack)
1461
+
1462
+
1463
  def _snap_to_grid(value: float, grid: float | None) -> float:
1464
  if not grid or grid <= 0.0:
1465
  return value
 
1486
  nozzle spacing β€” come out uniform across all pieces, whereas snapping the
1487
  centres would wobble by up to one fil where the last cell's width (and so
1488
  its centre phase) differs.
1489
+
1490
+ Multi-material group members (stacks carrying a shared `align_frame`)
1491
+ are aligned by the group frame's centre instead of their own bbox
1492
+ centre: every member gets the same delta, so the group moves as one
1493
+ rigid unit and parts keep their modelled relative positions.
1494
  """
1495
  grid = reference.align_grid
1496
  if (
 
1512
  reference_x, reference_y = reference.align_center
1513
  else:
1514
  reference_x, reference_y = _stack_center(reference)
1515
+ center_x, center_y = _alignment_center(stack)
1516
  return (
1517
  _snap_to_grid(reference_x - center_x, grid),
1518
  _snap_to_grid(reference_y - center_y, grid),
 
1543
  stacks: list[LayerStack | None],
1544
  grid: float | None = None,
1545
  ) -> LayerStack | None:
1546
+ """Union all shapes into one shared motion stack, alignment-centres aligned.
1547
 
1548
  Vector analog of the old centered "black wins" TIFF merge: every stack is
1549
+ translated so its alignment centre (its own XY bbox centre, or its
1550
+ multi-material group frame's centre β€” see `_alignment_center`) lands on
1551
+ the first stack's alignment centre, then each layer is the union of the
1552
+ translated layers. Multi-material group members share one frame, so the
1553
+ group translates as a rigid unit and its parts keep their modelled
1554
+ relative positions; the group holding the first stack does not move at
1555
+ all. Group members should be sliced on one common Z grid so layer indices
1556
+ line up; a part that starts higher simply contributes nothing to the
1557
+ lower layers.
1558
 
1559
  `grid` (the fil width) snaps each translation to grid multiples so every
1560
  shape's world scan-grid phase survives the alignment β€” required for split
 
1567
  return None
1568
 
1569
  n_layers = max(len(stack.layers) for stack in valid)
1570
+ reference_x, reference_y = _alignment_center(valid[0])
1571
  target = LayerStack(
1572
  layers=[],
1573
  z_values=[],
 
1675
  return adjusted
1676
 
1677
 
1678
+ def _linework_to_paths(geometry: object) -> list[list[tuple[float, float]]]:
1679
+ """Merge linework into maximal polylines, ordered/oriented
1680
+ deterministically so shapes sharing reference motion trace them
1681
+ identically. Open paths allowed; closed rings keep first == last."""
1682
+ pieces = list(_iter_linestrings(geometry))
 
 
 
 
 
 
 
 
 
1683
  if not pieces:
1684
  return []
1685
  merged = linemerge(MultiLineString(pieces)) if len(pieces) > 1 else pieces[0]
 
1704
  return paths
1705
 
1706
 
1707
+ def _clip_contour_paths(
1708
+ source_lines: object,
1709
+ cell: object,
1710
+ ) -> list[list[tuple[float, float]]]:
1711
+ """Contour polylines of a split piece: the parent outline inside the cell.
1712
+
1713
+ Clipping the PARENT's boundary linework (instead of taking the piece
1714
+ polygon's own boundary) excludes the cut seams between sibling pieces β€”
1715
+ only the true outer surface remains.
1716
+ """
1717
+ return _linework_to_paths(source_lines.intersection(cell))
1718
+
1719
+
1720
+ def group_contour_paths(
1721
+ member: LayerStack,
1722
+ siblings: list[LayerStack],
1723
+ tolerance: float,
1724
+ ) -> list[list[list[tuple[float, float]]]]:
1725
+ """Per-layer seam-free contour polylines for one multi-material member.
1726
+
1727
+ Parts sharing a nozzle assemble into ONE shape, so where a member's
1728
+ boundary meets a sibling material β€” or comes within `tolerance` of it
1729
+ (fit gaps between materials) β€” that edge is an internal interface, not a
1730
+ printable surface. Only the member boundary on the assembly's true
1731
+ outside is kept; a member fully embedded in the assembly gets no
1732
+ contours at all. Members should share one Z grid so layer indices align.
1733
+ """
1734
+ tolerance = max(float(tolerance), EPS)
1735
+ contour_paths: list[list[list[tuple[float, float]]]] = []
1736
+ for layer_number, layer in enumerate(member.layers):
1737
+ if layer is None or layer.is_empty:
1738
+ contour_paths.append([])
1739
+ continue
1740
+ sibling_parts = [
1741
+ sibling.layers[layer_number]
1742
+ for sibling in siblings
1743
+ if layer_number < len(sibling.layers)
1744
+ and sibling.layers[layer_number] is not None
1745
+ and not sibling.layers[layer_number].is_empty
1746
+ ]
1747
+ boundary = layer.boundary
1748
+ if sibling_parts:
1749
+ boundary = boundary.difference(unary_union(sibling_parts).buffer(tolerance))
1750
+ contour_paths.append(_linework_to_paths(boundary))
1751
+ return contour_paths
1752
+
1753
+
1754
  def split_layer_stack_grid(
1755
  stack: LayerStack,
1756
  columns: int,
 
1758
  overlapping_layers: bool = False,
1759
  overlap: float = 0.0,
1760
  grid: float | None = None,
1761
+ frame: tuple[float, float, float, float] | None = None,
1762
  ) -> list[LayerStack]:
1763
  """Split a sliced shape into a rows x columns grid of piece stacks.
1764
 
 
1768
  pieces interlock. `grid` (the fil width) sizes the cells in whole grid
1769
  multiples (see `_base_split_edges`). Piece `bounds` are the nominal
1770
  (un-shifted) cell boxes.
1771
+
1772
+ `frame` overrides the XY box the cell grid is computed over. Splitting
1773
+ every member of a multi-material group with the group's combined bounds
1774
+ as the frame clips all materials by the SAME cells (and one shared scan
1775
+ frame), so cell-mates assemble exactly.
1776
  """
1777
  columns = max(1, int(columns))
1778
  rows = max(1, int(rows))
1779
  (x_min, y_min, z_min), (x_max, y_max, z_max) = stack.bounds
1780
+ if frame is not None:
1781
+ x_min, y_min, x_max, y_max = (float(value) for value in frame)
1782
 
1783
  overlap_x = overlap if (overlapping_layers and columns > 1) else 0.0
1784
  overlap_y = overlap if (overlapping_layers and rows > 1) else 0.0