CyGuy8 Claude Fable 5 commited on
Commit
6041550
·
1 Parent(s): 8f71a0b

Sync pressure per port, merge visualization tabs, move Nozzle Spacing accordion

Browse files

Shape 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>

Files changed (3) hide show
  1. README.md +11 -11
  2. app.py +231 -87
  3. tests/test_nozzle_spacing.py +72 -0
README.md CHANGED
@@ -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
- - Visualizes generated or uploaded G-code tool paths, with the source selectable from any active generated shape or an uploaded file
60
- - Renders the tool path as a fast line plot or an animated 3D tube plot (play/pause, speed, scrub, frame-step, nozzle marker)
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
- ### G-code Visualization
128
 
129
- The G-code visualization tab renders generated shape G-code or an uploaded `.txt`, `.gcode`, or `.nc` file. It parses `G0`/`G1` movement lines, supports relative (`G91`) and absolute (`G90`) positioning, and offers two render modes:
130
 
131
- - **Line Plot** fast thin scatter lines (print and travel), with color/opacity controls.
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
- ### Parallel Printing Visualization
135
 
136
- The fourth tab plots the generated shapes' G-code at once using the nozzle spacing configured on the Generate G-Code tab, each in its own color. Shape Settings maps each STL to a nozzle number, so multiple shapes can share one nozzle offset while valves remain independent. Like the visualization tab it has a fast **Line Plot** and an animated **Tube Plot**; the animation advances all parts on a shared cumulative-path-length timeline, so a shorter part finishes first.
 
137
 
138
- It can also **export the animation as a GIF**, rendered server-side with Matplotlib (the `Agg` CPU backend — no WebGL, no headless browser, and no `ffmpeg`, so it works locally and on Hugging Face). The GIF is line-style with faint grey travel and white, black-outlined nozzle markers drawn on top; controls cover duration, frames per second, elevation/azimuth viewing angle, and travel opacity (0 hides travel).
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
 
app.py CHANGED
@@ -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": previous.get("pressure", 25.0),
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
- choices.append(GCODE_SOURCE_UPLOAD)
 
 
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 checkbox
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
- opacity,
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
- path = (records or [])[pos].get("gcode_path") if pos >= 0 else None
 
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
- with gr.Tab("G-Code Visualization"):
4052
- gr.Markdown("### 3D Tool-Path Viewer")
4053
- with gr.Row():
4054
- gcode_source = gr.Radio(choices=[GCODE_SOURCE_UPLOAD], value=GCODE_SOURCE_UPLOAD, label="G-Code source")
4055
- with gr.Column(elem_id="gcode-upload-col"):
4056
- gcode_upload = gr.File(label="Upload G-Code", file_types=[".txt", ".gcode", ".nc"], interactive=True, height=110)
4057
-
4058
- with gr.Row():
4059
- with gr.Column(scale=1, min_width=340):
4060
- render_line_button = gr.Button("Render Tool Path - Line Plot", variant="primary")
4061
- render_tube_button = gr.Button("Render Tool Path - Tube Plot with Animation", variant="primary")
4062
- gr.Markdown(
4063
- "&#9888;&#65039; For high-resolution models (small layer heights), the tube plot can take a while to build and render.",
4064
- elem_id="tube-render-warning",
4065
- )
4066
- anim_controls = gr.HTML(TOOLPATH_CONTROLS_HTML, visible=False)
4067
- with gr.Row():
4068
- travel_opacity_slider = gr.Slider(label="Travel (G0) opacity", minimum=0.0, maximum=1.0, value=0.2, step=0.05, min_width=150)
4069
- print_opacity_slider = gr.Slider(label="Print (G1) opacity", minimum=0.0, maximum=1.0, value=1.0, step=0.05, min_width=150)
4070
- with gr.Row():
4071
- travel_color_picker = gr.Dropdown(
4072
- label="Travel (G0) color",
4073
- choices=[("Grey", "#969696"), ("Orange", "#ff7f0e"), ("Green", "#2ca02c"), ("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"), ("Black", "#000000"), ("White", "#ffffff")],
4074
- value="#969696",
4075
- allow_custom_value=False,
4076
- min_width=150,
4077
- )
4078
- print_color_picker = gr.Dropdown(
4079
- label="Print (G1) color",
4080
- choices=[("Blue", "#1f77b4"), ("Orange", "#ff7f0e"), ("Green", "#2ca02c"), ("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"), ("Black", "#000000"), ("White", "#ffffff")],
4081
- value="#ff7f0e",
4082
- allow_custom_value=False,
4083
- min_width=150,
 
 
 
 
 
 
4084
  )
4085
- with gr.Row(visible=False) as width_row:
4086
- travel_width_slider = gr.Slider(label="Travel width (mm)", minimum=0.05, maximum=1.2, value=0.2, step=0.05, min_width=150)
4087
- print_width_slider = gr.Slider(label="Filament width (mm)", minimum=0.1, maximum=1.2, value=0.8, step=0.05, min_width=150)
4088
- toolpath_status = gr.Markdown("")
4089
- with gr.Column(scale=3, min_width=500):
4090
- toolpath_plot = gr.Plot(label="Tool Path", elem_id="toolpath_plot")
 
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("&#9888;&#65039; 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, model_opacity, scale_mode]
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("&#9888;&#65039; 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
+ "&#9888;&#65039; 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
  ]
tests/test_nozzle_spacing.py CHANGED
@@ -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