CyGuy8 Claude Fable 5 commited on
Commit
84c7b5c
·
1 Parent(s): 09c5dff

Show nozzle/port groups in the table, valve warnings, print time estimate

Browse files

Shape Settings table (client-side, restyled on every re-render):
- Rows sharing a nozzle get a per-group tint + accent stripe, and a
summary under the table names each assembly
- Shapes sharing a Port get a matching underline on their Pressure and
Port cells plus a subdued summary line with the shared pressure (one
regulator per serial port)
- Valve cells used by more than one shape turn red with a warning line;
new shapes default to their own unused valve number (was: always 4)
- The row scanner deduplicates by Shape number and prefers the live
(drag-drop-wrapped) table copy: Gradio renders rows in two tables and
the outer one can hold a STALE leftover of a previous render, which
duplicated group names and flagged phantom valve conflicts (also the
long-standing "stray duplicate row" artifact). Observer watches the
body (the container node is replaced on hydration) and debounces with
setTimeout (rAF never fires in hidden tabs)
- Header wrapping breaks only at spaces (keep-all): the Space's wider
font was splitting "Pressure" and "Contour" mid-word

Visualization:
- Nozzle Speed (mm/s) input + print time estimate in the render status
of both views: every move is one constant speed, so time = tool-path
length / speed. Parallel view uses the longest head's path (all heads
move together); single view shows the file's own path length

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (3) hide show
  1. README.md +4 -0
  2. app.py +279 -8
  3. tests/test_nozzle_spacing.py +35 -0
README.md CHANGED
@@ -46,6 +46,9 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
46
  - Lets you choose layer height and filament/line width
47
  - Slices each shape into per-layer vector outlines held in memory (no intermediate image files)
48
  - Shapes that share a **nozzle number** are treated automatically as one multi-material assembly: sliced on one shared Z grid and kept exactly where they were modeled, while shapes alone on their nozzle behave as ordinary independent parts
 
 
 
49
  - Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
50
  - 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 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
@@ -59,6 +62,7 @@ Then open the local Gradio URL in your browser, upload STL files or load the bun
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
 
46
  - Lets you choose layer height and filament/line width
47
  - Slices each shape into per-layer vector outlines held in memory (no intermediate image files)
48
  - Shapes that share a **nozzle number** are treated automatically as one multi-material assembly: sliced on one shared Z grid and kept exactly where they were modeled, while shapes alone on their nozzle behave as ordinary independent parts
49
+ - Nozzle groups are visible right in the table: rows sharing a nozzle get a shared tint and accent stripe, and a summary under the table names each assembly ("Nozzle 1: red_base + black_stripes print as one assembly")
50
+ - Valve safety check: valve cells used by more than one shape turn red with a warning under the table — shapes sharing a valve dispense simultaneously, which is almost never intended. New shapes default to their own unused valve number
51
+ - Port groups are marked too: shapes sharing a Port get a matching underline on their Pressure and Port cells and a summary line ("Port 1: A + B share one pressure regulator (25 psi)") — one regulator per serial port is why their pressures stay in sync
52
  - Automatically unions the sliced shapes into a combined reference layer set whenever shapes are sliced
53
  - 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 can be checked before generating G-code)
54
  - Splits one sliced shape's geometry into an editable row/column grid for multi-nozzle printing of one large shape
 
62
  - Previews selected generated G-code inline
63
  - 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
64
  - 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)
65
+ - Estimates print time from an inputted **Nozzle Speed (mm/s)**: every move runs at one constant speed, so time = tool-path length ÷ speed — shown with the path length in the render status for both the parallel view (longest head's path; all heads move together) and the single-tool-path view
66
  - 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
67
 
68
  ## Behavior and Implementation Notes
app.py CHANGED
@@ -258,13 +258,41 @@ APP_CSS = """
258
  display: none !important;
259
  }
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  /* Narrower columns: header labels ("Pressure (psi)", "Contour Tracing", ...)
262
- wrap to two lines instead of forcing single-line column widths. */
 
 
263
  #shape-settings-table thead th,
264
  #shape-settings-table thead th button,
265
  #shape-settings-table thead th span {
266
  white-space: normal !important;
267
- overflow-wrap: break-word;
 
 
268
  line-height: 1.15;
269
  }
270
  #shape-settings-table thead th {
@@ -525,6 +553,176 @@ APP_HEAD = """
525
  }
