Spaces:
Running
Sync pressure per port, merge visualization tabs, move Nozzle Spacing accordion
Browse filesShape Settings:
- Pressure is a PORT property (one regulator per serial port): editing a
pressure updates every shape on that port, moving a shape onto a port
adopts that port's pressure, and newly added shapes join at their
port's existing pressure. Runs in the table normalizer with the same
convergence rules as the nozzle-group scale propagation (unambiguous
source or no-op), so stale .change echoes cannot ping-pong values
- Removed the "Use 75% 3D Model Opacity" checkbox; the 3D preview
always renders at full opacity
Visualization (one tab, was two):
- The G-Code Visualization and Parallel Printing Visualization tabs are
merged. One source radio defaults to "Parallel print (all shapes)";
picking a single generated shape or "Upload G-Code file" switches to
the single-tool-path view
- Travel/print opacity sliders moved above the render buttons (they
only apply at render time)
- Per-view color pickers removed: a generated shape renders in its
Shape Settings color, uploads default to orange, travel is grey
- The Nozzle Spacing accordion (grid layout, Auto Align, advanced
table, spacing plot) moved from the Generate G-Code tab to the
Visualization tab, next to the parallel view that uses it
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- README.md +11 -11
- app.py +231 -87
- tests/test_nozzle_spacing.py +72 -0
|
@@ -50,15 +50,15 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
|
|
| 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
|
| 59 |
-
-
|
| 60 |
-
- Renders
|
| 61 |
-
- Plots the generated shapes using the configured nozzle spacing and animates them printing in parallel, with a server-side GIF export of that animation
|
| 62 |
- 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
|
| 63 |
|
| 64 |
## Behavior and Implementation Notes
|
|
@@ -124,18 +124,18 @@ When parsing G-code for visualization, the app decides print vs travel as follow
|
|
| 124 |
|
| 125 |
The parser also handles standard slicer G-code: single-axis and Z-only moves, axes in any order, and `F`/`E` tokens (feed rate, extrusion) are ignored for geometry.
|
| 126 |
|
| 127 |
-
###
|
| 128 |
|
| 129 |
-
The
|
| 130 |
|
| 131 |
-
|
| 132 |
-
- **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.
|
| 133 |
|
| 134 |
-
|
| 135 |
|
| 136 |
-
|
|
|
|
| 137 |
|
| 138 |
-
|
| 139 |
|
| 140 |
## Dependency Updates
|
| 141 |
|
|
|
|
| 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 |
+
- **Pressure is a port property** (one pressure regulator per serial port): shapes sharing a Port always share one pressure — editing one shape's pressure updates every shape on that port, moving a shape onto a port adopts that port's pressure, and newly added shapes join at their port's existing pressure
|
| 54 |
- **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
|
| 55 |
- Appends a shape outline contour after each enabled shape layer by tracing that layer's polygon boundary
|
| 56 |
- 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)
|
| 57 |
- 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
|
| 58 |
+
- Calculates X/Y nozzle spacing from a grid layout (columns/rows plus gaps) in the Visualization tab's Nozzle Spacing accordion, with an optional per-connection Advanced Grid Spacing table, then visualizes the resulting nozzle layout
|
| 59 |
- Previews selected generated G-code inline
|
| 60 |
+
- One Visualization tab that defaults to the parallel print of every generated shape (configured nozzle spacing, animated, with a server-side GIF export), and can switch to a single tool path from any generated shape or an uploaded G-code file
|
| 61 |
+
- Renders as a fast line plot or an animated 3D tube plot (play/pause, speed, scrub, frame-step, nozzle marker); print colors come from the Shape Settings table (uploads render orange, travel grey)
|
|
|
|
| 62 |
- 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
|
| 63 |
|
| 64 |
## Behavior and Implementation Notes
|
|
|
|
| 124 |
|
| 125 |
The parser also handles standard slicer G-code: single-axis and Z-only moves, axes in any order, and `F`/`E` tokens (feed rate, extrusion) are ignored for geometry.
|
| 126 |
|
| 127 |
+
### Visualization
|
| 128 |
|
| 129 |
+
The Visualization tab has one source selector. The default, **Parallel print (all shapes)**, plots every generated shape's G-code at once using the spacing configured in the tab's own Nozzle Spacing accordion, each in its Shape Settings color. Shape Settings maps each STL to a nozzle number, so multiple shapes can share one nozzle offset while valves remain independent; the animation advances all parts on a shared cumulative-path-length timeline, so a shorter part finishes first.
|
| 130 |
|
| 131 |
+
Selecting a single shape (or **Upload G-Code file** for a `.txt`, `.gcode`, or `.nc` file) switches to a single-tool-path view. The parser handles `G0`/`G1` movement lines and relative (`G91`) / absolute (`G90`) positioning. Colors are fixed: a generated shape prints in its Shape Settings color, an uploaded file prints in orange, and travel is always grey (opacity sliders control how visible each is).
|
|
|
|
| 132 |
|
| 133 |
+
Both views offer two render modes:
|
| 134 |
|
| 135 |
+
- **Line Plot** — fast thin scatter lines (print and travel).
|
| 136 |
+
- **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.
|
| 137 |
|
| 138 |
+
The parallel view can also **export the animation as a GIF**, rendered server-side with Matplotlib (the `Agg` CPU backend — no WebGL, no headless browser, and no `ffmpeg`, so it works locally and on Hugging Face). The GIF is line-style with faint grey travel and white, black-outlined nozzle markers drawn on top; controls cover duration, frames per second, elevation/azimuth viewing angle, and travel opacity (0 hides travel).
|
| 139 |
|
| 140 |
## Dependency Updates
|
| 141 |
|
|
@@ -1292,6 +1292,12 @@ def load_single_model(
|
|
| 1292 |
|
| 1293 |
|
| 1294 |
GCODE_SOURCE_UPLOAD = "Upload G-Code file"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1295 |
|
| 1296 |
|
| 1297 |
PARALLEL_COLOR_CHOICES = [
|
|
@@ -1739,6 +1745,17 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
|
|
| 1739 |
default_x, default_y, default_z = _default_target_extents_for_stl(path)
|
| 1740 |
nozzle = _record_nozzle_number(previous, index) if previous else _next_unused_nozzle(used_nozzles)
|
| 1741 |
used_nozzles.add(nozzle)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1742 |
records.append({
|
| 1743 |
"idx": index,
|
| 1744 |
"name": name,
|
|
@@ -1750,7 +1767,7 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
|
|
| 1750 |
"target_y": previous.get("target_y", default_y),
|
| 1751 |
"target_z": previous.get("target_z", default_z),
|
| 1752 |
"last_scaled_axis": previous.get("last_scaled_axis", "target_x"),
|
| 1753 |
-
"pressure":
|
| 1754 |
"valve": previous.get("valve", 4),
|
| 1755 |
"nozzle": nozzle,
|
| 1756 |
"port": previous.get("port", 1),
|
|
@@ -2249,7 +2266,9 @@ def _dropdown_update(records: list[dict], selected: str | None = None) -> dict[s
|
|
| 2249 |
def _gcode_dropdown_update(records: list[dict], selected: str | None = None, include_upload: bool = False) -> dict[str, Any]:
|
| 2250 |
choices = [_shape_choice(record) for record in records if record.get("gcode_path")]
|
| 2251 |
if include_upload:
|
| 2252 |
-
|
|
|
|
|
|
|
| 2253 |
value = selected if selected in choices else (choices[0] if choices else None)
|
| 2254 |
return gr.update(choices=choices, value=value)
|
| 2255 |
|
|
@@ -2507,6 +2526,123 @@ def _last_edited_nozzles(records: list[dict] | None, settings_table: Any) -> set
|
|
| 2507 |
return changed
|
| 2508 |
|
| 2509 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2510 |
def _record_scale_factors(record: dict) -> tuple[float, float, float] | None:
|
| 2511 |
"""Per-axis target/original factors, or None when they can't be computed."""
|
| 2512 |
try:
|
|
@@ -2602,7 +2738,8 @@ def normalize_shape_dimensions_for_mode(
|
|
| 2602 |
shape's other dimensions from the edited axis. In BOTH modes, a
|
| 2603 |
dimension edit on a multi-material group member (shapes sharing a
|
| 2604 |
nozzle) propagates its scale factors to the whole group, so assemblies
|
| 2605 |
-
stay proportional as one unit
|
|
|
|
| 2606 |
|
| 2607 |
Wired to the table's .change event, WHICH ALSO FIRES FOR OUR OWN
|
| 2608 |
WRITE-BACK (Gradio's Dataframe does not emit .input for cell edits at
|
|
@@ -2613,6 +2750,8 @@ def normalize_shape_dimensions_for_mode(
|
|
| 2613 |
"""
|
| 2614 |
edited_axes = _last_edited_target_axes(records, settings_table)
|
| 2615 |
joined_idx = _last_edited_nozzles(records, settings_table)
|
|
|
|
|
|
|
| 2616 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2617 |
if _normalize_scale_mode(scale_mode) != SCALE_MODE_UNIFORM_FACTOR:
|
| 2618 |
normalized = [dict(record) for record in records]
|
|
@@ -2621,6 +2760,7 @@ def normalize_shape_dimensions_for_mode(
|
|
| 2621 |
if idx in edited_axes:
|
| 2622 |
record["last_scaled_axis"] = edited_axes[idx]
|
| 2623 |
normalized = _propagate_group_scale_factors(normalized, edited_axes, set(), joined_idx)
|
|
|
|
| 2624 |
changed = any(
|
| 2625 |
not math.isclose(
|
| 2626 |
float(before.get(key) or 0.0),
|
|
@@ -2629,7 +2769,7 @@ def normalize_shape_dimensions_for_mode(
|
|
| 2629 |
abs_tol=1e-9,
|
| 2630 |
)
|
| 2631 |
for before, after in zip(records, normalized)
|
| 2632 |
-
for key in TARGET_DIMENSION_KEYS
|
| 2633 |
)
|
| 2634 |
if not changed and not _bool_cells_need_rewrite(settings_table):
|
| 2635 |
return normalized, gr.skip()
|
|
@@ -2714,10 +2854,11 @@ def normalize_shape_dimensions_for_mode(
|
|
| 2714 |
normalized = _propagate_group_scale_factors(
|
| 2715 |
normalized, edited_axes, recomputed_idx, joined_idx
|
| 2716 |
)
|
|
|
|
| 2717 |
|
| 2718 |
# Idempotence guard (breaks the .change write-back cascade): only write
|
| 2719 |
-
# the table when a dimension actually changed — or when the
|
| 2720 |
-
# columns need a canonical re-render after a header "select all".
|
| 2721 |
changed = any(
|
| 2722 |
not math.isclose(
|
| 2723 |
float(before.get(key) or 0.0),
|
|
@@ -2726,7 +2867,7 @@ def normalize_shape_dimensions_for_mode(
|
|
| 2726 |
abs_tol=1e-9,
|
| 2727 |
)
|
| 2728 |
for before, after in zip(records, normalized)
|
| 2729 |
-
for key in TARGET_DIMENSION_KEYS
|
| 2730 |
)
|
| 2731 |
if not changed and not _bool_cells_need_rewrite(settings_table):
|
| 2732 |
return normalized, gr.skip()
|
|
@@ -2737,7 +2878,6 @@ def show_selected_model(
|
|
| 2737 |
records: list[dict] | None,
|
| 2738 |
selected: str | None,
|
| 2739 |
settings_table: Any,
|
| 2740 |
-
opacity: float,
|
| 2741 |
scale_mode: str | None,
|
| 2742 |
) -> tuple:
|
| 2743 |
records = _apply_shape_settings(records or [], settings_table)
|
|
@@ -2747,7 +2887,7 @@ def show_selected_model(
|
|
| 2747 |
record = records[pos]
|
| 2748 |
return load_single_model(
|
| 2749 |
record.get("stl_path"),
|
| 2750 |
-
|
| 2751 |
True,
|
| 2752 |
scale_mode,
|
| 2753 |
record.get("target_x"),
|
|
@@ -3692,19 +3832,23 @@ def render_dynamic_toolpath(
|
|
| 3692 |
records: list[dict] | None,
|
| 3693 |
travel_opacity: float,
|
| 3694 |
print_opacity: float,
|
| 3695 |
-
travel_color: str,
|
| 3696 |
-
print_color: str,
|
| 3697 |
print_width: float,
|
| 3698 |
travel_width: float,
|
| 3699 |
tube: bool = True,
|
| 3700 |
) -> tuple[Any, str, dict]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3701 |
if source == GCODE_SOURCE_UPLOAD:
|
| 3702 |
path = uploaded_path
|
| 3703 |
label = "uploaded file"
|
| 3704 |
else:
|
| 3705 |
pos = _selected_record_index(records or [], source)
|
| 3706 |
-
|
|
|
|
| 3707 |
label = source or "selected shape"
|
|
|
|
| 3708 |
if not path:
|
| 3709 |
return None, f"No G-code available for {label}.", {}
|
| 3710 |
try:
|
|
@@ -3880,7 +4024,6 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 3880 |
)
|
| 3881 |
sync_uploads_button = gr.Button("Sync Uploaded STLs", variant="secondary", size="sm")
|
| 3882 |
reset_dimensions_button = gr.Button("Reset Dimensions", variant="secondary", size="sm")
|
| 3883 |
-
model_opacity = gr.Checkbox(label="Use 75% 3D Model Opacity", value=False)
|
| 3884 |
scale_mode = gr.Radio(
|
| 3885 |
choices=[SCALE_MODE_TARGET_DIMENSIONS, SCALE_MODE_UNIFORM_FACTOR],
|
| 3886 |
value=SCALE_MODE_TARGET_DIMENSIONS,
|
|
@@ -4011,6 +4154,23 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 4011 |
refresh_gcode_text_button = gr.Button("Refresh G-Code Preview", variant="secondary", size="sm")
|
| 4012 |
gcode_text = gr.Code(label="Selected G-Code", language=None, lines=18, max_lines=18, interactive=False, elem_classes=["gcode-view"])
|
| 4013 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4014 |
with gr.Accordion("Nozzle Spacing", open=False, elem_classes=["settings-accordion"]):
|
| 4015 |
with gr.Group():
|
| 4016 |
with gr.Row():
|
|
@@ -4048,80 +4208,56 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 4048 |
with gr.Column(scale=1, min_width=260):
|
| 4049 |
nozzle_spacing_status = gr.Markdown("Generate G-code, then visualize nozzle spacing.")
|
| 4050 |
|
| 4051 |
-
|
| 4052 |
-
gr.
|
| 4053 |
-
|
| 4054 |
-
|
| 4055 |
-
|
| 4056 |
-
|
| 4057 |
-
|
| 4058 |
-
|
| 4059 |
-
|
| 4060 |
-
|
| 4061 |
-
|
| 4062 |
-
|
| 4063 |
-
|
| 4064 |
-
|
| 4065 |
-
|
| 4066 |
-
|
| 4067 |
-
|
| 4068 |
-
|
| 4069 |
-
|
| 4070 |
-
|
| 4071 |
-
|
| 4072 |
-
label="Travel (
|
| 4073 |
-
|
| 4074 |
-
|
| 4075 |
-
|
| 4076 |
-
|
| 4077 |
-
|
| 4078 |
-
|
| 4079 |
-
|
| 4080 |
-
|
| 4081 |
-
|
| 4082 |
-
|
| 4083 |
-
min_width=150
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4084 |
)
|
| 4085 |
-
|
| 4086 |
-
|
| 4087 |
-
|
| 4088 |
-
|
| 4089 |
-
|
| 4090 |
-
|
|
|
|
| 4091 |
|
| 4092 |
parsed_state = gr.State({})
|
| 4093 |
render_mode = gr.State("tube")
|
| 4094 |
-
|
| 4095 |
-
with gr.Tab("Parallel Printing Visualization"):
|
| 4096 |
-
gr.Markdown(
|
| 4097 |
-
"### Parallel Printing Visualization\n"
|
| 4098 |
-
"Plots all generated shapes using the nozzle spacing configured on the Generate G-Code tab."
|
| 4099 |
-
)
|
| 4100 |
-
with gr.Row():
|
| 4101 |
-
with gr.Column(scale=1, min_width=340):
|
| 4102 |
-
parallel_line_button = gr.Button("Render Parallel Print - Line Plot", variant="primary")
|
| 4103 |
-
parallel_render_button = gr.Button("Render Parallel Print - Tube Plot with Animation", variant="primary")
|
| 4104 |
-
gr.Markdown("⚠️ Building multiple tube plots can take a while for high-resolution models.", elem_id="parallel-render-warning")
|
| 4105 |
-
parallel_anim_controls = gr.HTML(PARALLEL_CONTROLS_HTML, visible=False)
|
| 4106 |
-
pp_travel_opacity = gr.Slider(label="Travel opacity (0 = hidden)", minimum=0.0, maximum=1.0, value=0.2, step=0.05)
|
| 4107 |
-
with gr.Row(visible=False) as pp_width_row:
|
| 4108 |
-
pp_filament_width = gr.Slider(label="Filament width (mm)", minimum=0.1, maximum=3.0, value=0.8, step=0.05, min_width=150)
|
| 4109 |
-
pp_travel_width = gr.Slider(label="Travel width (mm)", minimum=0.05, maximum=3.0, value=0.2, step=0.05, min_width=150)
|
| 4110 |
-
parallel_status = gr.Markdown("")
|
| 4111 |
-
with gr.Group(visible=False) as pp_export_group:
|
| 4112 |
-
gr.Markdown("**Export animation (GIF)** - a server-side line animation of the parallel print.")
|
| 4113 |
-
with gr.Row():
|
| 4114 |
-
pp_gif_duration = gr.Slider(label="Duration (s)", minimum=2.0, maximum=20.0, value=6.0, step=1.0, min_width=150)
|
| 4115 |
-
pp_gif_fps = gr.Slider(label="Frames per second", minimum=5, maximum=30, value=10, step=1, min_width=150)
|
| 4116 |
-
with gr.Row():
|
| 4117 |
-
pp_elev = gr.Slider(label="Elevation angle", minimum=0, maximum=90, value=22, step=1, min_width=150)
|
| 4118 |
-
pp_azim = gr.Slider(label="Azimuth angle", minimum=-180, maximum=180, value=-60, step=5, min_width=150)
|
| 4119 |
-
pp_gif_travel_opacity = gr.Slider(label="Travel opacity in GIF (0 = hidden)", minimum=0.0, maximum=1.0, value=0.15, step=0.05)
|
| 4120 |
-
pp_export_button = gr.Button("Export Animation as GIF", variant="primary")
|
| 4121 |
-
pp_gif_file = gr.File(label="Download GIF", interactive=False)
|
| 4122 |
-
with gr.Column(scale=3, min_width=500):
|
| 4123 |
-
parallel_plot = gr.Plot(label="Parallel Tool Paths", elem_id="parallel_plot")
|
| 4124 |
-
|
| 4125 |
parallel_mode = gr.State("tube")
|
| 4126 |
|
| 4127 |
grid_spacing_refresh_inputs = [
|
|
@@ -4197,7 +4333,7 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 4197 |
queue=False,
|
| 4198 |
)
|
| 4199 |
|
| 4200 |
-
preview_inputs = [shape_records, selected_shape, shape_settings,
|
| 4201 |
# .change is the ONLY event the Dataframe emits for cell edits (it has
|
| 4202 |
# no working .input) — and it also fires for the normalizer's own
|
| 4203 |
# write-back. The infinite/minute-long cascade is prevented inside
|
|
@@ -4216,7 +4352,6 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 4216 |
)
|
| 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.
|
|
@@ -4400,14 +4535,23 @@ def build_dynamic_demo() -> gr.Blocks:
|
|
| 4400 |
return [];
|
| 4401 |
}""",
|
| 4402 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4403 |
render_inputs = [
|
| 4404 |
gcode_source,
|
| 4405 |
gcode_upload,
|
| 4406 |
shape_records,
|
| 4407 |
travel_opacity_slider,
|
| 4408 |
print_opacity_slider,
|
| 4409 |
-
travel_color_picker,
|
| 4410 |
-
print_color_picker,
|
| 4411 |
print_width_slider,
|
| 4412 |
travel_width_slider,
|
| 4413 |
]
|
|
|
|
| 1292 |
|
| 1293 |
|
| 1294 |
GCODE_SOURCE_UPLOAD = "Upload G-Code file"
|
| 1295 |
+
GCODE_SOURCE_PARALLEL = "Parallel print (all shapes)"
|
| 1296 |
+
|
| 1297 |
+
# Fixed toolpath-view colors: print color comes from the shape's table color
|
| 1298 |
+
# (uploads default to orange); travel is always grey.
|
| 1299 |
+
TOOLPATH_UPLOAD_PRINT_COLOR = "#ff7f0e"
|
| 1300 |
+
TOOLPATH_TRAVEL_COLOR = "#969696"
|
| 1301 |
|
| 1302 |
|
| 1303 |
PARALLEL_COLOR_CHOICES = [
|
|
|
|
| 1745 |
default_x, default_y, default_z = _default_target_extents_for_stl(path)
|
| 1746 |
nozzle = _record_nozzle_number(previous, index) if previous else _next_unused_nozzle(used_nozzles)
|
| 1747 |
used_nozzles.add(nozzle)
|
| 1748 |
+
# Pressure is a port property (one regulator per serial port): a new
|
| 1749 |
+
# shape adopts the pressure other shapes already use on its port.
|
| 1750 |
+
pressure = previous.get("pressure")
|
| 1751 |
+
if pressure is None:
|
| 1752 |
+
port = _coerce_int(previous.get("port"), 1)
|
| 1753 |
+
port_mates = [
|
| 1754 |
+
record
|
| 1755 |
+
for record in (previous_records or [])
|
| 1756 |
+
if _coerce_int(record.get("port"), 1) == port
|
| 1757 |
+
]
|
| 1758 |
+
pressure = port_mates[0].get("pressure", 25.0) if port_mates else 25.0
|
| 1759 |
records.append({
|
| 1760 |
"idx": index,
|
| 1761 |
"name": name,
|
|
|
|
| 1767 |
"target_y": previous.get("target_y", default_y),
|
| 1768 |
"target_z": previous.get("target_z", default_z),
|
| 1769 |
"last_scaled_axis": previous.get("last_scaled_axis", "target_x"),
|
| 1770 |
+
"pressure": pressure,
|
| 1771 |
"valve": previous.get("valve", 4),
|
| 1772 |
"nozzle": nozzle,
|
| 1773 |
"port": previous.get("port", 1),
|
|
|
|
| 2266 |
def _gcode_dropdown_update(records: list[dict], selected: str | None = None, include_upload: bool = False) -> dict[str, Any]:
|
| 2267 |
choices = [_shape_choice(record) for record in records if record.get("gcode_path")]
|
| 2268 |
if include_upload:
|
| 2269 |
+
# The visualization source radio: the parallel view leads and is the
|
| 2270 |
+
# default; single shapes and the upload option follow.
|
| 2271 |
+
choices = [GCODE_SOURCE_PARALLEL, *choices, GCODE_SOURCE_UPLOAD]
|
| 2272 |
value = selected if selected in choices else (choices[0] if choices else None)
|
| 2273 |
return gr.update(choices=choices, value=value)
|
| 2274 |
|
|
|
|
| 2526 |
return changed
|
| 2527 |
|
| 2528 |
|
| 2529 |
+
def _last_edited_pressures(records: list[dict] | None, settings_table: Any) -> dict[int, float]:
|
| 2530 |
+
"""Record idx -> new pressure for rows whose Pressure cell differs from
|
| 2531 |
+
the record — i.e. pressures the user just edited in the table."""
|
| 2532 |
+
rows = _normalise_rows(settings_table)
|
| 2533 |
+
previous_by_idx: dict[int, dict] = {}
|
| 2534 |
+
for record in records or []:
|
| 2535 |
+
try:
|
| 2536 |
+
previous_by_idx[int(record.get("idx", 0))] = record
|
| 2537 |
+
except (TypeError, ValueError):
|
| 2538 |
+
continue
|
| 2539 |
+
|
| 2540 |
+
pressure_pos = SHAPE_SETTINGS_HEADERS.index("Pressure (psi)")
|
| 2541 |
+
edited: dict[int, float] = {}
|
| 2542 |
+
for row in rows:
|
| 2543 |
+
try:
|
| 2544 |
+
idx = int(float(row[0]))
|
| 2545 |
+
except (IndexError, TypeError, ValueError):
|
| 2546 |
+
continue
|
| 2547 |
+
previous = previous_by_idx.get(idx)
|
| 2548 |
+
if not previous or len(row) <= pressure_pos:
|
| 2549 |
+
continue
|
| 2550 |
+
old_pressure = _coerce_float(previous.get("pressure"), 25.0)
|
| 2551 |
+
new_pressure = _coerce_float(row[pressure_pos], old_pressure)
|
| 2552 |
+
if not math.isclose(new_pressure, old_pressure, rel_tol=0.0, abs_tol=1e-9):
|
| 2553 |
+
edited[idx] = new_pressure
|
| 2554 |
+
return edited
|
| 2555 |
+
|
| 2556 |
+
|
| 2557 |
+
def _last_edited_ports(records: list[dict] | None, settings_table: Any) -> set[int]:
|
| 2558 |
+
"""Record idx whose Port cell differs from the record — i.e. shapes the
|
| 2559 |
+
user just moved onto a (possibly new) port via the table."""
|
| 2560 |
+
rows = _normalise_rows(settings_table)
|
| 2561 |
+
previous_by_idx: dict[int, dict] = {}
|
| 2562 |
+
for record in records or []:
|
| 2563 |
+
try:
|
| 2564 |
+
previous_by_idx[int(record.get("idx", 0))] = record
|
| 2565 |
+
except (TypeError, ValueError):
|
| 2566 |
+
continue
|
| 2567 |
+
|
| 2568 |
+
port_pos = SHAPE_SETTINGS_HEADERS.index("Port")
|
| 2569 |
+
changed: set[int] = set()
|
| 2570 |
+
for row in rows:
|
| 2571 |
+
try:
|
| 2572 |
+
idx = int(float(row[0]))
|
| 2573 |
+
except (IndexError, TypeError, ValueError):
|
| 2574 |
+
continue
|
| 2575 |
+
previous = previous_by_idx.get(idx)
|
| 2576 |
+
if not previous or len(row) <= port_pos:
|
| 2577 |
+
continue
|
| 2578 |
+
old_port = _coerce_int(previous.get("port"), 1)
|
| 2579 |
+
new_port = _coerce_int(row[port_pos], old_port)
|
| 2580 |
+
if new_port != old_port:
|
| 2581 |
+
changed.add(idx)
|
| 2582 |
+
return changed
|
| 2583 |
+
|
| 2584 |
+
|
| 2585 |
+
def _sync_port_pressures(
|
| 2586 |
+
records: list[dict],
|
| 2587 |
+
edited_pressures: dict[int, float],
|
| 2588 |
+
joined_idx: set[int] | None = None,
|
| 2589 |
+
) -> list[dict]:
|
| 2590 |
+
"""Sync pressures across shapes sharing a serial Port.
|
| 2591 |
+
|
| 2592 |
+
Pressure is a PORT property — one regulator per serial port — so every
|
| 2593 |
+
shape on a port must carry the same pressure. The source value must be
|
| 2594 |
+
unambiguous: the pressure the user just edited (all edits agreeing), or
|
| 2595 |
+
— when a shape just JOINED the group by a port edit — the incumbent
|
| 2596 |
+
members' shared pressure, which the newcomer adopts. Groups already in
|
| 2597 |
+
sync, or with no clear source (e.g. stale .change echoes that flag
|
| 2598 |
+
conflicting members at once), are left untouched, so the event storm
|
| 2599 |
+
converges instead of ping-ponging.
|
| 2600 |
+
"""
|
| 2601 |
+
joined_idx = joined_idx or set()
|
| 2602 |
+
by_port: dict[int, list[dict]] = {}
|
| 2603 |
+
for record in records:
|
| 2604 |
+
by_port.setdefault(_coerce_int(record.get("port"), 1), []).append(record)
|
| 2605 |
+
|
| 2606 |
+
for members in by_port.values():
|
| 2607 |
+
if len(members) < 2:
|
| 2608 |
+
continue
|
| 2609 |
+
pressures = [_coerce_float(member.get("pressure"), 25.0) for member in members]
|
| 2610 |
+
if all(math.isclose(p, pressures[0], rel_tol=0.0, abs_tol=1e-9) for p in pressures[1:]):
|
| 2611 |
+
continue # port group already in sync
|
| 2612 |
+
|
| 2613 |
+
edited_values = {
|
| 2614 |
+
round(edited_pressures[int(member.get("idx", 0))], 9)
|
| 2615 |
+
for member in members
|
| 2616 |
+
if int(member.get("idx", 0)) in edited_pressures
|
| 2617 |
+
}
|
| 2618 |
+
incumbents = [
|
| 2619 |
+
member for member in members if int(member.get("idx", 0)) not in joined_idx
|
| 2620 |
+
]
|
| 2621 |
+
newcomers = [
|
| 2622 |
+
member for member in members if int(member.get("idx", 0)) in joined_idx
|
| 2623 |
+
]
|
| 2624 |
+
if len(edited_values) == 1:
|
| 2625 |
+
value = next(iter(edited_values))
|
| 2626 |
+
elif not edited_values and newcomers and incumbents:
|
| 2627 |
+
incumbent_pressures = [
|
| 2628 |
+
_coerce_float(member.get("pressure"), 25.0) for member in incumbents
|
| 2629 |
+
]
|
| 2630 |
+
if not all(
|
| 2631 |
+
math.isclose(p, incumbent_pressures[0], rel_tol=0.0, abs_tol=1e-9)
|
| 2632 |
+
for p in incumbent_pressures[1:]
|
| 2633 |
+
):
|
| 2634 |
+
continue
|
| 2635 |
+
# A port edit pulled newcomers into the group: they adopt the
|
| 2636 |
+
# incumbents' shared pressure.
|
| 2637 |
+
value = incumbent_pressures[0]
|
| 2638 |
+
else:
|
| 2639 |
+
continue # ambiguous (stale echo): do not guess
|
| 2640 |
+
|
| 2641 |
+
for member in members:
|
| 2642 |
+
member["pressure"] = value
|
| 2643 |
+
return records
|
| 2644 |
+
|
| 2645 |
+
|
| 2646 |
def _record_scale_factors(record: dict) -> tuple[float, float, float] | None:
|
| 2647 |
"""Per-axis target/original factors, or None when they can't be computed."""
|
| 2648 |
try:
|
|
|
|
| 2738 |
shape's other dimensions from the edited axis. In BOTH modes, a
|
| 2739 |
dimension edit on a multi-material group member (shapes sharing a
|
| 2740 |
nozzle) propagates its scale factors to the whole group, so assemblies
|
| 2741 |
+
stay proportional as one unit — and a pressure edit propagates to every
|
| 2742 |
+
shape sharing the same serial Port (one pressure regulator per port).
|
| 2743 |
|
| 2744 |
Wired to the table's .change event, WHICH ALSO FIRES FOR OUR OWN
|
| 2745 |
WRITE-BACK (Gradio's Dataframe does not emit .input for cell edits at
|
|
|
|
| 2750 |
"""
|
| 2751 |
edited_axes = _last_edited_target_axes(records, settings_table)
|
| 2752 |
joined_idx = _last_edited_nozzles(records, settings_table)
|
| 2753 |
+
edited_pressures = _last_edited_pressures(records, settings_table)
|
| 2754 |
+
port_joined_idx = _last_edited_ports(records, settings_table)
|
| 2755 |
records = _apply_shape_settings(records or [], settings_table)
|
| 2756 |
if _normalize_scale_mode(scale_mode) != SCALE_MODE_UNIFORM_FACTOR:
|
| 2757 |
normalized = [dict(record) for record in records]
|
|
|
|
| 2760 |
if idx in edited_axes:
|
| 2761 |
record["last_scaled_axis"] = edited_axes[idx]
|
| 2762 |
normalized = _propagate_group_scale_factors(normalized, edited_axes, set(), joined_idx)
|
| 2763 |
+
normalized = _sync_port_pressures(normalized, edited_pressures, port_joined_idx)
|
| 2764 |
changed = any(
|
| 2765 |
not math.isclose(
|
| 2766 |
float(before.get(key) or 0.0),
|
|
|
|
| 2769 |
abs_tol=1e-9,
|
| 2770 |
)
|
| 2771 |
for before, after in zip(records, normalized)
|
| 2772 |
+
for key in (*TARGET_DIMENSION_KEYS, "pressure")
|
| 2773 |
)
|
| 2774 |
if not changed and not _bool_cells_need_rewrite(settings_table):
|
| 2775 |
return normalized, gr.skip()
|
|
|
|
| 2854 |
normalized = _propagate_group_scale_factors(
|
| 2855 |
normalized, edited_axes, recomputed_idx, joined_idx
|
| 2856 |
)
|
| 2857 |
+
normalized = _sync_port_pressures(normalized, edited_pressures, port_joined_idx)
|
| 2858 |
|
| 2859 |
# Idempotence guard (breaks the .change write-back cascade): only write
|
| 2860 |
+
# the table when a dimension or pressure actually changed — or when the
|
| 2861 |
+
# checkbox columns need a canonical re-render after a header "select all".
|
| 2862 |
changed = any(
|
| 2863 |
not math.isclose(
|
| 2864 |
float(before.get(key) or 0.0),
|
|
|
|
| 2867 |
abs_tol=1e-9,
|
| 2868 |
)
|
| 2869 |
for before, after in zip(records, normalized)
|
| 2870 |
+
for key in (*TARGET_DIMENSION_KEYS, "pressure")
|
| 2871 |
)
|
| 2872 |
if not changed and not _bool_cells_need_rewrite(settings_table):
|
| 2873 |
return normalized, gr.skip()
|
|
|
|
| 2878 |
records: list[dict] | None,
|
| 2879 |
selected: str | None,
|
| 2880 |
settings_table: Any,
|
|
|
|
| 2881 |
scale_mode: str | None,
|
| 2882 |
) -> tuple:
|
| 2883 |
records = _apply_shape_settings(records or [], settings_table)
|
|
|
|
| 2887 |
record = records[pos]
|
| 2888 |
return load_single_model(
|
| 2889 |
record.get("stl_path"),
|
| 2890 |
+
False, # full opacity (the 75%-opacity option was removed)
|
| 2891 |
True,
|
| 2892 |
scale_mode,
|
| 2893 |
record.get("target_x"),
|
|
|
|
| 3832 |
records: list[dict] | None,
|
| 3833 |
travel_opacity: float,
|
| 3834 |
print_opacity: float,
|
|
|
|
|
|
|
| 3835 |
print_width: float,
|
| 3836 |
travel_width: float,
|
| 3837 |
tube: bool = True,
|
| 3838 |
) -> tuple[Any, str, dict]:
|
| 3839 |
+
# Print color comes from the shape's table Color (uploads default to
|
| 3840 |
+
# orange); travel is always grey. No per-view color options.
|
| 3841 |
+
travel_color = TOOLPATH_TRAVEL_COLOR
|
| 3842 |
+
print_color = TOOLPATH_UPLOAD_PRINT_COLOR
|
| 3843 |
if source == GCODE_SOURCE_UPLOAD:
|
| 3844 |
path = uploaded_path
|
| 3845 |
label = "uploaded file"
|
| 3846 |
else:
|
| 3847 |
pos = _selected_record_index(records or [], source)
|
| 3848 |
+
record = (records or [])[pos] if pos >= 0 else {}
|
| 3849 |
+
path = record.get("gcode_path")
|
| 3850 |
label = source or "selected shape"
|
| 3851 |
+
print_color = str(record.get("color") or print_color)
|
| 3852 |
if not path:
|
| 3853 |
return None, f"No G-code available for {label}.", {}
|
| 3854 |
try:
|
|
|
|
| 4024 |
)
|
| 4025 |
sync_uploads_button = gr.Button("Sync Uploaded STLs", variant="secondary", size="sm")
|
| 4026 |
reset_dimensions_button = gr.Button("Reset Dimensions", variant="secondary", size="sm")
|
|
|
|
| 4027 |
scale_mode = gr.Radio(
|
| 4028 |
choices=[SCALE_MODE_TARGET_DIMENSIONS, SCALE_MODE_UNIFORM_FACTOR],
|
| 4029 |
value=SCALE_MODE_TARGET_DIMENSIONS,
|
|
|
|
| 4154 |
refresh_gcode_text_button = gr.Button("Refresh G-Code Preview", variant="secondary", size="sm")
|
| 4155 |
gcode_text = gr.Code(label="Selected G-Code", language=None, lines=18, max_lines=18, interactive=False, elem_classes=["gcode-view"])
|
| 4156 |
|
| 4157 |
+
with gr.Tab("Visualization"):
|
| 4158 |
+
gr.Markdown(
|
| 4159 |
+
"### Print Visualization\n"
|
| 4160 |
+
"Defaults to the parallel print of every generated shape, laid out with the "
|
| 4161 |
+
"spacing from the Nozzle Spacing accordion below. Pick a single shape (or "
|
| 4162 |
+
"upload a G-code file) to inspect one tool path — colors come from the Shape "
|
| 4163 |
+
"Settings table."
|
| 4164 |
+
)
|
| 4165 |
+
with gr.Row():
|
| 4166 |
+
gcode_source = gr.Radio(
|
| 4167 |
+
choices=[GCODE_SOURCE_PARALLEL, GCODE_SOURCE_UPLOAD],
|
| 4168 |
+
value=GCODE_SOURCE_PARALLEL,
|
| 4169 |
+
label="What to visualize",
|
| 4170 |
+
)
|
| 4171 |
+
with gr.Column(elem_id="gcode-upload-col"):
|
| 4172 |
+
gcode_upload = gr.File(label="Upload G-Code", file_types=[".txt", ".gcode", ".nc"], interactive=True, height=110)
|
| 4173 |
+
|
| 4174 |
with gr.Accordion("Nozzle Spacing", open=False, elem_classes=["settings-accordion"]):
|
| 4175 |
with gr.Group():
|
| 4176 |
with gr.Row():
|
|
|
|
| 4208 |
with gr.Column(scale=1, min_width=260):
|
| 4209 |
nozzle_spacing_status = gr.Markdown("Generate G-code, then visualize nozzle spacing.")
|
| 4210 |
|
| 4211 |
+
# Parallel view (the default).
|
| 4212 |
+
with gr.Column(visible=True) as parallel_section:
|
| 4213 |
+
with gr.Row():
|
| 4214 |
+
with gr.Column(scale=1, min_width=340):
|
| 4215 |
+
pp_travel_opacity = gr.Slider(label="Travel opacity (0 = hidden)", minimum=0.0, maximum=1.0, value=0.2, step=0.05)
|
| 4216 |
+
parallel_line_button = gr.Button("Render Parallel Print - Line Plot", variant="primary")
|
| 4217 |
+
parallel_render_button = gr.Button("Render Parallel Print - Tube Plot with Animation", variant="primary")
|
| 4218 |
+
gr.Markdown("⚠️ Building multiple tube plots can take a while for high-resolution models.", elem_id="parallel-render-warning")
|
| 4219 |
+
parallel_anim_controls = gr.HTML(PARALLEL_CONTROLS_HTML, visible=False)
|
| 4220 |
+
with gr.Row(visible=False) as pp_width_row:
|
| 4221 |
+
pp_filament_width = gr.Slider(label="Filament width (mm)", minimum=0.1, maximum=3.0, value=0.8, step=0.05, min_width=150)
|
| 4222 |
+
pp_travel_width = gr.Slider(label="Travel width (mm)", minimum=0.05, maximum=3.0, value=0.2, step=0.05, min_width=150)
|
| 4223 |
+
parallel_status = gr.Markdown("")
|
| 4224 |
+
with gr.Group(visible=False) as pp_export_group:
|
| 4225 |
+
gr.Markdown("**Export animation (GIF)** - a server-side line animation of the parallel print.")
|
| 4226 |
+
with gr.Row():
|
| 4227 |
+
pp_gif_duration = gr.Slider(label="Duration (s)", minimum=2.0, maximum=20.0, value=6.0, step=1.0, min_width=150)
|
| 4228 |
+
pp_gif_fps = gr.Slider(label="Frames per second", minimum=5, maximum=30, value=10, step=1, min_width=150)
|
| 4229 |
+
with gr.Row():
|
| 4230 |
+
pp_elev = gr.Slider(label="Elevation angle", minimum=0, maximum=90, value=22, step=1, min_width=150)
|
| 4231 |
+
pp_azim = gr.Slider(label="Azimuth angle", minimum=-180, maximum=180, value=-60, step=5, min_width=150)
|
| 4232 |
+
pp_gif_travel_opacity = gr.Slider(label="Travel opacity in GIF (0 = hidden)", minimum=0.0, maximum=1.0, value=0.15, step=0.05)
|
| 4233 |
+
pp_export_button = gr.Button("Export Animation as GIF", variant="primary")
|
| 4234 |
+
pp_gif_file = gr.File(label="Download GIF", interactive=False)
|
| 4235 |
+
with gr.Column(scale=3, min_width=500):
|
| 4236 |
+
parallel_plot = gr.Plot(label="Parallel Tool Paths", elem_id="parallel_plot")
|
| 4237 |
+
|
| 4238 |
+
# Single tool path view (a generated shape or an uploaded file).
|
| 4239 |
+
with gr.Column(visible=False) as single_section:
|
| 4240 |
+
with gr.Row():
|
| 4241 |
+
with gr.Column(scale=1, min_width=340):
|
| 4242 |
+
with gr.Row():
|
| 4243 |
+
travel_opacity_slider = gr.Slider(label="Travel (G0) opacity", minimum=0.0, maximum=1.0, value=0.2, step=0.05, min_width=150)
|
| 4244 |
+
print_opacity_slider = gr.Slider(label="Print (G1) opacity", minimum=0.0, maximum=1.0, value=1.0, step=0.05, min_width=150)
|
| 4245 |
+
render_line_button = gr.Button("Render Tool Path - Line Plot", variant="primary")
|
| 4246 |
+
render_tube_button = gr.Button("Render Tool Path - Tube Plot with Animation", variant="primary")
|
| 4247 |
+
gr.Markdown(
|
| 4248 |
+
"⚠️ For high-resolution models (small layer heights), the tube plot can take a while to build and render.",
|
| 4249 |
+
elem_id="tube-render-warning",
|
| 4250 |
)
|
| 4251 |
+
anim_controls = gr.HTML(TOOLPATH_CONTROLS_HTML, visible=False)
|
| 4252 |
+
with gr.Row(visible=False) as width_row:
|
| 4253 |
+
travel_width_slider = gr.Slider(label="Travel width (mm)", minimum=0.05, maximum=1.2, value=0.2, step=0.05, min_width=150)
|
| 4254 |
+
print_width_slider = gr.Slider(label="Filament width (mm)", minimum=0.1, maximum=1.2, value=0.8, step=0.05, min_width=150)
|
| 4255 |
+
toolpath_status = gr.Markdown("")
|
| 4256 |
+
with gr.Column(scale=3, min_width=500):
|
| 4257 |
+
toolpath_plot = gr.Plot(label="Tool Path", elem_id="toolpath_plot")
|
| 4258 |
|
| 4259 |
parsed_state = gr.State({})
|
| 4260 |
render_mode = gr.State("tube")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4261 |
parallel_mode = gr.State("tube")
|
| 4262 |
|
| 4263 |
grid_spacing_refresh_inputs = [
|
|
|
|
| 4333 |
queue=False,
|
| 4334 |
)
|
| 4335 |
|
| 4336 |
+
preview_inputs = [shape_records, selected_shape, shape_settings, scale_mode]
|
| 4337 |
# .change is the ONLY event the Dataframe emits for cell edits (it has
|
| 4338 |
# no working .input) — and it also fires for the normalizer's own
|
| 4339 |
# write-back. The infinite/minute-long cascade is prevented inside
|
|
|
|
| 4352 |
)
|
| 4353 |
selected_shape.change(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
|
| 4354 |
refresh_preview_button.click(fn=show_selected_model, inputs=preview_inputs, outputs=[model_viewer, model_details])
|
|
|
|
| 4355 |
# Sliced-layer preview. The slider uses .release only: the handler
|
| 4356 |
# writes the slider back (clamped max/value), and a .change wiring
|
| 4357 |
# would re-fire on that programmatic write.
|
|
|
|
| 4535 |
return [];
|
| 4536 |
}""",
|
| 4537 |
)
|
| 4538 |
+
# The source radio also decides which view is shown: the parallel
|
| 4539 |
+
# print (default) or the single-toolpath view for one shape / upload.
|
| 4540 |
+
gcode_source.change(
|
| 4541 |
+
fn=lambda source: (
|
| 4542 |
+
gr.update(visible=source == GCODE_SOURCE_PARALLEL),
|
| 4543 |
+
gr.update(visible=source != GCODE_SOURCE_PARALLEL),
|
| 4544 |
+
),
|
| 4545 |
+
inputs=[gcode_source],
|
| 4546 |
+
outputs=[parallel_section, single_section],
|
| 4547 |
+
queue=False,
|
| 4548 |
+
)
|
| 4549 |
render_inputs = [
|
| 4550 |
gcode_source,
|
| 4551 |
gcode_upload,
|
| 4552 |
shape_records,
|
| 4553 |
travel_opacity_slider,
|
| 4554 |
print_opacity_slider,
|
|
|
|
|
|
|
| 4555 |
print_width_slider,
|
| 4556 |
travel_width_slider,
|
| 4557 |
]
|
|
@@ -1006,6 +1006,78 @@ def test_joining_a_nozzle_group_adopts_the_group_scale() -> None:
|
|
| 1006 |
assert updated2[2]["target_x"] == 45.0
|
| 1007 |
|
| 1008 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1009 |
def test_split_pieces_are_never_rescaled_by_keep_proportions() -> None:
|
| 1010 |
# Regression: split pieces inherit the parent's original_* dims while
|
| 1011 |
# their targets are the CELL sizes; a table echo in Keep Proportions
|
|
|
|
| 1006 |
assert updated2[2]["target_x"] == 45.0
|
| 1007 |
|
| 1008 |
|
| 1009 |
+
def test_pressure_edit_propagates_to_shapes_sharing_the_port() -> None:
|
| 1010 |
+
from app import SCALE_MODE_TARGET_DIMENSIONS
|
| 1011 |
+
|
| 1012 |
+
# Shapes 1+2 share port 1 (one regulator); shape 3 is alone on port 2.
|
| 1013 |
+
records = [
|
| 1014 |
+
_mm_member(1, 1, 40.0, 20.0, 10.0),
|
| 1015 |
+
_mm_member(2, 2, 20.0, 20.0, 10.0),
|
| 1016 |
+
_mm_member(3, 3, 30.0, 30.0, 30.0),
|
| 1017 |
+
]
|
| 1018 |
+
records[2]["port"] = 2
|
| 1019 |
+
records[2]["pressure"] = 40.0
|
| 1020 |
+
rows = _shape_settings_rows(records)
|
| 1021 |
+
pressure_pos = SHAPE_SETTINGS_HEADERS.index("Pressure (psi)")
|
| 1022 |
+
rows[0][pressure_pos] = 32.0
|
| 1023 |
+
|
| 1024 |
+
updated, table_out = normalize_shape_dimensions_for_mode(
|
| 1025 |
+
records, rows, SCALE_MODE_TARGET_DIMENSIONS
|
| 1026 |
+
)
|
| 1027 |
+
|
| 1028 |
+
assert updated[0]["pressure"] == 32.0
|
| 1029 |
+
assert updated[1]["pressure"] == 32.0 # port-mate follows
|
| 1030 |
+
assert updated[2]["pressure"] == 40.0 # other port untouched
|
| 1031 |
+
assert isinstance(table_out, list) # propagation must be written back
|
| 1032 |
+
|
| 1033 |
+
# The echo of that write-back is a converged no-op.
|
| 1034 |
+
echoed = _shape_settings_rows(updated)
|
| 1035 |
+
updated2, table_out2 = normalize_shape_dimensions_for_mode(
|
| 1036 |
+
updated, echoed, SCALE_MODE_TARGET_DIMENSIONS
|
| 1037 |
+
)
|
| 1038 |
+
assert not isinstance(table_out2, list)
|
| 1039 |
+
assert updated2[1]["pressure"] == 32.0
|
| 1040 |
+
|
| 1041 |
+
|
| 1042 |
+
def test_moving_a_shape_onto_a_port_adopts_that_ports_pressure() -> None:
|
| 1043 |
+
from app import SCALE_MODE_TARGET_DIMENSIONS
|
| 1044 |
+
|
| 1045 |
+
first = _mm_member(1, 1, 40.0, 20.0, 10.0)
|
| 1046 |
+
second = _mm_member(2, 2, 20.0, 20.0, 10.0)
|
| 1047 |
+
mover = _mm_member(3, 3, 30.0, 30.0, 30.0)
|
| 1048 |
+
first["pressure"] = 32.0
|
| 1049 |
+
second["pressure"] = 32.0
|
| 1050 |
+
mover["port"] = 2
|
| 1051 |
+
mover["pressure"] = 40.0
|
| 1052 |
+
|
| 1053 |
+
records = [first, second, mover]
|
| 1054 |
+
rows = _shape_settings_rows(records)
|
| 1055 |
+
port_pos = SHAPE_SETTINGS_HEADERS.index("Port")
|
| 1056 |
+
rows[2][port_pos] = 1 # mover joins port 1
|
| 1057 |
+
|
| 1058 |
+
updated, table_out = normalize_shape_dimensions_for_mode(
|
| 1059 |
+
records, rows, SCALE_MODE_TARGET_DIMENSIONS
|
| 1060 |
+
)
|
| 1061 |
+
|
| 1062 |
+
assert updated[2]["port"] == 1
|
| 1063 |
+
assert updated[2]["pressure"] == 32.0 # newcomer adopts the port pressure
|
| 1064 |
+
assert updated[0]["pressure"] == 32.0 # incumbents unchanged
|
| 1065 |
+
assert isinstance(table_out, list)
|
| 1066 |
+
|
| 1067 |
+
|
| 1068 |
+
def test_new_shapes_adopt_the_existing_port_pressure() -> None:
|
| 1069 |
+
from app import _records_from_files
|
| 1070 |
+
|
| 1071 |
+
previous = _records_from_files(["first.stl"], None)
|
| 1072 |
+
previous[0]["pressure"] = 40.0
|
| 1073 |
+
|
| 1074 |
+
records = _records_from_files(["first.stl", "second.stl"], previous)
|
| 1075 |
+
|
| 1076 |
+
assert records[0]["pressure"] == 40.0
|
| 1077 |
+
assert records[1]["port"] == records[0]["port"]
|
| 1078 |
+
assert records[1]["pressure"] == 40.0 # same port -> same regulator
|
| 1079 |
+
|
| 1080 |
+
|
| 1081 |
def test_split_pieces_are_never_rescaled_by_keep_proportions() -> None:
|
| 1082 |
# Regression: split pieces inherit the parent's original_* dims while
|
| 1083 |
# their targets are the CELL sizes; a table echo in Keep Proportions
|