CyGuy8 commited on
Commit
29c82ca
·
1 Parent(s): 06dc0dc

Add vector G-code pipeline

Browse files
README.md CHANGED
@@ -5,12 +5,12 @@ sdk_version: 6.10.0
5
  python_version: "3.12"
6
  app_file: app.py
7
  fullWidth: true
8
- short_description: Upload STLs, export TIFF stacks, and generate G-code.
9
  ---
10
 
11
  # STL to G-Code Gradio App
12
 
13
- This project provides a Gradio app that takes any number of uploaded STL files, shows a selected-shape 3D viewer, slices each model along the Z axis, saves slices as TIFF images, generates G-code from those TIFF stacks, previews the resulting tool path (a fast line plot or an animated 3D tube plot), and can visualize the shapes printing in parallel and export that animation as a GIF.
14
 
15
  ## Prerequisites
16
 
@@ -33,7 +33,7 @@ uv run gradio app.py
33
 
34
  When `app.py` changes, Gradio will automatically rerun the file and refresh the demo.
35
 
36
- Then open the local Gradio URL in your browser, upload STL files or load the bundled samples, and generate the TIFF stacks.
37
 
38
  ## What the app does
39
 
@@ -43,17 +43,14 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
43
  - Shows an interactive selected-shape 3D viewer for rotating each model
44
  - Shows model extents, face count, vertex count, and watertight status
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
- - Keeps optional shape, TIFF, reference-stack, and nozzle-spacing previews in closed accordions to reduce clutter
47
- - Lets you choose layer height and XY pixel size
48
- - Produces one `.tif` image per slice
49
- - Encodes material as black (`0`) and empty space as white (`255`) in each TIFF slice
50
- - Lets you step through the slice stack in the browser
51
- - Exports a ZIP containing the generated TIFF images
52
- - Automatically combines generated stacks into a reference TIFF stack when TIFF stacks are generated
53
- - Splits one generated TIFF stack into an editable row/column grid for multi-nozzle printing of one large shape
54
- - Converts generated TIFF ZIPs into G-code files with pressure, valve, nozzle, and port settings per shape from the Shape Settings table
55
- - Appends a shape-optimized outer contour after each enabled shape layer by tracing that layer's active row envelope
56
- - Offers G-code generation options for raster pattern, **Use G1 for all moves** (no rapid travel command), and **Use Reference Stack for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
57
  - Calculates X/Y nozzle spacing from an editable adjacent-pair spacing table, then visualizes the resulting nozzle layout
58
  - Previews selected generated G-code inline
59
  - Visualizes generated or uploaded G-code tool paths, with the source selectable from any active generated shape or an uploaded file
@@ -62,40 +59,42 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
62
 
63
  ## Behavior and Implementation Notes
64
 
65
- ### Reference TIFF Stack Alignment
66
 
67
- When you click **Generate TIFF Stacks**, the app automatically combines available TIFF stacks layer-by-layer into the Reference TIFF Stack. The **Generate Reference TIFF Stack** button can still rebuild it manually from the current shape stacks.
68
 
69
- - If source TIFFs have different dimensions, each layer is placed on a canvas using the largest width and height.
70
- - Layers are centered in X and Y before merging.
71
- - Pixel merge uses a black-wins rule: a pixel is black in the reference if any source has black at that pixel.
72
- - Alignment is centered image placement, not bottom-left anchoring.
73
- - If image-size differences are odd, centering may produce a one-pixel shift due to integer rounding.
 
74
 
75
  ### Multi-Nozzle Split
76
 
77
- The **Multi-Nozzle Split** accordion on the **STL to TIFF Slicer** tab can split one generated shape stack into a grid of print-ready stacks. Choose a source shape that already has TIFF slices, set the number of columns and rows, choose the starting nozzle and valve numbers, then click **Split Selected Shape into Grid Pieces**.
78
 
79
- - Each slice is padded with white pixels as needed, then split into equal-width columns and equal-height rows so every generated piece in the grid has a matching TIFF canvas.
80
  - The selected shape is replaced in Shape Settings by one generated record per grid cell, named by row and column.
81
- - Nozzle and valve numbers are assigned sequentially from the starting values, and the existing **TIFF Slices to GCode** tab can generate separate G-code for each piece.
 
82
 
83
  ### G-code XY Step Size
84
 
85
- - G-code generation uses the slicer's `Pixel Size/Fill Width` for XY step distance by passing `fil_width=pixel_size` into `generate_snake_path_gcode()`.
86
 
87
  ### G-code Output
88
 
89
  - Generated G-code starts in relative coordinate mode (`G91`).
90
  - `G0` is travel and `G1` is print/feed.
91
- - The app generates print/feed moves from material pixels and travel moves between material regions.
92
  - Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port; the nozzle number controls layout/spacing assignment.
93
  - Pressure increases by `0.1` psi per layer by default.
94
  - **Use G1 for all moves**: when enabled, every movement line is emitted as `G1` (no `G0` rapid travel); the WAGO valve still marks where material is dispensed. Applies to all shapes.
95
- - **Use Reference Stack for motion**: when enabled, every shape's snake-path *motion* is taken from the combined Reference TIFF Stack while each shape's *valve/dispensing* comes from its own slices — so parallel print heads share one synchronized nozzle path and each deposits only its own geometry. The reference stack is generated automatically with TIFF stacks; shapes are skipped with a message if it is missing.
96
- - **Raster Pattern**: `X-direction raster` keeps the existing X-direction back-and-forth raster on every layer. `Y-direction raster` rasters every layer in Y. `Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps. `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` uses a shrinking circular spiral from the layer bounds toward the center, then reverses outward on the next layer.
97
  - **Auto Align Split Parts**: in Nozzle Spacing, fills Grid Layout gaps for split-piece alignment. X-direction raster uses X `-3.2` mm and Y `-0.8` mm; Y-direction raster switches those values.
98
- - **Contour Tracing**: enabled per row in Shape Settings. The app uses the shape-optimized row-envelope tracer, travels from the layer raster end to the nearest contour point, prints the contour, then returns to the raster endpoint before the next layer.
99
 
100
  ### Print vs Travel Classification
101
 
@@ -115,7 +114,7 @@ The G-code visualization tab renders generated shape G-code or an uploaded `.txt
115
 
116
  ### Parallel Printing Visualization
117
 
118
- The fourth tab plots the generated shapes' G-code at once using the nozzle spacing configured on the TIFF-to-G-code tab, each in its own color. Shape Settings maps each STL to a nozzle number, so multiple shapes can share one nozzle offset while valves remain independent. Like the visualization tab it has a fast **Line Plot** and an animated **Tube Plot**; the animation advances all parts on a shared cumulative-path-length timeline, so a shorter part finishes first.
119
 
120
  It can also **export the animation as a GIF**, rendered server-side with Matplotlib (the `Agg` CPU backend — no WebGL, no headless browser, and no `ffmpeg`, so it works locally and on Hugging Face). The GIF is line-style with faint grey travel and white, black-outlined nozzle markers drawn on top; controls cover duration, frames per second, elevation/azimuth viewing angle, and travel opacity (0 hides travel).
121
 
 
5
  python_version: "3.12"
6
  app_file: app.py
7
  fullWidth: true
8
+ short_description: Upload STLs, slice to vector outlines, and generate G-code.
9
  ---
10
 
11
  # STL to G-Code Gradio App
12
 
13
+ This project provides a Gradio app that takes any number of uploaded STL files, shows a selected-shape 3D viewer, slices each model along the Z axis into per-layer vector outlines (shapely polygons), generates parallel-nozzle G-code directly from those outlines, previews the resulting tool path (a fast line plot or an animated 3D tube plot), and can visualize the shapes printing in parallel and export that animation as a GIF.
14
 
15
  ## Prerequisites
16
 
 
33
 
34
  When `app.py` changes, Gradio will automatically rerun the file and refresh the demo.
35
 
36
+ Then open the local Gradio URL in your browser, upload STL files or load the bundled samples, and slice the shapes.
37
 
38
  ## What the app does
39
 
 
43
  - Shows an interactive selected-shape 3D viewer for rotating each model
44
  - Shows model extents, face count, vertex count, and watertight status
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, and port settings per shape from the Shape Settings table
51
+ - Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
52
+ - Offers G-code generation options for raster pattern, **Use G1 for all moves** (no rapid travel command), and **Use combined reference outline for motion** (all shapes share one nozzle path; each dispenses only its own geometry)
53
+ - Re-slices shapes automatically during G-code generation when their slices are missing or stale, so "upload, then Generate G-Code" works in one click
 
 
 
54
  - Calculates X/Y nozzle spacing from an editable adjacent-pair spacing table, then visualizes the resulting nozzle layout
55
  - Previews selected generated G-code inline
56
  - Visualizes generated or uploaded G-code tool paths, with the source selectable from any active generated shape or an uploaded file
 
59
 
60
  ## Behavior and Implementation Notes
61
 
62
+ ### Vector Slicing
63
 
64
+ Slicing uses `trimesh` cross-sections composed into shapely polygons (`slice_stl_to_layers` in `stl_slicer.py`). Each layer is a `MultiPolygon` in world-XY millimetres; the whole shape is a `LayerStack` (layers, z-values, bounds, layer height) held in the Gradio session state no files are written until G-code is generated.
65
 
66
+ ### Reference Layer Union
67
+
68
+ When you click **Slice Shapes**, the app automatically unions the sliced shapes layer-by-layer into a combined reference layer set (used for shared-motion G-code).
69
+
70
+ - Shapes are aligned by centering each shape's XY bounding box on a common center before the union.
71
+ - Alignment is centered placement (in exact millimetres), not bottom-left anchoring.
72
 
73
  ### Multi-Nozzle Split
74
 
75
+ 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**.
76
 
77
+ - Each layer's geometry is clipped against equal-size grid cells, so every piece keeps exact vector outlines.
78
  - The selected shape is replaced in Shape Settings by one generated record per grid cell, named by row and column.
79
+ - 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.
80
+ - **Overlapping Layers** alternates the interior cut lines by one filament width per layer so neighbouring pieces interlock.
81
 
82
  ### G-code XY Step Size
83
 
84
+ - G-code generation uses the slicer tab's `Filament/Line Width` as the raster line spacing by passing `fil_width` into `generate_vector_gcode()`.
85
 
86
  ### G-code Output
87
 
88
  - Generated G-code starts in relative coordinate mode (`G91`).
89
  - `G0` is travel and `G1` is print/feed.
90
+ - The app generates print/feed moves where the tool path is inside a shape's layer polygons and travel moves elsewhere.
91
  - Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port; the nozzle number controls layout/spacing assignment.
92
  - Pressure increases by `0.1` psi per layer by default.
93
  - **Use G1 for all moves**: when enabled, every movement line is emitted as `G1` (no `G0` rapid travel); the WAGO valve still marks where material is dispensed. Applies to all shapes.
94
+ - **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.
95
+ - **Raster Pattern**: `X-direction raster` sweeps every layer back-and-forth in X. `Y-direction raster` rasters every layer in Y. `Woodpile raster` alternates the raster axis by layer, switching between X-direction and Y-direction sweeps. `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` uses a shrinking circular spiral from the layer bounds toward the center, then reverses outward on the next layer. Spiral motion covers the layer bounds; the valve opens only where the path is inside material.
96
  - **Auto Align Split Parts**: in Nozzle Spacing, fills Grid Layout gaps for split-piece alignment. X-direction raster uses X `-3.2` mm and Y `-0.8` mm; Y-direction raster switches those values.
97
+ - **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.
98
 
99
  ### Print vs Travel Classification
100
 
 
114
 
115
  ### Parallel Printing Visualization
116
 
117
+ The fourth tab plots the generated shapes' G-code at once using the nozzle spacing configured on the Generate G-Code tab, each in its own color. Shape Settings maps each STL to a nozzle number, so multiple shapes can share one nozzle offset while valves remain independent. Like the visualization tab it has a fast **Line Plot** and an animated **Tube Plot**; the animation advances all parts on a shared cumulative-path-length timeline, so a shorter part finishes first.
118
 
119
  It can also **export the animation as a GIF**, rendered server-side with Matplotlib (the `Agg` CPU backend — no WebGL, no headless browser, and no `ffmpeg`, so it works locally and on Hugging Face). The GIF is line-style with faint grey travel and white, black-outlined nozzle markers drawn on top; controls cover duration, frames per second, elevation/azimuth viewing angle, and travel opacity (0 hides travel).
120
 
app.py CHANGED
@@ -1,10 +1,9 @@
1
  from __future__ import annotations
2
 
3
- import tempfile
4
  import math
 
5
  import time
6
  import warnings
7
- import zipfile
8
  from pathlib import Path
9
  from typing import Any
10
 