526
  })();
527
  </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
528
  """
529
 
530
  # Client-side build animation for the G-Code Visualization tab. The rendered
@@ -1750,11 +1948,24 @@ def _next_unused_nozzle(used_nozzles: set[int]) -> int:
1750
  return nozzle
1751
 
1752
 
 
 
 
 
 
 
 
 
 
 
1753
  def _records_from_files(files: Any, previous_records: list[dict] | None = None) -> list[dict]:
1754
  previous_by_path: dict[str | None, list[dict]] = {}
1755
  for record in previous_records or []:
1756
  previous_by_path.setdefault(record.get("stl_path"), []).append(record)
1757
  used_nozzles: set[int] = set()
 
 
 
1758
  records: list[dict] = []
1759
  for index, path in enumerate(_uploaded_file_paths(files), start=1):
1760
  previous_queue = previous_by_path.get(path) or []
@@ -1763,6 +1974,10 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
1763
  default_x, default_y, default_z = _default_target_extents_for_stl(path)
1764
  nozzle = _record_nozzle_number(previous, index) if previous else _next_unused_nozzle(used_nozzles)
1765
  used_nozzles.add(nozzle)
 
 
 
 
1766
  # Pressure is a port property (one regulator per serial port): a new
1767
  # shape adopts the pressure other shapes already use on its port.
1768
  pressure = previous.get("pressure")
@@ -1788,7 +2003,7 @@ def _records_from_files(files: Any, previous_records: list[dict] | None = None)
1788
  "target_z": round(_coerce_float(previous.get("target_z"), default_z), 1),
1789
  "last_scaled_axis": previous.get("last_scaled_axis", "target_x"),
1790
  "pressure": pressure,
1791
- "valve": previous.get("valve", 4),
1792
  "nozzle": nozzle,
1793
  "port": previous.get("port", 1),
1794
  "color": previous.get("color", _default_color(index)),
@@ -3835,6 +4050,33 @@ def _parts_from_records(records: list[dict] | None) -> tuple[list[dict], list[st
3835
  return parts, messages
3836
 
3837
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3838
  def render_dynamic_nozzle_spacing(
3839
  records: list[dict] | None,
3840
  columns: Any,
@@ -3867,6 +4109,7 @@ def render_dynamic_toolpath(
3867
  print_opacity: float,
3868
  print_width: float,
3869
  travel_width: float,
 
3870
  tube: bool = True,
3871
  ) -> tuple[Any, str, dict]:
3872
  # Print color comes from the shape's table Color (uploads default to
@@ -3901,11 +4144,19 @@ def render_dynamic_toolpath(
3901
  tube=tube,
3902
  )
3903
  (x_min, y_min, z_min), (x_max, y_max, z_max) = parsed["bounds"]
3904
- return figure, (
3905
  f"**{parsed['point_count']} moves parsed** - {len(parsed['print_segments'])} print segment(s), "
3906
  f"{len(parsed['travel_segments'])} travel segment(s). \n"
3907
  f"Bounds: X [{x_min:.2f}, {x_max:.2f}], Y [{y_min:.2f}, {y_max:.2f}], Z [{z_min:.2f}, {z_max:.2f}] mm."
3908
- ), parsed
 
 
 
 
 
 
 
 
3909
 
3910
 
3911
  def render_dynamic_toolpath_lines(*args: Any) -> tuple[Any, str, dict, str, dict[str, Any], dict[str, Any]]:
@@ -3935,6 +4186,7 @@ def render_dynamic_parallel(
3935
  row_spacing: Any,
3936
  use_grid_individual_spacing: bool,
3937
  grid_spacing_table: Any,
 
3938
  tube: bool = True,
3939
  ) -> tuple[Any, str]:
3940
  records = _apply_shape_settings(records or [], settings_table)
@@ -3958,6 +4210,15 @@ def render_dynamic_parallel(
3958
  travel_opacity=float(travel_opacity),
3959
  tube=tube,
3960
  )
 
 
 
 
 
 
 
 
 
3961
  messages.append(_format_nozzle_spacing_status(parts, offsets, spacings))
3962
  return figure, " \n".join(messages)
3963
 
@@ -4105,13 +4366,13 @@ def build_dynamic_demo() -> gr.Blocks:
4105
  "62px", # X (mm)
4106
  "62px", # Y (mm)
4107
  "62px", # Z (mm)
4108
- "76px", # Pressure (psi)
4109
  "56px", # Valve
4110
  "60px", # Nozzle
4111
  "52px", # Port
4112
  "104px", # Color
4113
- "58px", # Infill %
4114
- "78px", # Contour Tracing
4115
  "58px", # Lead In
4116
  "60px", # Delete
4117
  ],
@@ -4216,6 +4477,14 @@ def build_dynamic_demo() -> gr.Blocks:
4216
  value=GCODE_SOURCE_PARALLEL,
4217
  label="What to visualize",
4218
  )
 
 
 
 
 
 
 
 
4219
  with gr.Column(elem_id="gcode-upload-col"):
4220
  gcode_upload = gr.File(label="Upload G-Code", file_types=[".txt", ".gcode", ".nc"], interactive=True, height=110)
4221
 
@@ -4604,6 +4873,7 @@ def build_dynamic_demo() -> gr.Blocks:
4604
  print_opacity_slider,
4605
  print_width_slider,
4606
  travel_width_slider,
 
4607
  ]
4608
  render_line_button.click(fn=render_dynamic_toolpath_lines, inputs=render_inputs, outputs=[toolpath_plot, toolpath_status, parsed_state, render_mode, anim_controls, width_row])
4609
  render_tube_button.click(fn=render_dynamic_toolpath_tubes, inputs=render_inputs, outputs=[toolpath_plot, toolpath_status, parsed_state, render_mode, anim_controls, width_row])
@@ -4642,6 +4912,7 @@ def build_dynamic_demo() -> gr.Blocks:
4642
  nozzle_grid_row_spacing,
4643
  nozzle_grid_use_individual_spacing,
4644
  nozzle_grid_spacing_table,
 
4645
  ]
4646
  parallel_outputs = [parallel_plot, parallel_status, parallel_mode, parallel_anim_controls, pp_width_row, pp_export_group]
4647
  parallel_line_button.click(fn=render_dynamic_parallel_lines, inputs=parallel_render_inputs, outputs=parallel_outputs)
 
258
  display: none !important;
259
  }
260
 
261
+ /* Nozzle-group summary under the Shape Settings table (filled client-side). */
262
+ #pp-group-note {
263
+ padding: 0.4rem 0.3rem 0.1rem;
264
+ font-size: 0.85rem;
265
+ line-height: 1.55;
266
+ color: var(--body-text-color);
267
+ }
268
+ .pp-group-line {
269
+ display: block;
270
+ }
271
+ .pp-group-chip {
272
+ display: inline-block;
273
+ width: 10px;
274
+ height: 10px;
275
+ border-radius: 2px;
276
+ margin-right: 6px;
277
+ }
278
+ .pp-valve-warning {
279
+ color: #dc2626;
280
+ }
281
+ .pp-port-line {
282
+ color: var(--body-text-color-subdued);
283
+ }
284
+
285
  /* Narrower columns: header labels ("Pressure (psi)", "Contour Tracing", ...)
286
+ wrap to two lines instead of forcing single-line column widths. Wrapping
287
+ happens ONLY at spaces — words never break mid-word (columns grow to fit
288
+ their longest word instead), whatever font the host renders with. */
289
  #shape-settings-table thead th,
290
  #shape-settings-table thead th button,
291
  #shape-settings-table thead th span {
292
  white-space: normal !important;
293
+ overflow-wrap: normal !important;
294
+ word-break: keep-all !important;
295
+ hyphens: none;
296
  line-height: 1.15;
297
  }
298
  #shape-settings-table thead th {
 
553
  }
554
  })();
555
  </script>
556
+ <script>
557
+ // Nozzle-group highlighting: shapes sharing a nozzle number print as ONE
558
+ // multi-material assembly, so their rows get a shared tint + accent stripe,
559
+ // and a summary under the table names each assembly. Valve cells used by
560
+ // more than one shape turn red (they would dispense simultaneously).
561
+ // Pure presentation, recomputed from the rendered table on every re-render.
562
+ (function () {
563
+ var NAME_COL = 1, PRESSURE_COL = 5, VALVE_COL = 6, NOZZLE_COL = 7, PORT_COL = 8, COLUMNS = 14;
564
+ var TINTS = ['rgba(31,119,180,0.10)', 'rgba(255,127,14,0.12)', 'rgba(44,160,44,0.10)', 'rgba(148,103,189,0.12)', 'rgba(23,190,207,0.12)'];
565
+ var EDGES = ['#1f77b4', '#ff7f0e', '#2ca02c', '#9467bd', '#17becf'];
566
+ var PORT_EDGES = ['#64748b', '#0ea5e9', '#f59e0b', '#10b981'];
567
+ function cellNumber(td) {
568
+ var text = ((td && td.textContent) || '').replace(/[^0-9.\\-]/g, '');
569
+ if (!text) return null;
570
+ var value = parseFloat(text);
571
+ return isNaN(value) ? null : Math.round(value);
572
+ }
573
+ function cellName(td) {
574
+ return ((td && td.textContent) || '').replace(/\\u22ee/g, '').trim();
575
+ }
576
+ function refresh() {
577
+ var container = document.getElementById('shape-settings-table');
578
+ if (!container) return;
579
+ // The component renders rows in TWO tables (one wrapped in the
580
+ // drag-drop <button>, one outside), splitting or DUPLICATING rows
581
+ // between them — and the outer table can hold a stale leftover of a
582
+ // previous render. Counting raw rows duplicated group names and
583
+ // flagged phantom valve conflicts, so rows are deduplicated by
584
+ // their Shape number, preferring the button-wrapped (live) copy;
585
+ // every copy still gets styled.
586
+ var byIdx = {};
587
+ var entries = [];
588
+ Array.prototype.slice.call(container.querySelectorAll('table tbody tr')).forEach(function (tr) {
589
+ var tds = tr.querySelectorAll('td');
590
+ for (var i = 0; i < tds.length; i++) {
591
+ tds[i].style.background = '';
592
+ if (i === 0 || i === PRESSURE_COL || i === PORT_COL) tds[i].style.boxShadow = '';
593
+ if (i === VALVE_COL || i === PRESSURE_COL || i === PORT_COL) tds[i].removeAttribute('title');
594
+ }
595
+ tr.removeAttribute('title');
596
+ if (tds.length < COLUMNS) return;
597
+ var idx = cellNumber(tds[0]);
598
+ if (idx === null) return;
599
+ var fromLive = !!tr.closest('button');
600
+ var entry = byIdx[idx];
601
+ if (!entry) {
602
+ entry = byIdx[idx] = {
603
+ name: cellName(tds[NAME_COL]),
604
+ nozzle: cellNumber(tds[NOZZLE_COL]),
605
+ valve: cellNumber(tds[VALVE_COL]),
606
+ port: cellNumber(tds[PORT_COL]),
607
+ pressure: cellName(tds[PRESSURE_COL]),
608
+ fromLive: fromLive,
609
+ copies: []
610
+ };
611
+ entries.push(entry);
612
+ } else if (fromLive && !entry.fromLive) {
613
+ entry.name = cellName(tds[NAME_COL]);
614
+ entry.nozzle = cellNumber(tds[NOZZLE_COL]);
615
+ entry.valve = cellNumber(tds[VALVE_COL]);
616
+ entry.port = cellNumber(tds[PORT_COL]);
617
+ entry.pressure = cellName(tds[PRESSURE_COL]);
618
+ entry.fromLive = true;
619
+ }
620
+ entry.copies.push({tr: tr, tds: tds});
621
+ });
622
+ var byNozzle = {}, byValve = {}, byPort = {};
623
+ entries.forEach(function (entry) {
624
+ if (entry.nozzle !== null) (byNozzle[entry.nozzle] = byNozzle[entry.nozzle] || []).push(entry);
625
+ if (entry.valve !== null) (byValve[entry.valve] = byValve[entry.valve] || []).push(entry);
626
+ if (entry.port !== null) (byPort[entry.port] = byPort[entry.port] || []).push(entry);
627
+ });
628
+ var summary = [];
629
+ var groupNozzles = Object.keys(byNozzle).filter(function (n) { return byNozzle[n].length > 1; });
630
+ groupNozzles.sort(function (a, b) { return a - b; });
631
+ groupNozzles.forEach(function (nozzle, gi) {
632
+ var tint = TINTS[gi % TINTS.length];
633
+ var edge = EDGES[gi % EDGES.length];
634
+ var names = [];
635
+ byNozzle[nozzle].forEach(function (entry) {
636
+ entry.copies.forEach(function (copy) {
637
+ for (var i = 0; i < copy.tds.length; i++) copy.tds[i].style.background = tint;
638
+ copy.tds[0].style.boxShadow = 'inset 3px 0 0 ' + edge;
639
+ copy.tr.title = 'Nozzle ' + nozzle + ': these shapes print as one multi-material assembly';
640
+ });
641
+ if (entry.name) names.push(entry.name);
642
+ });
643
+ summary.push(
644
+ '<span class="pp-group-line"><span class="pp-group-chip" style="background:' + edge + '"></span>' +
645
+ 'Nozzle ' + nozzle + ': <b>' + names.join(' + ') + '</b> print as one assembly</span>'
646
+ );
647
+ });
648
+ // Port groups: pressure is a PORT property (one regulator per serial
649
+ // port), so shapes sharing a Port always share one pressure. Marked
650
+ // subtly - an underline on the Pressure + Port cells - since the row
651
+ // backgrounds belong to the nozzle assemblies.
652
+ Object.keys(byPort).filter(function (p) { return byPort[p].length > 1; }).sort(function (a, b) { return a - b; }).forEach(function (port, pi) {
653
+ var edge = PORT_EDGES[pi % PORT_EDGES.length];
654
+ var names = [];
655
+ var pressure = null;
656
+ byPort[port].forEach(function (entry) {
657
+ entry.copies.forEach(function (copy) {
658
+ [PRESSURE_COL, PORT_COL].forEach(function (col) {
659
+ copy.tds[col].style.boxShadow = 'inset 0 -2px 0 ' + edge;
660
+ copy.tds[col].title = 'Port ' + port + ': one pressure regulator - these shapes share one pressure';
661
+ });
662
+ });
663
+ if (entry.name) names.push(entry.name);
664
+ if (pressure === null && entry.pressure) pressure = entry.pressure;
665
+ });
666
+ summary.push(
667
+ '<span class="pp-group-line pp-port-line"><span class="pp-group-chip" style="background:' + edge + '"></span>' +
668
+ 'Port ' + port + ': <b>' + names.join(' + ') + '</b> share one pressure regulator' +
669
+ (pressure ? ' (' + pressure + ' psi)' : '') + '</span>'
670
+ );
671
+ });
672
+ Object.keys(byValve).filter(function (v) { return byValve[v].length > 1; }).sort(function (a, b) { return a - b; }).forEach(function (valve) {
673
+ var names = [];
674
+ byValve[valve].forEach(function (entry) {
675
+ entry.copies.forEach(function (copy) {
676
+ var td = copy.tds[VALVE_COL];
677
+ td.style.background = 'rgba(220,38,38,0.22)';
678
+ td.title = 'Valve ' + valve + ' is used by more than one shape - they would dispense together';
679
+ });
680
+ if (entry.name) names.push(entry.name);
681
+ });
682
+ summary.push(
683
+ '<span class="pp-group-line pp-valve-warning">&#9888;&#65039; Valve ' + valve + ' is shared by <b>' +
684
+ names.join('</b> and <b>') + '</b> - they would dispense at the same time. Give each shape its own valve.</span>'
685
+ );
686
+ });
687
+ var note = document.getElementById('pp-group-note');
688
+ if (!note) {
689
+ note = document.createElement('div');
690
+ note.id = 'pp-group-note';
691
+ container.appendChild(note);
692
+ }
693
+ var html = summary.join('');
694
+ // Only touch the DOM when the content changed: the observer watches
695
+ // childList, so an unconditional innerHTML write would loop forever.
696
+ if (note.__ppHtml !== html) {
697
+ note.__ppHtml = html;
698
+ note.innerHTML = html;
699
+ }
700
+ note.style.display = html ? '' : 'none';
701
+ }
702
+ var scheduled = false;
703
+ function schedule() {
704
+ if (scheduled) return;
705
+ scheduled = true;
706
+ // setTimeout, not requestAnimationFrame: rAF never fires in hidden
707
+ // tabs, which would leave the table unstyled after a background
708
+ // refresh (and breaks headless testing).
709
+ setTimeout(function () { scheduled = false; refresh(); }, 50);
710
+ }
711
+ function arm() {
712
+ // Observe the BODY, not the table container: Gradio replaces the
713
+ // container node when it hydrates, which would orphan the observer.
714
+ // Style writes are attribute mutations and the note rewrite is
715
+ // guarded, so observing childList/characterData cannot loop.
716
+ new MutationObserver(schedule).observe(document.body, {childList: true, subtree: true, characterData: true});
717
+ schedule();
718
+ }
719
+ if (document.readyState === 'loading') {
720
+ document.addEventListener('DOMContentLoaded', arm);
721
+ } else {
722
+ arm();
723
+ }
724
+ })();
725
+ </script>
726
  """
