Spaces:
Running
Add Download All zip, sliced-layer preview; always-on shared motion and G1 moves
Browse filesGenerate G-Code tab:
- New "Download All (.zip)" button next to the individual G-code file
list; the zip is rebuilt wherever the file list refreshes (generate,
delete, split, sync) and hides when nothing is generated. Name-
colliding entries get a _2 suffix instead of overwriting
- G-code files are named <shape>_gcode.txt (SnakePath dropped), so
individual and zipped downloads carry the shape's name
- Shared reference motion is always on: the checkbox is gone and every
generation rebuilds the combined outline at the current fil width -
forgetting it silently produced non-parallel G-code
- Constant speed is always on: the all-G1 checkbox is gone; every move
is emitted as G1 and the valve commands mark print vs travel
Shapes & Slicing tab:
- Sliced Layer Preview next to the 3D viewer: a layer slider through
the selected shape's polygon outlines drawn in its print color.
Shapes sharing the nozzle (assemblies, split cell-mates) are drawn
together at the same Z, dimmer with a legend - multi-material
slicing and Flip Z can be checked before generating G-code. Refreshes
on selection, slicing, and slider release; fixed axes across layers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- README.md +6 -6
- app.py +203 -29
- tests/test_nozzle_spacing.py +79 -1
- tests/test_vector_gcode.py +15 -0
- vector_gcode.py +1 -1
|
@@ -47,11 +47,12 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 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
|
| 52 |
- **Infill %** per shape skips dispensing on evenly-distributed raster lines (rings/revolutions for spiral patterns) — at 50% every other line prints — while the motion path stays exactly the same, so parallel shapes with different infill still share one print path
|
| 53 |
- Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
|
| 54 |
-
- Offers
|
| 55 |
- Re-slices shapes automatically during G-code generation when their slices are missing or stale, so "upload, then Generate G-Code" works in one click
|
| 56 |
- Calculates X/Y nozzle spacing from a grid layout (columns/rows plus gaps), with an optional per-connection Advanced Grid Spacing table, then visualizes the resulting nozzle layout
|
| 57 |
- Previews selected generated G-code inline
|
|
@@ -81,7 +82,7 @@ For a multi-material object exported as separate STLs (one per material), give e
|
|
| 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 |
-
-
|
| 85 |
- Nozzle renumbering in the table takes effect on the next slice or G-code generation — groups are re-detected automatically.
|
| 86 |
- **Dimension edits scale the whole assembly**: changing a dimension of one group member applies the same scale factor (target ÷ original, per axis) to every shape on that nozzle, in both scaling modes — in Keep Proportions one edit rescales every member proportionally; in Independent X/Y/Z the edited axis's factor propagates to the group. Factors (not absolute values) keep differently-sized parts proportional to each other, and group members are scaled about the assembly's shared corner so the parts stay assembled at any size.
|
| 87 |
- **Flip Z** (per-shape checkbox) prints a shape the other way up by mirroring it about its Z midplane. Checking it on any assembly member flips the **whole group** about the group's shared midplane, so the assembly stays together — useful when a multi-material model is authored with its display face down (e.g. the detail layer would otherwise print first instead of last).
|
|
@@ -103,13 +104,12 @@ The **Multi-Nozzle Split** accordion on the **Shapes & Slicing** tab can split o
|
|
| 103 |
### G-code Output
|
| 104 |
|
| 105 |
- Generated G-code starts in relative coordinate mode (`G91`).
|
| 106 |
-
-
|
| 107 |
-
- The app generates print/feed moves where the tool path is inside a shape's layer polygons and travel moves elsewhere.
|
| 108 |
- Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port; the nozzle number controls layout/spacing assignment.
|
| 109 |
- Pressure increases by `0.1` psi per layer by default.
|
| 110 |
-
-
|
| 111 |
- **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.
|
| 112 |
-
- **
|
| 113 |
- Generated files contain only machine commands (no metadata comments). The toolpath's world anchor — the position, in the shape's own frame, that the relative moves start from — is kept on the shape's session record and used to place parallel parts so split pieces reassemble.
|
| 114 |
- **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 rectangular loops from the outside toward the center, then reverses from center to edge on the next layer; the loops live on one family anchored to the shape frame, so every layer (and every split sibling) walks the same rectangles and the walls stack — outer loops that cannot touch a layer's material are skipped instead of traveled. `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. Under shared reference motion every shape's own wall radius joins the one shared ring set (all heads travel all walls, each dispenses only its own), and rings that would graze a shape's boundary are suppressed for that shape — so each parallel shape keeps a smooth, complete outer circle regardless of its dimensions.
|
| 115 |
- **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (world toolpath 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.
|
|
|
|
| 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 |
+
- Shows a sliced-layer preview in the Selected Shape Preview accordion (layer slider through the shape's polygon outlines, drawn in its print color; assembly parts sharing the nozzle are drawn together so multi-material slicing and Flip Z can be checked before generating G-code)
|
| 51 |
- Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
|
| 52 |
- Converts sliced layers into G-code files with pressure, valve, nozzle, port, and infill % settings per shape from the Shape Settings table
|
| 53 |
- **Infill %** per shape skips dispensing on evenly-distributed raster lines (rings/revolutions for spiral patterns) — at 50% every other line prints — while the motion path stays exactly the same, so parallel shapes with different infill still share one print path
|
| 54 |
- Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
|
| 55 |
+
- Offers a choice of raster pattern for G-code generation; all shapes always share one combined reference outline for motion (one nozzle path; each dispenses only its own geometry), and every move is emitted as `G1` at one constant speed (no `G0` rapid travel)
|
| 56 |
- Re-slices shapes automatically during G-code generation when their slices are missing or stale, so "upload, then Generate G-Code" works in one click
|
| 57 |
- Calculates X/Y nozzle spacing from a grid layout (columns/rows plus gaps), with an optional per-connection Advanced Grid Spacing table, then visualizes the resulting nozzle layout
|
| 58 |
- Previews selected generated G-code inline
|
|
|
|
| 82 |
- 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.
|
| 83 |
- 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.
|
| 84 |
- **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.
|
| 85 |
+
- Motion is always the combined reference outline, so all heads share one synchronized path while each dispenses only its own part.
|
| 86 |
- Nozzle renumbering in the table takes effect on the next slice or G-code generation — groups are re-detected automatically.
|
| 87 |
- **Dimension edits scale the whole assembly**: changing a dimension of one group member applies the same scale factor (target ÷ original, per axis) to every shape on that nozzle, in both scaling modes — in Keep Proportions one edit rescales every member proportionally; in Independent X/Y/Z the edited axis's factor propagates to the group. Factors (not absolute values) keep differently-sized parts proportional to each other, and group members are scaled about the assembly's shared corner so the parts stay assembled at any size.
|
| 88 |
- **Flip Z** (per-shape checkbox) prints a shape the other way up by mirroring it about its Z midplane. Checking it on any assembly member flips the **whole group** about the group's shared midplane, so the assembly stays together — useful when a multi-material model is authored with its display face down (e.g. the detail layer would otherwise print first instead of last).
|
|
|
|
| 104 |
### G-code Output
|
| 105 |
|
| 106 |
- Generated G-code starts in relative coordinate mode (`G91`).
|
| 107 |
+
- The app opens the valve where the tool path is inside a shape's layer polygons and closes it elsewhere.
|
|
|
|
| 108 |
- Generated files include pressure preset commands and WAGO valve commands based on the selected pressure, valve, and port; the nozzle number controls layout/spacing assignment.
|
| 109 |
- Pressure increases by `0.1` psi per layer by default.
|
| 110 |
+
- Every movement line is emitted as `G1` at one constant speed (no `G0` rapid travel); the WAGO valve commands mark where material is dispensed vs where the head just travels.
|
| 111 |
- **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.
|
| 112 |
+
- **Combined reference outline for motion** (always on): 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 whenever shapes are sliced or G-code is generated. Contour tracing stays synchronized too: every shape traces every traced shape's contour, opening its valve only on its own outline.
|
| 113 |
- Generated files contain only machine commands (no metadata comments). The toolpath's world anchor — the position, in the shape's own frame, that the relative moves start from — is kept on the shape's session record and used to place parallel parts so split pieces reassemble.
|
| 114 |
- **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 rectangular loops from the outside toward the center, then reverses from center to edge on the next layer; the loops live on one family anchored to the shape frame, so every layer (and every split sibling) walks the same rectangles and the walls stack — outer loops that cannot touch a layer's material are skipped instead of traveled. `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. Under shared reference motion every shape's own wall radius joins the one shared ring set (all heads travel all walls, each dispenses only its own), and rings that would graze a shape's boundary are suppressed for that shape — so each parallel shape keeps a smooth, complete outer circle regardless of its dimensions.
|
| 115 |
- **Auto Align Split Parts**: in Nozzle Spacing, computes exact per-connection grid gaps from the split pieces' generated G-code (world toolpath 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.
|
|
@@ -4,6 +4,7 @@ import math
|
|
| 4 |
import tempfile
|
| 5 |
import time
|
| 6 |
import warnings
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Any
|
| 9 |
|
|
@@ -2253,6 +2254,32 @@ def _gcode_dropdown_update(records: list[dict], selected: str | None = None, inc
|
|
| 2253 |
return gr.update(choices=choices, value=value)
|
| 2254 |
|
| 2255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2256 |
def _merge_file_paths(*file_groups: Any) -> list[str]:
|
| 2257 |
merged: list[str] = []
|
| 2258 |
seen: set[str] = set()
|
|
@@ -2288,6 +2315,7 @@ def sync_uploaded_shapes(
|
|
| 2288 |
_gcode_dropdown_update(next_records),
|
| 2289 |
_gcode_dropdown_update(next_records, include_upload=True),
|
| 2290 |
[record.get("gcode_path") for record in next_records if record.get("gcode_path")],
|
|
|
|
| 2291 |
)
|
| 2292 |
|
| 2293 |
|
|
@@ -2322,6 +2350,7 @@ def _shape_delete_outputs(
|
|
| 2322 |
_gcode_dropdown_update(records),
|
| 2323 |
_gcode_dropdown_update(records, include_upload=True),
|
| 2324 |
[record.get("gcode_path") for record in records if record.get("gcode_path")],
|
|
|
|
| 2325 |
float(last_delete_at or 0.0),
|
| 2326 |
)
|
| 2327 |
|
|
@@ -2332,7 +2361,7 @@ def _shape_select_noop() -> tuple:
|
|
| 2332 |
lands late); echoing records/rows here raced the dimension normalizer's
|
| 2333 |
write-back — the stale echo clobbered the recomputed proportions on the
|
| 2334 |
first Keep Proportions edit and re-rendered the table mid-typing."""
|
| 2335 |
-
return tuple(gr.skip() for _ in range(
|
| 2336 |
|
| 2337 |
|
| 2338 |
def delete_shape_from_settings(
|
|
@@ -2727,6 +2756,130 @@ def show_selected_model(
|
|
| 2727 |
)
|
| 2728 |
|
| 2729 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2730 |
def _slice_params_snapshot(
|
| 2731 |
record: dict,
|
| 2732 |
layer_height: float,
|
|
@@ -3188,6 +3341,7 @@ def split_selected_shape_for_grid(
|
|
| 3188 |
_gcode_dropdown_update(next_records, include_upload=True),
|
| 3189 |
_dropdown_update(next_records, selected_value),
|
| 3190 |
status,
|
|
|
|
| 3191 |
)
|
| 3192 |
|
| 3193 |
if not records:
|
|
@@ -3369,30 +3523,23 @@ def _lead_in_assembly_extension(records: list[dict], direction: str | None) -> f
|
|
| 3369 |
def generate_dynamic_gcode(
|
| 3370 |
records: list[dict] | None,
|
| 3371 |
settings_table: Any,
|
| 3372 |
-
all_g1: bool,
|
| 3373 |
-
use_reference_motion: bool,
|
| 3374 |
raster_pattern: str | None,
|
| 3375 |
pressure_ramp_enabled: bool,
|
| 3376 |
lead_in_length: float,
|
| 3377 |
lead_in_clearance: float,
|
| 3378 |
lead_in_lines: float,
|
| 3379 |
lead_in_direction: str | None,
|
| 3380 |
-
ref_layers: LayerStack | None,
|
| 3381 |
layer_height: float,
|
| 3382 |
fil_width: float,
|
| 3383 |
scale_mode: str | None,
|
| 3384 |
) -> tuple:
|
| 3385 |
records = _apply_shape_settings(records or [], settings_table)
|
| 3386 |
messages: list[str] = []
|
| 3387 |
-
|
| 3388 |
-
|
| 3389 |
-
|
| 3390 |
-
|
| 3391 |
-
|
| 3392 |
-
else:
|
| 3393 |
-
# Still refresh group frames + seam-free contours: nozzle numbers may
|
| 3394 |
-
# have been edited in the table since the last slice.
|
| 3395 |
-
_stamp_multi_material_frames(records, fil_width)
|
| 3396 |
contour_sources = _contour_tracing_sources(records)
|
| 3397 |
if contour_sources:
|
| 3398 |
enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
|
|
@@ -3424,8 +3571,8 @@ def generate_dynamic_gcode(
|
|
| 3424 |
if stack is None or not getattr(stack, "layers", None):
|
| 3425 |
messages.append(f"Shape {record['idx']}: skipped (no sliced layers).")
|
| 3426 |
continue
|
| 3427 |
-
if
|
| 3428 |
-
messages.append(f"Shape {record['idx']}: skipped (
|
| 3429 |
continue
|
| 3430 |
shape_name = str(record.get("name") or stack.name or f"shape_{record['idx']}").replace(" ", "_")
|
| 3431 |
try:
|
|
@@ -3438,8 +3585,11 @@ def generate_dynamic_gcode(
|
|
| 3438 |
layer_height=float(layer_height),
|
| 3439 |
fil_width=float(fil_width),
|
| 3440 |
pressure_ramp_enabled=bool(pressure_ramp_enabled),
|
| 3441 |
-
|
| 3442 |
-
|
|
|
|
|
|
|
|
|
|
| 3443 |
raster_pattern=raster_pattern,
|
| 3444 |
contour_sources=contour_sources,
|
| 3445 |
active_contour_owner=int(record.get("idx", 0)),
|
|
@@ -3450,7 +3600,7 @@ def generate_dynamic_gcode(
|
|
| 3450 |
lead_in_lines=max(1, _coerce_int(lead_in_lines, 3)),
|
| 3451 |
lead_in_direction=lead_in_direction or LEAD_IN_DIRECTION_LEFT,
|
| 3452 |
lead_in_dispense=bool(record.get("lead_in", True)),
|
| 3453 |
-
wall_sources=wall_sources
|
| 3454 |
origin_sink=(origin_sink := {}),
|
| 3455 |
)
|
| 3456 |
record["gcode_path"] = str(gcode_path)
|
|
@@ -3467,6 +3617,7 @@ def generate_dynamic_gcode(
|
|
| 3467 |
"\n".join(messages),
|
| 3468 |
_gcode_dropdown_update(records),
|
| 3469 |
_gcode_dropdown_update(records, include_upload=True),
|
|
|
|
| 3470 |
)
|
| 3471 |
|
| 3472 |
|
|
@@ -3803,6 +3954,15 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3803 |
camera_position=FRONT_CAMERA,
|
| 3804 |
height=360,
|
| 3805 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3806 |
with gr.Column(scale=1, min_width=300):
|
| 3807 |
model_details = gr.Markdown("No model loaded.")
|
| 3808 |
|
|
@@ -3813,13 +3973,9 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3813 |
"""
|
| 3814 |
# Generate G-Code
|
| 3815 |
Generate G-code for every sliced shape. Pressure, valve, nozzle, port, and color come from the Shape Settings table.
|
|
|
|
| 3816 |
"""
|
| 3817 |
)
|
| 3818 |
-
gcode_use_ref_motion = gr.Checkbox(
|
| 3819 |
-
label="Use combined reference outline for motion (all shapes share one nozzle path; each dispenses only its own geometry).",
|
| 3820 |
-
value=True,
|
| 3821 |
-
)
|
| 3822 |
-
gcode_all_g1 = gr.Checkbox(label="Move at one constant speed (no fast travel moves)", value=True)
|
| 3823 |
gcode_pressure_ramp_enabled = gr.Checkbox(label="Increase Pressure Each Layer", value=True)
|
| 3824 |
gcode_raster_pattern = gr.Dropdown(
|
| 3825 |
label="Raster Pattern",
|
|
@@ -3840,7 +3996,15 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3840 |
allow_custom_value=False,
|
| 3841 |
)
|
| 3842 |
gcode_button = gr.Button("Generate G-Code", variant="primary")
|
| 3843 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3844 |
gcode_status = gr.Markdown("")
|
| 3845 |
with gr.Row():
|
| 3846 |
gcode_text_source = gr.Dropdown(label="Preview G-Code", choices=[], value=None, allow_custom_value=False)
|
|
@@ -3970,7 +4134,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3970 |
nozzle_grid_use_individual_spacing,
|
| 3971 |
]
|
| 3972 |
|
| 3973 |
-
shape_sync_outputs = [shape_records, shape_settings, selected_shape, gcode_text_source, gcode_source, gcode_downloads]
|
| 3974 |
stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
|
| 3975 |
fn=lambda records: _dropdown_update(records),
|
| 3976 |
inputs=[shape_records],
|
|
@@ -4053,6 +4217,14 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 4053 |
selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
|
| 4054 |
refresh_preview_button.click(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
|
| 4055 |
model_opacity.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4056 |
scale_mode.change(
|
| 4057 |
fn=normalize_shape_dimensions_for_mode,
|
| 4058 |
inputs=[shape_records, shape_settings, scale_mode],
|
|
@@ -4077,6 +4249,10 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 4077 |
fn=generate_dynamic_layer_stacks,
|
| 4078 |
inputs=[shape_records, shape_settings, layer_height, scale_mode, fil_width],
|
| 4079 |
outputs=[shape_records, slicer_status, ref_layers],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4080 |
).then(
|
| 4081 |
fn=lambda records: _dropdown_update(records),
|
| 4082 |
inputs=[shape_records],
|
|
@@ -4115,6 +4291,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 4115 |
gcode_source,
|
| 4116 |
split_source,
|
| 4117 |
split_status,
|
|
|
|
| 4118 |
],
|
| 4119 |
).then(
|
| 4120 |
fn=generate_dynamic_reference_stack,
|
|
@@ -4132,20 +4309,17 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 4132 |
inputs=[
|
| 4133 |
shape_records,
|
| 4134 |
shape_settings,
|
| 4135 |
-
gcode_all_g1,
|
| 4136 |
-
gcode_use_ref_motion,
|
| 4137 |
gcode_raster_pattern,
|
| 4138 |
gcode_pressure_ramp_enabled,
|
| 4139 |
gcode_lead_in_length,
|
| 4140 |
gcode_lead_in_clearance,
|
| 4141 |
gcode_lead_in_lines,
|
| 4142 |
gcode_lead_in_direction,
|
| 4143 |
-
ref_layers,
|
| 4144 |
layer_height,
|
| 4145 |
fil_width,
|
| 4146 |
scale_mode,
|
| 4147 |
],
|
| 4148 |
-
outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source],
|
| 4149 |
).then(
|
| 4150 |
fn=load_selected_gcode_text,
|
| 4151 |
inputs=[shape_records, gcode_text_source],
|
|
|
|
| 4 |
import tempfile
|
| 5 |
import time
|
| 6 |
import warnings
|
| 7 |
+
import zipfile
|
| 8 |
from pathlib import Path
|
| 9 |
from typing import Any
|
| 10 |
|
|
|
|
| 2254 |
return gr.update(choices=choices, value=value)
|
| 2255 |
|
| 2256 |
|
| 2257 |
+
def _gcode_zip_update(records: list[dict] | None) -> dict[str, Any]:
|
| 2258 |
+
"""Bundle every generated G-code file into one ZIP for the Download All
|
| 2259 |
+
button. Rebuilt wherever the individual download list is refreshed so the
|
| 2260 |
+
two can never disagree; the button hides when there is nothing to bundle."""
|
| 2261 |
+
paths = [
|
| 2262 |
+
record.get("gcode_path")
|
| 2263 |
+
for record in (records or [])
|
| 2264 |
+
if record.get("gcode_path") and Path(record["gcode_path"]).exists()
|
| 2265 |
+
]
|
| 2266 |
+
if not paths:
|
| 2267 |
+
return gr.update(visible=False)
|
| 2268 |
+
zip_path = Path(tempfile.mkdtemp(prefix="gcode_zip_")) / "all_shapes_gcode.zip"
|
| 2269 |
+
used_names: set[str] = set()
|
| 2270 |
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as bundle:
|
| 2271 |
+
for path in paths:
|
| 2272 |
+
arcname = Path(path).name
|
| 2273 |
+
stem, suffix = Path(path).stem, Path(path).suffix
|
| 2274 |
+
counter = 2
|
| 2275 |
+
while arcname in used_names:
|
| 2276 |
+
arcname = f"{stem}_{counter}{suffix}"
|
| 2277 |
+
counter += 1
|
| 2278 |
+
used_names.add(arcname)
|
| 2279 |
+
bundle.write(path, arcname=arcname)
|
| 2280 |
+
return gr.update(value=str(zip_path), visible=True)
|
| 2281 |
+
|
| 2282 |
+
|
| 2283 |
def _merge_file_paths(*file_groups: Any) -> list[str]:
|
| 2284 |
merged: list[str] = []
|
| 2285 |
seen: set[str] = set()
|
|
|
|
| 2315 |
_gcode_dropdown_update(next_records),
|
| 2316 |
_gcode_dropdown_update(next_records, include_upload=True),
|
| 2317 |
[record.get("gcode_path") for record in next_records if record.get("gcode_path")],
|
| 2318 |
+
_gcode_zip_update(next_records),
|
| 2319 |
)
|
| 2320 |
|
| 2321 |
|
|
|
|
| 2350 |
_gcode_dropdown_update(records),
|
| 2351 |
_gcode_dropdown_update(records, include_upload=True),
|
| 2352 |
[record.get("gcode_path") for record in records if record.get("gcode_path")],
|
| 2353 |
+
_gcode_zip_update(records),
|
| 2354 |
float(last_delete_at or 0.0),
|
| 2355 |
)
|
| 2356 |
|
|
|
|
| 2361 |
lands late); echoing records/rows here raced the dimension normalizer's
|
| 2362 |
write-back — the stale echo clobbered the recomputed proportions on the
|
| 2363 |
first Keep Proportions edit and re-rendered the table mid-typing."""
|
| 2364 |
+
return tuple(gr.skip() for _ in range(9))
|
| 2365 |
|
| 2366 |
|
| 2367 |
def delete_shape_from_settings(
|
|
|
|
| 2756 |
)
|
| 2757 |
|
| 2758 |
|
| 2759 |
+
def _polygon_patch(polygon, **kwargs):
|
| 2760 |
+
"""A filled matplotlib patch for a shapely Polygon, holes included."""
|
| 2761 |
+
from matplotlib.patches import PathPatch
|
| 2762 |
+
from matplotlib.path import Path as MplPath
|
| 2763 |
+
|
| 2764 |
+
verts: list[tuple[float, float]] = []
|
| 2765 |
+
codes: list[int] = []
|
| 2766 |
+
for ring in [polygon.exterior, *polygon.interiors]:
|
| 2767 |
+
coords = list(ring.coords)
|
| 2768 |
+
if len(coords) < 3:
|
| 2769 |
+
continue
|
| 2770 |
+
verts.extend(coords)
|
| 2771 |
+
codes.extend([MplPath.MOVETO] + [MplPath.LINETO] * (len(coords) - 2) + [MplPath.CLOSEPOLY])
|
| 2772 |
+
return PathPatch(MplPath(verts, codes), **kwargs)
|
| 2773 |
+
|
| 2774 |
+
|
| 2775 |
+
def _layer_preview_message(message: str):
|
| 2776 |
+
from matplotlib.figure import Figure
|
| 2777 |
+
|
| 2778 |
+
fig = Figure(figsize=(5.4, 5.0), dpi=100)
|
| 2779 |
+
ax = fig.add_subplot(111)
|
| 2780 |
+
ax.axis("off")
|
| 2781 |
+
ax.text(0.5, 0.5, message, ha="center", va="center", wrap=True, fontsize=11, color="#666666")
|
| 2782 |
+
return fig
|
| 2783 |
+
|
| 2784 |
+
|
| 2785 |
+
def update_layer_preview(
|
| 2786 |
+
records: list[dict] | None,
|
| 2787 |
+
selected: str | None,
|
| 2788 |
+
settings_table: Any,
|
| 2789 |
+
layer_value: float,
|
| 2790 |
+
) -> tuple:
|
| 2791 |
+
"""Slider + figure for the sliced-layer preview.
|
| 2792 |
+
|
| 2793 |
+
Draws the selected shape's layer polygons in its print color. Shapes on
|
| 2794 |
+
the same nozzle are parts of one assembly, so their polygons at the same
|
| 2795 |
+
Z are drawn too (dimmer) — this is where multi-material slicing and Flip
|
| 2796 |
+
Z can be checked before generating any G-code.
|
| 2797 |
+
"""
|
| 2798 |
+
from matplotlib.figure import Figure
|
| 2799 |
+
from matplotlib.patches import Patch
|
| 2800 |
+
|
| 2801 |
+
records = _apply_shape_settings(list(records or []), settings_table)
|
| 2802 |
+
pos = _selected_record_index(records, selected)
|
| 2803 |
+
if pos < 0:
|
| 2804 |
+
return gr.update(maximum=1, value=1), _layer_preview_message("Load a shape to preview its sliced layers.")
|
| 2805 |
+
record = records[pos]
|
| 2806 |
+
stack = record.get("layer_stack")
|
| 2807 |
+
if stack is None or not getattr(stack, "layers", None):
|
| 2808 |
+
return (
|
| 2809 |
+
gr.update(maximum=1, value=1),
|
| 2810 |
+
_layer_preview_message("Slice Shapes to see this shape's layer outlines."),
|
| 2811 |
+
)
|
| 2812 |
+
|
| 2813 |
+
layer_count = len(stack.layers)
|
| 2814 |
+
layer_index = max(1, min(layer_count, _coerce_int(layer_value, 1)))
|
| 2815 |
+
z_value = stack.z_values[layer_index - 1]
|
| 2816 |
+
half_layer = float(stack.layer_height or 0.8) / 2.0
|
| 2817 |
+
|
| 2818 |
+
nozzle = _record_nozzle_number(record, int(record.get("idx", pos + 1) or (pos + 1)))
|
| 2819 |
+
drawn: list[tuple[dict, Any]] = [] # (record, MultiPolygon at ~z)
|
| 2820 |
+
for other_pos, other in enumerate(records):
|
| 2821 |
+
other_stack = other.get("layer_stack")
|
| 2822 |
+
if other_stack is None or not getattr(other_stack, "layers", None) or other_pos == pos:
|
| 2823 |
+
continue
|
| 2824 |
+
if _record_nozzle_number(other, int(other.get("idx", other_pos + 1) or (other_pos + 1))) != nozzle:
|
| 2825 |
+
continue
|
| 2826 |
+
z_values = other_stack.z_values
|
| 2827 |
+
nearest = min(range(len(z_values)), key=lambda k: abs(z_values[k] - z_value))
|
| 2828 |
+
if abs(z_values[nearest] - z_value) <= half_layer:
|
| 2829 |
+
drawn.append((other, other_stack.layers[nearest]))
|
| 2830 |
+
drawn.append((record, stack.layers[layer_index - 1])) # selected on top
|
| 2831 |
+
|
| 2832 |
+
fig = Figure(figsize=(5.4, 5.0), dpi=100)
|
| 2833 |
+
ax = fig.add_subplot(111)
|
| 2834 |
+
legend_handles = []
|
| 2835 |
+
has_material = False
|
| 2836 |
+
for member, layer in drawn:
|
| 2837 |
+
is_selected = member is record
|
| 2838 |
+
color = str(member.get("color") or _default_color(int(member.get("idx", 0) or 0)))
|
| 2839 |
+
for polygon in getattr(layer, "geoms", []):
|
| 2840 |
+
if polygon.is_empty:
|
| 2841 |
+
continue
|
| 2842 |
+
has_material = True
|
| 2843 |
+
ax.add_patch(
|
| 2844 |
+
_polygon_patch(
|
| 2845 |
+
polygon,
|
| 2846 |
+
facecolor=color,
|
| 2847 |
+
alpha=0.85 if is_selected else 0.45,
|
| 2848 |
+
edgecolor="#333333",
|
| 2849 |
+
linewidth=0.7,
|
| 2850 |
+
)
|
| 2851 |
+
)
|
| 2852 |
+
if len(drawn) > 1:
|
| 2853 |
+
legend_handles.append(
|
| 2854 |
+
Patch(facecolor=color, alpha=0.85 if is_selected else 0.45, label=str(member.get("name") or f"Shape {member.get('idx')}"))
|
| 2855 |
+
)
|
| 2856 |
+
|
| 2857 |
+
# Fixed frame across the whole stack (assembly bounds) so the view
|
| 2858 |
+
# doesn't jump between layers.
|
| 2859 |
+
xs: list[float] = []
|
| 2860 |
+
ys: list[float] = []
|
| 2861 |
+
for member, _layer in drawn:
|
| 2862 |
+
bounds = member["layer_stack"].bounds
|
| 2863 |
+
xs.extend((bounds[0][0], bounds[1][0]))
|
| 2864 |
+
ys.extend((bounds[0][1], bounds[1][1]))
|
| 2865 |
+
margin = max(1.0, 0.05 * max(max(xs) - min(xs), max(ys) - min(ys)))
|
| 2866 |
+
ax.set_xlim(min(xs) - margin, max(xs) + margin)
|
| 2867 |
+
ax.set_ylim(min(ys) - margin, max(ys) + margin)
|
| 2868 |
+
ax.set_aspect("equal")
|
| 2869 |
+
ax.grid(True, linewidth=0.3, alpha=0.4)
|
| 2870 |
+
ax.set_xlabel("X (mm)")
|
| 2871 |
+
ax.set_ylabel("Y (mm)")
|
| 2872 |
+
name = str(record.get("name") or f"Shape {record.get('idx')}")
|
| 2873 |
+
title = f"{name} — layer {layer_index}/{layer_count} (z = {z_value:.2f} mm)"
|
| 2874 |
+
if not has_material:
|
| 2875 |
+
title += " [empty layer]"
|
| 2876 |
+
ax.set_title(title, fontsize=10)
|
| 2877 |
+
if legend_handles:
|
| 2878 |
+
ax.legend(handles=legend_handles, fontsize=8, loc="upper right")
|
| 2879 |
+
fig.tight_layout()
|
| 2880 |
+
return gr.update(maximum=layer_count, value=layer_index), fig
|
| 2881 |
+
|
| 2882 |
+
|
| 2883 |
def _slice_params_snapshot(
|
| 2884 |
record: dict,
|
| 2885 |
layer_height: float,
|
|
|
|
| 3341 |
_gcode_dropdown_update(next_records, include_upload=True),
|
| 3342 |
_dropdown_update(next_records, selected_value),
|
| 3343 |
status,
|
| 3344 |
+
_gcode_zip_update(next_records),
|
| 3345 |
)
|
| 3346 |
|
| 3347 |
if not records:
|
|
|
|
| 3523 |
def generate_dynamic_gcode(
|
| 3524 |
records: list[dict] | None,
|
| 3525 |
settings_table: Any,
|
|
|
|
|
|
|
| 3526 |
raster_pattern: str | None,
|
| 3527 |
pressure_ramp_enabled: bool,
|
| 3528 |
lead_in_length: float,
|
| 3529 |
lead_in_clearance: float,
|
| 3530 |
lead_in_lines: float,
|
| 3531 |
lead_in_direction: str | None,
|
|
|
|
| 3532 |
layer_height: float,
|
| 3533 |
fil_width: float,
|
| 3534 |
scale_mode: str | None,
|
| 3535 |
) -> tuple:
|
| 3536 |
records = _apply_shape_settings(records or [], settings_table)
|
| 3537 |
messages: list[str] = []
|
| 3538 |
+
_ensure_records_sliced(records, layer_height, scale_mode, messages)
|
| 3539 |
+
# Shared reference motion is always on: every head traces the combined
|
| 3540 |
+
# outline and dispenses only its own geometry. Rebuilt with the CURRENT
|
| 3541 |
+
# fil width so the alignment snap grid matches this generation.
|
| 3542 |
+
ref_layers = generate_dynamic_reference_stack(records, fil_width)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3543 |
contour_sources = _contour_tracing_sources(records)
|
| 3544 |
if contour_sources:
|
| 3545 |
enabled = ", ".join(f"Shape {source.owner_idx}" for source in contour_sources)
|
|
|
|
| 3571 |
if stack is None or not getattr(stack, "layers", None):
|
| 3572 |
messages.append(f"Shape {record['idx']}: skipped (no sliced layers).")
|
| 3573 |
continue
|
| 3574 |
+
if ref_layers is None:
|
| 3575 |
+
messages.append(f"Shape {record['idx']}: skipped (no combined reference outline; slice a shape first).")
|
| 3576 |
continue
|
| 3577 |
shape_name = str(record.get("name") or stack.name or f"shape_{record['idx']}").replace(" ", "_")
|
| 3578 |
try:
|
|
|
|
| 3585 |
layer_height=float(layer_height),
|
| 3586 |
fil_width=float(fil_width),
|
| 3587 |
pressure_ramp_enabled=bool(pressure_ramp_enabled),
|
| 3588 |
+
# Always one constant speed: every move is emitted as G1 (no
|
| 3589 |
+
# G0 rapid travel); the valve commands still mark print vs
|
| 3590 |
+
# travel.
|
| 3591 |
+
all_g1=True,
|
| 3592 |
+
motion=ref_layers,
|
| 3593 |
raster_pattern=raster_pattern,
|
| 3594 |
contour_sources=contour_sources,
|
| 3595 |
active_contour_owner=int(record.get("idx", 0)),
|
|
|
|
| 3600 |
lead_in_lines=max(1, _coerce_int(lead_in_lines, 3)),
|
| 3601 |
lead_in_direction=lead_in_direction or LEAD_IN_DIRECTION_LEFT,
|
| 3602 |
lead_in_dispense=bool(record.get("lead_in", True)),
|
| 3603 |
+
wall_sources=wall_sources,
|
| 3604 |
origin_sink=(origin_sink := {}),
|
| 3605 |
)
|
| 3606 |
record["gcode_path"] = str(gcode_path)
|
|
|
|
| 3617 |
"\n".join(messages),
|
| 3618 |
_gcode_dropdown_update(records),
|
| 3619 |
_gcode_dropdown_update(records, include_upload=True),
|
| 3620 |
+
_gcode_zip_update(records),
|
| 3621 |
)
|
| 3622 |
|
| 3623 |
|
|
|
|
| 3954 |
camera_position=FRONT_CAMERA,
|
| 3955 |
height=360,
|
| 3956 |
)
|
| 3957 |
+
with gr.Column(scale=2, min_width=380):
|
| 3958 |
+
layer_preview_plot = gr.Plot(label="Sliced Layer Preview")
|
| 3959 |
+
layer_preview_slider = gr.Slider(
|
| 3960 |
+
label="Layer",
|
| 3961 |
+
minimum=1,
|
| 3962 |
+
maximum=1,
|
| 3963 |
+
step=1,
|
| 3964 |
+
value=1,
|
| 3965 |
+
)
|
| 3966 |
with gr.Column(scale=1, min_width=300):
|
| 3967 |
model_details = gr.Markdown("No model loaded.")
|
| 3968 |
|
|
|
|
| 3973 |
"""
|
| 3974 |
# Generate G-Code
|
| 3975 |
Generate G-code for every sliced shape. Pressure, valve, nozzle, port, and color come from the Shape Settings table.
|
| 3976 |
+
All shapes share one combined nozzle path (each dispenses only its own geometry), so parallel heads always stay in sync.
|
| 3977 |
"""
|
| 3978 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3979 |
gcode_pressure_ramp_enabled = gr.Checkbox(label="Increase Pressure Each Layer", value=True)
|
| 3980 |
gcode_raster_pattern = gr.Dropdown(
|
| 3981 |
label="Raster Pattern",
|
|
|
|
| 3996 |
allow_custom_value=False,
|
| 3997 |
)
|
| 3998 |
gcode_button = gr.Button("Generate G-Code", variant="primary")
|
| 3999 |
+
with gr.Row():
|
| 4000 |
+
with gr.Column(scale=4):
|
| 4001 |
+
gcode_downloads = gr.File(label="Download G-Code Files", file_count="multiple", interactive=False, elem_classes=["gcode-download"])
|
| 4002 |
+
with gr.Column(scale=1, min_width=200):
|
| 4003 |
+
gcode_download_all = gr.DownloadButton(
|
| 4004 |
+
"Download All (.zip)",
|
| 4005 |
+
variant="secondary",
|
| 4006 |
+
visible=False,
|
| 4007 |
+
)
|
| 4008 |
gcode_status = gr.Markdown("")
|
| 4009 |
with gr.Row():
|
| 4010 |
gcode_text_source = gr.Dropdown(label="Preview G-Code", choices=[], value=None, allow_custom_value=False)
|
|
|
|
| 4134 |
nozzle_grid_use_individual_spacing,
|
| 4135 |
]
|
| 4136 |
|
| 4137 |
+
shape_sync_outputs = [shape_records, shape_settings, selected_shape, gcode_text_source, gcode_source, gcode_downloads, gcode_download_all]
|
| 4138 |
stl_upload.change(fn=sync_uploaded_shapes, inputs=[stl_upload, shape_records, shape_settings], outputs=shape_sync_outputs).then(
|
| 4139 |
fn=lambda records: _dropdown_update(records),
|
| 4140 |
inputs=[shape_records],
|
|
|
|
| 4217 |
selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
|
| 4218 |
refresh_preview_button.click(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
|
| 4219 |
model_opacity.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
|
| 4220 |
+
# Sliced-layer preview. The slider uses .release only: the handler
|
| 4221 |
+
# writes the slider back (clamped max/value), and a .change wiring
|
| 4222 |
+
# would re-fire on that programmatic write.
|
| 4223 |
+
layer_preview_inputs = [shape_records, selected_shape, shape_settings, layer_preview_slider]
|
| 4224 |
+
layer_preview_outputs = [layer_preview_slider, layer_preview_plot]
|
| 4225 |
+
selected_shape.change(fn=update_layer_preview, inputs=layer_preview_inputs, outputs=layer_preview_outputs)
|
| 4226 |
+
refresh_preview_button.click(fn=update_layer_preview, inputs=layer_preview_inputs, outputs=layer_preview_outputs)
|
| 4227 |
+
layer_preview_slider.release(fn=update_layer_preview, inputs=layer_preview_inputs, outputs=layer_preview_outputs)
|
| 4228 |
scale_mode.change(
|
| 4229 |
fn=normalize_shape_dimensions_for_mode,
|
| 4230 |
inputs=[shape_records, shape_settings, scale_mode],
|
|
|
|
| 4249 |
fn=generate_dynamic_layer_stacks,
|
| 4250 |
inputs=[shape_records, shape_settings, layer_height, scale_mode, fil_width],
|
| 4251 |
outputs=[shape_records, slicer_status, ref_layers],
|
| 4252 |
+
).then(
|
| 4253 |
+
fn=update_layer_preview,
|
| 4254 |
+
inputs=layer_preview_inputs,
|
| 4255 |
+
outputs=layer_preview_outputs,
|
| 4256 |
).then(
|
| 4257 |
fn=lambda records: _dropdown_update(records),
|
| 4258 |
inputs=[shape_records],
|
|
|
|
| 4291 |
gcode_source,
|
| 4292 |
split_source,
|
| 4293 |
split_status,
|
| 4294 |
+
gcode_download_all,
|
| 4295 |
],
|
| 4296 |
).then(
|
| 4297 |
fn=generate_dynamic_reference_stack,
|
|
|
|
| 4309 |
inputs=[
|
| 4310 |
shape_records,
|
| 4311 |
shape_settings,
|
|
|
|
|
|
|
| 4312 |
gcode_raster_pattern,
|
| 4313 |
gcode_pressure_ramp_enabled,
|
| 4314 |
gcode_lead_in_length,
|
| 4315 |
gcode_lead_in_clearance,
|
| 4316 |
gcode_lead_in_lines,
|
| 4317 |
gcode_lead_in_direction,
|
|
|
|
| 4318 |
layer_height,
|
| 4319 |
fil_width,
|
| 4320 |
scale_mode,
|
| 4321 |
],
|
| 4322 |
+
outputs=[shape_records, ref_layers, gcode_downloads, gcode_status, gcode_text_source, gcode_source, gcode_download_all],
|
| 4323 |
).then(
|
| 4324 |
fn=load_selected_gcode_text,
|
| 4325 |
inputs=[shape_records, gcode_text_source],
|
|
@@ -723,7 +723,7 @@ def test_non_delete_cell_selection_touches_nothing() -> None:
|
|
| 723 |
|
| 724 |
outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
|
| 725 |
|
| 726 |
-
assert len(outputs) ==
|
| 727 |
assert all(not isinstance(value, list) for value in outputs)
|
| 728 |
assert not isinstance(outputs[1], list) # records State untouched
|
| 729 |
|
|
@@ -1101,6 +1101,84 @@ def test_apply_bulk_bool_selection_sets_a_whole_column() -> None:
|
|
| 1101 |
assert refused[0].get("color") == cleared[0].get("color")
|
| 1102 |
|
| 1103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1104 |
def test_load_sample_shapes_respects_the_selected_set() -> None:
|
| 1105 |
from app import DEFAULT_SAMPLE_STL_SET, SAMPLE_STL_SETS, load_sample_shapes
|
| 1106 |
|
|
|
|
| 723 |
|
| 724 |
outputs = delete_shape_from_settings(records, _shape_settings_rows(records), 0.0, Event())
|
| 725 |
|
| 726 |
+
assert len(outputs) == 9
|
| 727 |
assert all(not isinstance(value, list) for value in outputs)
|
| 728 |
assert not isinstance(outputs[1], list) # records State untouched
|
| 729 |
|
|
|
|
| 1101 |
assert refused[0].get("color") == cleared[0].get("color")
|
| 1102 |
|
| 1103 |
|
| 1104 |
+
def test_download_all_zip_bundles_gcode_files_by_shape_name(tmp_path) -> None:
|
| 1105 |
+
import zipfile
|
| 1106 |
+
|
| 1107 |
+
from app import _gcode_zip_update
|
| 1108 |
+
|
| 1109 |
+
first = tmp_path / "circle_gcode.txt"
|
| 1110 |
+
first.write_text("G91\n")
|
| 1111 |
+
other_dir = tmp_path / "other"
|
| 1112 |
+
other_dir.mkdir()
|
| 1113 |
+
duplicate = other_dir / "circle_gcode.txt"
|
| 1114 |
+
duplicate.write_text("G91\n")
|
| 1115 |
+
square = tmp_path / "square_gcode.txt"
|
| 1116 |
+
square.write_text("G91\n")
|
| 1117 |
+
|
| 1118 |
+
update = _gcode_zip_update(
|
| 1119 |
+
[
|
| 1120 |
+
{"idx": 1, "gcode_path": str(first)},
|
| 1121 |
+
{"idx": 2, "gcode_path": str(duplicate)},
|
| 1122 |
+
{"idx": 3, "gcode_path": str(square)},
|
| 1123 |
+
{"idx": 4, "gcode_path": None},
|
| 1124 |
+
]
|
| 1125 |
+
)
|
| 1126 |
+
assert update["visible"] is True
|
| 1127 |
+
with zipfile.ZipFile(update["value"]) as bundle:
|
| 1128 |
+
assert sorted(bundle.namelist()) == [
|
| 1129 |
+
"circle_gcode.txt",
|
| 1130 |
+
"circle_gcode_2.txt",
|
| 1131 |
+
"square_gcode.txt",
|
| 1132 |
+
]
|
| 1133 |
+
|
| 1134 |
+
# Nothing generated yet: the button hides instead of offering an empty zip.
|
| 1135 |
+
hidden = _gcode_zip_update([{"idx": 1, "gcode_path": None}])
|
| 1136 |
+
assert hidden["visible"] is False
|
| 1137 |
+
|
| 1138 |
+
|
| 1139 |
+
def test_layer_preview_draws_the_selection_and_its_nozzle_group() -> None:
|
| 1140 |
+
from shapely.geometry import MultiPolygon, box
|
| 1141 |
+
|
| 1142 |
+
from app import update_layer_preview
|
| 1143 |
+
from stl_slicer import LayerStack
|
| 1144 |
+
|
| 1145 |
+
def _preview_stack(name: str, poly) -> LayerStack:
|
| 1146 |
+
return LayerStack(
|
| 1147 |
+
layers=[MultiPolygon([poly]), MultiPolygon([poly])],
|
| 1148 |
+
z_values=[0.4, 1.2],
|
| 1149 |
+
bounds=((0.0, 0.0, 0.0), (10.0, 10.0, 1.6)),
|
| 1150 |
+
layer_height=0.8,
|
| 1151 |
+
name=name,
|
| 1152 |
+
)
|
| 1153 |
+
|
| 1154 |
+
records = [
|
| 1155 |
+
{"idx": 1, "name": "base", "stl_path": "base.stl", "nozzle": 1, "color": "#ff0000",
|
| 1156 |
+
"layer_stack": _preview_stack("base", box(0.0, 0.0, 10.0, 10.0))},
|
| 1157 |
+
{"idx": 2, "name": "stripe", "stl_path": "stripe.stl", "nozzle": 1, "color": "#0000ff",
|
| 1158 |
+
"layer_stack": _preview_stack("stripe", box(2.0, 2.0, 8.0, 8.0))},
|
| 1159 |
+
{"idx": 3, "name": "solo", "stl_path": "solo.stl", "nozzle": 2, "color": "#00ff00",
|
| 1160 |
+
"layer_stack": _preview_stack("solo", box(0.0, 0.0, 5.0, 5.0))},
|
| 1161 |
+
]
|
| 1162 |
+
|
| 1163 |
+
# Selected shape plus its same-nozzle assembly sibling are both drawn.
|
| 1164 |
+
slider, fig = update_layer_preview(records, "1: base", None, 2)
|
| 1165 |
+
assert slider["maximum"] == 2
|
| 1166 |
+
assert slider["value"] == 2
|
| 1167 |
+
assert len(fig.axes[0].patches) == 2
|
| 1168 |
+
|
| 1169 |
+
# A shape alone on its nozzle draws only itself; out-of-range slider
|
| 1170 |
+
# values clamp to the layer count.
|
| 1171 |
+
slider_solo, fig_solo = update_layer_preview(records, "3: solo", None, 99)
|
| 1172 |
+
assert slider_solo["value"] == 2
|
| 1173 |
+
assert len(fig_solo.axes[0].patches) == 1
|
| 1174 |
+
|
| 1175 |
+
# Unsliced shape: slider resets, figure carries the hint (no patches).
|
| 1176 |
+
unsliced = [{"idx": 1, "name": "raw", "stl_path": "raw.stl", "nozzle": 1, "color": "#ff0000", "layer_stack": None}]
|
| 1177 |
+
slider_reset, fig_hint = update_layer_preview(unsliced, "1: raw", None, 5)
|
| 1178 |
+
assert slider_reset["maximum"] == 1
|
| 1179 |
+
assert not fig_hint.axes[0].patches
|
| 1180 |
+
|
| 1181 |
+
|
| 1182 |
def test_load_sample_shapes_respects_the_selected_set() -> None:
|
| 1183 |
from app import DEFAULT_SAMPLE_STL_SET, SAMPLE_STL_SETS, load_sample_shapes
|
| 1184 |
|
|
@@ -176,6 +176,21 @@ def test_slanted_shape_gcode_round_trips_through_the_viewer(tmp_path) -> None:
|
|
| 176 |
assert -1.0 <= y <= 21.0
|
| 177 |
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> None:
|
| 180 |
gcode_path = generate_vector_gcode(
|
| 181 |
_stack(box(0.0, 0.0, 1.0, 1.0)),
|
|
|
|
| 176 |
assert -1.0 <= y <= 21.0
|
| 177 |
|
| 178 |
|
| 179 |
+
def test_gcode_file_is_named_after_the_shape(tmp_path) -> None:
|
| 180 |
+
gcode_path = generate_vector_gcode(
|
| 181 |
+
_stack(box(0.0, 0.0, 1.0, 1.0)),
|
| 182 |
+
shape_name="Simple_Circle",
|
| 183 |
+
pressure=25,
|
| 184 |
+
valve=7,
|
| 185 |
+
port=3,
|
| 186 |
+
fil_width=1.0,
|
| 187 |
+
origin_sink=(origin_sink := {}),
|
| 188 |
+
output_dir=tmp_path,
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
assert gcode_path.name == "Simple_Circle_gcode.txt"
|
| 192 |
+
|
| 193 |
+
|
| 194 |
def test_gcode_header_writes_presets_before_initial_aux_commands(tmp_path) -> None:
|
| 195 |
gcode_path = generate_vector_gcode(
|
| 196 |
_stack(box(0.0, 0.0, 1.0, 1.0)),
|
|
@@ -353,7 +353,7 @@ def generate_vector_gcode(
|
|
| 353 |
output_dir = Path(output_dir)
|
| 354 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 355 |
|
| 356 |
-
gcode_path = output_dir / f"{shape_name}
|
| 357 |
write_gcode_file(
|
| 358 |
gcode_path,
|
| 359 |
gcode_list,
|
|
|
|
| 353 |
output_dir = Path(output_dir)
|
| 354 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 355 |
|
| 356 |
+
gcode_path = output_dir / f"{shape_name}_gcode.txt"
|
| 357 |
write_gcode_file(
|
| 358 |
gcode_path,
|
| 359 |
gcode_list,
|