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