727
 
728
  # Client-side build animation for the G-Code Visualization tab. The rendered
 
1948
  return nozzle
1949
 
1950
 
1951
+ def _next_unused_valve(used_valves: set[int]) -> int:
1952
+ """New shapes default to their own valve (numbering starts at the
1953
+ historical default, 4): shapes sharing a valve dispense simultaneously,
1954
+ which is almost never intended."""
1955
+ valve = 4
1956
+ while valve in used_valves:
1957
+ valve += 1
1958
+ return valve
1959
+
1960
+
1961
  def _records_from_files(files: Any, previous_records: list[dict] | None = None) -> list[dict]:
1962
  previous_by_path: dict[str | None, list[dict]] = {}
1963
  for record in previous_records or []:
1964
  previous_by_path.setdefault(record.get("stl_path"), []).append(record)
1965
  used_nozzles: set[int] = set()
1966
+ used_valves: set[int] = {
1967
+ _coerce_int(record.get("valve"), 0) for record in (previous_records or [])
1968
+ }
1969
  records: list[dict] = []
1970
  for index, path in enumerate(_uploaded_file_paths(files), start=1):
1971
  previous_queue = previous_by_path.get(path) or []
 
1974
  default_x, default_y, default_z = _default_target_extents_for_stl(path)
1975
  nozzle = _record_nozzle_number(previous, index) if previous else _next_unused_nozzle(used_nozzles)