@@ -20,7 +19,6 @@ warnings.filterwarnings(
20
 
21
  import gradio as gr
22
  import numpy as np
23
- from PIL import Image, ImageDraw, ImageFont
24
  import trimesh
25
 
26
  from gcode_viewer import (
@@ -31,22 +29,23 @@ from gcode_viewer import (
31
  parse_gcode_path,
32
  )
33
  from stl_slicer import (
34
- SliceStack,
35
  load_mesh,
36
  scale_factors_for_target_extents,
37
  scale_mesh,
38
- slice_stl_to_tiffs,
39
  )
40
- from tiff_to_gcode import (
41
- CONTOUR_MODE_ROW_ENVELOPE,
42
  RASTER_PATTERN_CHOICES,
43
  RASTER_PATTERN_SAME_DIRECTION,
44
  RASTER_PATTERN_Y_DIRECTION,
45
- generate_snake_path_gcode,
 
 
46
  )
47
 
48
 
49
- ViewerState = dict[str, Any]
50
  SAMPLE_STL_FILENAMES = ("Hollow_Pyramid.stl", "Rounded_Cube_Through_Holes.stl", "halfsphere.stl")
51
  SAMPLE_STL_DIR = Path(__file__).resolve().parent / "sample_stls"
52
  DEFAULT_TARGET_EXTENTS = (20.0, 20.0, 20.0)
@@ -783,54 +782,6 @@ PARALLEL_CONTROLS_HTML = """
783
  """
784
 
785
 
786
- def _read_slice_preview(path: str) -> Image.Image:
787
- with Image.open(path) as image:
788
- preview = image.copy()
789
-
790
- # Upscale low-resolution TIFF previews so they fill the viewer area better.
791
- min_display_side = 480
792
- width, height = preview.size
793
- max_dim = max(width, height)
794
- if max_dim > 0 and max_dim < min_display_side:
795
- scale = min_display_side / max_dim
796
- new_size = (
797
- max(1, int(round(width * scale))),
798
- max(1, int(round(height * scale))),
799
- )
800
- preview = preview.resize(new_size, resample=Image.Resampling.NEAREST)
801
-
802
- return preview
803
-
804
-
805
- def _empty_state() -> ViewerState:
806
- return {
807
- "tiff_paths": [],
808
- "z_values": [],
809
- "pixel_size": 0.0,
810
- "x_min": 0.0,
811
- "y_min": 0.0,
812
- "image_width": 0,
813
- "image_height": 0,
814
- }
815
-
816
-
817
- def _reset_slider() -> dict[str, Any]:
818
- return gr.update(minimum=0, maximum=0, value=0, step=1, interactive=False)
819
-
820
-
821
- def _stack_to_state(stack: SliceStack) -> ViewerState:
822
- (x_min, y_min, _z_min), (_x_max, _y_max, _z_max) = stack.bounds
823
- return {
824
- "tiff_paths": [str(path) for path in stack.tiff_paths],
825
- "z_values": stack.z_values,
826
- "pixel_size": stack.pixel_size,
827
- "x_min": x_min,
828
- "y_min": y_min,
829
- "image_width": stack.image_size[0],
830
- "image_height": stack.image_size[1],
831
- }
832
-
833
-
834
  def _format_model_details(
835
  source_name: str,
836
  mesh,
@@ -865,190 +816,6 @@ def _format_model_details(
865
  return "\n".join(lines)
866
 
867
 
868
- def _slice_label(state: ViewerState, index: int) -> str:
869
- path = Path(state["tiff_paths"][index]).name
870
- z_value = state["z_values"][index]
871
- total = len(state["tiff_paths"])
872
- return f"Slice {index + 1} / {total} | z = {z_value:.4f} | {path}"
873
-
874
-
875
- def _annotate_preview(
876
- image: Image.Image,
877
- pixel_size: float,
878
- x_min: float,
879
- y_min: float,
880
- orig_width: int,
881
- orig_height: int,
882
- ) -> Image.Image:
883
- """Draw a blue origin crosshair with axis labels and a scale bar."""
884
- rgb = image.convert("RGB")
885
- draw = ImageDraw.Draw(rgb)
886
-
887
- preview_w, preview_h = rgb.size
888
- scale_x = preview_w / orig_width if orig_width else 1.0
889
- scale_y = preview_h / orig_height if orig_height else 1.0
890
-
891
- BLUE = (50, 120, 255)
892
-
893
- try:
894
- font = ImageFont.load_default(size=14)
895
- except TypeError:
896
- font = ImageFont.load_default()
897
- try:
898
- small_font = ImageFont.load_default(size=12)
899
- except TypeError:
900
- small_font = font
901
-
902
- # --- Origin crosshair & axis indicators ---
903
- origin_px = (0.0 - x_min) / pixel_size
904
- origin_py_from_bottom = (0.0 - y_min) / pixel_size
905
- origin_img_y = orig_height - 1 - origin_py_from_bottom
906
-
907
- ox = int(round(origin_px * scale_x))
908
- oy = int(round(origin_img_y * scale_y))
909
-
910
- arm = 20
911
- margin_edge = 8 # inset from image border for off-screen indicators
912
- on_screen = 0 <= ox < preview_w and 0 <= oy < preview_h
913
-
914
- if on_screen:
915
- # +X axis (rightward)
916
- x_start = max(0, ox)
917
- x_end = min(preview_w - 1, ox + arm)
918
- if x_end > x_start:
919
- draw.line([(x_start, oy), (x_end, oy)], fill=BLUE, width=2)
920
- draw.polygon(
921
- [(x_end, oy), (x_end - 5, oy - 4), (x_end - 5, oy + 4)],
922
- fill=BLUE,
923
- )
924
- if x_end + 4 < preview_w:
925
- draw.text((x_end + 4, oy - 7), "X", fill=BLUE, font=small_font)
926
-
927
- # +Y axis (upward in world = upward in image)
928
- y_end = max(0, oy - arm)
929
- y_start = min(preview_h - 1, oy)
930
- if y_start > y_end:
931
- draw.line([(ox, y_start), (ox, y_end)], fill=BLUE, width=2)
932
- draw.polygon(
933
- [(ox, y_end), (ox - 4, y_end + 5), (ox + 4, y_end + 5)],
934
- fill=BLUE,
935
- )
936
- if y_end - 16 >= 0:
937
- draw.text((ox + 5, y_end - 16), "Y", fill=BLUE, font=small_font)
938
-
939
- # -X stub (leftward from origin)
940
- stub = min(8, max(0, ox))
941
- if stub > 0:
942
- draw.line([(ox - stub, oy), (ox, oy)], fill=BLUE, width=2)
943
-
944
- # -Y stub (downward from origin in image)
945
- stub_y = min(8, max(0, preview_h - 1 - oy))
946
- if stub_y > 0:
947
- draw.line([(ox, oy), (ox, oy + stub_y)], fill=BLUE, width=2)
948
-
949
- # Origin label
950
- lx = ox + arm + 4 if ox + arm + 40 < preview_w else ox - 45
951
- ly = oy + 6
952
- if 0 <= ly < preview_h:
953
- draw.text((max(0, lx), ly), "(0, 0)", fill=BLUE, font=small_font)
954
-
955
- else:
956
- # Origin is off-screen — draw edge indicator(s) pointing toward it.
957
- arrow_len = 14
958
- arrow_half = 5
959
-
960
- # Compute direction label text showing approximate origin coordinates
961
- origin_x_mm = x_min
962
- origin_y_mm = y_min
963
- coord_text = f"Origin ({-origin_x_mm:+.1f}, {-origin_y_mm:+.1f})"
964
-
965
- if ox < 0:
966
- # Origin is to the LEFT — draw left-pointing arrow on left edge
967
- ay = max(margin_edge + arrow_half, min(preview_h - margin_edge - arrow_half, oy))
968
- draw.polygon(
969
- [(margin_edge, ay), (margin_edge + arrow_len, ay - arrow_half), (margin_edge + arrow_len, ay + arrow_half)],
970
- fill=BLUE,
971
- )
972
- draw.text((margin_edge + arrow_len + 4, ay - 7), coord_text, fill=BLUE, font=small_font)
973
- elif ox >= preview_w:
974
- # Origin is to the RIGHT
975
- ay = max(margin_edge + arrow_half, min(preview_h - margin_edge - arrow_half, oy))
976
- rx = preview_w - margin_edge
977
- draw.polygon(
978
- [(rx, ay), (rx - arrow_len, ay - arrow_half), (rx - arrow_len, ay + arrow_half)],
979
- fill=BLUE,
980
- )
981
- tw = len(coord_text) * 7
982
- draw.text((max(0, rx - arrow_len - tw - 4), ay - 7), coord_text, fill=BLUE, font=small_font)
983
-
984
- if oy < 0:
985
- # Origin is ABOVE — draw upward-pointing arrow on top edge
986
- ax = max(margin_edge + arrow_half, min(preview_w - margin_edge - arrow_half, ox))
987
- draw.polygon(
988
- [(ax, margin_edge), (ax - arrow_half, margin_edge + arrow_len), (ax + arrow_half, margin_edge + arrow_len)],
989
- fill=BLUE,
990
- )
991
- elif oy >= preview_h:
992
- # Origin is BELOW — draw downward-pointing arrow on bottom edge
993
- ax = max(margin_edge + arrow_half, min(preview_w - margin_edge - arrow_half, ox))
994
- by = preview_h - margin_edge
995
- draw.polygon(
996
- [(ax, by), (ax - arrow_half, by - arrow_len), (ax + arrow_half, by - arrow_len)],
997
- fill=BLUE,
998
- )
999
- # If we didn't already draw a left/right label, label here
1000
- if 0 <= ox < preview_w:
1001
- draw.text((ax + arrow_half + 4, by - arrow_len - 2), coord_text, fill=BLUE, font=small_font)
1002
-
1003
- # --- Scale bar (bottom-left) ---
1004
- image_width_mm = orig_width * pixel_size
1005
- target_bar_mm = image_width_mm * 0.2
1006
- nice = [0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500]
1007
- bar_mm = min(nice, key=lambda v: abs(v - target_bar_mm))
1008
-
1009
- bar_px = (bar_mm / pixel_size) * scale_x
1010
- margin = 12
1011
- bar_y = preview_h - margin
1012
- bar_x0 = margin
1013
- bar_x1 = bar_x0 + bar_px
1014
- cap = 5
1015
-
1016
- draw.line([(int(bar_x0), int(bar_y)), (int(bar_x1), int(bar_y))], fill=BLUE, width=3)
1017
- draw.line([(int(bar_x0), int(bar_y - cap)), (int(bar_x0), int(bar_y + cap))], fill=BLUE, width=2)
1018
- draw.line([(int(bar_x1), int(bar_y - cap)), (int(bar_x1), int(bar_y + cap))], fill=BLUE, width=2)
1019
-
1020
- bar_label = f"{bar_mm:g} mm"
1021
- draw.text((int(bar_x0), int(bar_y - 20)), bar_label, fill=BLUE, font=font)
1022
-
1023
- return rgb
1024
-
1025
-
1026
- def _render_selected_slice(state: ViewerState, index: int) -> tuple[str, Image.Image | None]:
1027
- tiff_paths = state.get("tiff_paths", [])
1028
- if not tiff_paths:
1029
- return "No slice stack loaded yet.", None
1030
-
1031
- bounded_index = max(0, min(int(index), len(tiff_paths) - 1))
1032
- selected_path = tiff_paths[bounded_index]
1033
- preview = _read_slice_preview(selected_path)
1034
-
1035
- pixel_size = state.get("pixel_size", 0.0)
1036
- if pixel_size and pixel_size > 0:
1037
- preview = _annotate_preview(
1038
- preview,
1039
- pixel_size=pixel_size,
1040
- x_min=state.get("x_min", 0.0),
1041
- y_min=state.get("y_min", 0.0),
1042
- orig_width=state.get("image_width", 0) or preview.size[0],
1043
- orig_height=state.get("image_height", 0) or preview.size[1],
1044
- )
1045
-
1046
- return (
1047
- _slice_label(state, bounded_index),
1048
- preview,
1049
- )
1050
-
1051
-
1052
  def _opacity_to_alpha(opacity: float) -> int:
1053
  bounded = max(0.05, min(float(opacity), 1.0))
1054
  return int(round(255 * bounded))
@@ -1383,10 +1150,6 @@ def load_single_model(
1383
  return _viewer_update(glb_path), _format_model_details(Path(stl_file).name, mesh, scale_factors)
1384
 
1385
 
1386
- def jump_to_slice(state: ViewerState, index: float) -> tuple[str, Image.Image | None]:
1387
- return _render_selected_slice(state, int(index))
1388
-
1389
-
1390
  GCODE_SOURCE_UPLOAD = "Upload G-Code file"
1391
 
1392
 
@@ -1718,342 +1481,6 @@ def _format_nozzle_spacing_status(
1718
  return " \n".join(lines)
1719
 
1720
 
1721
- def shift_slice(state: ViewerState, index: float, delta: int) -> tuple[int, str, Image.Image | None]:
1722
- tiff_paths = state.get("tiff_paths", [])
1723
- if not tiff_paths:
1724
- return 0, "No slice stack loaded yet.", None
1725
-
1726
- new_index = max(0, min(int(index) + delta, len(tiff_paths) - 1))
1727
- label, preview = _render_selected_slice(state, new_index)
1728
- return new_index, label, preview
1729
-
1730
-
1731
- def generate_reference_stack(
1732
- *states: ViewerState,
1733
- progress: gr.Progress = gr.Progress(),
1734
- ) -> tuple:
1735
- """Combine all available TIFF stacks into a single reference stack.
1736
-
1737
- For each pixel in each layer the result is black (0) when *any* source
1738
- stack has a black pixel at that position, and white (255) only when *all*
1739
- sources are white. Images of different sizes are centred on a canvas
1740
- sized to the largest dimensions.
1741
- """
1742
- active_states = [s for s in states if s.get("tiff_paths")]
1743
-
1744
- if not active_states:
1745
- return (
1746
- _empty_state(),
1747
- _reset_slider(),
1748
- "No TIFF stacks available. Generate TIFF stacks first.",
1749
- None,
1750
- )
1751
-
1752
- max_layers = max(len(s["tiff_paths"]) for s in active_states)
1753
-
1754
- # Determine the largest image dimensions across all stacks.
1755
- max_width = 0
1756
- max_height = 0
1757
- source_sizes: list[tuple[int, int]] = []
1758
- for state in active_states:
1759
- w = state.get("image_width", 0)
1760
- h = state.get("image_height", 0)
1761
- if not w or not h:
1762
- with Image.open(state["tiff_paths"][0]) as img:
1763
- w, h = img.size
1764
- source_sizes.append((w, h))
1765
- max_width = max(max_width, w)
1766
- max_height = max(max_height, h)
1767
-
1768
- # Compute annotation metadata from the first active state, accounting for
1769
- # the centering offset applied to its image on the larger canvas.
1770
- first = active_states[0]
1771
- first_w, first_h = source_sizes[0]
1772
- ref_pixel_size = first.get("pixel_size", 0.0)
1773
- x_off_first = (max_width - first_w) // 2
1774
- y_off_first = (max_height - first_h) // 2
1775
- ref_x_min = first.get("x_min", 0.0) - x_off_first * ref_pixel_size
1776
- ref_y_min = first.get("y_min", 0.0) - y_off_first * ref_pixel_size
1777
-
1778
- output_dir = Path(tempfile.mkdtemp(prefix="reference_stack_"))
1779
- slices_dir = output_dir / "tiff_slices"
1780
- slices_dir.mkdir(parents=True, exist_ok=True)
1781
-
1782
- tiff_paths: list[Path] = []
1783
- z_values: list[float] = []
1784
-
1785
- for layer_idx in range(max_layers):
1786
- progress(
1787
- layer_idx / max_layers,
1788
- desc=f"Compositing reference layer {layer_idx + 1}/{max_layers}",
1789
- )
1790
-
1791
- # Start with an all-white canvas.
1792
- ref_array = np.full((max_height, max_width), 255, dtype=np.uint8)
1793
-
1794
- for state in active_states:
1795
- paths = state["tiff_paths"]
1796
- if layer_idx >= len(paths):
1797
- continue # Stack exhausted – contributes white.
1798
-
1799
- with Image.open(paths[layer_idx]) as img:
1800
- arr = np.asarray(img)
1801
-
1802
- h, w = arr.shape[:2]
1803
- y_off = (max_height - h) // 2
1804
- x_off = (max_width - w) // 2
1805
-
1806
- # Black (0) wins: pixel-wise minimum keeps any black pixel.
1807
- region = ref_array[y_off : y_off + h, x_off : x_off + w]
1808
- ref_array[y_off : y_off + h, x_off : x_off + w] = np.minimum(region, arr)
1809
-
1810
- ref_image = Image.fromarray(ref_array, mode="L")
1811
- tiff_path = slices_dir / f"ref_slice_{layer_idx:04d}.tif"
1812
- ref_image.save(tiff_path, compression="tiff_deflate")
1813
- tiff_paths.append(tiff_path)
1814
-
1815
- # Use z-value from the first active state that covers this layer.
1816
- z_val = 0.0
1817
- for state in active_states:
1818
- if layer_idx < len(state["z_values"]):
1819
- z_val = state["z_values"][layer_idx]
1820
- break
1821
- z_values.append(z_val)
1822
-
1823
- ref_state: ViewerState = {
1824
- "tiff_paths": [str(p) for p in tiff_paths],
1825
- "z_values": z_values,
1826
- "pixel_size": ref_pixel_size,
1827
- "x_min": ref_x_min,
1828
- "y_min": ref_y_min,
1829
- "image_width": max_width,
1830
- "image_height": max_height,
1831
- }
1832
-
1833
- label, preview = _render_selected_slice(ref_state, 0)
1834
- slider = gr.update(
1835
- minimum=0,
1836
- maximum=max(0, len(tiff_paths) - 1),
1837
- value=0,
1838
- step=1,
1839
- interactive=len(tiff_paths) > 1,
1840
- )
1841
-
1842
- return ref_state, slider, label, preview
1843
-
1844
-
1845
- def _zip_tiff_paths(tiff_paths: list[Path], zip_path: Path) -> None:
1846
- with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
1847
- for tiff_path in tiff_paths:
1848
- archive.write(tiff_path, arcname=tiff_path.name)
1849
-
1850
-
1851
- def _partition_length(length: int, count: int) -> list[tuple[int, int]]:
1852
- base = length // count
1853
- remainder = length % count
1854
- spans: list[tuple[int, int]] = []
1855
- start = 0
1856
- for index in range(count):
1857
- size = base + (1 if index < remainder else 0)
1858
- end = start + size
1859
- spans.append((start, end))
1860
- start = end
1861
- return spans
1862
-
1863
-
1864
- def _padded_grid_axis(length: int, count: int) -> dict[str, Any]:
1865
- cell_size = max(1, math.ceil(length / count))
1866
- padded_length = cell_size * count
1867
- total_pad = padded_length - length
1868
- leading_pad = total_pad // 2
1869
- trailing_pad = total_pad - leading_pad
1870
- spans = [
1871
- (index * cell_size, (index + 1) * cell_size)
1872
- for index in range(count)
1873
- ]
1874
- return {
1875
- "cell_size": cell_size,
1876
- "padded_length": padded_length,
1877
- "leading_pad": leading_pad,
1878
- "trailing_pad": trailing_pad,
1879
- "spans": spans,
1880
- }
1881
-
1882
-
1883
- def _layer_split_spans(length: int, count: int, layer_index: int, overlap_pixels: int) -> list[tuple[int, int]]:
1884
- base_spans = _partition_length(length, count)
1885
- if overlap_pixels <= 0 or count <= 1:
1886
- return base_spans
1887
-
1888
- boundaries = [base_spans[0][0], *[end for _start, end in base_spans]]
1889
- adjusted = list(boundaries)
1890
- for boundary_index in range(1, len(boundaries) - 1):
1891
- direction = 1 if (layer_index + boundary_index) % 2 == 1 else -1
1892
- lower = adjusted[boundary_index - 1] + 1
1893
- upper = boundaries[boundary_index + 1] - 1
1894
- adjusted[boundary_index] = max(lower, min(upper, boundaries[boundary_index] + direction * overlap_pixels))
1895
- return [(adjusted[index], adjusted[index + 1]) for index in range(count)]
1896
-
1897
-
1898
- def split_tiff_stack_grid(
1899
- state: ViewerState,
1900
- base_name: str = "split_shape",
1901
- columns: float = 2,
1902
- rows: float = 1,
1903
- overlapping_layers: bool | None = False,
1904
- progress: gr.Progress = gr.Progress(),
1905
- ) -> list[dict[str, Any]]:
1906
- tiff_paths = [Path(path) for path in state.get("tiff_paths", [])]
1907
- if not tiff_paths:
1908
- raise ValueError("Generate a TIFF stack for the selected shape before splitting it.")
1909
-
1910
- with Image.open(tiff_paths[0]) as first_image:
1911
- width, height = first_image.size
1912
- column_count = max(1, _coerce_int(columns, 2))
1913
- row_count = max(1, _coerce_int(rows, 1))
1914
- if column_count > width:
1915
- raise ValueError(f"Cannot split {width}-pixel-wide TIFF slices into {column_count} columns.")
1916
- if row_count > height:
1917
- raise ValueError(f"Cannot split {height}-pixel-tall TIFF slices into {row_count} rows.")
1918
-
1919
- safe_name = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in base_name).strip("_") or "split_shape"
1920
- output_dir = Path(tempfile.mkdtemp(prefix=f"{safe_name}_split_"))
1921
- x_axis = _padded_grid_axis(width, column_count)
1922
- y_axis = _padded_grid_axis(height, row_count)
1923
- x_spans = x_axis["spans"]
1924
- y_spans = y_axis["spans"]
1925
- overlap_pixels = 1 if overlapping_layers else 0
1926
- overlap_x = overlap_pixels if column_count > 1 else 0
1927
- overlap_y = overlap_pixels if row_count > 1 else 0
1928
- padded_width = int(x_axis["padded_length"])
1929
- padded_height = int(y_axis["padded_length"])
1930
- working_width = padded_width + (2 * overlap_x)
1931
- working_height = padded_height + (2 * overlap_y)
1932
- source_x = int(x_axis["leading_pad"]) + overlap_x
1933
- source_y = int(y_axis["leading_pad"]) + overlap_y
1934
- working_x_min = base_x_min = float(state.get("x_min", 0.0) or 0.0)
1935
- working_y_min = base_y_min = float(state.get("y_min", 0.0) or 0.0)
1936
- pixel_size = float(state.get("pixel_size", 0.0) or 0.0)
1937
- working_x_min = base_x_min - (source_x * pixel_size)
1938
- working_y_min = base_y_min - ((int(y_axis["trailing_pad"]) + overlap_y) * pixel_size)
1939
- pieces: list[dict[str, Any]] = []
1940
- for row_index, (y_start, y_end) in enumerate(y_spans, start=1):
1941
- for col_index, (x_start, x_end) in enumerate(x_spans, start=1):
1942
- piece_dir = output_dir / f"r{row_index:02d}_c{col_index:02d}_tiff_slices"
1943
- piece_dir.mkdir(parents=True, exist_ok=True)
1944
- pieces.append({
1945
- "row": row_index,
1946
- "col": col_index,
1947
- "x_start": x_start,
1948
- "x_end": x_end,
1949
- "y_start": y_start,
1950
- "y_end": y_end,
1951
- "tiff_dir": piece_dir,
1952
- "tiff_paths": [],
1953
- })
1954
-
1955
- for index, source_path in enumerate(tiff_paths):
1956
- progress(index / max(len(tiff_paths), 1), desc=f"Splitting layer {index + 1}/{len(tiff_paths)}")
1957
- with Image.open(source_path) as image:
1958
- layer = image.convert("L")
1959
- if layer.size != (width, height):
1960
- raise ValueError("All TIFF slices must have the same dimensions to split the stack.")
1961
-
1962
- padded_layer = Image.new("L", (working_width, working_height), 255)
1963
- padded_layer.paste(layer, (source_x, source_y))
1964
- layer_x_spans = [
1965
- (x_start + overlap_x, x_end + overlap_x)
1966
- for x_start, x_end in _layer_split_spans(
1967
- padded_width,
1968
- column_count,
1969
- index,
1970
- overlap_x,
1971
- )
1972
- ]
1973
- layer_y_spans = [
1974
- (y_start + overlap_y, y_end + overlap_y)
1975
- for y_start, y_end in _layer_split_spans(
1976
- padded_height,
1977
- row_count,
1978
- index,
1979
- overlap_y,
1980
- )
1981
- ]
1982
- for piece in pieces:
1983
- canvas_x_start = piece["x_start"]
1984
- canvas_x_end = piece["x_end"]
1985
- canvas_y_start = piece["y_start"]
1986
- canvas_y_end = piece["y_end"]
1987
- if overlapping_layers:
1988
- canvas_x_start -= overlap_x
1989
- canvas_x_end += overlap_x
1990
- canvas_y_start -= overlap_y
1991
- canvas_y_end += overlap_y
1992
- canvas_x_start += overlap_x
1993
- canvas_x_end += overlap_x
1994
- canvas_y_start += overlap_y
1995
- canvas_y_end += overlap_y
1996
- x_start, x_end = layer_x_spans[piece["col"] - 1]
1997
- y_start, y_end = layer_y_spans[piece["row"] - 1]
1998
- if overlapping_layers:
1999
- piece_image = Image.new("L", (canvas_x_end - canvas_x_start, canvas_y_end - canvas_y_start), 255)
2000
- piece_image.paste(
2001
- padded_layer.crop((x_start, y_start, x_end, y_end)),
2002
- (x_start - canvas_x_start, y_start - canvas_y_start),
2003
- )
2004
- else:
2005
- piece_image = padded_layer.crop((x_start, y_start, x_end, y_end))
2006
- piece_path = piece["tiff_dir"] / f"slice_{index:04d}.tif"
2007
- piece_image.save(piece_path, compression="tiff_deflate")
2008
- piece["tiff_paths"].append(piece_path)
2009
-
2010
- z_values = list(state.get("z_values", []))
2011
- if len(z_values) < len(tiff_paths):
2012
- z_values.extend([0.0] * (len(tiff_paths) - len(z_values)))
2013
-
2014
- for piece in pieces:
2015
- canvas_x_start = piece["x_start"]
2016
- canvas_x_end = piece["x_end"]
2017
- canvas_y_start = piece["y_start"]
2018
- canvas_y_end = piece["y_end"]
2019
- if overlapping_layers:
2020
- canvas_x_start -= overlap_x
2021
- canvas_x_end += overlap_x
2022
- canvas_y_start -= overlap_y
2023
- canvas_y_end += overlap_y
2024
- canvas_x_start += overlap_x
2025
- canvas_x_end += overlap_x
2026
- canvas_y_start += overlap_y
2027
- canvas_y_end += overlap_y
2028
- piece_width = canvas_x_end - canvas_x_start
2029
- piece_height = canvas_y_end - canvas_y_start
2030
- zip_path = output_dir / f"{safe_name}_r{piece['row']:02d}_c{piece['col']:02d}_tiff_slices.zip"
2031
- _zip_tiff_paths(piece["tiff_paths"], zip_path)
2032
- piece_state: ViewerState = {
2033
- "tiff_paths": [str(path) for path in piece["tiff_paths"]],
2034
- "z_values": z_values[: len(piece["tiff_paths"])],
2035
- "pixel_size": pixel_size,
2036
- "x_min": working_x_min + (canvas_x_start * pixel_size),
2037
- "y_min": working_y_min + ((working_height - canvas_y_end) * pixel_size),
2038
- "image_width": piece_width,
2039
- "image_height": piece_height,
2040
- "zip_path": str(zip_path),
2041
- "overlapping_layers": bool(overlapping_layers),
2042
- }
2043
- piece["state"] = piece_state
2044
- piece["zip_path"] = zip_path
2045
- return pieces
2046
-
2047
-
2048
- def split_tiff_stack_left_right(
2049
- state: ViewerState,
2050
- base_name: str = "split_shape",
2051
- progress: gr.Progress = gr.Progress(),
2052
- ) -> tuple[ViewerState, ViewerState, Path, Path]:
2053
- pieces = split_tiff_stack_grid(state, base_name=base_name, columns=2, rows=1, progress=progress)
2054
- return pieces[0]["state"], pieces[1]["state"], pieces[0]["zip_path"], pieces[1]["zip_path"]
2055
-
2056
-
2057
  SHAPE_SETTINGS_HEADERS = [
2058
  "Shape",
2059
  "STL",
@@ -2234,8 +1661,8 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
2234
  "port": previous.get("port", 1),
2235
  "color": previous.get("color", _default_color(index)),
2236
  "contour_tracing": previous.get("contour_tracing", False),
2237
- "tiff_state": previous.get("tiff_state", _empty_state()),
2238
- "zip_path": previous.get("zip_path"),
2239
  "gcode_path": previous.get("gcode_path"),
2240
  })
2241
  return records
@@ -2697,7 +2124,6 @@ def sync_uploaded_shapes(
2697
  _dropdown_update(next_records),
2698
  _gcode_dropdown_update(next_records),
2699
  _gcode_dropdown_update(next_records, include_upload=True),
2700
- [record.get("zip_path") for record in next_records if record.get("zip_path")],
2701
  [record.get("gcode_path") for record in next_records if record.get("gcode_path")],
2702
  )
2703
 
@@ -2741,7 +2167,6 @@ def _shape_delete_outputs(
2741
  _dropdown_update(records),
2742
  _gcode_dropdown_update(records),
2743
  _gcode_dropdown_update(records, include_upload=True),
2744
- [record.get("zip_path") for record in records if record.get("zip_path")],
2745
  [record.get("gcode_path") for record in records if record.get("gcode_path")],
2746
  float(last_delete_at or 0.0),
2747
  )
@@ -2889,9 +2314,9 @@ def show_selected_model(
2889
  records = _apply_shape_settings(records or [], settings_table)
2890
  pos = _selected_record_index(records, selected)
2891
  if pos < 0:
2892
- return _viewer_update(None), "No model loaded.", _reset_slider(), "No slice stack loaded yet.", None
2893
  record = records[pos]
2894
- viewer, details = load_single_model(
2895
  record.get("stl_path"),
2896
  opacity,
2897
  True,
@@ -2900,185 +2325,89 @@ def show_selected_model(
2900
  record.get("target_y"),
2901
  record.get("target_z"),
2902
  )
2903
- state = record.get("tiff_state") or _empty_state()
2904
- label, preview = _render_selected_slice(state, 0)
2905
- slider = gr.update(
2906
- minimum=0,
2907
- maximum=max(0, len(state.get("tiff_paths", [])) - 1),
2908
- value=0,
2909
- step=1,
2910
- interactive=len(state.get("tiff_paths", [])) > 1,
2911
- )
2912
- return viewer, details, slider, label, preview
2913
 
2914
 
2915
- def jump_to_selected_slice(records: list[dict] | None, selected: str | None, index: float) -> tuple[str, Image.Image | None]:
2916
- pos = _selected_record_index(records or [], selected)
2917
- if pos < 0:
2918
- return "No slice stack loaded yet.", None
2919
- return _render_selected_slice((records or [])[pos].get("tiff_state") or _empty_state(), int(index))
 
 
 
2920
 
2921
 
2922
- def shift_selected_slice(records: list[dict] | None, selected: str | None, index: float, delta: int) -> tuple:
2923
- pos = _selected_record_index(records or [], selected)
2924
- if pos < 0:
2925
- return gr.update(value=0), "No slice stack loaded yet.", None
2926
- state = (records or [])[pos].get("tiff_state") or _empty_state()
2927
- paths = state.get("tiff_paths", [])
2928
- if not paths:
2929
- return gr.update(value=0), "No slice stack loaded yet.", None
2930
- next_index = max(0, min(int(index) + delta, len(paths) - 1))
2931
- label, preview = _render_selected_slice(state, next_index)
2932
- return gr.update(value=next_index), label, preview
2933
-
2934
-
2935
- def _tiff_preview_update(records: list[dict], selected: str | None = None) -> tuple[dict[str, Any], dict[str, Any], str, Image.Image | None]:
2936
- dropdown = _dropdown_update(records, selected)
2937
- selected_value = dropdown.get("value") if isinstance(dropdown, dict) else selected
2938
- pos = _selected_record_index(records, selected_value)
2939
- state = (records[pos].get("tiff_state") if pos >= 0 else None) or _empty_state()
2940
- label, preview = _render_selected_slice(state, 0)
2941
- slider = gr.update(
2942
- minimum=0,
2943
- maximum=max(0, len(state.get("tiff_paths", [])) - 1),
2944
- value=0,
2945
- step=1,
2946
- interactive=len(state.get("tiff_paths", [])) > 1,
2947
  )
2948
- return dropdown, slider, label, preview
 
 
2949
 
2950
 
2951
- def generate_dynamic_stacks(
2952
  records: list[dict] | None,
2953
  settings_table: Any,
2954
  layer_height: float,
2955
- pixel_size: float,
2956
  scale_mode: str | None,
2957
  progress: gr.Progress = gr.Progress(),
2958
  ) -> tuple:
2959
  records = _apply_shape_settings(records or [], settings_table)
2960
  if not records:
2961
- return (
2962
- records,
2963
- [],
2964
- "Upload at least one STL first.",
2965
- _dropdown_update(records),
2966
- _reset_slider(),
2967
- "No slice stack loaded yet.",
2968
- None,
2969
- _empty_state(),
2970
- _reset_slider(),
2971
- "No reference stack generated yet.",
2972
- None,
2973
- )
2974
  total = len(records)
2975
  messages: list[str] = []
2976
  for pos, record in enumerate(records):
2977
  stl_path = record.get("stl_path")
2978
  if not stl_path:
2979
- messages.append(f"Shape {record['idx']}: skipped (no STL file).")
 
 
 
2980
  continue
2981
 
2982
  def report_progress(cur: int, tot: int, offset: int = pos) -> None:
2983
  progress((offset + cur / tot) / total, desc=f"Slicing shape {offset + 1} of {total}...")
2984
 
2985
- mesh = load_mesh(stl_path)
2986
- scale_factors = _resolve_mesh_scale_factors(
2987
- mesh,
2988
- True,
2989
- scale_mode,
2990
- record.get("target_x"),
2991
- record.get("target_y"),
2992
- record.get("target_z"),
2993
- )
2994
  try:
2995
- stack = slice_stl_to_tiffs(
2996
- stl_path,
2997
- layer_height=float(layer_height),
2998
- pixel_size=float(pixel_size),
2999
- progress_callback=report_progress,
3000
- scale_factors=scale_factors,
3001
  )
3002
- record["tiff_state"] = _stack_to_state(stack)
3003
- record["zip_path"] = str(stack.zip_path)
3004
- messages.append(f"Shape {record['idx']}: wrote `{stack.zip_path.name}`.")
3005
  except Exception as exc:
3006
  messages.append(f"Shape {record['idx']}: failed ({exc}).")
3007
- ref_state, ref_slider, ref_label, ref_preview = generate_dynamic_reference_stack(records, progress=progress)
3008
- if (ref_state or {}).get("tiff_paths"):
3009
- messages.append("Reference TIFF Stack: updated automatically.")
3010
  else:
3011
- messages.append("Reference TIFF Stack: skipped (no generated shape slices available).")
3012
- selected_update, slider, label, preview = _tiff_preview_update(records)
3013
- return (
3014
- records,
3015
- [record.get("zip_path") for record in records if record.get("zip_path")],
3016
- "\n".join(messages),
3017
- selected_update,
3018
- slider,
3019
- label,
3020
- preview,
3021
- ref_state,
3022
- ref_slider,
3023
- ref_label,
3024
- ref_preview,
3025
- )
3026
-
3027
-
3028
- def generate_dynamic_reference_stack(records: list[dict] | None, progress: gr.Progress = gr.Progress()) -> tuple:
3029
- states = [record.get("tiff_state") or _empty_state() for record in (records or [])]
3030
- return generate_reference_stack(*states, progress=progress)
3031
-
3032
 
3033
- def _split_piece_choice(piece: dict[str, Any]) -> str:
3034
- return f"R{piece['row']} C{piece['col']}"
3035
 
3036
-
3037
- def _split_piece_dropdown_update(pieces: list[dict[str, Any]], selected: str | None = None) -> dict[str, Any]:
3038
- choices = [_split_piece_choice(piece) for piece in pieces]
3039
- value = selected if selected in choices else (choices[0] if choices else None)
3040
- return gr.update(choices=choices, value=value)
3041
-
3042
-
3043
- def _selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None) -> dict[str, Any] | None:
3044
- if not pieces:
3045
- return None
3046
- for piece in pieces:
3047
- if _split_piece_choice(piece) == selected:
3048
- return piece
3049
- return pieces[0]
3050
-
3051
-
3052
- def preview_selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None) -> tuple:
3053
- piece = _selected_split_piece(pieces, selected)
3054
- if not piece:
3055
- return _reset_slider(), "No split stack generated yet.", None
3056
- state = piece.get("state") or _empty_state()
3057
- label, preview = _render_selected_slice(state, 0)
3058
- slider = gr.update(
3059
- minimum=0,
3060
- maximum=max(0, len(state.get("tiff_paths", [])) - 1),
3061
- value=0,
3062
- step=1,
3063
- interactive=len(state.get("tiff_paths", [])) > 1,
3064
- )
3065
- return slider, label, preview
3066
-
3067
-
3068
- def jump_to_selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None, index: float) -> tuple:
3069
- piece = _selected_split_piece(pieces, selected)
3070
- if not piece:
3071
- return "No split stack generated yet.", None
3072
- return _render_selected_slice(piece.get("state") or _empty_state(), int(index))
3073
-
3074
-
3075
- def shift_selected_split_piece(pieces: list[dict[str, Any]] | None, selected: str | None, index: float, delta: int) -> tuple:
3076
- piece = _selected_split_piece(pieces, selected)
3077
- if not piece:
3078
- return gr.update(value=0), "No split stack generated yet.", None
3079
- state = piece.get("state") or _empty_state()
3080
- new_index, label, preview = shift_slice(state, index, delta)
3081
- return gr.update(value=new_index), label, preview
3082
 
3083
 
3084
  def split_selected_shape_for_grid(
@@ -3092,153 +2421,138 @@ def split_selected_shape_for_grid(
3092
  overlapping_layers: bool,
3093
  starting_nozzle: float,
3094
  starting_valve: float,
3095
- progress: gr.Progress = gr.Progress(),
3096
  ) -> tuple:
3097
  records = _apply_shape_settings(records or [], settings_table)
3098
- if not records:
3099
- empty = _empty_state()
3100
  return (
3101
- records,
3102
- _shape_settings_rows(records),
3103
- _spacing_table_update(records, existing_spacing, use_individual_spacing),
3104
- _dropdown_update(records),
3105
- _reset_slider(),
3106
- "No slice stack loaded yet.",
3107
- None,
3108
- [],
3109
- [],
3110
- _gcode_dropdown_update(records),
3111
- _gcode_dropdown_update(records, include_upload=True),
3112
- _dropdown_update(records),
3113
- [],
3114
- [],
3115
- _split_piece_dropdown_update([]),
3116
- _reset_slider(),
3117
- "No split stack generated yet.",
3118
- None,
3119
- "Generate TIFF stacks for a shape before splitting it.",
3120
  )
3121
 
 
 
 
3122
  pos = _selected_record_index(records, selected)
3123
  if pos < 0:
3124
  pos = 0
3125
  source = records[pos]
3126
- state = source.get("tiff_state") or _empty_state()
 
 
 
 
 
 
 
 
 
3127
  try:
3128
- pieces = split_tiff_stack_grid(
3129
- state,
3130
- base_name=str(source.get("name") or f"shape_{source.get('idx', pos + 1)}"),
3131
- columns=columns,
3132
- rows=rows,
3133
- overlapping_layers=overlapping_layers,
3134
- progress=progress,
3135
  )
3136
  except Exception as exc:
3137
- empty = _empty_state()
3138
- return (
3139
- records,
3140
- _shape_settings_rows(records),
3141
- _spacing_table_update(records, existing_spacing, use_individual_spacing),
3142
- _dropdown_update(records, selected),
3143
- _reset_slider(),
3144
- "No slice stack loaded yet.",
3145
- None,
3146
- [record.get("zip_path") for record in records if record.get("zip_path")],
3147
- [record.get("gcode_path") for record in records if record.get("gcode_path")],
3148
- _gcode_dropdown_update(records),
3149
- _gcode_dropdown_update(records, include_upload=True),
3150
- _dropdown_update(records, selected),
3151
- [],
3152
- [],
3153
- _split_piece_dropdown_update([]),
3154
- _reset_slider(),
3155
- "No split stack generated yet.",
3156
- None,
3157
- f"Split failed: {exc}",
3158
- )
3159
 
3160
  base_name = str(source.get("name") or f"Shape {source.get('idx', pos + 1)}")
3161
  first_nozzle = max(1, _coerce_int(starting_nozzle, 1))
3162
  first_valve = max(1, _coerce_int(starting_valve, _coerce_int(source.get("valve", 4), 4)))
3163
- split_column_count = max(1, _coerce_int(columns, 2))
3164
- split_row_count = max(1, _coerce_int(rows, 1))
3165
  split_group_id = f"split-{int(time.time() * 1_000_000)}-{source.get('idx', pos + 1)}"
3166
  split_records: list[dict] = []
3167
  for index, piece in enumerate(pieces):
3168
- piece_state = piece["state"]
3169
- piece_width_mm = float(piece_state.get("image_width", 0) or 0) * float(piece_state.get("pixel_size", 0.0) or 0.0)
3170
- piece_height_mm = float(piece_state.get("image_height", 0) or 0) * float(piece_state.get("pixel_size", 0.0) or 0.0)
3171
  piece_record = dict(source)
3172
  piece_record.update({
3173
- "name": f"{base_name} - R{piece['row']}C{piece['col']}",
3174
  "stl_path": None,
3175
- "target_x": piece_width_mm or source.get("target_x", DEFAULT_TARGET_EXTENTS[0]),
3176
- "target_y": piece_height_mm or source.get("target_y", DEFAULT_TARGET_EXTENTS[1]),
3177
  "nozzle": first_nozzle + index,
3178
  "valve": first_valve + index,
3179
  "split_group_id": split_group_id,
3180
  "split_index": index,
3181
- "split_row": int(piece["row"]),
3182
- "split_col": int(piece["col"]),
3183
  "split_rows": split_row_count,
3184
  "split_columns": split_column_count,
3185
- "tiff_state": piece_state,
3186
- "zip_path": str(piece["zip_path"]),
3187
  "gcode_path": None,
3188
  })
3189
  split_records.append(piece_record)
3190
  next_records = _reindex_shape_records([*records[:pos], *split_records, *records[pos + 1:]])
3191
  split_selected = _shape_choice(next_records[pos]) if pos < len(next_records) else None
3192
- selected_update, main_slider, main_label, main_preview = _tiff_preview_update(next_records, split_selected)
3193
- slider, label, preview = preview_selected_split_piece(pieces, None)
3194
  status = (
3195
- f"Split Shape {source.get('idx', pos + 1)} into {len(pieces)} print-ready stacks "
3196
- f"({max(1, _coerce_int(columns, 2))} columns x {max(1, _coerce_int(rows, 1))} rows). \n"
3197
  f"Nozzles {first_nozzle}-{first_nozzle + len(pieces) - 1}; valves {first_valve}-{first_valve + len(pieces) - 1}."
3198
  )
3199
  if overlapping_layers:
3200
- status += " \nOverlapping Layers is enabled: split boundaries alternate by 1 pixel per layer with small blank margins for alignment."
3201
- return (
3202
- next_records,
3203
- _shape_settings_rows(next_records),
3204
- _spacing_table_update(next_records, existing_spacing, use_individual_spacing),
3205
- selected_update,
3206
- main_slider,
3207
- main_label,
3208
- main_preview,
3209
- [record.get("zip_path") for record in next_records if record.get("zip_path")],
3210
- [record.get("gcode_path") for record in next_records if record.get("gcode_path")],
3211
- _gcode_dropdown_update(next_records),
3212
- _gcode_dropdown_update(next_records, include_upload=True),
3213
- _dropdown_update(next_records),
3214
- [str(piece["zip_path"]) for piece in pieces],
3215
- pieces,
3216
- _split_piece_dropdown_update(pieces),
3217
- slider,
3218
- label,
3219
- preview,
3220
- status,
3221
- )
3222
 
3223
 
3224
- def _contour_tracing_sources(records: list[dict]) -> list[dict]:
3225
- sources: list[dict] = []
3226
  for record in records:
3227
  if not record.get("contour_tracing"):
3228
  continue
3229
- state = record.get("tiff_state") or {}
3230
- tiff_paths = state.get("tiff_paths") or []
3231
- source = {
3232
- "owner_idx": int(record.get("idx", len(sources) + 1)),
3233
- "contour_mode": CONTOUR_MODE_ROW_ENVELOPE,
3234
- "tiff_paths": list(tiff_paths),
3235
- "zip_path": record.get("zip_path"),
3236
- }
3237
- if source["tiff_paths"] or source["zip_path"]:
3238
- sources.append(source)
3239
  return sources
3240
 
3241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3242
  def generate_dynamic_gcode(
3243
  records: list[dict] | None,
3244
  settings_table: Any,
@@ -3250,40 +2564,43 @@ def generate_dynamic_gcode(
3250
  lead_in_length: float,
3251
  lead_in_clearance: float,
3252
  lead_in_lines: float,
3253
- ref_state: ViewerState | None,
3254
  layer_height: float,
3255
- pixel_size: float,
 
3256
  ) -> tuple:
3257
  records = _apply_shape_settings(records or [], settings_table)
3258
- motion_tiffs = (ref_state or {}).get("tiff_paths") if use_reference_motion else None
3259
- contour_sources = _contour_tracing_sources(records)
3260
  messages: list[str] = []
 
 
 
 
3261
  if contour_sources:
3262
- enabled = ", ".join(f"Shape {source['owner_idx']}" for source in contour_sources)
3263
- messages.append(f"Shape-optimized contour tracing enabled for {enabled}.")
3264
  for record in records:
3265
- zip_path = record.get("zip_path")
3266
- if not zip_path:
3267
- messages.append(f"Shape {record['idx']}: skipped (no TIFF ZIP available).")
3268
  continue
3269
- if use_reference_motion and not motion_tiffs:
3270
- messages.append(f"Shape {record['idx']}: skipped (Reference motion selected, but no Reference TIFF Stack exists).")
3271
  continue
3272
- shape_name = str(record.get("name") or Path(zip_path).stem).replace(" ", "_")
3273
  try:
3274
- gcode_path = generate_snake_path_gcode(
3275
- zip_path=zip_path,
3276
  shape_name=shape_name,
3277
  pressure=float(record.get("pressure", 25.0)),
3278
  valve=int(record.get("valve", 4)),
3279
  port=int(record.get("port", 1)),
3280
  layer_height=float(layer_height),
3281
- fil_width=float(pixel_size),
3282
  pressure_ramp_enabled=bool(pressure_ramp_enabled),
3283
  all_g1=bool(all_g1),
3284
- motion_tiffs=motion_tiffs,
3285
  raster_pattern=raster_pattern,
3286
- contour_tiff_sets=contour_sources,
3287
  active_contour_owner=int(record.get("idx", 0)),
3288
  lead_in_enabled=bool(lead_in_enabled),
3289
  lead_in_length=float(lead_in_length),
@@ -3296,6 +2613,7 @@ def generate_dynamic_gcode(
3296
  messages.append(f"Shape {record['idx']}: failed ({exc}).")
3297
  return (
3298
  records,
 
3299
  [record.get("gcode_path") for record in records if record.get("gcode_path")],
3300
  "\n".join(messages),
3301
  _gcode_dropdown_update(records),
@@ -3323,7 +2641,7 @@ def _parts_from_records(records: list[dict] | None) -> tuple[list[dict], list[st
3323
  path = record.get("gcode_path")
3324
  idx = int(record.get("idx", len(parts) + 1))
3325
  if not path:
3326
- messages.append(f"Shape {idx}: no G-code (generate it on the TIFF Slices to GCode tab).")
3327
  continue
3328
  try:
3329
  parsed = parse_gcode_path(Path(path).read_text())
@@ -3461,7 +2779,7 @@ def render_dynamic_parallel(
3461
  records = _apply_shape_settings(records or [], settings_table)
3462
  parts, messages = _parts_from_records(records)
3463
  if not parts:
3464
- return None, "No shape G-code available. Generate G-code on the TIFF Slices to GCode tab first."
3465
  offsets, spacings = _resolve_layout_from_spacing_controls(
3466
  parts,
3467
  layout_mode,
@@ -3556,17 +2874,16 @@ def export_dynamic_parallel_gif(
3556
  return str(result) if result else None
3557
 
3558
  def build_dynamic_demo() -> gr.Blocks:
3559
- with gr.Blocks(title="STL TIFF Slicer", css=APP_CSS, head=APP_HEAD + TOOLPATH_ANIM_HEAD + PARALLEL_ANIM_HEAD) as demo:
3560
  shape_records = gr.State([])
3561
  last_shape_delete_at = gr.State(0.0)
3562
- ref_state = gr.State(_empty_state())
3563
- split_piece_states = gr.State([])
3564
 
3565
- with gr.Tab("STL to TIFF Slicer"):
3566
  gr.Markdown(
3567
  """
3568
- # STL to TIFF Slicer
3569
- Upload any number of STL files, edit per-shape dimensions and print settings in the table, then generate TIFF stacks.
3570
  """
3571
  )
3572
  with gr.Row():
@@ -3600,8 +2917,8 @@ def build_dynamic_demo() -> gr.Blocks:
3600
  )
3601
  with gr.Row():
3602
  layer_height = gr.Number(label="Layer Height (mm)", value=0.8, minimum=0.0001, step=0.01)
3603
- pixel_size = gr.Number(label="Pixel Size/Fill Width (mm)", value=0.8, minimum=0.0001, step=0.01)
3604
- generate_button = gr.Button("Generate TIFF Stacks", variant="primary")
3605
 
3606
  with gr.Accordion("Multi-Nozzle Split", open=False, elem_classes=["settings-accordion"]):
3607
  with gr.Row():
@@ -3614,18 +2931,7 @@ def build_dynamic_demo() -> gr.Blocks:
3614
  split_start_valve = gr.Number(label="Starting Valve", value=4, minimum=1, step=1)
3615
  split_overlapping_layers = gr.Checkbox(label="Overlapping Layers", value=False)
3616
  split_button = gr.Button("Split Selected Shape into Grid Pieces", variant="primary")
3617
- split_status = gr.Markdown("Generate a TIFF stack, then split it for multi-nozzle printing.")
3618
- split_downloads = gr.File(label="Download Split TIFF ZIPs", file_count="multiple", interactive=False)
3619
- with gr.Row():
3620
- with gr.Column(scale=1, min_width=260):
3621
- split_piece_source = gr.Dropdown(label="Preview Generated Piece", choices=[], value=None, allow_custom_value=False)
3622
- with gr.Row():
3623
- split_piece_prev = gr.Button("Prev", scale=1, min_width=90, size="sm")
3624
- split_piece_next = gr.Button("Next", scale=1, min_width=90, size="sm")
3625
- split_piece_slider = gr.Slider(label="Piece Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
3626
- split_piece_label = gr.Markdown("No split stack generated yet.")
3627
- with gr.Column(scale=2, min_width=420):
3628
- split_piece_preview = gr.Image(label="Generated Piece Preview", type="pil", image_mode="RGB", height=330)
3629
 
3630
  with gr.Accordion("Selected Shape Preview", open=False, elem_classes=["settings-accordion"]):
3631
  with gr.Row():
@@ -3644,40 +2950,17 @@ def build_dynamic_demo() -> gr.Blocks:
3644
  with gr.Column(scale=1, min_width=300):
3645
  model_details = gr.Markdown("No model loaded.")
3646
 
3647
- with gr.Row():
3648
- with gr.Column(scale=2, min_width=420):
3649
- slice_preview = gr.Image(label="Selected Slice Preview", type="pil", image_mode="RGB", height=320)
3650
- with gr.Column(scale=1, min_width=300):
3651
- slice_label = gr.Markdown("No slice stack loaded yet.")
3652
- with gr.Row():
3653
- prev_button = gr.Button("Prev", scale=1, min_width=90, size="sm")
3654
- next_button = gr.Button("Next", scale=1, min_width=90, size="sm")
3655
- slice_slider = gr.Slider(label="Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
3656
-
3657
- tiff_downloads = gr.File(label="Download TIFF ZIPs", file_count="multiple", interactive=False)
3658
  slicer_status = gr.Markdown("")
3659
 
3660
- with gr.Accordion("Reference TIFF Stack Preview", open=False, elem_classes=["settings-accordion"]):
3661
- with gr.Row():
3662
- with gr.Column(scale=1, min_width=200):
3663
- ref_generate_button = gr.Button("Generate Reference TIFF Stack", variant="primary")
3664
- with gr.Column(scale=3, min_width=250):
3665
- ref_slice_label = gr.Markdown("No reference stack generated yet.")
3666
- ref_slice_preview = gr.Image(label="Reference Slice Preview", type="pil", image_mode="RGB", height=270)
3667
- with gr.Row():
3668
- ref_prev_button = gr.Button("Prev", scale=1, min_width=90, size="sm")
3669
- ref_next_button = gr.Button("Next", scale=1, min_width=90, size="sm")
3670
- ref_slice_slider = gr.Slider(label="Slice Index", minimum=0, maximum=0, value=0, step=1, interactive=False)
3671
-
3672
- with gr.Tab("TIFF Slices to GCode"):
3673
  gr.Markdown(
3674
  """
3675
- # TIFF Slices to GCode
3676
- Generate G-code for every shape with a TIFF stack. Pressure, valve, nozzle, port, and color come from the Shape Settings table.
3677
  """
3678
  )
3679
  gcode_use_ref_motion = gr.Checkbox(
3680
- label="Use Reference Stack for motion (all shapes share one nozzle path; each dispenses only its own geometry).",
3681
  value=True,
3682
  )
3683
  gcode_all_g1 = gr.Checkbox(label="Move at one constant speed (no fast travel moves)", value=True)
@@ -3798,7 +3081,7 @@ def build_dynamic_demo() -> gr.Blocks:
3798
  with gr.Tab("Parallel Printing Visualization"):
3799
  gr.Markdown(
3800
  "### Parallel Printing Visualization\n"
3801
- "Plots all generated shapes using the nozzle spacing configured on the TIFF Slices to GCode tab."
3802
  )
3803
  with gr.Row():
3804
  with gr.Column(scale=1, min_width=340):
@@ -3837,7 +3120,7 @@ def build_dynamic_demo() -> gr.Blocks:
3837
  nozzle_grid_use_individual_spacing,
3838
  ]
3839
 
3840
- shape_sync_outputs = [shape_records, shape_settings, nozzle_spacing_table, selected_shape, gcode_text_source, gcode_source, tiff_downloads, gcode_downloads]
3841
  stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=shape_sync_outputs).then(
3842
  fn=lambda records: _dropdown_update(records),
3843
  inputs=[shape_records],
@@ -3899,9 +3182,9 @@ def build_dynamic_demo() -> gr.Blocks:
3899
  outputs=[nozzle_grid_spacing_table],
3900
  queue=False,
3901
  )
3902
- selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview])
3903
- refresh_preview_button.click(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview])
3904
- model_opacity.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview])
3905
  scale_mode.change(
3906
  fn=normalize_shape_dimensions_for_mode,
3907
  inputs=[shape_records, shape_settings, scale_mode],
@@ -3910,7 +3193,7 @@ def build_dynamic_demo() -> gr.Blocks:
3910
  ).then(
3911
  fn=show_selected_model,
3912
  inputs=preview_inputs,
3913
- outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview],
3914
  )
3915
  reset_dimensions_button.click(
3916
  fn=reset_shape_dimensions,
@@ -3919,39 +3202,19 @@ def build_dynamic_demo() -> gr.Blocks:
3919
  ).then(
3920
  fn=show_selected_model,
3921
  inputs=preview_inputs,
3922
- outputs=[model_viewer, model_details, slice_slider, slice_label, slice_preview],
3923
  )
3924
 
3925
- slice_slider.release(fn=jump_to_selected_slice, inputs=[shape_records, selected_shape, slice_slider], outputs=[slice_label, slice_preview], queue=False)
3926
- prev_button.click(fn=lambda records, selected, idx: shift_selected_slice(records, selected, idx, -1), inputs=[shape_records, selected_shape, slice_slider], outputs=[slice_slider, slice_label, slice_preview], queue=False)
3927
- next_button.click(fn=lambda records, selected, idx: shift_selected_slice(records, selected, idx, 1), inputs=[shape_records, selected_shape, slice_slider], outputs=[slice_slider, slice_label, slice_preview], queue=False)
3928
-
3929
  generate_button.click(
3930
- fn=generate_dynamic_stacks,
3931
- inputs=[shape_records, shape_settings, layer_height, pixel_size, scale_mode],
3932
- outputs=[
3933
- shape_records,
3934
- tiff_downloads,
3935
- slicer_status,
3936
- selected_shape,
3937
- slice_slider,
3938
- slice_label,
3939
- slice_preview,
3940
- ref_state,
3941
- ref_slice_slider,
3942
- ref_slice_label,
3943
- ref_slice_preview,
3944
- ],
3945
  ).then(
3946
  fn=lambda records: _dropdown_update(records),
3947
  inputs=[shape_records],
3948
  outputs=[split_source],
3949
  queue=False,
3950
  )
3951
- ref_generate_button.click(fn=generate_dynamic_reference_stack, inputs=[shape_records], outputs=[ref_state, ref_slice_slider, ref_slice_label, ref_slice_preview])
3952
- ref_slice_slider.release(fn=jump_to_slice, inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_label, ref_slice_preview], queue=False)
3953
- ref_prev_button.click(fn=lambda sv, idx: shift_slice(sv, idx, -1), inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_slider, ref_slice_label, ref_slice_preview], queue=False)
3954
- ref_next_button.click(fn=lambda sv, idx: shift_slice(sv, idx, 1), inputs=[ref_state, ref_slice_slider], outputs=[ref_slice_slider, ref_slice_label, ref_slice_preview], queue=False)
3955
 
3956
  split_refresh_sources.click(fn=lambda records: _dropdown_update(records), inputs=[shape_records], outputs=[split_source], queue=False)
3957
  split_button.click(
@@ -3967,42 +3230,29 @@ def build_dynamic_demo() -> gr.Blocks:
3967
  split_overlapping_layers,
3968
  split_start_nozzle,
3969
  split_start_valve,
 
3970
  ],
3971
  outputs=[
3972
  shape_records,
3973
  shape_settings,
3974
  nozzle_spacing_table,
3975
  selected_shape,
3976
- slice_slider,
3977
- slice_label,
3978
- slice_preview,
3979
- tiff_downloads,
3980
  gcode_downloads,
3981
  gcode_text_source,
3982
  gcode_source,
3983
  split_source,
3984
- split_downloads,
3985
- split_piece_states,
3986
- split_piece_source,
3987
- split_piece_slider,
3988
- split_piece_label,
3989
- split_piece_preview,
3990
  split_status,
3991
  ],
3992
  ).then(
3993
  fn=generate_dynamic_reference_stack,
3994
  inputs=[shape_records],
3995
- outputs=[ref_state, ref_slice_slider, ref_slice_label, ref_slice_preview],
3996
  ).then(
3997
  fn=_grid_spacing_table_update,
3998
  inputs=grid_spacing_refresh_inputs,
3999
  outputs=[nozzle_grid_spacing_table],
4000
  queue=False,
4001
  )
4002
- split_piece_source.change(fn=preview_selected_split_piece, inputs=[split_piece_states, split_piece_source], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
4003
- split_piece_slider.release(fn=jump_to_selected_split_piece, inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_label, split_piece_preview], queue=False)
4004
- split_piece_prev.click(fn=lambda pieces, selected, idx: shift_selected_split_piece(pieces, selected, idx, -1), inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
4005
- split_piece_next.click(fn=lambda pieces, selected, idx: shift_selected_split_piece(pieces, selected, idx, 1), inputs=[split_piece_states, split_piece_source, split_piece_slider], outputs=[split_piece_slider, split_piece_label, split_piece_preview], queue=False)
4006
 
4007
  gcode_button.click(
4008
  fn=generate_dynamic_gcode,
@@ -4017,11 +3267,12 @@ def build_dynamic_demo() -> gr.Blocks:
4017
  gcode_lead_in_length,
4018
  gcode_lead_in_clearance,
4019
  gcode_lead_in_lines,
4020
- ref_state,
4021
  layer_height,
4022
- pixel_size,
 
4023
  ],
4024
- outputs=[shape_records, gcode_downloads, gcode_status, gcode_text_source, gcode_source],
4025
  ).then(
4026
  fn=load_selected_gcode_text,
4027
  inputs=[shape_records, gcode_text_source],
 
1
  from __future__ import annotations
2
 
 
3
  import math
4
+ import tempfile
5
  import time
6
  import warnings
 
7
  from pathlib import Path
8
  from typing import Any
9
 
 
19
 
20
  import gradio as gr
21
  import numpy as np
 
22
  import trimesh
23
 
24
  from gcode_viewer import (
 
29
  parse_gcode_path,
30
  )
31
  from stl_slicer import (
32
+ LayerStack,
33
  load_mesh,
34
  scale_factors_for_target_extents,
35
  scale_mesh,
36
+ slice_stl_to_layers,
37
  )
38
+ from vector_gcode import generate_vector_gcode
39
+ from vector_toolpath import (
40
  RASTER_PATTERN_CHOICES,
41
  RASTER_PATTERN_SAME_DIRECTION,
42
  RASTER_PATTERN_Y_DIRECTION,
43
+ ContourSource,
44
+ build_reference_stack,
45
+ split_layer_stack_grid,
46
  )
47
 
48
 
 
49
  SAMPLE_STL_FILENAMES = ("Hollow_Pyramid.stl", "Rounded_Cube_Through_Holes.stl", "halfsphere.stl")
50
  SAMPLE_STL_DIR = Path(__file__).resolve().parent / "sample_stls"
51
  DEFAULT_TARGET_EXTENTS = (20.0, 20.0, 20.0)
 
782
  """
783
 
784
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
785
  def _format_model_details(
786
  source_name: str,
787
  mesh,
 
816
  return "\n".join(lines)
817
 
818
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
819
  def _opacity_to_alpha(opacity: float) -> int:
820
  bounded = max(0.05, min(float(opacity), 1.0))
821
  return int(round(255 * bounded))
 
1150
  return _viewer_update(glb_path), _format_model_details(Path(stl_file).name, mesh, scale_factors)
1151
 
1152
 
 
 
 
 
1153
  GCODE_SOURCE_UPLOAD = "Upload G-Code file"
1154
 
1155
 
 
1481
  return " \n".join(lines)
1482
 
1483
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1484
  SHAPE_SETTINGS_HEADERS = [
1485
  "Shape",
1486
  "STL",
 
1661
  "port": previous.get("port", 1),
1662
  "color": previous.get("color", _default_color(index)),
1663
  "contour_tracing": previous.get("contour_tracing", False),
1664
+ "layer_stack": previous.get("layer_stack"),
1665
+ "slice_params": previous.get("slice_params"),
1666
  "gcode_path": previous.get("gcode_path"),
1667
  })
1668
  return records
 
2124
  _dropdown_update(next_records),
2125
  _gcode_dropdown_update(next_records),
2126
  _gcode_dropdown_update(next_records, include_upload=True),
 
2127
  [record.get("gcode_path") for record in next_records if record.get("gcode_path")],
2128
  )
2129
 
 
2167
  _dropdown_update(records),
2168
  _gcode_dropdown_update(records),
2169
  _gcode_dropdown_update(records, include_upload=True),
 
2170
  [record.get("gcode_path") for record in records if record.get("gcode_path")],
2171
  float(last_delete_at or 0.0),
2172
  )
 
2314
  records = _apply_shape_settings(records or [], settings_table)
2315
  pos = _selected_record_index(records, selected)
2316
  if pos < 0:
2317
+ return _viewer_update(None), "No model loaded."
2318
  record = records[pos]
2319
+ return load_single_model(
2320
  record.get("stl_path"),
2321
  opacity,
2322
  True,
 
2325
  record.get("target_y"),
2326
  record.get("target_z"),
2327
  )
 
 
 
 
 
 
 
 
 
 
2328
 
2329
 
2330
+ def _slice_params_snapshot(record: dict, layer_height: float, scale_mode: str | None) -> dict:
2331
+ return {
2332
+ "layer_height": float(layer_height),
2333
+ "scale_mode": _normalize_scale_mode(scale_mode),
2334
+ "target_x": record.get("target_x"),
2335
+ "target_y": record.get("target_y"),
2336
+ "target_z": record.get("target_z"),
2337
+ }
2338
 
2339
 
2340
+ def _slice_record(
2341
+ record: dict,
2342
+ layer_height: float,
2343
+ scale_mode: str | None,
2344
+ progress_callback=None,
2345
+ ) -> LayerStack:
2346
+ stl_path = record["stl_path"]
2347
+ mesh = load_mesh(stl_path)
2348
+ scale_factors = _resolve_mesh_scale_factors(
2349
+ mesh,
2350
+ True,
2351
+ scale_mode,
2352
+ record.get("target_x"),
2353
+ record.get("target_y"),
2354
+ record.get("target_z"),
2355
+ )
2356
+ stack = slice_stl_to_layers(
2357
+ stl_path,
2358
+ layer_height=float(layer_height),
2359
+ progress_callback=progress_callback,
2360
+ scale_factors=scale_factors,
2361
+ name=str(record.get("name") or Path(stl_path).stem),
 
 
 
2362
  )
2363
+ record["layer_stack"] = stack
2364
+ record["slice_params"] = _slice_params_snapshot(record, layer_height, scale_mode)
2365
+ return stack
2366
 
2367
 
2368
+ def generate_dynamic_layer_stacks(
2369
  records: list[dict] | None,
2370
  settings_table: Any,
2371
  layer_height: float,
 
2372
  scale_mode: str | None,
2373
  progress: gr.Progress = gr.Progress(),
2374
  ) -> tuple:
2375
  records = _apply_shape_settings(records or [], settings_table)
2376
  if not records:
2377
+ return records, "Upload at least one STL first.", None
 
 
 
 
 
 
 
 
 
 
 
 
2378
  total = len(records)
2379
  messages: list[str] = []
2380
  for pos, record in enumerate(records):
2381
  stl_path = record.get("stl_path")
2382
  if not stl_path:
2383
+ if record.get("layer_stack") is not None:
2384
+ messages.append(f"Shape {record['idx']}: kept existing split-piece slices.")
2385
+ else:
2386
+ messages.append(f"Shape {record['idx']}: skipped (no STL file).")
2387
  continue
2388
 
2389
  def report_progress(cur: int, tot: int, offset: int = pos) -> None:
2390
  progress((offset + cur / tot) / total, desc=f"Slicing shape {offset + 1} of {total}...")
2391
 
 
 
 
 
 
 
 
 
 
2392
  try:
2393
+ stack = _slice_record(record, layer_height, scale_mode, report_progress)
2394
+ (x_min, y_min, _z_min), (x_max, y_max, _z_max) = stack.bounds
2395
+ messages.append(
2396
+ f"Shape {record['idx']}: sliced {len(stack.layers)} layers "
2397
+ f"(footprint {x_max - x_min:.2f} x {y_max - y_min:.2f} mm)."
 
2398
  )
 
 
 
2399
  except Exception as exc:
2400
  messages.append(f"Shape {record['idx']}: failed ({exc}).")
2401
+ ref_layers = generate_dynamic_reference_stack(records)
2402
+ if ref_layers is not None:
2403
+ messages.append("Reference layers: updated automatically.")
2404
  else:
2405
+ messages.append("Reference layers: skipped (no sliced shapes available).")
2406
+ return records, "\n".join(messages), ref_layers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2407
 
 
 
2408
 
2409
+ def generate_dynamic_reference_stack(records: list[dict] | None) -> LayerStack | None:
2410
+ return build_reference_stack([record.get("layer_stack") for record in (records or [])])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2411
 
2412
 
2413
  def split_selected_shape_for_grid(
 
2421
  overlapping_layers: bool,
2422
  starting_nozzle: float,
2423
  starting_valve: float,
2424
+ fil_width: float,
2425
  ) -> tuple:
2426
  records = _apply_shape_settings(records or [], settings_table)
2427
+
2428
+ def _outputs(next_records: list[dict], selected_value: str | None, status: str) -> tuple:
2429
  return (
2430
+ next_records,
2431
+ _shape_settings_rows(next_records),
2432
+ _spacing_table_update(next_records, existing_spacing, use_individual_spacing),
2433
+ _dropdown_update(next_records, selected_value),
2434
+ [record.get("gcode_path") for record in next_records if record.get("gcode_path")],
2435
+ _gcode_dropdown_update(next_records),
2436
+ _gcode_dropdown_update(next_records, include_upload=True),
2437
+ _dropdown_update(next_records, selected_value),
2438
+ status,
 
 
 
 
 
 
 
 
 
 
2439
  )
2440
 
2441
+ if not records:
2442
+ return _outputs(records, None, "Slice a shape before splitting it.")
2443
+
2444
  pos = _selected_record_index(records, selected)
2445
  if pos < 0:
2446
  pos = 0
2447
  source = records[pos]
2448
+ stack = source.get("layer_stack")
2449
+ if stack is None or not getattr(stack, "layers", None):
2450
+ return _outputs(
2451
+ records,
2452
+ selected,
2453
+ f"Split failed: Shape {source.get('idx', pos + 1)} has no sliced layers yet - press Slice Shapes first.",
2454
+ )
2455
+
2456
+ split_column_count = max(1, _coerce_int(columns, 2))
2457
+ split_row_count = max(1, _coerce_int(rows, 1))
2458
  try:
2459
+ pieces = split_layer_stack_grid(
2460
+ stack,
2461
+ columns=split_column_count,
2462
+ rows=split_row_count,
2463
+ overlapping_layers=bool(overlapping_layers),
2464
+ overlap=float(fil_width) if overlapping_layers else 0.0,
 
2465
  )
2466
  except Exception as exc:
2467
+ return _outputs(records, selected, f"Split failed: {exc}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2468
 
2469
  base_name = str(source.get("name") or f"Shape {source.get('idx', pos + 1)}")
2470
  first_nozzle = max(1, _coerce_int(starting_nozzle, 1))
2471
  first_valve = max(1, _coerce_int(starting_valve, _coerce_int(source.get("valve", 4), 4)))
 
 
2472
  split_group_id = f"split-{int(time.time() * 1_000_000)}-{source.get('idx', pos + 1)}"
2473
  split_records: list[dict] = []
2474
  for index, piece in enumerate(pieces):
2475
+ row_index = index // split_column_count + 1
2476
+ col_index = index % split_column_count + 1
2477
+ (piece_x_min, piece_y_min, _z_min), (piece_x_max, piece_y_max, _z_max) = piece.bounds
2478
  piece_record = dict(source)
2479
  piece_record.update({
2480
+ "name": f"{base_name} - R{row_index}C{col_index}",
2481
  "stl_path": None,
2482
+ "target_x": (piece_x_max - piece_x_min) or source.get("target_x", DEFAULT_TARGET_EXTENTS[0]),
2483
+ "target_y": (piece_y_max - piece_y_min) or source.get("target_y", DEFAULT_TARGET_EXTENTS[1]),
2484
  "nozzle": first_nozzle + index,
2485
  "valve": first_valve + index,
2486
  "split_group_id": split_group_id,
2487
  "split_index": index,
2488
+ "split_row": row_index,
2489
+ "split_col": col_index,
2490
  "split_rows": split_row_count,
2491
  "split_columns": split_column_count,
2492
+ "layer_stack": piece,
2493
+ "slice_params": source.get("slice_params"),
2494
  "gcode_path": None,
2495
  })
2496
  split_records.append(piece_record)
2497
  next_records = _reindex_shape_records([*records[:pos], *split_records, *records[pos + 1:]])
2498
  split_selected = _shape_choice(next_records[pos]) if pos < len(next_records) else None
 
 
2499
  status = (
2500
+ f"Split Shape {source.get('idx', pos + 1)} into {len(pieces)} print-ready piece stacks "
2501
+ f"({split_column_count} columns x {split_row_count} rows). \n"
2502
  f"Nozzles {first_nozzle}-{first_nozzle + len(pieces) - 1}; valves {first_valve}-{first_valve + len(pieces) - 1}."
2503
  )
2504
  if overlapping_layers:
2505
+ status += (
2506
+ " \nOverlapping Layers is enabled: split boundaries alternate by one "
2507
+ "filament width per layer so neighbouring pieces interlock."
2508
+ )
2509
+ return _outputs(next_records, split_selected, status)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2510
 
2511
 
2512
+ def _contour_tracing_sources(records: list[dict]) -> list[ContourSource]:
2513
+ sources: list[ContourSource] = []
2514
  for record in records:
2515
  if not record.get("contour_tracing"):
2516
  continue
2517
+ stack = record.get("layer_stack")
2518
+ if stack is None or not getattr(stack, "layers", None):
2519
+ continue
2520
+ sources.append(
2521
+ ContourSource(
2522
+ owner_idx=int(record.get("idx", len(sources) + 1)),
2523
+ stack=stack,
2524
+ )
2525
+ )
 
2526
  return sources
2527
 
2528
 
2529
+ def _ensure_records_sliced(
2530
+ records: list[dict],
2531
+ layer_height: float,
2532
+ scale_mode: str | None,
2533
+ messages: list[str],
2534
+ ) -> bool:
2535
+ """Re-slice records whose layers are missing or stale for the current settings."""
2536
+ resliced = False
2537
+ for record in records:
2538
+ stl_path = record.get("stl_path")
2539
+ if not stl_path:
2540
+ continue # Split pieces carry their clipped layers; nothing to re-slice.
2541
+ current = _slice_params_snapshot(record, layer_height, scale_mode)
2542
+ if record.get("layer_stack") is not None and record.get("slice_params") == current:
2543
+ continue
2544
+ try:
2545
+ stack = _slice_record(record, layer_height, scale_mode)
2546
+ messages.append(
2547
+ f"Shape {record['idx']}: sliced automatically ({len(stack.layers)} layers)."
2548
+ )
2549
+ except Exception as exc:
2550
+ record["layer_stack"] = None
2551
+ messages.append(f"Shape {record['idx']}: slicing failed ({exc}).")
2552
+ resliced = True
2553
+ return resliced
2554
+
2555
+
2556
  def generate_dynamic_gcode(
2557
  records: list[dict] | None,
2558
  settings_table: Any,
 
2564
  lead_in_length: float,
2565
  lead_in_clearance: float,
2566
  lead_in_lines: float,
2567
+ ref_layers: LayerStack | None,
2568
  layer_height: float,
2569
+ fil_width: float,
2570
+ scale_mode: str | None,
2571
  ) -> tuple:
2572
  records = _apply_shape_settings(records or [], settings_table)
 
 
2573
  messages: list[str] = []
2574
+ if _ensure_records_sliced(records, layer_height, scale_mode, messages):
2575
+ # Anything re-sliced invalidates the previous reference union.
2576
+ ref_layers = generate_dynamic_reference_stack(records)
2577
+ contour_sources = _contour_tracing_sources(records)
2578
  if contour_sources:
2579
+ enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
2580
+ messages.append(f"Contour tracing enabled for {enabled}.")
2581
  for record in records:
2582
+ stack = record.get("layer_stack")
2583
+ if stack is None or not getattr(stack, "layers", None):
2584
+ messages.append(f"Shape {record['idx']}: skipped (no sliced layers).")
2585
  continue
2586
+ if use_reference_motion and ref_layers is None:
2587
+ messages.append(f"Shape {record['idx']}: skipped (Reference motion selected, but no shapes are sliced).")
2588
  continue
2589
+ shape_name = str(record.get("name") or stack.name or f"shape_{record['idx']}").replace(" ", "_")
2590
  try:
2591
+ gcode_path = generate_vector_gcode(
2592
+ stack,
2593
  shape_name=shape_name,
2594
  pressure=float(record.get("pressure", 25.0)),
2595
  valve=int(record.get("valve", 4)),
2596
  port=int(record.get("port", 1)),
2597
  layer_height=float(layer_height),
2598
+ fil_width=float(fil_width),
2599
  pressure_ramp_enabled=bool(pressure_ramp_enabled),
2600
  all_g1=bool(all_g1),
2601
+ motion=ref_layers if use_reference_motion else None,
2602
  raster_pattern=raster_pattern,
2603
+ contour_sources=contour_sources,
2604
  active_contour_owner=int(record.get("idx", 0)),
2605
  lead_in_enabled=bool(lead_in_enabled),
2606
  lead_in_length=float(lead_in_length),
 
2613
  messages.append(f"Shape {record['idx']}: failed ({exc}).")
2614
  return (
2615
  records,
2616
+ ref_layers,
2617
  [record.get("gcode_path") for record in records if record.get("gcode_path")],
2618
  "\n".join(messages),
2619
  _gcode_dropdown_update(records),
 
2641
  path = record.get("gcode_path")
2642
  idx = int(record.get("idx", len(parts) + 1))
2643
  if not path:
2644
+ messages.append(f"Shape {idx}: no G-code (generate it on the Generate G-Code tab).")
2645
  continue
2646
  try:
2647
  parsed = parse_gcode_path(Path(path).read_text())
 
2779
  records = _apply_shape_settings(records or [], settings_table)
2780
  parts, messages = _parts_from_records(records)
2781
  if not parts:
2782
+ return None, "No shape G-code available. Generate G-code on the Generate G-Code tab first."
2783
  offsets, spacings = _resolve_layout_from_spacing_controls(
2784
  parts,
2785
  layout_mode,
 
2874
  return str(result) if result else None
2875
 
2876
  def build_dynamic_demo() -> gr.Blocks:
2877
+ with gr.Blocks(title="ParallelPrint: STL to G-Code", css=APP_CSS, head=APP_HEAD + TOOLPATH_ANIM_HEAD + PARALLEL_ANIM_HEAD) as demo:
2878
  shape_records = gr.State([])
2879
  last_shape_delete_at = gr.State(0.0)
2880
+ ref_layers = gr.State(None)
 
2881
 
2882
+ with gr.Tab("Shapes & Slicing"):
2883
  gr.Markdown(
2884
  """
2885
+ # Shapes & Slicing
2886
+ Upload any number of STL files, edit per-shape dimensions and print settings in the table, then slice each shape into per-layer outlines.
2887
  """
2888
  )
2889
  with gr.Row():
 
2917
  )
2918
  with gr.Row():
2919
  layer_height = gr.Number(label="Layer Height (mm)", value=0.8, minimum=0.0001, step=0.01)
2920
+ fil_width = gr.Number(label="Filament/Line Width (mm)", value=0.8, minimum=0.0001, step=0.01)
2921
+ generate_button = gr.Button("Slice Shapes", variant="primary")
2922
 
2923
  with gr.Accordion("Multi-Nozzle Split", open=False, elem_classes=["settings-accordion"]):
2924
  with gr.Row():
 
2931
  split_start_valve = gr.Number(label="Starting Valve", value=4, minimum=1, step=1)
2932
  split_overlapping_layers = gr.Checkbox(label="Overlapping Layers", value=False)
2933
  split_button = gr.Button("Split Selected Shape into Grid Pieces", variant="primary")
2934
+ split_status = gr.Markdown("Slice a shape, then split it for multi-nozzle printing.")
 
 
 
 
 
 
 
 
 
 
 
2935
 
2936
  with gr.Accordion("Selected Shape Preview", open=False, elem_classes=["settings-accordion"]):
2937
  with gr.Row():
 
2950
  with gr.Column(scale=1, min_width=300):
2951
  model_details = gr.Markdown("No model loaded.")
2952
 
 
 
 
 
 
 
 
 
 
 
 
2953
  slicer_status = gr.Markdown("")
2954
 
2955
+ with gr.Tab("Generate G-Code"):
 
 
 
 
 
 
 
 
 
 
 
 
2956
  gr.Markdown(
2957
  """
2958
+ # Generate G-Code
2959
+ Generate G-code for every sliced shape. Pressure, valve, nozzle, port, and color come from the Shape Settings table.
2960
  """
2961
  )
2962
  gcode_use_ref_motion = gr.Checkbox(
2963
+ label="Use combined reference outline for motion (all shapes share one nozzle path; each dispenses only its own geometry).",
2964
  value=True,
2965
  )
2966
  gcode_all_g1 = gr.Checkbox(label="Move at one constant speed (no fast travel moves)", value=True)
 
3081
  with gr.Tab("Parallel Printing Visualization"):
3082
  gr.Markdown(
3083
  "### Parallel Printing Visualization\n"
3084
+ "Plots all generated shapes using the nozzle spacing configured on the Generate G-Code tab."
3085
  )
3086
  with gr.Row():
3087
  with gr.Column(scale=1, min_width=340):
 
3120
  nozzle_grid_use_individual_spacing,
3121
  ]
3122
 
3123
+ shape_sync_outputs = [shape_records, shape_settings, nozzle_spacing_table, selected_shape, gcode_text_source, gcode_source, gcode_downloads]
3124
  stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings, nozzle_spacing_table, nozzle_use_individual_spacing], outputs=shape_sync_outputs).then(
3125
  fn=lambda records: _dropdown_update(records),
3126
  inputs=[shape_records],
 
3182
  outputs=[nozzle_grid_spacing_table],
3183
  queue=False,
3184
  )
3185
+ selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
3186
+ refresh_preview_button.click(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
3187
+ model_opacity.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
3188
  scale_mode.change(
3189
  fn=normalize_shape_dimensions_for_mode,
3190
  inputs=[shape_records, shape_settings, scale_mode],
 
3193
  ).then(
3194
  fn=show_selected_model,
3195
  inputs=preview_inputs,
3196
+ outputs=[model_viewer, model_details],
3197
  )
3198
  reset_dimensions_button.click(
3199
  fn=reset_shape_dimensions,
 
3202
  ).then(
3203
  fn=show_selected_model,
3204
  inputs=preview_inputs,
3205
+ outputs=[model_viewer, model_details],
3206
  )
3207
 
 
 
 
 
3208
  generate_button.click(
3209
+ fn=generate_dynamic_layer_stacks,
3210
+ inputs=[shape_records, shape_settings, layer_height, scale_mode],
3211
+ outputs=[shape_records, slicer_status, ref_layers],
 
 
 
 
 
 
 
 
 
 
 
 
3212
  ).then(
3213
  fn=lambda records: _dropdown_update(records),
3214
  inputs=[shape_records],
3215
  outputs=[split_source],
3216
  queue=False,
3217
  )
 
 
 
 
3218
 
3219
  split_refresh_sources.click(fn=lambda records: _dropdown_update(records), inputs=[shape_records], outputs=[split_source], queue=False)
3220
  split_button.click(
 
3230
  split_overlapping_layers,
3231
  split_start_nozzle,
3232
  split_start_valve,
3233
+ fil_width,
3234
  ],
3235
  outputs=[
3236
  shape_records,
3237
  shape_settings,
3238
  nozzle_spacing_table,
3239
  selected_shape,
 
 
 
 
3240
  gcode_downloads,
3241
  gcode_text_source,
3242
  gcode_source,
3243
  split_source,
 
 
 
 
 
 
3244
  split_status,
3245
  ],
3246
  ).then(
3247
  fn=generate_dynamic_reference_stack,
3248
  inputs=[shape_records],
3249
+ outputs=[ref_layers],
3250
  ).then(
3251
  fn=_grid_spacing_table_update,
3252
  inputs=grid_spacing_refresh_inputs,
3253
  outputs=[nozzle_grid_spacing_table],
3254
  queue=False,
3255
  )
 
 
 
 
3256
 
3257
  gcode_button.click(
3258
  fn=generate_dynamic_gcode,
 
3267
  gcode_lead_in_length,
3268
  gcode_lead_in_clearance,
3269
  gcode_lead_in_lines,
3270
+ ref_layers,
3271
  layer_height,
3272
+ fil_width,
3273
+ scale_mode,
3274
  ],
3275
+ outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source],
3276
  ).then(
3277
  fn=load_selected_gcode_text,
3278
  inputs=[shape_records, gcode_text_source],
gcode_viewer.py CHANGED
@@ -13,7 +13,11 @@ import plotly.graph_objects as go
13
  # the axes named on a line change. This matches standard slicer/firmware G-code
14
  # as well as this app's own always-paired "X Y" output.
15
  _CMD_RE = re.compile(r"^G0*([01])(?![0-9])", re.IGNORECASE)
16
- _AXIS_RE = re.compile(r"([XYZ])\s*([-+]?(?:\d*\.\d+|\d+\.?))", re.IGNORECASE)
 
 
 
 
17
  # Pneumatic valve toggle: WAGO_ValveCommands(<valve>, <0=close|1=open>). Some
18
  # generators emit every move as G1 and convey extrusion only through the valve.
19
  _VALVE_RE = re.compile(r"WAGO_ValveCommands\(\s*(\d+)\s*,\s*(\d+)\s*\)", re.IGNORECASE)
 
13
  # the axes named on a line change. This matches standard slicer/firmware G-code
14
  # as well as this app's own always-paired "X Y" output.
15
  _CMD_RE = re.compile(r"^G0*([01])(?![0-9])", re.IGNORECASE)
16
+ # The value accepts a lowercase scientific exponent ("X-5.1e-08") so
17
+ # Python-formatted floats never get truncated to their mantissa ("X-5.1").
18
+ # Uppercase "E" is deliberately NOT treated as an exponent: in compact G-code
19
+ # it starts the extrusion token ("X1.2E3" means X=1.2, E=3).
20
+ _AXIS_RE = re.compile(r"([XYZxyz])\s*([-+]?(?:\d*\.\d+|\d+\.?)(?:e[-+]?\d+)?)")
21
  # Pneumatic valve toggle: WAGO_ValveCommands(<valve>, <0=close|1=open>). Some
22
  # generators emit every move as G1 and convey extrusion only through the valve.
23
  _VALVE_RE = re.compile(r"WAGO_ValveCommands\(\s*(\d+)\s*,\s*(\d+)\s*\)", re.IGNORECASE)
stl_slicer.py CHANGED
@@ -1,33 +1,37 @@
1
  from __future__ import annotations
2
 
3
  import math
4
- import tempfile
5
- import uuid
6
- import zipfile
7
  from dataclasses import dataclass
8
  from pathlib import Path
9
  from typing import Callable, Sequence
10
 
11
  import numpy as np
12
- from PIL import Image, ImageDraw
13
  from shapely.geometry import GeometryCollection, MultiPolygon, Polygon
 
14
  import trimesh
15
 
16
 
17
  ProgressCallback = Callable[[int, int], None] | None
18
  ScaleFactors = tuple[float, float, float]
 
 
 
19
 
20
 
21
  @dataclass(slots=True)
22
- class SliceStack:
23
- output_dir: Path
24
- zip_path: Path
25
- tiff_paths: list[Path]
 
 
 
 
 
26
  z_values: list[float]
27
- image_size: tuple[int, int]
28
- bounds: tuple[tuple[float, float, float], tuple[float, float, float]]
29
  layer_height: float
30
- pixel_size: float
31
 
32
 
33
  def load_mesh(stl_path: str | Path) -> trimesh.Trimesh:
@@ -110,21 +114,6 @@ def calculate_z_levels(z_min: float, z_max: float, layer_height: float) -> list[
110
  ]
111
 
112
 
113
- def _to_pixel_ring(
114
- coords: np.ndarray,
115
- x_min: float,
116
- y_min: float,
117
- pixel_size: float,
118
- image_height: int,
119
- ) -> list[tuple[int, int]]:
120
- pixels: list[tuple[int, int]] = []
121
- for x_value, y_value in coords:
122
- x_pixel = int(round((float(x_value) - x_min) / pixel_size))
123
- y_pixel = int(round((float(y_value) - y_min) / pixel_size))
124
- pixels.append((x_pixel, image_height - 1 - y_pixel))
125
- return pixels
126
-
127
-
128
  def _ring_to_world_xy(ring_coords: object, to_3d: np.ndarray) -> np.ndarray:
129
  planar = np.asarray(ring_coords, dtype=float)
130
  if planar.ndim != 2 or planar.shape[1] < 2:
@@ -171,102 +160,75 @@ def _extract_world_polygons(section: trimesh.path.Path3D) -> list[tuple[np.ndarr
171
  return polygons
172
 
173
 
174
- def _render_slice(
175
- section: trimesh.path.Path3D | None,
176
- x_min: float,
177
- y_min: float,
178
- image_size: tuple[int, int],
179
- pixel_size: float,
180
- ) -> Image.Image:
181
- image = Image.new("L", image_size, 255)
182
 
183
- if section is None:
184
- return image
 
 
 
185
 
186
- polygons = _extract_world_polygons(section)
187
- if not polygons:
188
- return image
 
 
 
 
 
 
189
 
190
- draw = ImageDraw.Draw(image)
191
- for exterior, holes in polygons:
192
- draw.polygon(
193
- _to_pixel_ring(exterior, x_min, y_min, pixel_size, image.height),
194
- fill=0,
195
- )
196
- for hole in holes:
197
- draw.polygon(
198
- _to_pixel_ring(hole, x_min, y_min, pixel_size, image.height),
199
- fill=255,
200
- )
201
 
202
- return image
203
 
 
 
 
204
 
205
- def _make_output_paths(stl_path: Path, output_root: str | Path | None) -> tuple[Path, Path]:
206
- root = Path(output_root) if output_root else Path(tempfile.mkdtemp(prefix="stl_slices_"))
207
- stem = stl_path.stem or "mesh"
208
- job_dir = root / f"{stem}_{uuid.uuid4().hex[:8]}"
209
- slices_dir = job_dir / "tiff_slices"
210
- slices_dir.mkdir(parents=True, exist_ok=True)
211
- zip_path = job_dir / f"{stem}_tiff_slices.zip"
212
- return slices_dir, zip_path
213
-
214
 
215
- def _zip_tiffs(tiff_paths: list[Path], zip_path: Path) -> None:
216
- with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
217
- for tiff_path in tiff_paths:
218
- archive.write(tiff_path, arcname=tiff_path.name)
219
 
220
 
221
- def slice_stl_to_tiffs(
222
  stl_path: str | Path,
223
  layer_height: float,
224
- pixel_size: float,
225
- output_root: str | Path | None = None,
226
  progress_callback: ProgressCallback = None,
227
  scale_factors: Sequence[float] | None = None,
228
- ) -> SliceStack:
229
- if pixel_size <= 0:
230
- raise ValueError("Pixel size must be greater than zero.")
231
-
232
  stl_path = Path(stl_path)
233
  mesh = scale_mesh(load_mesh(stl_path), scale_factors)
234
- bounds = mesh.bounds
235
- (x_min, y_min, z_min), (x_max, y_max, z_max) = bounds
236
 
237
  z_values = calculate_z_levels(float(z_min), float(z_max), layer_height)
238
- width = max(1, math.ceil((float(x_max) - float(x_min)) / pixel_size) + 1)
239
- height = max(1, math.ceil((float(y_max) - float(y_min)) / pixel_size) + 1)
240
- image_size = (width, height)
241
-
242
- output_dir, zip_path = _make_output_paths(stl_path, output_root)
243
- tiff_paths: list[Path] = []
244
 
 
245
  for index, z_value in enumerate(z_values):
246
  section = mesh.section(
247
  plane_origin=np.array([0.0, 0.0, z_value], dtype=float),
248
  plane_normal=np.array([0.0, 0.0, 1.0], dtype=float),
249
  )
250
- image = _render_slice(section, float(x_min), float(y_min), image_size, pixel_size)
251
- tiff_path = output_dir / f"slice_{index:04d}.tif"
252
- image.save(tiff_path, compression="tiff_deflate")
253
- tiff_paths.append(tiff_path)
254
 
255
  if progress_callback is not None:
256
  progress_callback(index + 1, len(z_values))
257
 
258
- _zip_tiffs(tiff_paths, zip_path)
259
-
260
- return SliceStack(
261
- output_dir=output_dir,
262
- zip_path=zip_path,
263
- tiff_paths=tiff_paths,
264
  z_values=z_values,
265
- image_size=image_size,
266
  bounds=(
267
  (float(x_min), float(y_min), float(z_min)),
268
  (float(x_max), float(y_max), float(z_max)),
269
  ),
270
  layer_height=layer_height,
271
- pixel_size=pixel_size,
272
  )
 
1
  from __future__ import annotations
2
 
3
  import math
 
 
 
4
  from dataclasses import dataclass
5
  from pathlib import Path
6
  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
 
13
 
14
  ProgressCallback = Callable[[int, int], None] | None
15
  ScaleFactors = tuple[float, float, float]
16
+ Bounds3D = tuple[tuple[float, float, float], tuple[float, float, float]]
17
+
18
+ MIN_POLYGON_AREA = 1e-9
19
 
20
 
21
  @dataclass(slots=True)
22
+ class LayerStack:
23
+ """A sliced shape as per-layer vector outlines in world-XY millimetres.
24
+
25
+ `bounds` is the scaled mesh's axis-aligned bounding box. For grid-split
26
+ pieces it is the piece's nominal cell box, which keeps reference-stack
27
+ centring stable even when the clipped geometry shrinks.
28
+ """
29
+
30
+ layers: list[MultiPolygon]
31
  z_values: list[float]
32
+ bounds: Bounds3D
 
33
  layer_height: float
34
+ name: str = ""
35
 
36
 
37
  def load_mesh(stl_path: str | Path) -> trimesh.Trimesh:
 
114
  ]
115
 
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  def _ring_to_world_xy(ring_coords: object, to_3d: np.ndarray) -> np.ndarray:
118
  planar = np.asarray(ring_coords, dtype=float)
119
  if planar.ndim != 2 or planar.shape[1] < 2:
 
160
  return polygons
161
 
162
 
163
+ def _as_multipolygon(geometry: object, min_area: float = MIN_POLYGON_AREA) -> MultiPolygon:
164
+ """Flatten any shapely result into a MultiPolygon, dropping slivers.
 
 
 
 
 
 
165
 
166
+ Single choke point for shapely's habit of returning mixed geometry types
167
+ from overlay operations (Polygon / MultiPolygon / GeometryCollection /
168
+ lines / points / empties).
169
+ """
170
+ polygons: list[Polygon] = []
171
 
172
+ def collect(geom: object) -> None:
173
+ if geom is None or getattr(geom, "is_empty", True):
174
+ return
175
+ if isinstance(geom, Polygon):
176
+ if geom.area > min_area:
177
+ polygons.append(geom)
178
+ elif isinstance(geom, (MultiPolygon, GeometryCollection)):
179
+ for part in geom.geoms:
180
+ collect(part)
181
 
182
+ collect(geometry)
183
+ return MultiPolygon(polygons)
 
 
 
 
 
 
 
 
 
184
 
 
185
 
186
+ def _section_to_multipolygon(section: trimesh.path.Path3D | None) -> MultiPolygon:
187
+ if section is None:
188
+ return MultiPolygon()
189
 
190
+ polygons = [
191
+ Polygon(exterior, holes)
192
+ for exterior, holes in _extract_world_polygons(section)
193
+ ]
194
+ if not polygons:
195
+ return MultiPolygon()
 
 
 
196
 
197
+ return _as_multipolygon(make_valid(MultiPolygon(polygons)))
 
 
 
198
 
199
 
200
+ def slice_stl_to_layers(
201
  stl_path: str | Path,
202
  layer_height: float,
 
 
203
  progress_callback: ProgressCallback = None,
204
  scale_factors: Sequence[float] | None = None,
205
+ name: str | None = None,
206
+ ) -> LayerStack:
207
+ """Slice an STL into per-layer vector outlines (world-XY millimetres)."""
 
208
  stl_path = Path(stl_path)
209
  mesh = scale_mesh(load_mesh(stl_path), scale_factors)
210
+ (x_min, y_min, z_min), (x_max, y_max, z_max) = mesh.bounds
 
211
 
212
  z_values = calculate_z_levels(float(z_min), float(z_max), layer_height)
 
 
 
 
 
 
213
 
214
+ layers: list[MultiPolygon] = []
215
  for index, z_value in enumerate(z_values):
216
  section = mesh.section(
217
  plane_origin=np.array([0.0, 0.0, z_value], dtype=float),
218
  plane_normal=np.array([0.0, 0.0, 1.0], dtype=float),
219
  )
220
+ layers.append(_section_to_multipolygon(section))
 
 
 
221
 
222
  if progress_callback is not None:
223
  progress_callback(index + 1, len(z_values))
224
 
225
+ return LayerStack(
226
+ layers=layers,
 
 
 
 
227
  z_values=z_values,
 
228
  bounds=(
229
  (float(x_min), float(y_min), float(z_min)),
230
  (float(x_max), float(y_max), float(z_max)),
231
  ),
232
  layer_height=layer_height,
233
+ name=name if name is not None else (stl_path.stem or "mesh"),
234
  )
tests/test_app_scaling.py CHANGED
@@ -2,14 +2,11 @@ from __future__ import annotations
2
 
3
  import numpy as np
4
  import trimesh
5
- from PIL import Image
6
 
7
  from app import (
8
  SCALE_MODE_TARGET_DIMENSIONS,
9
  SCALE_MODE_UNIFORM_FACTOR,
10
- generate_dynamic_stacks,
11
- split_tiff_stack_grid,
12
- split_tiff_stack_left_right,
13
  _resolve_mesh_scale_factors,
14
  _uniform_target_extents_from_anchor,
15
  )
@@ -59,163 +56,46 @@ def test_uniform_target_extents_update_from_changed_side() -> None:
59
  np.testing.assert_allclose(target_extents, (6.0, 12.0, 24.0))
60
 
61
 
62
- def test_generate_dynamic_stacks_empty_input_resets_reference_stack() -> None:
63
- outputs = generate_dynamic_stacks([], [], 0.8, 0.8, SCALE_MODE_TARGET_DIMENSIONS)
64
-
65
- assert len(outputs) == 11
66
- assert outputs[0] == []
67
- assert outputs[2] == "Upload at least one STL first."
68
- assert outputs[7]["tiff_paths"] == []
69
- assert outputs[9] == "No reference stack generated yet."
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
- def test_split_tiff_stack_left_right_preserves_pixels_and_metadata(tmp_path) -> None:
73
- pixels = np.array(
74
- [
75
- [0, 255, 0, 255, 0],
76
- [255, 0, 255, 0, 255],
77
- ],
78
- dtype=np.uint8,
79
  )
80
- tiff_path = tmp_path / "slice_0000.tif"
81
- Image.fromarray(pixels, mode="L").save(tiff_path)
82
- state = {
83
- "tiff_paths": [str(tiff_path)],
84
- "z_values": [1.25],
85
- "pixel_size": 0.5,
86
- "x_min": 10.0,
87
- "y_min": -2.0,
88
- "image_width": 5,
89
- "image_height": 2,
90
- }
91
-
92
- left_state, right_state, left_zip, right_zip = split_tiff_stack_left_right(state, "wide-part")
93
-
94
- assert left_zip.exists()
95
- assert right_zip.exists()
96
- assert left_state["image_width"] == 3
97
- assert right_state["image_width"] == 3
98
- assert left_state["x_min"] == 10.0
99
- assert right_state["x_min"] == 11.5
100
- assert left_state["z_values"] == [1.25]
101
- assert right_state["z_values"] == [1.25]
102
-
103
- with Image.open(left_state["tiff_paths"][0]) as left_image:
104
- np.testing.assert_array_equal(np.asarray(left_image), pixels[:, :3])
105
- with Image.open(right_state["tiff_paths"][0]) as right_image:
106
- expected = np.full((2, 3), 255, dtype=np.uint8)
107
- expected[:, :2] = pixels[:, 3:]
108
- np.testing.assert_array_equal(np.asarray(right_image), expected)
109
-
110
-
111
- def test_split_tiff_stack_grid_preserves_pixels_and_offsets(tmp_path) -> None:
112
- pixels = np.arange(20, dtype=np.uint8).reshape((4, 5))
113
- tiff_path = tmp_path / "slice_0000.tif"
114
- Image.fromarray(pixels, mode="L").save(tiff_path)
115
- state = {
116
- "tiff_paths": [str(tiff_path)],
117
- "z_values": [2.0],
118
- "pixel_size": 0.25,
119
- "x_min": 4.0,
120
- "y_min": 10.0,
121
- "image_width": 5,
122
- "image_height": 4,
123
- }
124
-
125
- pieces = split_tiff_stack_grid(state, "grid-part", columns=2, rows=2)
126
-
127
- assert [(piece["row"], piece["col"]) for piece in pieces] == [(1, 1), (1, 2), (2, 1), (2, 2)]
128
- assert [piece["state"]["image_width"] for piece in pieces] == [3, 3, 3, 3]
129
- assert [piece["state"]["image_height"] for piece in pieces] == [2, 2, 2, 2]
130
- assert [piece["state"]["x_min"] for piece in pieces] == [4.0, 4.75, 4.0, 4.75]
131
- assert [piece["state"]["y_min"] for piece in pieces] == [10.5, 10.5, 10.0, 10.0]
132
- assert all(piece["zip_path"].exists() for piece in pieces)
133
-
134
- right_top = np.full((2, 3), 255, dtype=np.uint8)
135
- right_top[:, :2] = pixels[:2, 3:]
136
- right_bottom = np.full((2, 3), 255, dtype=np.uint8)
137
- right_bottom[:, :2] = pixels[2:, 3:]
138
- expected = [
139
- pixels[:2, :3],
140
- right_top,
141
- pixels[2:, :3],
142
- right_bottom,
143
- ]
144
- for piece, expected_pixels in zip(pieces, expected):
145
- with Image.open(piece["state"]["tiff_paths"][0]) as image:
146
- np.testing.assert_array_equal(np.asarray(image), expected_pixels)
147
-
148
-
149
- def test_split_tiff_stack_grid_pads_more_than_three_columns_to_equal_widths(tmp_path) -> None:
150
- pixels = np.arange(20, dtype=np.uint8).reshape((2, 10))
151
- tiff_path = tmp_path / "slice_0000.tif"
152
- Image.fromarray(pixels, mode="L").save(tiff_path)
153
- state = {
154
- "tiff_paths": [str(tiff_path)],
155
- "z_values": [0.0],
156
- "pixel_size": 1.0,
157
- "x_min": 100.0,
158
- "y_min": 0.0,
159
- "image_width": 10,
160
- "image_height": 2,
161
- }
162
-
163
- pieces = split_tiff_stack_grid(state, "four-way", columns=4, rows=1)
164
-
165
- assert [piece["state"]["image_width"] for piece in pieces] == [3, 3, 3, 3]
166
- assert [piece["state"]["x_min"] for piece in pieces] == [99.0, 102.0, 105.0, 108.0]
167
-
168
- first_expected = np.full((2, 3), 255, dtype=np.uint8)
169
- first_expected[:, 1:] = pixels[:, :2]
170
- last_expected = np.full((2, 3), 255, dtype=np.uint8)
171
- last_expected[:, :2] = pixels[:, 8:]
172
- expected = [
173
- first_expected,
174
- pixels[:, 2:5],
175
- pixels[:, 5:8],
176
- last_expected,
177
- ]
178
- for piece, expected_pixels in zip(pieces, expected):
179
- with Image.open(piece["state"]["tiff_paths"][0]) as image:
180
- np.testing.assert_array_equal(np.asarray(image), expected_pixels)
181
-
182
-
183
- def test_split_tiff_stack_grid_overlapping_layers_keep_small_alignment_margin(tmp_path) -> None:
184
- layer0 = np.zeros((2, 6), dtype=np.uint8)
185
- layer1 = np.zeros((2, 6), dtype=np.uint8)
186
- layer0_path = tmp_path / "slice_0000.tif"
187
- layer1_path = tmp_path / "slice_0001.tif"
188
- Image.fromarray(layer0, mode="L").save(layer0_path)
189
- Image.fromarray(layer1, mode="L").save(layer1_path)
190
- state = {
191
- "tiff_paths": [str(layer0_path), str(layer1_path)],
192
- "z_values": [0.0, 1.0],
193
- "pixel_size": 0.5,
194
- "x_min": 10.0,
195
- "y_min": -2.0,
196
- "image_width": 6,
197
- "image_height": 2,
198
- }
199
-
200
- left, right = split_tiff_stack_grid(state, "overlap-part", columns=2, rows=1, overlapping_layers=True)
201
-
202
- assert left["state"]["image_width"] == 5
203
- assert right["state"]["image_width"] == 5
204
- assert left["state"]["x_min"] == 9.5
205
- assert right["state"]["x_min"] == 11.0
206
- with Image.open(left["state"]["tiff_paths"][0]) as left_layer0:
207
- expected = np.full((2, 5), 255, dtype=np.uint8)
208
- expected[:, 1:] = 0
209
- np.testing.assert_array_equal(np.asarray(left_layer0), expected)
210
- with Image.open(right["state"]["tiff_paths"][0]) as right_layer0:
211
- expected = np.full((2, 5), 255, dtype=np.uint8)
212
- expected[:, 2:4] = 0
213
- np.testing.assert_array_equal(np.asarray(right_layer0), expected)
214
- with Image.open(left["state"]["tiff_paths"][1]) as left_layer1:
215
- expected = np.full((2, 5), 255, dtype=np.uint8)
216
- expected[:, 1:3] = 0
217
- np.testing.assert_array_equal(np.asarray(left_layer1), expected)
218
- with Image.open(right["state"]["tiff_paths"][1]) as right_layer1:
219
- expected = np.full((2, 5), 255, dtype=np.uint8)
220
- expected[:, :4] = 0
221
- np.testing.assert_array_equal(np.asarray(right_layer1), expected)
 
2
 
3
  import numpy as np
4
  import trimesh
 
5
 
6
  from app import (
7
  SCALE_MODE_TARGET_DIMENSIONS,
8
  SCALE_MODE_UNIFORM_FACTOR,
9
+ generate_dynamic_layer_stacks,
 
 
10
  _resolve_mesh_scale_factors,
11
  _uniform_target_extents_from_anchor,
12
  )
 
56
  np.testing.assert_allclose(target_extents, (6.0, 12.0, 24.0))
57
 
58
 
59
+ def test_generate_dynamic_layer_stacks_empty_input_resets_reference() -> None:
60
+ records, status, ref_layers = generate_dynamic_layer_stacks(
61
+ [],
62
+ [],
63
+ 0.8,
64
+ SCALE_MODE_TARGET_DIMENSIONS,
65
+ )
 
66
 
67
+ assert records == []
68
+ assert status == "Upload at least one STL first."
69
+ assert ref_layers is None
70
+
71
+
72
+ def test_generate_dynamic_layer_stacks_slices_shapes_and_builds_reference(tmp_path) -> None:
73
+ mesh = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
74
+ stl_path = tmp_path / "cube.stl"
75
+ mesh.export(stl_path)
76
+ records = [
77
+ {
78
+ "idx": 1,
79
+ "name": "cube",
80
+ "stl_path": str(stl_path),
81
+ "target_x": 2.0,
82
+ "target_y": 2.0,
83
+ "target_z": 2.0,
84
+ }
85
+ ]
86
 
87
+ next_records, status, ref_layers = generate_dynamic_layer_stacks(
88
+ records,
89
+ None,
90
+ 0.5,
91
+ SCALE_MODE_TARGET_DIMENSIONS,
 
 
92
  )
93
+
94
+ stack = next_records[0]["layer_stack"]
95
+ assert stack is not None
96
+ assert len(stack.layers) == 4
97
+ assert next_records[0]["slice_params"]["layer_height"] == 0.5
98
+ assert "sliced 4 layers" in status
99
+ assert ref_layers is not None
100
+ assert len(ref_layers.layers) == 4
101
+ assert ref_layers.layers[0].area == stack.layers[0].area
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_gcode_viewer.py CHANGED
@@ -16,3 +16,35 @@ def test_parse_gcode_classifies_g0_as_travel_and_g1_as_print() -> None:
16
 
17
  assert len(parsed["travel_segments"]) == 1
18
  assert len(parsed["print_segments"]) == 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  assert len(parsed["travel_segments"]) == 1
18
  assert len(parsed["print_segments"]) == 1
19
+
20
+
21
+ def test_parse_gcode_reads_scientific_notation_instead_of_mantissa() -> None:
22
+ parsed = parse_gcode_path(
23
+ "\n".join(
24
+ [
25
+ "G91",
26
+ "G0 X-5.1e-08 Y0.8 ; tiny float-noise travel",
27
+ "G1 X4.0 Y0.0 ; print",
28
+ ]
29
+ )
30
+ )
31
+
32
+ (segment,) = parsed["print_segments"]
33
+ start, end = segment[0], segment[-1]
34
+ # "X-5.1e-08" must move the head by ~0, not by -5.1.
35
+ assert abs(start[0]) < 1e-6
36
+ assert abs(end[0] - 4.0) < 1e-6
37
+
38
+
39
+ def test_parse_gcode_keeps_uppercase_extrusion_token_out_of_x_axis() -> None:
40
+ parsed = parse_gcode_path(
41
+ "\n".join(
42
+ [
43
+ "G91",
44
+ "G1 X1.2E3 Y0.5", # compact G-code: X=1.2, E=3 (not X=1200)
45
+ ]
46
+ )
47
+ )
48
+
49
+ (segment,) = parsed["print_segments"]
50
+ assert abs(segment[-1][0] - 1.2) < 1e-9
tests/test_nozzle_spacing.py CHANGED
@@ -22,8 +22,7 @@ from app import (
22
  _resolve_nozzle_grid_layout,
23
  _resolve_nozzle_layout,
24
  )
25
- from tiff_to_gcode import (
26
- CONTOUR_MODE_ROW_ENVELOPE,
27
  RASTER_PATTERN_SAME_DIRECTION,
28
  RASTER_PATTERN_Y_DIRECTION,
29
  )
@@ -256,25 +255,29 @@ def test_shape_settings_round_trip_contour_tracing_column() -> None:
256
  assert updated[0]["contour_tracing"] is True
257
 
258
 
259
- def test_contour_tracing_sources_request_shape_optimized_mode() -> None:
 
 
 
 
 
 
 
 
 
 
 
260
  sources = _contour_tracing_sources(
261
  [
262
- {
263
- "idx": 2,
264
- "contour_tracing": True,
265
- "tiff_state": {"tiff_paths": ["slice_0000.tif"]},
266
- }
267
  ]
268
  )
269
 
270
- assert sources == [
271
- {
272
- "owner_idx": 2,
273
- "contour_mode": CONTOUR_MODE_ROW_ENVELOPE,
274
- "tiff_paths": ["slice_0000.tif"],
275
- "zip_path": None,
276
- }
277
- ]
278
 
279
 
280
  def test_repeated_sample_path_gets_next_unused_nozzle() -> None:
 
22
  _resolve_nozzle_grid_layout,
23
  _resolve_nozzle_layout,
24
  )
25
+ from vector_toolpath import (
 
26
  RASTER_PATTERN_SAME_DIRECTION,
27
  RASTER_PATTERN_Y_DIRECTION,
28
  )
 
255
  assert updated[0]["contour_tracing"] is True
256
 
257
 
258
+ def test_contour_tracing_sources_use_sliced_layer_stacks() -> None:
259
+ from shapely.geometry import MultiPolygon, box
260
+
261
+ from stl_slicer import LayerStack
262
+
263
+ stack = LayerStack(
264
+ layers=[MultiPolygon([box(0.0, 0.0, 1.0, 1.0)])],
265
+ z_values=[0.5],
266
+ bounds=((0.0, 0.0, 0.0), (1.0, 1.0, 1.0)),
267
+ layer_height=1.0,
268
+ name="traced",
269
+ )
270
  sources = _contour_tracing_sources(
271
  [
272
+ {"idx": 1, "contour_tracing": False, "layer_stack": stack},
273
+ {"idx": 2, "contour_tracing": True, "layer_stack": stack},
274
+ {"idx": 3, "contour_tracing": True, "layer_stack": None},
 
 
275
  ]
276
  )
277
 
278
+ assert len(sources) == 1
279
+ assert sources[0].owner_idx == 2
280
+ assert sources[0].stack is stack
 
 
 
 
 
281
 
282
 
283
  def test_repeated_sample_path_gets_next_unused_nozzle() -> None:
tests/test_stl_slicer.py CHANGED
@@ -1,16 +1,17 @@
1
  from __future__ import annotations
2
 
3
  import numpy as np
4
- from PIL import Image
5
  from shapely.geometry import Polygon
6
  import trimesh
7
 
 
 
8
  from stl_slicer import (
9
  _compose_even_odd_polygons,
10
  calculate_z_levels,
11
  scale_factors_for_target_extents,
12
  scale_mesh,
13
- slice_stl_to_tiffs,
14
  )
15
 
16
 
@@ -21,26 +22,43 @@ def test_calculate_z_levels_creates_single_layer_for_thin_mesh() -> None:
21
  assert 0.0 <= z_values[0] < 0.01
22
 
23
 
24
- def test_slice_stl_to_tiffs_creates_non_empty_tiffs(tmp_path) -> None:
25
  mesh = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
26
  stl_path = tmp_path / "cube.stl"
27
  mesh.export(stl_path)
28
 
29
- stack = slice_stl_to_tiffs(
30
- stl_path,
31
- layer_height=0.5,
32
- pixel_size=0.25,
33
- output_root=tmp_path / "generated",
 
 
34
  )
 
 
35
 
36
- assert len(stack.tiff_paths) == 4
37
- assert stack.zip_path.exists()
38
- assert all(path.exists() for path in stack.tiff_paths)
 
 
 
 
 
 
 
39
 
40
- with Image.open(stack.tiff_paths[0]) as first_image:
41
- pixels = np.array(first_image)
 
 
 
42
 
43
- assert np.any(pixels == 0)
 
 
 
44
 
45
 
46
  def test_scale_mesh_matches_target_extents_and_preserves_min_corner() -> None:
@@ -55,25 +73,6 @@ def test_scale_mesh_matches_target_extents_and_preserves_min_corner() -> None:
55
  np.testing.assert_allclose(scaled.bounds[0], mesh.bounds[0])
56
 
57
 
58
- def test_slice_stl_to_tiffs_applies_scale_factors(tmp_path) -> None:
59
- mesh = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
60
- stl_path = tmp_path / "cube.stl"
61
- mesh.export(stl_path)
62
-
63
- stack = slice_stl_to_tiffs(
64
- stl_path,
65
- layer_height=0.5,
66
- pixel_size=0.25,
67
- output_root=tmp_path / "scaled",
68
- scale_factors=(2.0, 1.0, 0.5),
69
- )
70
-
71
- bounds = np.array(stack.bounds)
72
- np.testing.assert_allclose(bounds[1] - bounds[0], (4.0, 2.0, 1.0))
73
- assert stack.image_size == (17, 9)
74
- assert len(stack.tiff_paths) == 2
75
-
76
-
77
  def test_compose_even_odd_polygons_preserves_holes() -> None:
78
  outer = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
79
  inner = Polygon([(3, 3), (7, 3), (7, 7), (3, 7)])
 
1
  from __future__ import annotations
2
 
3
  import numpy as np
 
4
  from shapely.geometry import Polygon
5
  import trimesh
6
 
7
+ import pytest
8
+
9
  from stl_slicer import (
10
  _compose_even_odd_polygons,
11
  calculate_z_levels,
12
  scale_factors_for_target_extents,
13
  scale_mesh,
14
+ slice_stl_to_layers,
15
  )
16
 
17
 
 
22
  assert 0.0 <= z_values[0] < 0.01
23
 
24
 
25
+ def test_slice_stl_to_layers_creates_layer_polygons(tmp_path) -> None:
26
  mesh = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
27
  stl_path = tmp_path / "cube.stl"
28
  mesh.export(stl_path)
29
 
30
+ stack = slice_stl_to_layers(stl_path, layer_height=0.5)
31
+
32
+ assert len(stack.layers) == 4
33
+ assert len(stack.z_values) == 4
34
+ assert all(
35
+ later > earlier
36
+ for earlier, later in zip(stack.z_values, stack.z_values[1:])
37
  )
38
+ for layer in stack.layers:
39
+ assert layer.area == pytest.approx(4.0)
40
 
41
+ (x_min, y_min, z_min), (x_max, y_max, z_max) = stack.bounds
42
+ assert (x_max - x_min, y_max - y_min, z_max - z_min) == pytest.approx((2.0, 2.0, 2.0))
43
+ assert stack.name == "cube"
44
+ assert stack.layer_height == 0.5
45
+
46
+
47
+ def test_slice_stl_to_layers_applies_scale_factors(tmp_path) -> None:
48
+ mesh = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
49
+ stl_path = tmp_path / "cube.stl"
50
+ mesh.export(stl_path)
51
 
52
+ stack = slice_stl_to_layers(
53
+ stl_path,
54
+ layer_height=0.5,
55
+ scale_factors=(2.0, 1.0, 0.5),
56
+ )
57
 
58
+ bounds = np.array(stack.bounds)
59
+ np.testing.assert_allclose(bounds[1] - bounds[0], (4.0, 2.0, 1.0))
60
+ assert len(stack.layers) == 2
61
+ assert stack.layers[0].area == pytest.approx(8.0)
62
 
63
 
64
  def test_scale_mesh_matches_target_extents_and_preserves_min_corner() -> None:
 
73
  np.testing.assert_allclose(scaled.bounds[0], mesh.bounds[0])
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  def test_compose_even_odd_polygons_preserves_holes() -> None:
77
  outer = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
78
  inner = Polygon([(3, 3), (7, 3), (7, 7), (3, 7)])
tests/test_vector_gcode.py ADDED
@@ -0,0 +1,823 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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
9
+ from vector_toolpath import (
10
+ RASTER_PATTERN_CIRCLE_SPIRAL,
11
+ RASTER_PATTERN_RECTANGULAR_SPIRAL,
12
+ RASTER_PATTERN_WOODPILE,
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,
20
+ split_layer_stack_grid,
21
+ )
22
+
23
+
24
+ def _stack(
25
+ *layers: Polygon | MultiPolygon | None,
26
+ layer_height: float = 1.0,
27
+ name: str = "shape",
28
+ ) -> LayerStack:
29
+ multipolygons: list[MultiPolygon] = []
30
+ for layer in layers:
31
+ if layer is None:
32
+ multipolygons.append(MultiPolygon())
33
+ elif isinstance(layer, MultiPolygon):
34
+ multipolygons.append(layer)
35
+ else:
36
+ multipolygons.append(MultiPolygon([layer]))
37
+
38
+ bounds_list = [layer.bounds for layer in multipolygons if not layer.is_empty]
39
+ if bounds_list:
40
+ x_min = min(b[0] for b in bounds_list)
41
+ y_min = min(b[1] for b in bounds_list)
42
+ x_max = max(b[2] for b in bounds_list)
43
+ y_max = max(b[3] for b in bounds_list)
44
+ else:
45
+ x_min = y_min = x_max = y_max = 0.0
46
+
47
+ return LayerStack(
48
+ layers=multipolygons,
49
+ z_values=[(index + 0.5) * layer_height for index in range(len(multipolygons))],
50
+ bounds=((x_min, y_min, 0.0), (x_max, y_max, len(multipolygons) * layer_height)),
51
+ layer_height=layer_height,
52
+ name=name,
53
+ )
54
+
55
+
56
+ def _move_signature(gcode_text: str) -> list[tuple[float | None, float | None, float | None]]:
57
+ signature: list[tuple[float | None, float | None, float | None]] = []
58
+ for line in gcode_text.splitlines():
59
+ if not line.startswith(("G0", "G1")):
60
+ continue
61
+ axes: dict[str, float] = {}
62
+ for token in line.split():
63
+ if token[:1] in {"X", "Y", "Z"}:
64
+ axes[token[0]] = float(token[1:])
65
+ signature.append((axes.get("X"), axes.get("Y"), axes.get("Z")))
66
+ return signature
67
+
68
+
69
+ def _move_endpoints_for_color(gcode_text: str, color: int) -> list[tuple[float, float]]:
70
+ x = y = 0.0
71
+ endpoints: list[tuple[float, float]] = []
72
+ for line in gcode_text.splitlines():
73
+ if not line.startswith(("G0", "G1")):
74
+ continue
75
+ start = (x, y)
76
+ for token in line.split():
77
+ if token.startswith("X"):
78
+ x += float(token[1:])
79
+ if token.startswith("Y"):
80
+ y += float(token[1:])
81
+ if f"; Color {color}" in line:
82
+ endpoints.extend([start, (x, y)])
83
+ return endpoints
84
+
85
+
86
+ def _moves_with_colors(gcode_text: str) -> list[dict]:
87
+ x = y = z = 0.0
88
+ moves: list[dict] = []
89
+ for line in gcode_text.splitlines():
90
+ if not line.startswith(("G0", "G1")):
91
+ continue
92
+ start = (x, y, z)
93
+ for token in line.split():
94
+ if token.startswith("X"):
95
+ x += float(token[1:])
96
+ if token.startswith("Y"):
97
+ y += float(token[1:])
98
+ if token.startswith("Z"):
99
+ z += float(token[1:])
100
+ color = None
101
+ if "; Color " in line:
102
+ color = int(line.rsplit("; Color ", 1)[1])
103
+ moves.append({"start": start, "end": (x, y, z), "color": color})
104
+ return moves
105
+
106
+
107
+ def _pressure_set_count(gcode_text: str) -> int:
108
+ return gcode_text.count("\\x30\\x38\\x50\\x53") + gcode_text.count("setpress(")
109
+
110
+
111
+ def test_gcode_writes_fixed_point_coordinates_never_scientific(tmp_path) -> None:
112
+ from vector_gcode import write_gcode_file
113
+
114
+ gcode_path = tmp_path / "noise.txt"
115
+ write_gcode_file(
116
+ gcode_path,
117
+ [
118
+ {"X": -5.1e-08, "Y": 0.8, "Color": 0},
119
+ {"X": 1.2e-05, "Y": 0.0, "Color": 255},
120
+ {"X": 4.0, "Y": -0.0, "Color": 0},
121
+ ],
122
+ pressure=25,
123
+ valve=7,
124
+ port=3,
125
+ increase_pressure_per_layer=0.1,
126
+ pressure_ramp_enabled=True,
127
+ all_g1=False,
128
+ )
129
+
130
+ move_lines = [
131
+ line for line in gcode_path.read_text().splitlines() if line.startswith(("G0", "G1"))
132
+ ]
133
+ assert move_lines == [
134
+ "G0 X0.0 Y0.8 ; Color 0",
135
+ "G1 X0.000012 Y0.0 ; Color 255",
136
+ "G0 X4.0 Y0.0 ; Color 0",
137
+ ]
138
+
139
+
140
+ def test_slanted_shape_gcode_round_trips_through_the_viewer(tmp_path) -> None:
141
+ from gcode_viewer import parse_gcode_path
142
+
143
+ # Slanted edges produce float-noise sweep bounds that differ between rows
144
+ # (the pyramid failure mode); the parsed positions must stay inside the
145
+ # material footprint on every layer.
146
+ layers = [
147
+ Polygon(
148
+ [
149
+ (inset, inset),
150
+ (20.0 - inset, inset),
151
+ (20.0 - inset, 20.0 - inset),
152
+ (inset, 20.0 - inset),
153
+ ]
154
+ )
155
+ for inset in (0.0, 0.57735026918962, 1.15470053837925, 1.73205080756887)
156
+ ]
157
+ gcode_path = generate_vector_gcode(
158
+ _stack(*layers),
159
+ shape_name="slanted",
160
+ pressure=25,
161
+ valve=7,
162
+ port=3,
163
+ fil_width=0.8,
164
+ layer_height=1.0,
165
+ output_dir=tmp_path,
166
+ )
167
+
168
+ parsed = parse_gcode_path(gcode_path.read_text())
169
+ for segment in parsed["print_segments"]:
170
+ for x, y, _z in segment:
171
+ # Origin sits one fil_width left of the layer-0 sweep start; all
172
+ # print positions stay within the 20 mm footprint plus buffers.
173
+ assert -1.0 <= x <= 21.0
174
+ assert -1.0 <= y <= 21.0
175
+
176
+
177
+ def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> None:
178
+ gcode_path = generate_vector_gcode(
179
+ _stack(box(0.0, 0.0, 1.0, 1.0)),
180
+ shape_name="header_order",
181
+ pressure=25,
182
+ valve=7,
183
+ port=3,
184
+ fil_width=1.0,
185
+ output_dir=tmp_path,
186
+ )
187
+
188
+ lines = [
189
+ line.strip()
190
+ for line in gcode_path.read_text().splitlines()
191
+ if line.strip()
192
+ ]
193
+
194
+ assert lines[0] == "G91"
195
+ assert lines[1] == "{aux_command}WAGO_ValveCommands(7, 0)"
196
+ assert lines[2] == "serialPort3.write(eval(setpress(25)))"
197
+ assert lines[3] == "serialPort3.write(eval(togglepress()))"
198
+ assert lines[4].startswith("{aux_command}WAGO_ValveCommands(")
199
+ assert lines[5].startswith("{aux_command}WAGO_ValveCommands(")
200
+
201
+
202
+ def test_gcode_lead_in_runs_once_before_first_layer(tmp_path) -> None:
203
+ gcode_path = generate_vector_gcode(
204
+ _stack(box(0.0, 0.0, 0.5, 0.5), box(0.0, 0.0, 0.5, 0.5)),
205
+ shape_name="lead_in",
206
+ pressure=25,
207
+ valve=7,
208
+ port=3,
209
+ fil_width=0.5,
210
+ layer_height=1.0,
211
+ lead_in_enabled=True,
212
+ lead_in_length=3.0,
213
+ lead_in_clearance=4.0,
214
+ lead_in_lines=3,
215
+ output_dir=tmp_path,
216
+ )
217
+
218
+ moves = _moves_with_colors(gcode_path.read_text())
219
+
220
+ assert moves[:7] == [
221
+ {"start": (0.0, 0.0, 0.0), "end": (-7.0, 0.0, 0.0), "color": 0},
222
+ {"start": (-7.0, 0.0, 0.0), "end": (-4.0, 0.0, 0.0), "color": 255},
223
+ {"start": (-4.0, 0.0, 0.0), "end": (-4.0, 0.5, 0.0), "color": 0},
224
+ {"start": (-4.0, 0.5, 0.0), "end": (-7.0, 0.5, 0.0), "color": 255},
225
+ {"start": (-7.0, 0.5, 0.0), "end": (-7.0, 1.0, 0.0), "color": 0},
226
+ {"start": (-7.0, 1.0, 0.0), "end": (-4.0, 1.0, 0.0), "color": 255},
227
+ {"start": (-4.0, 1.0, 0.0), "end": (0.0, 0.0, 0.0), "color": 0},
228
+ ]
229
+ assert all(move["end"][2] == 0.0 for move in moves[:7])
230
+
231
+ first_z_index = next(index for index, move in enumerate(moves) if move["end"][2] > 0.0)
232
+ assert first_z_index > 7
233
+ assert not any(
234
+ move["start"][0] < -3.0 or move["end"][0] < -3.0
235
+ for move in moves[first_z_index:]
236
+ )
237
+
238
+
239
+ def test_gcode_pressure_ramp_can_be_disabled(tmp_path) -> None:
240
+ stack = _stack(box(0.0, 0.0, 1.0, 1.0), box(0.0, 0.0, 1.0, 1.0))
241
+
242
+ ramped_path = generate_vector_gcode(
243
+ stack,
244
+ shape_name="pressure_ramped",
245
+ pressure=25,
246
+ valve=7,
247
+ port=3,
248
+ fil_width=1.0,
249
+ layer_height=1.0,
250
+ pressure_ramp_enabled=True,
251
+ output_dir=tmp_path / "ramped",
252
+ )
253
+ fixed_path = generate_vector_gcode(
254
+ stack,
255
+ shape_name="pressure_fixed",
256
+ pressure=25,
257
+ valve=7,
258
+ port=3,
259
+ fil_width=1.0,
260
+ layer_height=1.0,
261
+ pressure_ramp_enabled=False,
262
+ output_dir=tmp_path / "fixed",
263
+ )
264
+
265
+ assert _pressure_set_count(ramped_path.read_text()) > 1
266
+ assert _pressure_set_count(fixed_path.read_text()) == 1
267
+
268
+
269
+ def test_gcode_uses_g1_for_print_and_g0_for_travel(tmp_path) -> None:
270
+ gcode_path = generate_vector_gcode(
271
+ _stack(box(0.0, 0.0, 1.0, 1.0)),
272
+ shape_name="move_types",
273
+ pressure=25,
274
+ valve=7,
275
+ port=3,
276
+ fil_width=1.0,
277
+ output_dir=tmp_path,
278
+ )
279
+
280
+ move_lines = [
281
+ line.strip()
282
+ for line in gcode_path.read_text().splitlines()
283
+ if line.startswith(("G0", "G1"))
284
+ ]
285
+
286
+ assert any(line.startswith("G1") and "; Color 255" in line for line in move_lines)
287
+ assert all(not line.startswith("G0") for line in move_lines if "; Color 255" in line)
288
+ assert all(not line.startswith("G1") for line in move_lines if "; Color 0" in line)
289
+
290
+
291
+ def test_woodpile_raster_switches_print_axis_between_layers(tmp_path) -> None:
292
+ layer = box(0.0, 0.0, 3.0, 2.0)
293
+ gcode_path = generate_vector_gcode(
294
+ _stack(layer, layer, layer, layer),
295
+ shape_name="woodpile",
296
+ pressure=25,
297
+ valve=7,
298
+ port=3,
299
+ fil_width=1.0,
300
+ raster_pattern=RASTER_PATTERN_WOODPILE,
301
+ output_dir=tmp_path,
302
+ )
303
+
304
+ gcode_text = gcode_path.read_text()
305
+ move_lines = [
306
+ line.strip()
307
+ for line in gcode_text.splitlines()
308
+ if line.startswith(("G0", "G1"))
309
+ ]
310
+ z_move_index = next(i for i, line in enumerate(move_lines) if " Z" in line)
311
+ first_layer_prints = [
312
+ line
313
+ for line in move_lines[:z_move_index]
314
+ if line.startswith("G1") and "; Color 255" in line
315
+ ]
316
+ second_layer_end = next(
317
+ (i for i, line in enumerate(move_lines[z_move_index + 1 :], start=z_move_index + 1) if " Z" in line),
318
+ len(move_lines),
319
+ )
320
+ second_layer_prints = [
321
+ line
322
+ for line in move_lines[z_move_index + 1 : second_layer_end]
323
+ if line.startswith("G1") and "; Color 255" in line
324
+ ]
325
+
326
+ assert move_lines[0] == "G0 X1.0 Y0.0 ; Color 0"
327
+ # Layer 0 prints sweep along X, layer 1 prints sweep along Y.
328
+ assert first_layer_prints
329
+ assert all("Y0.0" in line for line in first_layer_prints)
330
+ assert second_layer_prints
331
+ assert all("X0.0" in line for line in second_layer_prints)
332
+
333
+ x = y = 0.0
334
+ x_positions = [x]
335
+ y_positions = [y]
336
+ for line in move_lines:
337
+ for token in line.split():
338
+ if token.startswith("X"):
339
+ x += float(token[1:])
340
+ if token.startswith("Y"):
341
+ y += float(token[1:])
342
+ x_positions.append(x)
343
+ y_positions.append(y)
344
+ assert min(x_positions) == 0.0
345
+ assert max(x_positions) == 5.0
346
+ assert min(y_positions) == -1.5
347
+ assert max(y_positions) == 2.5
348
+
349
+ # Each layer restarts at the sweep-start candidate nearest the previous
350
+ # layer's endpoint. Candidates are the four buffered sweep corners, here
351
+ # in cumulative coordinates (origin = layer 0's start at world (-1, 0.5)).
352
+ y_axis_candidates = [(1.5, -1.5), (1.5, 2.5), (3.5, -1.5), (3.5, 2.5)]
353
+ x_axis_candidates = [(0.0, 0.0), (0.0, 1.0), (5.0, 0.0), (5.0, 1.0)]
354
+ moves = _moves_with_colors(gcode_text)
355
+ layer_changes = [move for move in moves if move["end"][2] > move["start"][2]]
356
+ assert len(layer_changes) == 3
357
+ for layer_number, layer_change in enumerate(layer_changes, start=1):
358
+ candidates = y_axis_candidates if layer_number % 2 == 1 else x_axis_candidates
359
+ start = layer_change["start"][:2]
360
+ end = layer_change["end"][:2]
361
+ assert end in candidates
362
+ best = min(math.dist(start, candidate) for candidate in candidates)
363
+ assert math.dist(start, end) <= best + 1e-9
364
+
365
+
366
+ def test_y_direction_raster_prints_each_layer_along_y_axis(tmp_path) -> None:
367
+ layer = box(0.0, 0.0, 3.0, 2.0)
368
+ gcode_path = generate_vector_gcode(
369
+ _stack(layer, layer),
370
+ shape_name="y_direction",
371
+ pressure=25,
372
+ valve=7,
373
+ port=3,
374
+ fil_width=1.0,
375
+ raster_pattern=RASTER_PATTERN_Y_DIRECTION,
376
+ output_dir=tmp_path,
377
+ )
378
+
379
+ gcode_text = gcode_path.read_text()
380
+ move_lines = [
381
+ line.strip()
382
+ for line in gcode_text.splitlines()
383
+ if line.startswith(("G0", "G1"))
384
+ ]
385
+ print_lines = [
386
+ line
387
+ for line in move_lines
388
+ if line.startswith("G1") and "; Color 255" in line
389
+ ]
390
+ assert print_lines
391
+ assert move_lines[0] == "G0 X0.0 Y1.0 ; Color 0"
392
+ assert all("X0.0" in line and "Y0.0" not in line for line in print_lines)
393
+
394
+ x = y = 0.0
395
+ x_positions = [x]
396
+ y_positions = [y]
397
+ for line in move_lines:
398
+ for token in line.split():
399
+ if token.startswith("X"):
400
+ x += float(token[1:])
401
+ if token.startswith("Y"):
402
+ y += float(token[1:])
403
+ x_positions.append(x)
404
+ y_positions.append(y)
405
+ assert min(x_positions) == 0.0
406
+ assert max(x_positions) == 2.0
407
+ assert min(y_positions) == 0.0
408
+ assert max(y_positions) == 4.0
409
+
410
+ moves = _moves_with_colors(gcode_text)
411
+ first_layer_change = next(
412
+ move for move in moves if move["end"][2] > move["start"][2]
413
+ )
414
+ assert first_layer_change["start"][:2] == first_layer_change["end"][:2]
415
+
416
+
417
+ def test_raster_crosses_interior_holes_with_valve_off(tmp_path) -> None:
418
+ hollow = Polygon(
419
+ box(0.0, 0.0, 6.0, 6.0).exterior.coords,
420
+ [list(box(2.0, 2.0, 4.0, 4.0).exterior.coords)],
421
+ )
422
+ gcode_path = generate_vector_gcode(
423
+ _stack(hollow),
424
+ shape_name="hollow",
425
+ pressure=25,
426
+ valve=7,
427
+ port=3,
428
+ fil_width=1.0,
429
+ output_dir=tmp_path,
430
+ )
431
+
432
+ moves = _moves_with_colors(gcode_path.read_text())
433
+ print_moves = [move for move in moves if move["color"] == 255]
434
+ travel_moves = [move for move in moves if move["color"] == 0]
435
+
436
+ # Middle sweeps must split into two print runs around the hole.
437
+ assert len(print_moves) == 4 + 4 # 4 full-width rows + 2 rows split in two
438
+ # Some interior travel (crossing the hole) exists besides the buffers.
439
+ assert any(
440
+ 0.0 < move["start"][0] < 7.0 and 0.0 < move["end"][0] < 7.0
441
+ for move in travel_moves
442
+ )
443
+
444
+
445
+ def test_rectangular_spiral_polyline_reverses_center_to_edge() -> None:
446
+ inward = _rectangular_spiral_polyline((0.0, 0.0, 3.0, 3.0), 1.0)
447
+ outward = _rectangular_spiral_polyline((0.0, 0.0, 3.0, 3.0), 1.0, reverse=True)
448
+
449
+ assert inward[0] != inward[-1]
450
+ assert outward[0] == inward[-1]
451
+ assert outward[-1] == inward[0]
452
+
453
+
454
+ def test_rectangular_spiral_raster_reverses_between_layers(tmp_path) -> None:
455
+ layer = box(0.0, 0.0, 3.0, 3.0)
456
+ gcode_path = generate_vector_gcode(
457
+ _stack(layer, layer),
458
+ shape_name="rectangular_spiral",
459
+ pressure=25,
460
+ valve=7,
461
+ port=3,
462
+ fil_width=1.0,
463
+ layer_height=1.0,
464
+ raster_pattern=RASTER_PATTERN_RECTANGULAR_SPIRAL,
465
+ output_dir=tmp_path,
466
+ )
467
+
468
+ moves = _moves_with_colors(gcode_path.read_text())
469
+ first_layer_change = next(
470
+ move for move in moves if move["end"][2] > move["start"][2]
471
+ )
472
+
473
+ assert first_layer_change["start"][:2] == first_layer_change["end"][:2]
474
+ end_x, end_y, end_z = moves[-1]["end"]
475
+ assert abs(end_x) < 1e-9
476
+ assert abs(end_y) < 1e-9
477
+ assert end_z == 1.0
478
+
479
+
480
+ def test_circle_spiral_points_decrease_radius_to_center() -> None:
481
+ points = _circle_spiral_points(2.0, 3.0, outer_radius=4.0, pitch=1.0)
482
+ radii = [math.hypot(x - 2.0, y - 3.0) for x, y in points]
483
+
484
+ assert radii[0] == 4.0
485
+ assert radii[-1] == 0.0
486
+ assert all(
487
+ current <= previous + 1e-9
488
+ for previous, current in zip(radii, radii[1:])
489
+ )
490
+
491
+
492
+ def test_circle_spiral_raster_reverses_between_layers(tmp_path) -> None:
493
+ layer = box(0.0, 0.0, 5.0, 5.0)
494
+ gcode_path = generate_vector_gcode(
495
+ _stack(layer, layer),
496
+ shape_name="circle_spiral",
497
+ pressure=25,
498
+ valve=7,
499
+ port=3,
500
+ fil_width=1.0,
501
+ layer_height=1.0,
502
+ raster_pattern=RASTER_PATTERN_CIRCLE_SPIRAL,
503
+ output_dir=tmp_path,
504
+ )
505
+
506
+ moves = _moves_with_colors(gcode_path.read_text())
507
+ first_layer_change = next(
508
+ move for move in moves if move["end"][2] > move["start"][2]
509
+ )
510
+
511
+ assert first_layer_change["start"][:2] == first_layer_change["end"][:2]
512
+ end_x, end_y, end_z = moves[-1]["end"]
513
+ assert abs(end_x) < 1e-9
514
+ assert abs(end_y) < 1e-9
515
+ assert end_z == 1.0
516
+
517
+
518
+ def test_layer_contour_loops_follow_polygon_rings() -> None:
519
+ hollow = MultiPolygon(
520
+ [
521
+ Polygon(
522
+ box(0.0, 0.0, 4.0, 4.0).exterior.coords,
523
+ [list(box(1.0, 1.0, 2.0, 2.0).exterior.coords)],
524
+ )
525
+ ]
526
+ )
527
+
528
+ loops = _layer_contour_loops(hollow)
529
+
530
+ assert len(loops) == 2
531
+ # Largest loop (the exterior) sorts first.
532
+ assert set(loops[0]) == {(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)}
533
+ assert set(loops[1]) == {(1.0, 1.0), (2.0, 1.0), (2.0, 2.0), (1.0, 2.0)}
534
+ assert loops[0][0] == loops[0][-1]
535
+ assert loops[1][0] == loops[1][-1]
536
+
537
+
538
+ def test_contour_tracing_travels_to_nearest_border_after_infill(tmp_path) -> None:
539
+ layer = box(0.0, 0.0, 1.0, 1.0)
540
+ stack = _stack(layer)
541
+ gcode_path = generate_vector_gcode(
542
+ stack,
543
+ shape_name="nearest_border_contour",
544
+ pressure=25,
545
+ valve=7,
546
+ port=3,
547
+ fil_width=1.0,
548
+ all_g1=True,
549
+ contour_sources=[ContourSource(owner_idx=1, stack=stack)],
550
+ active_contour_owner=1,
551
+ output_dir=tmp_path,
552
+ )
553
+
554
+ moves = _moves_with_colors(gcode_path.read_text())
555
+
556
+ # Move 0 is the valve-settle approach, move 1 the single infill sweep.
557
+ assert moves[1]["color"] == 255
558
+ infill_end = moves[1]["end"]
559
+ # The contour starts printing from the point nearest the infill end,
560
+ # with no travel in between (the trailing buffer is rewound).
561
+ assert moves[2]["color"] == 255
562
+ assert moves[2]["start"] == infill_end
563
+
564
+
565
+ def test_contour_tracing_closes_loop_and_restores_raster_endpoint(tmp_path) -> None:
566
+ layer = box(0.0, 0.0, 2.0, 2.0)
567
+ stack = _stack(layer, layer)
568
+ gcode_path = generate_vector_gcode(
569
+ stack,
570
+ shape_name="contour_loop",
571
+ pressure=25,
572
+ valve=7,
573
+ port=3,
574
+ fil_width=1.0,
575
+ layer_height=1.0,
576
+ all_g1=True,
577
+ contour_sources=[ContourSource(owner_idx=1, stack=stack)],
578
+ active_contour_owner=1,
579
+ output_dir=tmp_path,
580
+ )
581
+
582
+ all_moves = _moves_with_colors(gcode_path.read_text())
583
+ for layer_z in (0.0, 1.0):
584
+ layer_moves = [
585
+ move
586
+ for move in all_moves
587
+ if move["start"][2] == layer_z and move["end"][2] == layer_z
588
+ ]
589
+ layer_prints = [move for move in layer_moves if move["color"] == 255]
590
+ assert layer_prints
591
+
592
+ # The contour is a closed loop: the last print returns to where the
593
+ # contour started.
594
+ contour_prints = layer_prints[2:]
595
+ assert contour_prints
596
+ assert contour_prints[-1]["end"] == contour_prints[0]["start"]
597
+
598
+ # After the contour, a travel move restores the raster endpoint.
599
+ last_print_index = max(
600
+ idx for idx, move in enumerate(layer_moves) if move["color"] == 255
601
+ )
602
+ trailing = layer_moves[last_print_index + 1 :]
603
+ assert trailing
604
+ assert all(move["color"] == 0 for move in trailing)
605
+
606
+
607
+ def test_contour_tracing_keeps_hollow_rings_separate() -> None:
608
+ output = [{"X": 0.0, "Y": 0.0, "Color": 255}]
609
+ contour_layers = [
610
+ [
611
+ {
612
+ "owner_idx": 1,
613
+ "contours": [
614
+ [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0), (0.0, 0.0)],
615
+ [(1.0, 1.0), (2.0, 1.0), (2.0, 2.0), (1.0, 2.0), (1.0, 1.0)],
616
+ ],
617
+ }
618
+ ]
619
+ ]
620
+
621
+ current_x, current_y = _append_layer_contours(
622
+ output,
623
+ 0.0,
624
+ 0.0,
625
+ contour_layers,
626
+ layer_number=0,
627
+ active_owner_idx=1,
628
+ )
629
+
630
+ contour_print_moves = [move for move in output[1:] if move["Color"] == 255]
631
+
632
+ assert len(contour_print_moves) == 8
633
+ assert (current_x, current_y) == (1.0, 1.0)
634
+
635
+
636
+ def test_contour_tracing_skips_inactive_nozzle_outline(tmp_path) -> None:
637
+ blank_stack = _stack(None)
638
+ contour_stack = _stack(box(0.0, 0.0, 1.0, 1.0))
639
+ contour_sources = [ContourSource(owner_idx=1, stack=contour_stack)]
640
+
641
+ active_path = generate_vector_gcode(
642
+ blank_stack,
643
+ shape_name="active_contour",
644
+ pressure=25,
645
+ valve=7,
646
+ port=3,
647
+ fil_width=1.0,
648
+ all_g1=True,
649
+ contour_sources=contour_sources,
650
+ active_contour_owner=1,
651
+ output_dir=tmp_path / "active",
652
+ )
653
+ inactive_path = generate_vector_gcode(
654
+ blank_stack,
655
+ shape_name="inactive_contour",
656
+ pressure=25,
657
+ valve=7,
658
+ port=3,
659
+ fil_width=1.0,
660
+ all_g1=True,
661
+ contour_sources=contour_sources,
662
+ active_contour_owner=2,
663
+ output_dir=tmp_path / "inactive",
664
+ )
665
+
666
+ active_text = active_path.read_text()
667
+ inactive_text = inactive_path.read_text()
668
+
669
+ assert _move_signature(active_text)
670
+ assert _move_signature(inactive_text) == []
671
+ assert any(
672
+ line.startswith("G1") and "; Color 255" in line
673
+ for line in active_text.splitlines()
674
+ )
675
+ assert not any("; Color 255" in line for line in inactive_text.splitlines())
676
+
677
+
678
+ def test_inactive_contour_tracing_preserves_original_raster_moves(tmp_path) -> None:
679
+ layer = box(1.0, 1.0, 3.0, 2.0)
680
+ stack = _stack(layer, layer)
681
+
682
+ original_path = generate_vector_gcode(
683
+ stack,
684
+ shape_name="original_raster",
685
+ pressure=25,
686
+ valve=7,
687
+ port=3,
688
+ fil_width=1.0,
689
+ all_g1=True,
690
+ output_dir=tmp_path / "original",
691
+ )
692
+ inactive_path = generate_vector_gcode(
693
+ stack,
694
+ shape_name="inactive_contour_raster",
695
+ pressure=25,
696
+ valve=7,
697
+ port=3,
698
+ fil_width=1.0,
699
+ all_g1=True,
700
+ contour_sources=[ContourSource(owner_idx=2, stack=stack)],
701
+ active_contour_owner=1,
702
+ output_dir=tmp_path / "inactive",
703
+ )
704
+
705
+ assert _move_signature(inactive_path.read_text()) == _move_signature(
706
+ original_path.read_text()
707
+ )
708
+
709
+
710
+ def test_reference_motion_shares_path_and_gates_valve_per_shape(tmp_path) -> None:
711
+ small = _stack(box(0.0, 0.0, 2.0, 2.0), name="small")
712
+ big = _stack(box(0.0, 0.0, 4.0, 4.0), name="big")
713
+ reference = build_reference_stack([small, big])
714
+ assert reference is not None
715
+
716
+ def _generate(stack: LayerStack, label: str):
717
+ return generate_vector_gcode(
718
+ stack,
719
+ shape_name=label,
720
+ pressure=25,
721
+ valve=7,
722
+ port=3,
723
+ fil_width=1.0,
724
+ motion=reference,
725
+ output_dir=tmp_path / label,
726
+ )
727
+
728
+ small_moves = _moves_with_colors(_generate(small, "small").read_text())
729
+ big_moves = _moves_with_colors(_generate(big, "big").read_text())
730
+
731
+ # Both shapes follow the same shared motion path: identical final position
732
+ # and identical total path length (the moves split at different valve
733
+ # boundaries, but the traversed polyline is the same).
734
+ assert small_moves[-1]["end"] == big_moves[-1]["end"]
735
+
736
+ def _total_length(moves: list[dict]) -> float:
737
+ return sum(math.dist(move["start"][:2], move["end"][:2]) for move in moves)
738
+
739
+ assert abs(_total_length(small_moves) - _total_length(big_moves)) < 1e-6
740
+
741
+ def _print_length(moves: list[dict]) -> float:
742
+ return sum(
743
+ math.dist(move["start"][:2], move["end"][:2])
744
+ for move in moves
745
+ if move["color"] == 255
746
+ )
747
+
748
+ # The big shape dispenses over more of the shared path than the small one.
749
+ assert _print_length(small_moves) > 0
750
+ assert _print_length(big_moves) > _print_length(small_moves)
751
+ # The small shape's total print length matches its own area coverage:
752
+ # 2mm-wide rows on the shared 4-row sweep -> only rows inside the small box.
753
+ assert _print_length(small_moves) < _print_length(big_moves) / 2 + 4.0
754
+
755
+
756
+ def test_build_reference_stack_unions_center_aligned_layers() -> None:
757
+ first = _stack(box(0.0, 0.0, 2.0, 2.0), name="first")
758
+ second = _stack(box(10.0, 10.0, 14.0, 14.0), name="second")
759
+
760
+ reference = build_reference_stack([first, second])
761
+
762
+ assert reference is not None
763
+ # The second stack is re-centred onto the first stack's bbox centre (1, 1).
764
+ assert reference.bounds == ((-1.0, -1.0, 0.0), (3.0, 3.0, 1.0))
765
+ assert reference.layers[0].area == 16.0
766
+ assert len(reference.layers) == 1
767
+ assert reference.z_values == [0.5]
768
+
769
+
770
+ def test_split_layer_stack_grid_produces_row_major_cells() -> None:
771
+ layer = box(10.0, -2.0, 12.5, -1.0)
772
+ stack = _stack(layer, name="strip")
773
+
774
+ pieces = split_layer_stack_grid(stack, columns=2, rows=1)
775
+
776
+ assert [piece.name for piece in pieces] == ["strip_r01_c01", "strip_r01_c02"]
777
+ assert pieces[0].bounds[0][0] == 10.0
778
+ assert pieces[1].bounds[0][0] == 11.25
779
+ total_area = sum(piece.layers[0].area for piece in pieces)
780
+ assert abs(total_area - layer.area) < 1e-9
781
+
782
+
783
+ def test_split_layer_stack_grid_orders_rows_top_down() -> None:
784
+ layer = box(0.0, 0.0, 4.0, 4.0)
785
+ stack = _stack(layer, name="grid")
786
+
787
+ pieces = split_layer_stack_grid(stack, columns=2, rows=2)
788
+
789
+ assert [piece.name for piece in pieces] == [
790
+ "grid_r01_c01",
791
+ "grid_r01_c02",
792
+ "grid_r02_c01",
793
+ "grid_r02_c02",
794
+ ]
795
+ # Row 1 is the top strip (max-Y side).
796
+ assert pieces[0].bounds == ((0.0, 2.0, 0.0), (2.0, 4.0, 1.0))
797
+ assert pieces[3].bounds == ((2.0, 0.0, 0.0), (4.0, 2.0, 1.0))
798
+ assert all(piece.layers[0].area == 4.0 for piece in pieces)
799
+
800
+
801
+ def test_split_layer_stack_grid_overlap_alternates_between_layers() -> None:
802
+ layer = box(0.0, 0.0, 4.0, 2.0)
803
+ stack = _stack(layer, layer, name="interlock")
804
+
805
+ pieces = split_layer_stack_grid(
806
+ stack,
807
+ columns=2,
808
+ rows=1,
809
+ overlapping_layers=True,
810
+ overlap=0.5,
811
+ )
812
+
813
+ left, right = pieces
814
+ # The cut line alternates by +/- overlap between layers, so each piece's
815
+ # area differs between layer 0 and layer 1 while the totals stay constant.
816
+ assert left.layers[0].area != left.layers[1].area
817
+ assert abs(left.layers[0].area - left.layers[1].area) == 2.0 # 2*(0.5*2)
818
+ for index in range(2):
819
+ combined = left.layers[index].area + right.layers[index].area
820
+ assert abs(combined - layer.area) < 1e-9
821
+ # Nominal bounds stay the un-shifted cells.
822
+ assert left.bounds == ((0.0, 0.0, 0.0), (2.0, 2.0, 2.0))
823
+ assert right.bounds == ((2.0, 0.0, 0.0), (4.0, 2.0, 2.0))
vector_gcode.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """G-code emission for vector layer stacks.
2
+
3
+ Writes the same machine dialect as the old TIFF pipeline: G91 relative moves,
4
+ G0 travel / G1 print (or all-G1), WAGO valve commands on every valve-state
5
+ change, and serial pressure preset/toggle commands with an optional per-layer
6
+ pressure ramp.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import tempfile
12
+ from codecs import encode
13
+ from pathlib import Path
14
+ from textwrap import wrap
15
+
16
+ from stl_slicer import LayerStack
17
+ from vector_toolpath import (
18
+ RASTER_PATTERN_CHOICES,
19
+ RASTER_PATTERN_CIRCLE_SPIRAL,
20
+ RASTER_PATTERN_RECTANGULAR_SPIRAL,
21
+ RASTER_PATTERN_SAME_DIRECTION,
22
+ RASTER_PATTERN_WOODPILE,
23
+ RASTER_PATTERN_Y_DIRECTION,
24
+ ContourSource,
25
+ _lead_in_moves,
26
+ _normalize_raster_pattern,
27
+ align_stack_to,
28
+ build_contour_layers,
29
+ plan_layer_moves,
30
+ )
31
+
32
+ __all__ = [
33
+ "RASTER_PATTERN_CHOICES",
34
+ "RASTER_PATTERN_CIRCLE_SPIRAL",
35
+ "RASTER_PATTERN_RECTANGULAR_SPIRAL",
36
+ "RASTER_PATTERN_SAME_DIRECTION",
37
+ "RASTER_PATTERN_WOODPILE",
38
+ "RASTER_PATTERN_Y_DIRECTION",
39
+ "ContourSource",
40
+ "generate_vector_gcode",
41
+ "write_gcode_file",
42
+ ]
43
+
44
+
45
+ def _setpress(pressure: float) -> str:
46
+ pressure_str = str(int(pressure * 10)).zfill(4)
47
+ command_bytes = bytes("08PS " + pressure_str, "utf-8")
48
+ hex_command = encode(command_bytes, "hex").decode("utf-8")
49
+ format_command = "\\x" + "\\x".join(
50
+ hex_command[i : i + 2] for i in range(0, len(hex_command), 2)
51
+ )
52
+
53
+ hex_pairs = wrap(hex_command, 2)
54
+ decimal_sum = sum(int(pair, 16) for pair in hex_pairs)
55
+ checksum_bin = bin(decimal_sum % 256)[2:].zfill(8)
56
+ inverted = int("".join("1" if c == "0" else "0" for c in checksum_bin), 2) + 1
57
+ checksum_hex = hex(inverted)[2:].upper()
58
+ format_checksum = "\\x" + "\\x".join(
59
+ checksum_hex[i : i + 2] for i in range(0, len(checksum_hex), 2)
60
+ )
61
+
62
+ return "b'" + "\\x05\\x02" + format_command + format_checksum + "\\x03" + "'"
63
+
64
+
65
+ def _togglepress() -> str:
66
+ return "b'\\x05\\x02\\x30\\x34\\x44\\x49\\x20\\x20\\x43\\x46\\x03'"
67
+
68
+
69
+ def _setpress_cmd(port: str, pressure: float, start: bool) -> str:
70
+ if start:
71
+ return f"\n\r{port}.write(eval(setpress({pressure:g})))"
72
+ insert = ""
73
+ return f"\n\r{insert}{port}.write({_setpress(pressure)})"
74
+
75
+
76
+ def _toggle_cmd(port: str, start: bool) -> str:
77
+ if start:
78
+ return f"\n\r{port}.write(eval(togglepress()))"
79
+ insert = ""
80
+ return f"\n\r{insert}{port}.write({_togglepress()})"
81
+
82
+
83
+ def _valve_cmd(valve: int, command: int) -> str:
84
+ return f"\n{{aux_command}}WAGO_ValveCommands({valve}, {command})\n"
85
+
86
+
87
+ def _coord(value: float) -> str:
88
+ """Format a coordinate in fixed-point notation, never scientific.
89
+
90
+ Python's repr writes small floats as e.g. "-5.1e-08", which G-code axis
91
+ parsers (including this project's viewer) misread as "-5.1".
92
+ """
93
+ text = f"{float(value):.6f}".rstrip("0")
94
+ if text.endswith("."):
95
+ text += "0"
96
+ if text in ("-0.0", "-0"):
97
+ return "0.0"
98
+ return text
99
+
100
+
101
+ def write_gcode_file(
102
+ gcode_path: Path,
103
+ gcode_list: list[dict],
104
+ pressure: float,
105
+ valve: int,
106
+ port: int,
107
+ increase_pressure_per_layer: float,
108
+ pressure_ramp_enabled: bool,
109
+ all_g1: bool,
110
+ ) -> None:
111
+ off_color = 0
112
+ com_port = f"serialPort{port}"
113
+ color_dict: dict[int, int] = {0: 100, 255: valve}
114
+
115
+ setpress_lines = [_setpress_cmd(com_port, pressure, start=True)]
116
+ pressure_on_lines = [_toggle_cmd(com_port, start=True)]
117
+ pressure_off_lines = [_toggle_cmd(com_port, start=False)]
118
+
119
+ pressure_cur = float(pressure)
120
+
121
+ with open(gcode_path, "w") as f:
122
+ f.write("G91\n")
123
+ f.write(_valve_cmd(valve, 0))
124
+ for line in setpress_lines:
125
+ f.write(f"{line}\n")
126
+ for line in pressure_on_lines:
127
+ f.write(f"{line}\n")
128
+ for color in color_dict:
129
+ f.write(_valve_cmd(color_dict[color], 0))
130
+
131
+ pressure_next: str | None = None
132
+ for i, move in enumerate(gcode_list):
133
+ prev_color = gcode_list[i - 1]["Color"] if i > 0 else 0
134
+ cur_color = move["Color"]
135
+ if prev_color != cur_color:
136
+ if cur_color == off_color:
137
+ f.write(_valve_cmd(color_dict[prev_color], 0))
138
+ else:
139
+ if prev_color == off_color:
140
+ f.write(_valve_cmd(color_dict[cur_color], 1))
141
+ else:
142
+ f.write(_valve_cmd(color_dict[cur_color], 1))
143
+ f.write(_valve_cmd(color_dict[prev_color], 0))
144
+
145
+ # When all_g1 is set, every move is emitted as G1 regardless of
146
+ # valve state; the valve commands still mark print vs travel.
147
+ move_type = "G1" if (all_g1 or cur_color != off_color) else "G0"
148
+ if "Z" in move:
149
+ line = (
150
+ f"{move_type} X{_coord(move['X'])} Y{_coord(move['Y'])} "
151
+ f"Z{_coord(move['Z'])} ; Color {move['Color']}"
152
+ )
153
+ if pressure_ramp_enabled:
154
+ pressure_cur += increase_pressure_per_layer
155
+ pressure_next = _setpress_cmd(com_port, pressure_cur, start=False)
156
+ else:
157
+ pressure_next = None
158
+ else:
159
+ line = (
160
+ f"{move_type} X{_coord(move['X'])} Y{_coord(move['Y'])} "
161
+ f"; Color {move['Color']}"
162
+ )
163
+ pressure_next = None
164
+
165
+ f.write(f"{line}\n")
166
+ if pressure_next is not None:
167
+ f.write(f"{pressure_next}\n")
168
+ pressure_next = None
169
+
170
+ for color in color_dict:
171
+ f.write(_valve_cmd(color_dict[color], 0))
172
+ for line in pressure_off_lines:
173
+ f.write(f"{line}\n")
174
+
175
+
176
+ def generate_vector_gcode(
177
+ shape: LayerStack,
178
+ *,
179
+ shape_name: str,
180
+ pressure: float,
181
+ valve: int,
182
+ port: int,
183
+ fil_width: float,
184
+ layer_height: float | None = None,
185
+ raster_pattern: str | None = RASTER_PATTERN_SAME_DIRECTION,
186
+ motion: LayerStack | None = None,
187
+ contour_sources: list[ContourSource] | None = None,
188
+ active_contour_owner: int | None = None,
189
+ increase_pressure_per_layer: float = 0.1,
190
+ pressure_ramp_enabled: bool = True,
191
+ all_g1: bool = False,
192
+ lead_in_enabled: bool = False,
193
+ lead_in_length: float = 5.0,
194
+ lead_in_clearance: float = 5.0,
195
+ lead_in_lines: int = 3,
196
+ output_dir: str | Path | None = None,
197
+ ) -> Path:
198
+ """Generate G-code for one sliced shape.
199
+
200
+ Without `motion`, the shape's own layers drive both the nozzle path and
201
+ the valve. With `motion` (the combined reference stack), the nozzle
202
+ follows the shared reference path while the valve opens only inside this
203
+ shape's own geometry, aligned into the reference frame — so parallel
204
+ heads share one motion but each dispenses only its own shape.
205
+ """
206
+ if shape is None or not shape.layers:
207
+ raise ValueError("The shape has no sliced layers to generate G-code from.")
208
+ if fil_width <= 0:
209
+ raise ValueError("Filament width must be greater than zero.")
210
+
211
+ raster_pattern = _normalize_raster_pattern(raster_pattern)
212
+ if layer_height is None:
213
+ layer_height = shape.layer_height
214
+
215
+ if motion is not None:
216
+ if not motion.layers:
217
+ raise ValueError("The reference stack has no layers for motion.")
218
+ motion_layers = motion.layers
219
+ valve_layers = align_stack_to(shape, motion, len(motion.layers))
220
+ contour_reference = motion
221
+ else:
222
+ motion_layers = shape.layers
223
+ valve_layers = shape.layers
224
+ contour_reference = None
225
+
226
+ contour_layers = build_contour_layers(
227
+ contour_sources,
228
+ len(motion_layers),
229
+ reference=contour_reference,
230
+ )
231
+
232
+ gcode_list = plan_layer_moves(
233
+ motion_layers,
234
+ valve_layers,
235
+ fil_width,
236
+ float(layer_height),
237
+ raster_pattern,
238
+ contour_layers,
239
+ active_contour_owner,
240
+ )
241
+
242
+ lead_in = _lead_in_moves(
243
+ lead_in_enabled,
244
+ lead_in_length,
245
+ lead_in_clearance,
246
+ lead_in_lines,
247
+ fil_width,
248
+ 255,
249
+ 0,
250
+ )
251
+ if lead_in:
252
+ gcode_list = [*lead_in, *gcode_list]
253
+
254
+ if output_dir is None:
255
+ output_dir = Path(tempfile.mkdtemp(prefix="vector_gcode_"))
256
+ else:
257
+ output_dir = Path(output_dir)
258
+ output_dir.mkdir(parents=True, exist_ok=True)
259
+
260
+ gcode_path = output_dir / f"{shape_name}_SnakePath_gcode.txt"
261
+ write_gcode_file(
262
+ gcode_path,
263
+ gcode_list,
264
+ pressure=float(pressure),
265
+ valve=int(valve),
266
+ port=int(port),
267
+ increase_pressure_per_layer=float(increase_pressure_per_layer),
268
+ pressure_ramp_enabled=bool(pressure_ramp_enabled),
269
+ all_g1=bool(all_g1),
270
+ )
271
+ return gcode_path
vector_toolpath.py ADDED
@@ -0,0 +1,1209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vector toolpath generation: shapely layer polygons -> printable move lists.
2
+
3
+ Replaces the pixel-raster core of the old TIFF pipeline. All geometry lives in
4
+ world-XY millimetres; every motion segment is a 5-tuple
5
+ ``(x0, y0, x1, y1, color)`` where color 255 means the dispensing valve is open
6
+ and 0 means travel. `fil_width` is both the raster line spacing and the
7
+ filament width.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from dataclasses import dataclass
14
+
15
+ from shapely import prepare
16
+ from shapely.affinity import translate
17
+ from shapely.geometry import LineString, MultiPolygon, Point, box
18
+ from shapely.geometry.polygon import orient
19
+ from shapely.ops import unary_union
20
+
21
+ from stl_slicer import LayerStack, _as_multipolygon
22
+
23
+
24
+ EPS = 1e-6
25
+
26
+ RASTER_PATTERN_SAME_DIRECTION = "X-direction raster"
27
+ RASTER_PATTERN_Y_DIRECTION = "Y-direction raster"
28
+ RASTER_PATTERN_WOODPILE = "Woodpile raster"
29
+ RASTER_PATTERN_RECTANGULAR_SPIRAL = "Rectangular Spiral raster"
30
+ RASTER_PATTERN_CIRCLE_SPIRAL = "Circle Spiral raster"
31
+ RASTER_PATTERN_CHOICES = (
32
+ RASTER_PATTERN_SAME_DIRECTION,
33
+ RASTER_PATTERN_Y_DIRECTION,
34
+ RASTER_PATTERN_WOODPILE,
35
+ RASTER_PATTERN_RECTANGULAR_SPIRAL,
36
+ RASTER_PATTERN_CIRCLE_SPIRAL,
37
+ )
38
+
39
+ Seg = tuple[float, float, float, float, int]
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class ContourSource:
44
+ owner_idx: int
45
+ stack: LayerStack
46
+
47
+
48
+ def _normalize_raster_pattern(pattern: str | None) -> str:
49
+ if pattern in RASTER_PATTERN_CHOICES:
50
+ return pattern
51
+ return RASTER_PATTERN_SAME_DIRECTION
52
+
53
+
54
+ def _raster_axis_for_pattern(pattern: str, layer_number: int) -> str:
55
+ if pattern == RASTER_PATTERN_Y_DIRECTION:
56
+ return "Y"
57
+ if pattern == RASTER_PATTERN_WOODPILE and layer_number % 2 == 1:
58
+ return "Y"
59
+ return "X"
60
+
61
+
62
+ def _iter_linestrings(geometry: object):
63
+ if geometry is None or getattr(geometry, "is_empty", True):
64
+ return
65
+ geom_type = geometry.geom_type
66
+ if geom_type == "LineString":
67
+ yield geometry
68
+ elif geom_type in ("MultiLineString", "GeometryCollection"):
69
+ for part in geometry.geoms:
70
+ yield from _iter_linestrings(part)
71
+
72
+
73
+ def _iter_coords(geometry: object):
74
+ if geometry is None or getattr(geometry, "is_empty", True):
75
+ return
76
+ geom_type = geometry.geom_type
77
+ if geom_type == "Point":
78
+ yield geometry.x, geometry.y
79
+ elif geom_type == "LineString":
80
+ for coord in geometry.coords:
81
+ yield coord[0], coord[1]
82
+ elif geom_type in ("MultiPoint", "MultiLineString", "GeometryCollection"):
83
+ for part in geometry.geoms:
84
+ yield from _iter_coords(part)
85
+
86
+
87
+ def _point_distance_sq(ax: float, ay: float, bx: float, by: float) -> float:
88
+ return (ax - bx) ** 2 + (ay - by) ** 2
89
+
90
+
91
+ def _closest_point_on_segment(
92
+ px: float,
93
+ py: float,
94
+ ax: float,
95
+ ay: float,
96
+ bx: float,
97
+ by: float,
98
+ ) -> tuple[float, float, float]:
99
+ dx = bx - ax
100
+ dy = by - ay
101
+ length_sq = dx * dx + dy * dy
102
+ if length_sq == 0:
103
+ return ax, ay, 0.0
104
+ t = ((px - ax) * dx + (py - ay) * dy) / length_sq
105
+ t = max(0.0, min(1.0, t))
106
+ return ax + t * dx, ay + t * dy, t
107
+
108
+
109
+ def _append_colored_segment(
110
+ segments: list[Seg],
111
+ start_x: float,
112
+ start_y: float,
113
+ end_x: float,
114
+ end_y: float,
115
+ color: int,
116
+ ) -> None:
117
+ if start_x == end_x and start_y == end_y:
118
+ return
119
+
120
+ if segments:
121
+ prev_start_x, prev_start_y, prev_end_x, prev_end_y, prev_color = segments[-1]
122
+ if (
123
+ prev_color == color
124
+ and prev_end_x == start_x
125
+ and prev_end_y == start_y
126
+ ):
127
+ prev_dx = prev_end_x - prev_start_x
128
+ prev_dy = prev_end_y - prev_start_y
129
+ next_dx = end_x - start_x
130
+ next_dy = end_y - start_y
131
+ if abs((prev_dx * next_dy) - (prev_dy * next_dx)) < 1e-9:
132
+ segments[-1] = (
133
+ prev_start_x,
134
+ prev_start_y,
135
+ end_x,
136
+ end_y,
137
+ color,
138
+ )
139
+ return
140
+
141
+ segments.append((start_x, start_y, end_x, end_y, color))
142
+
143
+
144
+ def _append_relative_move(
145
+ output_list: list[dict],
146
+ current_x: float,
147
+ current_y: float,
148
+ target_x: float,
149
+ target_y: float,
150
+ color: int,
151
+ z_step: float | None = None,
152
+ ) -> tuple[float, float]:
153
+ # Round away GEOS float noise (sub-micron residues, -0.0) so the emitted
154
+ # G-code stays clean; position tracking keeps the exact targets. Six
155
+ # decimals (1 nm) is far below any printable resolution, and rounding here
156
+ # also guarantees the writer can format every delta without scientific
157
+ # notation (which G-code parsers misread: "X-5.1e-08" parses as X-5.1).
158
+ dx = round(target_x - current_x, 6) + 0.0
159
+ dy = round(target_y - current_y, 6) + 0.0
160
+ if dx == 0 and dy == 0 and z_step is None:
161
+ return current_x, current_y
162
+ move = {"X": dx, "Y": dy, "Color": color}
163
+ if z_step is not None:
164
+ move["Z"] = z_step
165
+ output_list.append(move)
166
+ return target_x, target_y
167
+
168
+
169
+ def _chord_runs(
170
+ region: MultiPolygon,
171
+ axis: str,
172
+ coord: float,
173
+ eps: float = EPS,
174
+ ) -> list[tuple[float, float]]:
175
+ """Sorted, merged (lo, hi) intervals where an axis-aligned scanline is inside `region`.
176
+
177
+ Axis "X": horizontal scanline at y=coord, runs measured along X.
178
+ Axis "Y": vertical scanline at x=coord, runs measured along Y.
179
+ """
180
+ if region is None or region.is_empty:
181
+ return []
182
+
183
+ min_x, min_y, max_x, max_y = region.bounds
184
+ if axis == "Y":
185
+ if coord < min_x - eps or coord > max_x + eps:
186
+ return []
187
+ line = LineString([(coord, min_y - 1.0), (coord, max_y + 1.0)])
188
+ index = 1
189
+ else:
190
+ if coord < min_y - eps or coord > max_y + eps:
191
+ return []
192
+ line = LineString([(min_x - 1.0, coord), (max_x + 1.0, coord)])
193
+ index = 0
194
+
195
+ runs: list[tuple[float, float]] = []
196
+ for piece in _iter_linestrings(region.intersection(line)):
197
+ if piece.length <= eps:
198
+ continue
199
+ values = [coords[index] for coords in piece.coords]
200
+ runs.append((min(values), max(values)))
201
+
202
+ runs.sort()
203
+ merged: list[tuple[float, float]] = []
204
+ for lo, hi in runs:
205
+ if merged and lo <= merged[-1][1] + eps:
206
+ merged[-1] = (merged[-1][0], max(merged[-1][1], hi))
207
+ else:
208
+ merged.append((lo, hi))
209
+ return merged
210
+
211
+
212
+ def _point_along(
213
+ t: float,
214
+ x0: float,
215
+ y0: float,
216
+ x1: float,
217
+ y1: float,
218
+ ) -> tuple[float, float]:
219
+ if t <= 0.0:
220
+ return x0, y0
221
+ if t >= 1.0:
222
+ return x1, y1
223
+ return x0 + (x1 - x0) * t, y0 + (y1 - y0) * t
224
+
225
+
226
+ def _classify_polyline(
227
+ points: list[tuple[float, float]],
228
+ valve: MultiPolygon,
229
+ eps: float = EPS,
230
+ ) -> list[Seg]:
231
+ """Split a motion polyline at valve-boundary crossings and color the pieces.
232
+
233
+ Preserves path order (a whole-polyline shapely intersection would not).
234
+ Pieces on the boundary count as printing (`covers`), matching the raster
235
+ convention that boundary-grazing sweeps dispense.
236
+ """
237
+ segments: list[Seg] = []
238
+ has_valve = valve is not None and not valve.is_empty
239
+ if has_valve:
240
+ prepare(valve)
241
+ boundary = valve.boundary
242
+
243
+ for (x0, y0), (x1, y1) in zip(points, points[1:]):
244
+ dx = x1 - x0
245
+ dy = y1 - y0
246
+ seg_len = math.hypot(dx, dy)
247
+ if seg_len <= eps:
248
+ continue
249
+
250
+ if not has_valve:
251
+ _append_colored_segment(segments, x0, y0, x1, y1, 0)
252
+ continue
253
+
254
+ ts = {0.0, 1.0}
255
+ crossings = LineString([(x0, y0), (x1, y1)]).intersection(boundary)
256
+ for cx, cy in _iter_coords(crossings):
257
+ t = ((cx - x0) * dx + (cy - y0) * dy) / (seg_len * seg_len)
258
+ ts.add(max(0.0, min(1.0, t)))
259
+
260
+ t_eps = eps / seg_len
261
+ ordered = sorted(ts)
262
+ deduped = [ordered[0]]
263
+ for t in ordered[1:]:
264
+ if t - deduped[-1] > t_eps:
265
+ deduped.append(t)
266
+ else:
267
+ deduped[-1] = max(deduped[-1], t)
268
+
269
+ for t0, t1 in zip(deduped, deduped[1:]):
270
+ if (t1 - t0) * seg_len <= eps:
271
+ continue
272
+ mid_x, mid_y = _point_along((t0 + t1) / 2.0, x0, y0, x1, y1)
273
+ color = 255 if valve.covers(Point(mid_x, mid_y)) else 0
274
+ start = _point_along(t0, x0, y0, x1, y1)
275
+ end = _point_along(t1, x0, y0, x1, y1)
276
+ _append_colored_segment(segments, *start, *end, color)
277
+
278
+ return segments
279
+
280
+
281
+ def _scan_coords(lo: float, hi: float, fil_width: float) -> list[float]:
282
+ """Scanline positions at half-fil_width offsets; always at least one line."""
283
+ if hi - lo < fil_width:
284
+ return [(lo + hi) / 2.0]
285
+
286
+ coords: list[float] = []
287
+ value = lo + fil_width / 2.0
288
+ limit = hi - fil_width / 2.0 + 1e-9
289
+ while value <= limit:
290
+ coords.append(value)
291
+ value += fil_width
292
+ return coords
293
+
294
+
295
+ def _axis_raster_segments(
296
+ motion: MultiPolygon,
297
+ valve: MultiPolygon,
298
+ fil_width: float,
299
+ axis: str,
300
+ reverse_order: bool = False,
301
+ start_forward: bool = True,
302
+ ) -> list[Seg]:
303
+ """Snake raster of `motion`, dispensing only inside `valve`.
304
+
305
+ Axis "X" sweeps along X with rows stacked in Y; axis "Y" sweeps along Y
306
+ with columns stacked in X. Each sweep spans from the first to the last
307
+ motion chord (crossing interior gaps valve-off) and gets a fil_width
308
+ valve-settle travel buffer before and after.
309
+ """
310
+ if motion is None or motion.is_empty:
311
+ return []
312
+
313
+ min_x, min_y, max_x, max_y = motion.bounds
314
+ if axis == "Y":
315
+ scan_lo, scan_hi = min_x, max_x
316
+ else:
317
+ scan_lo, scan_hi = min_y, max_y
318
+
319
+ coords = _scan_coords(scan_lo, scan_hi, fil_width)
320
+ if reverse_order:
321
+ coords = coords[::-1]
322
+
323
+ segments: list[Seg] = []
324
+ sweep_number = 0
325
+ for coord in coords:
326
+ motion_runs = _chord_runs(motion, axis, coord)
327
+ if not motion_runs:
328
+ continue
329
+
330
+ sweep_lo = motion_runs[0][0]
331
+ sweep_hi = motion_runs[-1][1]
332
+ valve_runs = []
333
+ for lo, hi in _chord_runs(valve, axis, coord):
334
+ lo = max(lo, sweep_lo)
335
+ hi = min(hi, sweep_hi)
336
+ if hi - lo > EPS:
337
+ valve_runs.append((lo, hi))
338
+
339
+ def emit(a: float, b: float, color: int) -> None:
340
+ if axis == "Y":
341
+ _append_colored_segment(segments, coord, a, coord, b, color)
342
+ else:
343
+ _append_colored_segment(segments, a, coord, b, coord, color)
344
+
345
+ forward = (sweep_number % 2 == 0) == start_forward
346
+ sweep_number += 1
347
+
348
+ if forward:
349
+ emit(sweep_lo - fil_width, sweep_lo, 0)
350
+ current = sweep_lo
351
+ for lo, hi in valve_runs:
352
+ if lo - current > EPS:
353
+ emit(current, lo, 0)
354
+ current = lo
355
+ start = max(lo, current)
356
+ if hi - start > EPS:
357
+ emit(start, hi, 255)
358
+ current = hi
359
+ if sweep_hi - current > EPS:
360
+ emit(current, sweep_hi, 0)
361
+ current = sweep_hi
362
+ emit(current, current + fil_width, 0)
363
+ else:
364
+ emit(sweep_hi + fil_width, sweep_hi, 0)
365
+ current = sweep_hi
366
+ for lo, hi in reversed(valve_runs):
367
+ if current - hi > EPS:
368
+ emit(current, hi, 0)
369
+ current = hi
370
+ start = min(hi, current)
371
+ if start - lo > EPS:
372
+ emit(start, lo, 255)
373
+ current = lo
374
+ if current - sweep_lo > EPS:
375
+ emit(current, sweep_lo, 0)
376
+ current = sweep_lo
377
+ emit(current, current - fil_width, 0)
378
+
379
+ return segments
380
+
381
+
382
+ def _oriented_axis_raster_segments(
383
+ motion: MultiPolygon,
384
+ valve: MultiPolygon,
385
+ fil_width: float,
386
+ axis: str,
387
+ current_x: float,
388
+ current_y: float,
389
+ prefer_default: bool = False,
390
+ ) -> list[Seg]:
391
+ """Pick the raster orientation whose start is nearest the current position."""
392
+ default_segments = _axis_raster_segments(motion, valve, fil_width, axis)
393
+ if prefer_default or not default_segments:
394
+ return default_segments
395
+
396
+ candidates: list[list[Seg]] = [default_segments]
397
+ for reverse_order in (False, True):
398
+ for start_forward in (False, True):
399
+ segments = _axis_raster_segments(
400
+ motion,
401
+ valve,
402
+ fil_width,
403
+ axis,
404
+ reverse_order=reverse_order,
405
+ start_forward=start_forward,
406
+ )
407
+ if segments and segments not in candidates:
408
+ candidates.append(segments)
409
+
410
+ return min(
411
+ candidates,
412
+ key=lambda segments: _point_distance_sq(
413
+ current_x,
414
+ current_y,
415
+ segments[0][0],
416
+ segments[0][1],
417
+ ),
418
+ )
419
+
420
+
421
+ def _extend_polyline_ends(
422
+ points: list[tuple[float, float]],
423
+ length: float,
424
+ ) -> list[tuple[float, float]]:
425
+ """Extend both polyline ends by `length` along their local directions."""
426
+ if len(points) < 2:
427
+ return points
428
+
429
+ (x0, y0), (x1, y1) = points[0], points[1]
430
+ distance = math.hypot(x1 - x0, y1 - y0)
431
+ if distance > 0:
432
+ points = [
433
+ (x0 - (x1 - x0) / distance * length, y0 - (y1 - y0) / distance * length),
434
+ *points,
435
+ ]
436
+
437
+ (xa, ya), (xb, yb) = points[-2], points[-1]
438
+ distance = math.hypot(xb - xa, yb - ya)
439
+ if distance > 0:
440
+ points = [
441
+ *points,
442
+ (xb + (xb - xa) / distance * length, yb + (yb - ya) / distance * length),
443
+ ]
444
+ return points
445
+
446
+
447
+ def _rectangular_spiral_polyline(
448
+ bounds: tuple[float, float, float, float],
449
+ fil_width: float,
450
+ reverse: bool = False,
451
+ ) -> list[tuple[float, float]]:
452
+ """Corner polyline spiralling from the bounds inward (or outward if reversed)."""
453
+ min_x, min_y, max_x, max_y = bounds
454
+ half = fil_width / 2.0
455
+ left = min_x + half
456
+ right = max_x - half
457
+ bottom = min_y + half
458
+ top = max_y - half
459
+ if right < left:
460
+ left = right = (min_x + max_x) / 2.0
461
+ if top < bottom:
462
+ bottom = top = (min_y + max_y) / 2.0
463
+
464
+ eps = 1e-9
465
+ points: list[tuple[float, float]] = []
466
+
467
+ def add(x: float, y: float) -> None:
468
+ if not points or _point_distance_sq(points[-1][0], points[-1][1], x, y) > eps:
469
+ points.append((x, y))
470
+
471
+ while left <= right + eps and bottom <= top + eps:
472
+ add(left, top)
473
+ add(right, top)
474
+ top -= fil_width
475
+
476
+ if bottom <= top + eps:
477
+ add(right, bottom)
478
+ right -= fil_width
479
+
480
+ if bottom <= top + eps:
481
+ add(left, bottom)
482
+ bottom += fil_width
483
+
484
+ if left <= right + eps:
485
+ if bottom <= top + eps:
486
+ add(left, top)
487
+ left += fil_width
488
+
489
+ if len(points) == 1:
490
+ center_x, center_y = points[0]
491
+ points = [(center_x - half, center_y), (center_x + half, center_y)]
492
+ if reverse:
493
+ points.reverse()
494
+ return points
495
+
496
+ if reverse:
497
+ points.reverse()
498
+ return _extend_polyline_ends(points, half)
499
+
500
+
501
+ def _circle_spiral_points(
502
+ center_x: float,
503
+ center_y: float,
504
+ outer_radius: float,
505
+ pitch: float,
506
+ ) -> list[tuple[float, float]]:
507
+ if outer_radius <= 0.0:
508
+ return [(center_x, center_y)]
509
+
510
+ pitch = max(float(pitch), 1e-9)
511
+ sample_spacing = pitch
512
+ theta_max = (outer_radius / pitch) * 2.0 * math.pi
513
+ theta = 0.0
514
+ points = [(center_x + outer_radius, center_y)]
515
+
516
+ while theta < theta_max:
517
+ radius = max(outer_radius - (pitch * theta / (2.0 * math.pi)), 0.0)
518
+ d_theta = min(math.pi / 10.0, sample_spacing / max(radius, pitch))
519
+ theta = min(theta + d_theta, theta_max)
520
+ radius = max(outer_radius - (pitch * theta / (2.0 * math.pi)), 0.0)
521
+ points.append(
522
+ (
523
+ center_x + (radius * math.cos(theta)),
524
+ center_y + (radius * math.sin(theta)),
525
+ )
526
+ )
527
+
528
+ if points[-1] != (center_x, center_y):
529
+ points.append((center_x, center_y))
530
+ return points
531
+
532
+
533
+ def _circle_spiral_polyline(
534
+ bounds: tuple[float, float, float, float],
535
+ fil_width: float,
536
+ reverse: bool = False,
537
+ ) -> list[tuple[float, float]]:
538
+ min_x, min_y, max_x, max_y = bounds
539
+ center_x = (min_x + max_x) / 2.0
540
+ center_y = (min_y + max_y) / 2.0
541
+ outer_radius = max(
542
+ math.hypot(corner_x - center_x, corner_y - center_y)
543
+ for corner_x, corner_y in (
544
+ (min_x, min_y),
545
+ (max_x, min_y),
546
+ (max_x, max_y),
547
+ (min_x, max_y),
548
+ )
549
+ )
550
+
551
+ points = _circle_spiral_points(center_x, center_y, outer_radius, fil_width)
552
+ if reverse:
553
+ points.reverse()
554
+ return points
555
+
556
+
557
+ def _contour_area2(points: list[tuple[float, float]]) -> float:
558
+ return sum(
559
+ x0 * y1 - x1 * y0
560
+ for (x0, y0), (x1, y1) in zip(points, points[1:])
561
+ )
562
+
563
+
564
+ def _contour_sort_key(points: list[tuple[float, float]]) -> tuple[float, float, float]:
565
+ xs = [point[0] for point in points]
566
+ ys = [point[1] for point in points]
567
+ return (-abs(_contour_area2(points)), min(ys), min(xs))
568
+
569
+
570
+ def _layer_contour_loops(layer: MultiPolygon) -> list[list[tuple[float, float]]]:
571
+ """Closed contour loops of a layer: exterior rings first, holes after."""
572
+ loops: list[list[tuple[float, float]]] = []
573
+ for polygon in layer.geoms:
574
+ oriented = orient(polygon)
575
+ for ring in (oriented.exterior, *oriented.interiors):
576
+ simplified = ring.simplify(0)
577
+ coords = [(float(x), float(y)) for x, y in simplified.coords]
578
+ if len(coords) >= 4:
579
+ loops.append(coords)
580
+
581
+ loops.sort(key=_contour_sort_key)
582
+ return loops
583
+
584
+
585
+ def _rotate_closed_contour_to_nearest_border(
586
+ contour: list[tuple[float, float]],
587
+ current_x: float,
588
+ current_y: float,
589
+ approach_dx: float = 0.0,
590
+ approach_dy: float = 0.0,
591
+ ) -> list[tuple[float, float]]:
592
+ if len(contour) < 3:
593
+ return contour
594
+
595
+ ring = contour[:-1] if contour[0] == contour[-1] else contour
596
+ if len(ring) < 2:
597
+ return contour
598
+
599
+ best_idx = 0
600
+ best_t = 0.0
601
+ best_point = ring[0]
602
+ best_dist = float("inf")
603
+ for idx, (ax, ay) in enumerate(ring):
604
+ bx, by = ring[(idx + 1) % len(ring)]
605
+ point_x, point_y, t = _closest_point_on_segment(
606
+ current_x,
607
+ current_y,
608
+ ax,
609
+ ay,
610
+ bx,
611
+ by,
612
+ )
613
+ distance = _point_distance_sq(current_x, current_y, point_x, point_y)
614
+ if distance < best_dist:
615
+ best_dist = distance
616
+ best_idx = idx
617
+ best_t = t
618
+ best_point = (point_x, point_y)
619
+
620
+ eps = 1e-9
621
+
622
+ def choose_direction(
623
+ forward: list[tuple[float, float]],
624
+ reverse: list[tuple[float, float]],
625
+ ) -> list[tuple[float, float]]:
626
+ if (
627
+ len(forward) < 2
628
+ or len(reverse) < 2
629
+ or (approach_dx == 0 and approach_dy == 0)
630
+ ):
631
+ return forward
632
+
633
+ def score(candidate: list[tuple[float, float]]) -> float:
634
+ tx = candidate[1][0] - candidate[0][0]
635
+ ty = candidate[1][1] - candidate[0][1]
636
+ # Positive when the shape interior, opposite the approach vector,
637
+ # is to the left of the contour's first move.
638
+ return (ty * approach_dx) - (tx * approach_dy)
639
+
640
+ return reverse if score(reverse) > score(forward) else forward
641
+
642
+ if best_t <= eps:
643
+ forward = ring[best_idx:] + ring[:best_idx] + [ring[best_idx]]
644
+ reverse_ring = list(reversed(ring))
645
+ reverse_idx = reverse_ring.index(ring[best_idx])
646
+ reverse = (
647
+ reverse_ring[reverse_idx:]
648
+ + reverse_ring[:reverse_idx]
649
+ + [reverse_ring[reverse_idx]]
650
+ )
651
+ return choose_direction(forward, reverse)
652
+
653
+ next_idx = (best_idx + 1) % len(ring)
654
+ if best_t >= 1.0 - eps:
655
+ forward = ring[next_idx:] + ring[:next_idx] + [ring[next_idx]]
656
+ reverse_ring = list(reversed(ring))
657
+ reverse_idx = reverse_ring.index(ring[next_idx])
658
+ reverse = (
659
+ reverse_ring[reverse_idx:]
660
+ + reverse_ring[:reverse_idx]
661
+ + [reverse_ring[reverse_idx]]
662
+ )
663
+ return choose_direction(forward, reverse)
664
+
665
+ forward = [best_point]
666
+ for step in range(1, len(ring) + 1):
667
+ forward.append(ring[(best_idx + step) % len(ring)])
668
+ forward.append(best_point)
669
+
670
+ reverse = [best_point]
671
+ for step in range(0, len(ring)):
672
+ reverse.append(ring[(best_idx - step) % len(ring)])
673
+ reverse.append(best_point)
674
+ return choose_direction(forward, reverse)
675
+
676
+
677
+ def _last_print_reference(output_list: list[dict]) -> tuple[float, float, float, float]:
678
+ x = y = 0.0
679
+ last_x = last_y = 0.0
680
+ last_dx = last_dy = 0.0
681
+ for move in output_list:
682
+ dx = float(move.get("X", 0.0))
683
+ dy = float(move.get("Y", 0.0))
684
+ x += dx
685
+ y += dy
686
+ if move.get("Color") == 255 and "Z" not in move:
687
+ last_x = x
688
+ last_y = y
689
+ last_dx = dx
690
+ last_dy = dy
691
+ return last_x, last_y, last_dx, last_dy
692
+
693
+
694
+ def _rewind_trailing_travel(
695
+ output_list: list[dict],
696
+ current_x: float,
697
+ current_y: float,
698
+ ) -> tuple[float, float]:
699
+ if not output_list:
700
+ return current_x, current_y
701
+
702
+ last_move = output_list[-1]
703
+ if last_move.get("Color") != 0 or "Z" in last_move:
704
+ return current_x, current_y
705
+
706
+ has_layer_print = any(
707
+ move.get("Color") == 255 and "Z" not in move
708
+ for move in reversed(output_list[:-1])
709
+ )
710
+ if not has_layer_print:
711
+ return current_x, current_y
712
+
713
+ output_list.pop()
714
+ return (
715
+ current_x - float(last_move.get("X", 0.0)),
716
+ current_y - float(last_move.get("Y", 0.0)),
717
+ )
718
+
719
+
720
+ def build_contour_layers(
721
+ sources: list[ContourSource] | None,
722
+ n_layers: int,
723
+ reference: LayerStack | None = None,
724
+ ) -> list[list[dict]]:
725
+ """Per-layer contour loops per owning shape.
726
+
727
+ When `reference` is given (reference-motion mode) each source stack is
728
+ translated by its centering delta so contours land in the shared frame.
729
+ """
730
+ contour_layers: list[list[dict]] = [[] for _ in range(n_layers)]
731
+ for source in sources or []:
732
+ stack = source.stack
733
+ if stack is None or not stack.layers:
734
+ continue
735
+
736
+ if reference is not None:
737
+ layers = align_stack_to(stack, reference, n_layers)
738
+ else:
739
+ layers = stack.layers
740
+
741
+ for layer_number in range(min(n_layers, len(layers))):
742
+ layer = layers[layer_number]
743
+ if layer is None or layer.is_empty:
744
+ continue
745
+ contours = _layer_contour_loops(layer)
746
+ if contours:
747
+ contour_layers[layer_number].append(
748
+ {
749
+ "owner_idx": source.owner_idx,
750
+ "contours": contours,
751
+ }
752
+ )
753
+
754
+ return contour_layers
755
+
756
+
757
+ def _append_layer_contours(
758
+ output_list: list[dict],
759
+ current_x: float,
760
+ current_y: float,
761
+ contour_layers: list[list[dict]],
762
+ layer_number: int,
763
+ active_owner_idx: int | None,
764
+ origin_x: float = 0.0,
765
+ origin_y: float = 0.0,
766
+ ) -> tuple[float, float]:
767
+ # `origin_x/origin_y` is the world position of the move list's start:
768
+ # _last_print_reference sums relative moves from zero, so its result must
769
+ # be shifted back into the world frame the contours live in.
770
+ if layer_number >= len(contour_layers):
771
+ return current_x, current_y
772
+
773
+ active_sources = [
774
+ source
775
+ for source in contour_layers[layer_number]
776
+ if source.get("owner_idx") == active_owner_idx
777
+ and any(len(contour) >= 2 for contour in source.get("contours", []))
778
+ ]
779
+ if not active_sources:
780
+ return current_x, current_y
781
+
782
+ current_x, current_y = _rewind_trailing_travel(
783
+ output_list,
784
+ current_x,
785
+ current_y,
786
+ )
787
+
788
+ use_infill_reference = True
789
+
790
+ for source in active_sources:
791
+ color = 255
792
+ for contour in source.get("contours", []):
793
+ if len(contour) < 2:
794
+ continue
795
+ if use_infill_reference:
796
+ nearest_x, nearest_y, approach_dx, approach_dy = _last_print_reference(
797
+ output_list
798
+ )
799
+ nearest_x += origin_x
800
+ nearest_y += origin_y
801
+ else:
802
+ nearest_x, nearest_y = current_x, current_y
803
+ approach_dx, approach_dy = 0.0, 0.0
804
+ contour = _rotate_closed_contour_to_nearest_border(
805
+ contour,
806
+ nearest_x,
807
+ nearest_y,
808
+ approach_dx,
809
+ approach_dy,
810
+ )
811
+ if contour[0] != contour[-1]:
812
+ contour = [*contour, contour[0]]
813
+ start_x, start_y = contour[0]
814
+ use_infill_reference = False
815
+ current_x, current_y = _append_relative_move(
816
+ output_list,
817
+ current_x,
818
+ current_y,
819
+ start_x,
820
+ start_y,
821
+ 0,
822
+ )
823
+ for target_x, target_y in contour[1:]:
824
+ current_x, current_y = _append_relative_move(
825
+ output_list,
826
+ current_x,
827
+ current_y,
828
+ target_x,
829
+ target_y,
830
+ color,
831
+ )
832
+
833
+ return current_x, current_y
834
+
835
+
836
+ def _lead_in_moves(
837
+ enabled: bool,
838
+ length: float,
839
+ clearance: float,
840
+ line_count: int,
841
+ line_spacing: float,
842
+ print_color: int,
843
+ off_color: int,
844
+ ) -> list[dict]:
845
+ if not enabled:
846
+ return []
847
+ lead_length = max(0.0, float(length))
848
+ if lead_length <= 0.0:
849
+ return []
850
+ lead_clearance = max(0.0, float(clearance))
851
+ pass_count = max(1, int(line_count))
852
+ spacing = max(0.0, float(line_spacing))
853
+
854
+ moves: list[dict] = []
855
+ current_x = 0.0
856
+ current_y = 0.0
857
+
858
+ def append_move(dx: float, dy: float, color: int) -> None:
859
+ nonlocal current_x, current_y
860
+ if dx == 0.0 and dy == 0.0:
861
+ return
862
+ moves.append({"X": dx, "Y": dy, "Color": color})
863
+ current_x += dx
864
+ current_y += dy
865
+
866
+ append_move(-(lead_clearance + lead_length), 0.0, off_color)
867
+ direction = 1.0
868
+ for pass_index in range(pass_count):
869
+ append_move(direction * lead_length, 0.0, print_color)
870
+ direction *= -1.0
871
+ if pass_index < pass_count - 1:
872
+ append_move(0.0, spacing, off_color)
873
+ append_move(-current_x, -current_y, off_color)
874
+ return moves
875
+
876
+
877
+ def plan_layer_moves(
878
+ motion_layers: list[MultiPolygon],
879
+ valve_layers: list[MultiPolygon],
880
+ fil_width: float,
881
+ layer_height: float,
882
+ raster_pattern: str,
883
+ contour_layers: list[list[dict]] | None = None,
884
+ active_contour_owner: int | None = None,
885
+ ) -> list[dict]:
886
+ """Assemble per-layer segments into a relative move list for all patterns."""
887
+ raster_pattern = _normalize_raster_pattern(raster_pattern)
888
+ gcode_list: list[dict] = []
889
+ current_x = 0.0
890
+ current_y = 0.0
891
+ origin_x = 0.0
892
+ origin_y = 0.0
893
+ raster_origin_initialized = False
894
+ contour_layers = contour_layers or []
895
+
896
+ for layer_number, (motion, valve) in enumerate(zip(motion_layers, valve_layers)):
897
+ if motion is None or motion.is_empty:
898
+ segments: list[Seg] = []
899
+ elif raster_pattern == RASTER_PATTERN_CIRCLE_SPIRAL:
900
+ points = _circle_spiral_polyline(
901
+ motion.bounds,
902
+ fil_width,
903
+ reverse=layer_number % 2 == 1,
904
+ )
905
+ segments = _classify_polyline(points, valve)
906
+ elif raster_pattern == RASTER_PATTERN_RECTANGULAR_SPIRAL:
907
+ points = _rectangular_spiral_polyline(
908
+ motion.bounds,
909
+ fil_width,
910
+ reverse=layer_number % 2 == 1,
911
+ )
912
+ segments = _classify_polyline(points, valve)
913
+ else:
914
+ raster_axis = _raster_axis_for_pattern(raster_pattern, layer_number)
915
+ segments = _oriented_axis_raster_segments(
916
+ motion,
917
+ valve,
918
+ fil_width,
919
+ raster_axis,
920
+ current_x,
921
+ current_y,
922
+ prefer_default=not raster_origin_initialized,
923
+ )
924
+
925
+ if not segments:
926
+ if layer_number > 0:
927
+ gcode_list.append({"X": 0.0, "Y": 0.0, "Z": layer_height, "Color": 0})
928
+ layer_end_x, layer_end_y = current_x, current_y
929
+ current_x, current_y = _append_layer_contours(
930
+ gcode_list,
931
+ current_x,
932
+ current_y,
933
+ contour_layers,
934
+ layer_number,
935
+ active_contour_owner,
936
+ origin_x,
937
+ origin_y,
938
+ )
939
+ current_x, current_y = _append_relative_move(
940
+ gcode_list,
941
+ current_x,
942
+ current_y,
943
+ layer_end_x,
944
+ layer_end_y,
945
+ 0,
946
+ )
947
+ continue
948
+
949
+ first_x, first_y = segments[0][0], segments[0][1]
950
+ if not raster_origin_initialized:
951
+ if layer_number > 0:
952
+ current_x, current_y = _append_relative_move(
953
+ gcode_list,
954
+ current_x,
955
+ current_y,
956
+ current_x,
957
+ current_y,
958
+ 0,
959
+ z_step=layer_height,
960
+ )
961
+ current_x, current_y = first_x, first_y
962
+ origin_x, origin_y = first_x, first_y
963
+ raster_origin_initialized = True
964
+ elif layer_number > 0:
965
+ current_x, current_y = _append_relative_move(
966
+ gcode_list,
967
+ current_x,
968
+ current_y,
969
+ first_x,
970
+ first_y,
971
+ 0,
972
+ z_step=layer_height,
973
+ )
974
+ else:
975
+ current_x, current_y = _append_relative_move(
976
+ gcode_list,
977
+ current_x,
978
+ current_y,
979
+ first_x,
980
+ first_y,
981
+ 0,
982
+ )
983
+
984
+ for start_x, start_y, end_x, end_y, color in segments:
985
+ current_x, current_y = _append_relative_move(
986
+ gcode_list,
987
+ current_x,
988
+ current_y,
989
+ start_x,
990
+ start_y,
991
+ 0,
992
+ )
993
+ current_x, current_y = _append_relative_move(
994
+ gcode_list,
995
+ current_x,
996
+ current_y,
997
+ end_x,
998
+ end_y,
999
+ color,
1000
+ )
1001
+
1002
+ layer_end_x, layer_end_y = current_x, current_y
1003
+ current_x, current_y = _append_layer_contours(
1004
+ gcode_list,
1005
+ current_x,
1006
+ current_y,
1007
+ contour_layers,
1008
+ layer_number,
1009
+ active_contour_owner,
1010
+ origin_x,
1011
+ origin_y,
1012
+ )
1013
+ current_x, current_y = _append_relative_move(
1014
+ gcode_list,
1015
+ current_x,
1016
+ current_y,
1017
+ layer_end_x,
1018
+ layer_end_y,
1019
+ 0,
1020
+ )
1021
+
1022
+ return gcode_list
1023
+
1024
+
1025
+ def _stack_center(stack: LayerStack) -> tuple[float, float]:
1026
+ (x_min, y_min, _z_min), (x_max, y_max, _z_max) = stack.bounds
1027
+ return ((x_min + x_max) / 2.0, (y_min + y_max) / 2.0)
1028
+
1029
+
1030
+ def _centering_delta(stack: LayerStack, reference: LayerStack) -> tuple[float, float]:
1031
+ reference_x, reference_y = _stack_center(reference)
1032
+ center_x, center_y = _stack_center(stack)
1033
+ return reference_x - center_x, reference_y - center_y
1034
+
1035
+
1036
+ def align_stack_to(
1037
+ stack: LayerStack,
1038
+ reference: LayerStack,
1039
+ n_layers: int,
1040
+ ) -> list[MultiPolygon]:
1041
+ """Translate a stack's layers into the reference frame, padded with empties."""
1042
+ delta_x, delta_y = _centering_delta(stack, reference)
1043
+ layers: list[MultiPolygon] = []
1044
+ for index in range(n_layers):
1045
+ if index < len(stack.layers) and not stack.layers[index].is_empty:
1046
+ layers.append(
1047
+ _as_multipolygon(
1048
+ translate(stack.layers[index], xoff=delta_x, yoff=delta_y)
1049
+ )
1050
+ )
1051
+ else:
1052
+ layers.append(MultiPolygon())
1053
+ return layers
1054
+
1055
+
1056
+ def build_reference_stack(stacks: list[LayerStack | None]) -> LayerStack | None:
1057
+ """Union all shapes into one shared motion stack, bbox-centres aligned.
1058
+
1059
+ Vector analog of the old centered "black wins" TIFF merge: every stack is
1060
+ translated so its XY bbox centre lands on the first stack's centre, then
1061
+ each layer is the union of the translated layers.
1062
+ """
1063
+ valid = [stack for stack in stacks if stack is not None and stack.layers]
1064
+ if not valid:
1065
+ return None
1066
+
1067
+ n_layers = max(len(stack.layers) for stack in valid)
1068
+ reference_x, reference_y = _stack_center(valid[0])
1069
+
1070
+ layer_parts: list[list[MultiPolygon]] = [[] for _ in range(n_layers)]
1071
+ x_min = y_min = z_min = math.inf
1072
+ x_max = y_max = z_max = -math.inf
1073
+
1074
+ for stack in valid:
1075
+ center_x, center_y = _stack_center(stack)
1076
+ delta_x = reference_x - center_x
1077
+ delta_y = reference_y - center_y
1078
+
1079
+ (sx_min, sy_min, sz_min), (sx_max, sy_max, sz_max) = stack.bounds
1080
+ x_min = min(x_min, sx_min + delta_x)
1081
+ x_max = max(x_max, sx_max + delta_x)
1082
+ y_min = min(y_min, sy_min + delta_y)
1083
+ y_max = max(y_max, sy_max + delta_y)
1084
+ z_min = min(z_min, sz_min)
1085
+ z_max = max(z_max, sz_max)
1086
+
1087
+ for index, layer in enumerate(stack.layers):
1088
+ if layer is None or layer.is_empty:
1089
+ continue
1090
+ layer_parts[index].append(translate(layer, xoff=delta_x, yoff=delta_y))
1091
+
1092
+ layers = [
1093
+ _as_multipolygon(unary_union(parts)) if parts else MultiPolygon()
1094
+ for parts in layer_parts
1095
+ ]
1096
+
1097
+ z_values: list[float] = []
1098
+ for index in range(n_layers):
1099
+ z_value = 0.0
1100
+ for stack in valid:
1101
+ if index < len(stack.z_values):
1102
+ z_value = stack.z_values[index]
1103
+ break
1104
+ z_values.append(z_value)
1105
+
1106
+ return LayerStack(
1107
+ layers=layers,
1108
+ z_values=z_values,
1109
+ bounds=((x_min, y_min, z_min), (x_max, y_max, z_max)),
1110
+ layer_height=valid[0].layer_height,
1111
+ name="reference",
1112
+ )
1113
+
1114
+
1115
+ def _split_boundaries(
1116
+ lo: float,
1117
+ hi: float,
1118
+ count: int,
1119
+ layer_index: int,
1120
+ overlap: float,
1121
+ ) -> list[float]:
1122
+ """Cell boundaries for one axis; interior ones alternate by ±overlap per layer."""
1123
+ edges = [lo + index * (hi - lo) / count for index in range(count + 1)]
1124
+ if overlap <= 0.0 or count <= 1:
1125
+ return edges
1126
+
1127
+ adjusted = list(edges)
1128
+ for boundary_index in range(1, count):
1129
+ direction = 1 if (layer_index + boundary_index) % 2 == 1 else -1
1130
+ lower = adjusted[boundary_index - 1] + overlap
1131
+ upper = edges[boundary_index + 1] - overlap
1132
+ shifted = edges[boundary_index] + direction * overlap
1133
+ if lower <= upper:
1134
+ shifted = max(lower, min(upper, shifted))
1135
+ else:
1136
+ shifted = edges[boundary_index]
1137
+ adjusted[boundary_index] = shifted
1138
+ return adjusted
1139
+
1140
+
1141
+ def split_layer_stack_grid(
1142
+ stack: LayerStack,
1143
+ columns: int,
1144
+ rows: int,
1145
+ overlapping_layers: bool = False,
1146
+ overlap: float = 0.0,
1147
+ ) -> list[LayerStack]:
1148
+ """Split a sliced shape into a rows x columns grid of piece stacks.
1149
+
1150
+ Pieces are returned row-major with row 1 the top strip (max-Y side),
1151
+ matching the legacy image-grid ordering. With `overlapping_layers`, the
1152
+ interior cut lines alternate by ±overlap between layers so neighbouring
1153
+ pieces interlock. Piece `bounds` are the nominal (un-shifted) cell boxes.
1154
+ """
1155
+ columns = max(1, int(columns))
1156
+ rows = max(1, int(rows))
1157
+ (x_min, y_min, z_min), (x_max, y_max, z_max) = stack.bounds
1158
+
1159
+ overlap_x = overlap if (overlapping_layers and columns > 1) else 0.0
1160
+ overlap_y = overlap if (overlapping_layers and rows > 1) else 0.0
1161
+
1162
+ base_x_edges = _split_boundaries(x_min, x_max, columns, 0, 0.0)
1163
+ base_y_edges = _split_boundaries(y_min, y_max, rows, 0, 0.0)
1164
+ layer_x_edges = [
1165
+ _split_boundaries(x_min, x_max, columns, index, overlap_x)
1166
+ for index in range(len(stack.layers))
1167
+ ]
1168
+ layer_y_edges = [
1169
+ _split_boundaries(y_min, y_max, rows, index, overlap_y)
1170
+ for index in range(len(stack.layers))
1171
+ ]
1172
+
1173
+ base_name = stack.name or "shape"
1174
+ pieces: list[LayerStack] = []
1175
+ for row_index in range(1, rows + 1):
1176
+ # Row 1 is the top strip: count y-cells down from the max edge.
1177
+ y_cell = rows - row_index
1178
+ for col_index in range(1, columns + 1):
1179
+ x_cell = col_index - 1
1180
+
1181
+ layers: list[MultiPolygon] = []
1182
+ for layer_number, layer in enumerate(stack.layers):
1183
+ if layer is None or layer.is_empty:
1184
+ layers.append(MultiPolygon())
1185
+ continue
1186
+ x_edges = layer_x_edges[layer_number]
1187
+ y_edges = layer_y_edges[layer_number]
1188
+ cell = box(
1189
+ x_edges[x_cell],
1190
+ y_edges[y_cell],
1191
+ x_edges[x_cell + 1],
1192
+ y_edges[y_cell + 1],
1193
+ )
1194
+ layers.append(_as_multipolygon(layer.intersection(cell)))
1195
+
1196
+ pieces.append(
1197
+ LayerStack(
1198
+ layers=layers,
1199
+ z_values=list(stack.z_values),
1200
+ bounds=(
1201
+ (base_x_edges[x_cell], base_y_edges[y_cell], z_min),
1202
+ (base_x_edges[x_cell + 1], base_y_edges[y_cell + 1], z_max),
1203
+ ),
1204
+ layer_height=stack.layer_height,
1205
+ name=f"{base_name}_r{row_index:02d}_c{col_index:02d}",
1206
+ )
1207
+ )
1208
+
1209
+ return pieces