1976
  used_nozzles.add(nozzle)
1977
+ valve = _coerce_int(previous.get("valve"), 0) if previous else 0
1978
+ if valve <= 0:
1979
+ valve = _next_unused_valve(used_valves)
1980
+ used_valves.add(valve)
1981
  # Pressure is a port property (one regulator per serial port): a new
1982
  # shape adopts the pressure other shapes already use on its port.
1983
  pressure = previous.get("pressure")
 
2003
  "target_z": round(_coerce_float(previous.get("target_z"), default_z), 1),
2004
  "last_scaled_axis": previous.get("last_scaled_axis", "target_x"),
2005
  "pressure": pressure,
2006
+ "valve": valve,
2007
  "nozzle": nozzle,
2008
  "port": previous.get("port", 1),
2009
  "color": previous.get("color", _default_color(index)),
 
4050
  return parts, messages
4051
 
4052
 
4053
+ def _parsed_path_length(parsed: dict) -> float:
4054
+ """Total tool-path length (print + travel) of a parsed G-code file, mm."""
4055
+ total = 0.0
4056
+ for key in ("print_segments", "travel_segments"):
4057
+ for segment in parsed.get(key) or []:
4058
+ for start, end in zip(segment, segment[1:]):
4059
+ total += math.dist(start, end)
4060
+ return total
4061
+
4062
+
4063
+ def _print_time_estimate(length_mm: float, nozzle_speed: Any) -> str | None:
4064
+ """Human-readable print duration at a constant nozzle speed, or None.
4065
+
4066
+ Every move is emitted as G1 at one constant speed, so time is simply
4067
+ path length / speed (valve switching is assumed instantaneous)."""
4068
+ speed = _coerce_float(nozzle_speed, 0.0)
4069
+ if speed <= 0.0 or length_mm <= 0.0:
4070
+ return None
4071
+ hours, remainder = divmod(int(round(length_mm / speed)), 3600)
4072
+ minutes, seconds = divmod(remainder, 60)
4073
+ if hours:
4074
+ return f"{hours} h {minutes:02d} min"
4075
+ if minutes:
4076
+ return f"{minutes} min {seconds:02d} s"
4077
+ return f"{seconds} s"
4078
+
4079
+
4080
  def render_dynamic_nozzle_spacing(
4081
  records: list[dict] | None,
4082
  columns: Any,
 
4109
  print_opacity: float,
4110
  print_width: float,
4111
  travel_width: float,
4112
+ nozzle_speed: Any = None,
4113
  tube: bool = True,
4114
  ) -> tuple[Any, str, dict]:
4115
  # Print color comes from the shape's table Color (uploads default to
 
4144
  tube=tube,
4145
  )
4146
  (x_min, y_min, z_min), (x_max, y_max, z_max) = parsed["bounds"]
4147
+ status = (
4148
  f"**{parsed['point_count']} moves parsed** - {len(parsed['print_segments'])} print segment(s), "
4149
  f"{len(parsed['travel_segments'])} travel segment(s). \n"
4150
  f"Bounds: X [{x_min:.2f}, {x_max:.2f}], Y [{y_min:.2f}, {y_max:.2f}], Z [{z_min:.2f}, {z_max:.2f}] mm."
4151
+ )
4152
+ path_length = _parsed_path_length(parsed)
4153
+ estimate = _print_time_estimate(path_length, nozzle_speed)
4154
+ if estimate:
4155
+ status += (
4156
+ f" \nPath length {path_length:,.0f} mm - estimated print time at "
4157
+ f"{_coerce_float(nozzle_speed, 0.0):g} mm/s: **{estimate}**."
4158
+ )
4159
+ return figure, status, parsed
4160
 
4161
 
4162
  def render_dynamic_toolpath_lines(*args: Any) -> tuple[Any, str, dict, str, dict[str, Any], dict[str, Any]]:
 
4186
  row_spacing: Any,
4187
  use_grid_individual_spacing: bool,
4188
  grid_spacing_table: Any,
4189
+ nozzle_speed: Any = None,
4190
  tube: bool = True,
4191
  ) -> tuple[Any, str]:
4192
  records = _apply_shape_settings(records or [], settings_table)
 
4210
  travel_opacity=float(travel_opacity),
4211
  tube=tube,
4212
  )
4213
+ # All heads share one motion path, so the print takes as long as the
4214
+ # longest part's tool path at the constant nozzle speed.
4215
+ longest = max(_parsed_path_length(part["parsed"]) for part in parts)
4216
+ estimate = _print_time_estimate(longest, nozzle_speed)
4217
+ if estimate:
4218
+ messages.append(
4219
+ f"Estimated print time at {_coerce_float(nozzle_speed, 0.0):g} mm/s: **{estimate}** "
4220
+ f"({longest:,.0f} mm per nozzle; all heads move together)."
4221
+ )
4222
  messages.append(_format_nozzle_spacing_status(parts, offsets, spacings))
4223
  return figure, " \n".join(messages)
4224
 
 
4366
  "62px", # X (mm)
4367
  "62px", # Y (mm)
4368
  "62px", # Z (mm)
4369
+ "86px", # Pressure (psi)
4370
  "56px", # Valve
4371
  "60px", # Nozzle
4372
  "52px", # Port
4373
  "104px", # Color
4374
+ "62px", # Infill %
4375
+ "86px", # Contour Tracing
4376
  "58px", # Lead In
4377
  "60px", # Delete
4378
  ],
 
4477
  value=GCODE_SOURCE_PARALLEL,
4478
  label="What to visualize",
4479
  )
4480
+ with gr.Column(scale=0, min_width=180):
4481
+ viz_nozzle_speed = gr.Number(
4482
+ label="Nozzle Speed (mm/s)",
4483
+ value=10.0,
4484
+ minimum=0.01,
4485
+ step=0.5,
4486
+ info="Used for the print time estimate.",
4487
+ )
4488
  with gr.Column(elem_id="gcode-upload-col"):
4489
  gcode_upload = gr.File(label="Upload G-Code", file_types=[".txt", ".gcode", ".nc"], interactive=True, height=110)
4490
 
 
4873
  print_opacity_slider,
4874
  print_width_slider,
4875
  travel_width_slider,
4876
+ viz_nozzle_speed,
4877
  ]
4878
  render_line_button.click(fn=render_dynamic_toolpath_lines, inputs=render_inputs, outputs=[toolpath_plot, toolpath_status, parsed_state, render_mode, anim_controls, width_row])
4879
  render_tube_button.click(fn=render_dynamic_toolpath_tubes, inputs=render_inputs, outputs=[toolpath_plot, toolpath_status, parsed_state, render_mode, anim_controls, width_row])
 
4912
  nozzle_grid_row_spacing,
4913
  nozzle_grid_use_individual_spacing,
4914
  nozzle_grid_spacing_table,
4915
+ viz_nozzle_speed,
4916
  ]
4917
  parallel_outputs = [parallel_plot, parallel_status, parallel_mode, parallel_anim_controls, pp_width_row, pp_export_group]
4918
  parallel_line_button.click(fn=render_dynamic_parallel_lines, inputs=parallel_render_inputs, outputs=parallel_outputs)
tests/test_nozzle_spacing.py CHANGED
@@ -313,6 +313,41 @@ def test_sample_reload_appends_next_nozzle_set() -> None:
313
  assert [record["nozzle"] for record in second_load] == [1, 2, 3, 4, 5, 6]
314
 
315
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  def _split_piece_records(
317
  tmp_path,
318
  columns: int = 2,
 
313
  assert [record["nozzle"] for record in second_load] == [1, 2, 3, 4, 5, 6]
314
 
315
 
316
+ def test_print_time_estimate_from_path_length_and_speed() -> None:
317
+ from app import _parsed_path_length, _print_time_estimate
318
+
319
+ parsed = {
320
+ "print_segments": [[(0.0, 0.0, 0.0), (10.0, 0.0, 0.0), (10.0, 5.0, 0.0)]],
321
+ "travel_segments": [[(10.0, 5.0, 0.0), (10.0, 5.0, 2.0)]],
322
+ }
323
+ assert _parsed_path_length(parsed) == 17.0
324
+
325
+ assert _print_time_estimate(17.0, 1.0) == "17 s"
326
+ assert _print_time_estimate(150.0, 1.0) == "2 min 30 s"
327
+ assert _print_time_estimate(3600.0, 1.0) == "1 h 00 min"
328
+ assert _print_time_estimate(29532.0, 10.0) == "49 min 13 s"
329
+ # No estimate without a usable speed or path.
330
+ assert _print_time_estimate(100.0, 0) is None
331
+ assert _print_time_estimate(100.0, None) is None
332
+ assert _print_time_estimate(0.0, 5.0) is None
333
+
334
+
335
+ def test_new_shapes_default_to_unique_valves() -> None:
336
+ # Shapes sharing a valve dispense together, so defaults must not collide.
337
+ first_load = _records_from_files(["a.stl", "b.stl", "c.stl"])
338
+ assert [record["valve"] for record in first_load] == [4, 5, 6]
339
+
340
+ # Re-syncing keeps the assigned valves.
341
+ resync = _records_from_files(["a.stl", "b.stl", "c.stl"], first_load)
342
+ assert [record["valve"] for record in resync] == [4, 5, 6]
343
+
344
+ # User-set valves survive, and new shapes take the smallest unused number.
345
+ first_load[0]["valve"] = 9
346
+ more = _records_from_files(["a.stl", "d.stl"], first_load)
347
+ assert more[0]["valve"] == 9
348
+ assert more[1]["valve"] == 4
349
+
350
+
351
  def _split_piece_records(
352
  tmp_path,
353
  columns: int = 2,