MichaelRKessler Claude Fable 5 commited on
Commit
594b22c
·
1 Parent(s): 8cc1ac6

Add Parallel Printing Visualization tab

Browse files

New fourth tab plots all three shapes simultaneously as filament tubes,
offset along X so they do not overlap, and animates them printing in
parallel on a shared cumulative-path-length time axis (shorter parts
finish first). Features:
- per-part color dropdowns (live recolor)
- travel tubes per part with an opacity slider (0 = hidden)
- filament/travel width and gap-between-parts sliders
- full animation control bar (play/pause, speed, scrub, frame step),
driven by a dedicated multi-part engine independent of the single-plot
tab so that tab is unaffected

Adds build_parallel_figure() in gcode_viewer.py.

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

Files changed (2) hide show
  1. app.py +370 -2
  2. gcode_viewer.py +109 -0
app.py CHANGED
@@ -21,7 +21,7 @@ import numpy as np
21
  from PIL import Image, ImageDraw, ImageFont
22
  import trimesh
23
 
24
- from gcode_viewer import build_toolpath_figure, parse_gcode_path
25
  from stl_slicer import SliceStack, load_mesh, slice_stl_to_tiffs
26
  from tiff_to_gcode import generate_snake_path_gcode
27
 
@@ -168,6 +168,43 @@ APP_CSS = """
168
  #gcode-upload-col {
169
  display: none;
170
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  """
172
 
173
  # Gradio 6.10's gr.Model3D leaves the Undo (reset view) button permanently
@@ -462,6 +499,173 @@ TOOLPATH_CONTROLS_HTML = """
462
  """
463
 
464
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
  def _read_slice_preview(path: str) -> Image.Image:
466
  with Image.open(path) as image:
467
  preview = image.copy()
@@ -1177,6 +1381,61 @@ def rerender_toolpath_current_mode(
1177
  )
1178
 
1179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1180
  def update_toolpath_opacity(
1181
  parsed: dict,
1182
  travel_opacity: float,
@@ -1313,7 +1572,7 @@ def generate_reference_stack(
1313
  return ref_state, slider, label, preview
1314
 
1315
  def build_demo() -> gr.Blocks:
1316
- with gr.Blocks(title="STL TIFF Slicer", css=APP_CSS, head=APP_HEAD + TOOLPATH_ANIM_HEAD) as demo:
1317
  with gr.Tab("STL to TIFF Slicer"):
1318
  gr.Markdown(
1319
  """
@@ -1959,6 +2218,115 @@ def build_demo() -> gr.Blocks:
1959
  }"""
1960
  )
1961
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1962
  return demo
1963
 
1964
 
 
21
  from PIL import Image, ImageDraw, ImageFont
22
  import trimesh
23
 
24
+ from gcode_viewer import build_parallel_figure, build_toolpath_figure, parse_gcode_path
25
  from stl_slicer import SliceStack, load_mesh, slice_stl_to_tiffs
26
  from tiff_to_gcode import generate_snake_path_gcode
27
 
 
168
  #gcode-upload-col {
169
  display: none;
170
  }
171
+
172
+ /* Parallel-printing animation controls share the look of the single-plot bar. */
173
+ #parallel-anim-controls {
174
+ display: flex;
175
+ align-items: center;
176
+ gap: 0.6rem;
177
+ flex-wrap: wrap;
178
+ padding: 0.4rem 0.2rem;
179
+ }
180
+
181
+ #parallel-anim-controls button,
182
+ #parallel-anim-controls select {
183
+ background: var(--button-secondary-background-fill);
184
+ color: var(--button-secondary-text-color);
185
+ border: 1px solid var(--border-color-primary);
186
+ border-radius: 0.4rem;
187
+ padding: 0.25rem 0.7rem;
188
+ font-size: 0.85rem;
189
+ cursor: pointer;
190
+ }
191
+
192
+ #parallel-anim-controls button:hover,
193
+ #parallel-anim-controls select:hover {
194
+ border-color: #f97316;
195
+ }
196
+
197
+ #pp-scrub {
198
+ flex: 1 1 140px;
199
+ min-width: 120px;
200
+ accent-color: #f97316;
201
+ }
202
+
203
+ #pp-readout {
204
+ font-size: 0.85rem;
205
+ color: var(--body-text-color-subdued);
206
+ flex-basis: 100%;
207
+ }
208
  """
209
 
210
  # Gradio 6.10's gr.Model3D leaves the Undo (reset view) button permanently
 
499
  """
500
 
501
 
502
+ # Parallel-printing animation engine: drives multiple parts in one plot off a
503
+ # shared cumulative-length time axis. Independent of the single-plot engine so
504
+ # the G-Code Visualization tab is unaffected. Targets #parallel_plot / #pp-*.
505
+ PARALLEL_ANIM_HEAD = """
506
+ <script>
507
+ (function () {
508
+ var anim = { gd:null, meta:null, cache:null, playing:false, scrubbing:false,
509
+ cutoff:0, speed:1, lastTick:null, lastDraw:0, raf:null };
510
+
511
+ function findPlot() {
512
+ var c = document.getElementById('parallel_plot');
513
+ return c ? c.querySelector('.js-plotly-plot') : null;
514
+ }
515
+
516
+ function ensureInit() {
517
+ var gd = findPlot();
518
+ if (!gd || !gd.data || !gd.layout || !gd.layout.meta || !gd.layout.meta.animation) return false;
519
+ var meta = gd.layout.meta.animation;
520
+ if (gd === anim.gd && meta === anim.meta && anim.cache) return true;
521
+
522
+ var nameToIdx = {};
523
+ for (var i = 0; i < gd.data.length; i++) nameToIdx[gd.data[i].name] = i;
524
+ function snapMesh(idx, t) {
525
+ if (idx == null || idx < 0 || !t || !t.length) return null;
526
+ var tr = gd.data[idx];
527
+ return { i: Array.from(tr.i), j: Array.from(tr.j), k: Array.from(tr.k), t: t };
528
+ }
529
+ var parts = meta.parts.map(function (p) {
530
+ var printIdx = nameToIdx[p.printName]; if (printIdx == null) printIdx = -1;
531
+ var travelIdx = nameToIdx[p.travelName]; if (travelIdx == null) travelIdx = -1;
532
+ var nozzleIdx = nameToIdx[p.nozzleName]; if (nozzleIdx == null) nozzleIdx = -1;
533
+ return {
534
+ printIdx: printIdx, travelIdx: travelIdx, nozzleIdx: nozzleIdx,
535
+ printMesh: snapMesh(printIdx, p.print_face_t),
536
+ travelMesh: snapMesh(travelIdx, p.travel_face_t),
537
+ path: (p.path_t && p.path_t.length) ? {x:p.path_x, y:p.path_y, z:p.path_z, t:p.path_t} : null
538
+ };
539
+ });
540
+ var times = [];
541
+ meta.parts.forEach(function (p) { if (p.path_t) for (var j=0;j<p.path_t.length;j++) times.push(p.path_t[j]); });
542
+ times.sort(function (a, b) { return a - b; });
543
+ anim.cache = { parts: parts, times: times.filter(function (v, j) { return j === 0 || v !== times[j-1]; }) };
544
+ anim.gd = gd; anim.meta = meta; anim.cutoff = meta.total_length; anim.playing = false;
545
+ return true;
546
+ }
547
+
548
+ function upperBound(arr, v) {
549
+ var lo = 0, hi = arr.length;
550
+ while (lo < hi) { var m = (lo + hi) >> 1; if (arr[m] <= v) lo = m + 1; else hi = m; }
551
+ return lo;
552
+ }
553
+ function nozzlePos(path, cutoff) {
554
+ var n = upperBound(path.t, cutoff);
555
+ if (n <= 0) return [path.x[0], path.y[0], path.z[0]];
556
+ if (n >= path.t.length) { var m = path.t.length - 1; return [path.x[m], path.y[m], path.z[m]]; }
557
+ var t0 = path.t[n-1], t1 = path.t[n], f = t1 > t0 ? (cutoff - t0) / (t1 - t0) : 1;
558
+ return [path.x[n-1]+(path.x[n]-path.x[n-1])*f, path.y[n-1]+(path.y[n]-path.y[n-1])*f, path.z[n-1]+(path.z[n]-path.z[n-1])*f];
559
+ }
560
+
561
+ function applyCutoff() {
562
+ if (!ensureInit()) return;
563
+ var cutoff = anim.cutoff;
564
+ var meshIdxs=[], fis=[], fjs=[], fks=[], nozIdxs=[], nx=[], ny=[], nz=[];
565
+ anim.cache.parts.forEach(function (pt) {
566
+ [['travelMesh','travelIdx'],['printMesh','printIdx']].forEach(function (pair) {
567
+ var mesh = pt[pair[0]], idx = pt[pair[1]];
568
+ if (!mesh || idx < 0) return;
569
+ var nf = upperBound(mesh.t, cutoff);
570
+ meshIdxs.push(idx); fis.push(mesh.i.slice(0,nf)); fjs.push(mesh.j.slice(0,nf)); fks.push(mesh.k.slice(0,nf));
571
+ });
572
+ if (pt.nozzleIdx >= 0 && pt.path) {
573
+ var pos = nozzlePos(pt.path, cutoff);
574
+ nozIdxs.push(pt.nozzleIdx); nx.push([pos[0]]); ny.push([pos[1]]); nz.push([pos[2]]);
575
+ }
576
+ });
577
+ if (nozIdxs.length) Plotly.restyle(anim.gd, { x:nx, y:ny, z:nz }, nozIdxs);
578
+ if (meshIdxs.length) Plotly.restyle(anim.gd, { i:fis, j:fjs, k:fks }, meshIdxs);
579
+ syncUI();
580
+ }
581
+
582
+ function syncUI() {
583
+ if (!anim.meta) return;
584
+ var total = anim.meta.total_length || 1;
585
+ var frac = Math.max(0, Math.min(1, anim.cutoff / total));
586
+ var scrub = document.getElementById('pp-scrub');
587
+ if (scrub && !anim.scrubbing) scrub.value = String(Math.round(frac * 1000));
588
+ var readout = document.getElementById('pp-readout');
589
+ if (readout) readout.textContent = Math.round(frac * 100) + '% of build';
590
+ }
591
+
592
+ function setPlaying(on) {
593
+ anim.playing = on;
594
+ var btn = document.getElementById('pp-play');
595
+ if (btn) btn.innerHTML = on ? '&#9208; Pause' : '&#9654; Play';
596
+ if (on) { anim.lastTick = null; anim.raf = requestAnimationFrame(tick); }
597
+ else if (anim.raf) { cancelAnimationFrame(anim.raf); anim.raf = null; }
598
+ }
599
+
600
+ function tick(ts) {
601
+ if (!anim.playing) return;
602
+ if (!ensureInit()) { setPlaying(false); return; }
603
+ if (anim.lastTick == null) anim.lastTick = ts;
604
+ var dt = (ts - anim.lastTick) / 1000; anim.lastTick = ts;
605
+ var rate = (anim.meta.total_length / 60) * anim.speed;
606
+ anim.cutoff = Math.min(anim.meta.total_length, anim.cutoff + dt * rate);
607
+ if (anim.cutoff >= anim.meta.total_length) { applyCutoff(); setPlaying(false); return; }
608
+ if (ts - anim.lastDraw >= 33) { anim.lastDraw = ts; applyCutoff(); }
609
+ anim.raf = requestAnimationFrame(tick);
610
+ }
611
+
612
+ document.addEventListener('click', function (e) {
613
+ var target = e.target.closest ? e.target.closest('#pp-play, #pp-restart, #pp-step-back, #pp-step-fwd') : null;
614
+ if (!target) return;
615
+ if (!ensureInit()) return;
616
+ var times = anim.cache.times || [];
617
+ if (target.id === 'pp-play') {
618
+ if (!anim.playing && anim.cutoff >= anim.meta.total_length) anim.cutoff = 0;
619
+ setPlaying(!anim.playing);
620
+ } else if (target.id === 'pp-step-fwd') {
621
+ setPlaying(false);
622
+ var i = upperBound(times, anim.cutoff);
623
+ if (i < times.length) { anim.cutoff = times[i]; applyCutoff(); }
624
+ } else if (target.id === 'pp-step-back') {
625
+ setPlaying(false);
626
+ var lo = 0, hi = times.length;
627
+ while (lo < hi) { var mid = (lo + hi) >> 1; if (times[mid] < anim.cutoff) lo = mid + 1; else hi = mid; }
628
+ anim.cutoff = lo > 0 ? times[lo - 1] : 0;
629
+ applyCutoff();
630
+ } else {
631
+ setPlaying(false); anim.cutoff = 0; applyCutoff();
632
+ }
633
+ });
634
+ document.addEventListener('input', function (e) {
635
+ if (e.target && e.target.id === 'pp-scrub') {
636
+ if (!ensureInit()) return;
637
+ setPlaying(false); anim.scrubbing = true;
638
+ anim.cutoff = (parseFloat(e.target.value) / 1000) * anim.meta.total_length;
639
+ applyCutoff(); anim.scrubbing = false;
640
+ }
641
+ });
642
+ document.addEventListener('change', function (e) {
643
+ if (e.target && e.target.id === 'pp-speed') anim.speed = parseFloat(e.target.value) || 1;
644
+ });
645
+ })();
646
+ </script>
647
+ """
648
+
649
+ PARALLEL_CONTROLS_HTML = """
650
+ <div id="parallel-anim-controls">
651
+ <button id="pp-restart" type="button" title="Back to start">&#9198;</button>
652
+ <button id="pp-step-back" type="button" title="Step back one move">&#9204;</button>
653
+ <button id="pp-play" type="button">&#9654; Play</button>
654
+ <button id="pp-step-fwd" type="button" title="Step forward one move">&#9205;</button>
655
+ <select id="pp-speed" title="Playback speed">
656
+ <option value="0.25">0.25&times;</option>
657
+ <option value="0.5">0.5&times;</option>
658
+ <option value="1" selected>1&times;</option>
659
+ <option value="2">2&times;</option>
660
+ <option value="5">5&times;</option>
661
+ <option value="10">10&times;</option>
662
+ </select>
663
+ <input id="pp-scrub" type="range" min="0" max="1000" value="1000" step="1" title="Build progress">
664
+ <span id="pp-readout">Render the parallel print, then press Play.</span>
665
+ </div>
666
+ """
667
+
668
+
669
  def _read_slice_preview(path: str) -> Image.Image:
670
  with Image.open(path) as image:
671
  preview = image.copy()
 
1381
  )
1382
 
1383
 
1384
+ PARALLEL_COLOR_CHOICES = [
1385
+ ("Orange", "#ff7f0e"), ("Blue", "#1f77b4"), ("Green", "#2ca02c"),
1386
+ ("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"),
1387
+ ("Teal", "#17becf"), ("Black", "#000000"),
1388
+ ]
1389
+
1390
+
1391
+ def render_parallel(
1392
+ path1: str | None,
1393
+ path2: str | None,
1394
+ path3: str | None,
1395
+ color1: str,
1396
+ color2: str,
1397
+ color3: str,
1398
+ travel_opacity: float,
1399
+ filament_width: float,
1400
+ travel_width: float,
1401
+ gap: float,
1402
+ ) -> tuple[Any, str, dict[str, Any]]:
1403
+ specs = [(1, path1, color1), (2, path2, color2), (3, path3, color3)]
1404
+ parts: list[dict] = []
1405
+ messages: list[str] = []
1406
+
1407
+ for idx, path, color in specs:
1408
+ if not path:
1409
+ messages.append(f"Shape {idx}: no G-code (generate it on the TIFF Slices to GCode tab).")
1410
+ continue
1411
+ try:
1412
+ parsed = parse_gcode_path(Path(path).read_text())
1413
+ except OSError as exc:
1414
+ messages.append(f"Shape {idx}: failed to read ({exc}).")
1415
+ continue
1416
+ if not parsed.get("point_count"):
1417
+ messages.append(f"Shape {idx}: no G0/G1 moves found.")
1418
+ continue
1419
+ parts.append({"idx": idx, "color": color, "parsed": parsed})
1420
+ messages.append(f"Shape {idx}: {parsed['point_count']} moves, {parsed.get('layer_count', 0)} layer(s).")
1421
+
1422
+ if not parts:
1423
+ return (
1424
+ None,
1425
+ "No shape G-code available. Generate G-code on the TIFF Slices to GCode tab first.",
1426
+ gr.update(visible=False),
1427
+ )
1428
+
1429
+ figure = build_parallel_figure(
1430
+ parts,
1431
+ gap=float(gap),
1432
+ filament_width=float(filament_width),
1433
+ travel_width=float(travel_width),
1434
+ travel_opacity=float(travel_opacity),
1435
+ )
1436
+ return figure, " \n".join(messages), gr.update(visible=True)
1437
+
1438
+
1439
  def update_toolpath_opacity(
1440
  parsed: dict,
1441
  travel_opacity: float,
 
1572
  return ref_state, slider, label, preview
1573
 
1574
  def build_demo() -> gr.Blocks:
1575
+ with gr.Blocks(title="STL TIFF Slicer", css=APP_CSS, head=APP_HEAD + TOOLPATH_ANIM_HEAD + PARALLEL_ANIM_HEAD) as demo:
1576
  with gr.Tab("STL to TIFF Slicer"):
1577
  gr.Markdown(
1578
  """
 
2218
  }"""
2219
  )
2220
 
2221
+ with gr.Tab("Parallel Printing Visualization"):
2222
+ gr.Markdown(
2223
+ "### Parallel Printing Visualization\n"
2224
+ "Plots all three shapes side by side (offset in X) and animates "
2225
+ "them printing in parallel. Uses the G-code generated on the "
2226
+ "**TIFF Slices to GCode** tab."
2227
+ )
2228
+ with gr.Row():
2229
+ # --- Left column: controls ---
2230
+ with gr.Column(scale=1, min_width=340):
2231
+ parallel_render_button = gr.Button(
2232
+ "Render Parallel Print", variant="primary"
2233
+ )
2234
+ gr.Markdown(
2235
+ "&#9888;&#65039; Building three tube plots can take a while "
2236
+ "for high-resolution models.",
2237
+ elem_id="parallel-render-warning",
2238
+ )
2239
+ parallel_anim_controls = gr.HTML(PARALLEL_CONTROLS_HTML, visible=False)
2240
+ with gr.Row():
2241
+ pp_color_1 = gr.Dropdown(
2242
+ label="Shape 1 color", choices=PARALLEL_COLOR_CHOICES,
2243
+ value="#ff7f0e", allow_custom_value=False, min_width=120,
2244
+ )
2245
+ pp_color_2 = gr.Dropdown(
2246
+ label="Shape 2 color", choices=PARALLEL_COLOR_CHOICES,
2247
+ value="#1f77b4", allow_custom_value=False, min_width=120,
2248
+ )
2249
+ pp_color_3 = gr.Dropdown(
2250
+ label="Shape 3 color", choices=PARALLEL_COLOR_CHOICES,
2251
+ value="#2ca02c", allow_custom_value=False, min_width=120,
2252
+ )
2253
+ pp_travel_opacity = gr.Slider(
2254
+ label="Travel opacity (0 = hidden)",
2255
+ minimum=0.0, maximum=1.0, value=0.2, step=0.05,
2256
+ )
2257
+ with gr.Row():
2258
+ pp_filament_width = gr.Slider(
2259
+ label="Filament width (mm)", minimum=0.1, maximum=3.0,
2260
+ value=0.8, step=0.05, min_width=150,
2261
+ )
2262
+ pp_travel_width = gr.Slider(
2263
+ label="Travel width (mm)", minimum=0.05, maximum=3.0,
2264
+ value=0.2, step=0.05, min_width=150,
2265
+ )
2266
+ pp_gap = gr.Slider(
2267
+ label="Gap between parts (mm)",
2268
+ minimum=0.0, maximum=50.0, value=5.0, step=0.5,
2269
+ )
2270
+ parallel_status = gr.Markdown("")
2271
+ # --- Right column: the plot ---
2272
+ with gr.Column(scale=3, min_width=500):
2273
+ parallel_plot = gr.Plot(label="Parallel Tool Paths", elem_id="parallel_plot")
2274
+
2275
+ parallel_render_inputs = [
2276
+ gcode_file_1, gcode_file_2, gcode_file_3,
2277
+ pp_color_1, pp_color_2, pp_color_3,
2278
+ pp_travel_opacity, pp_filament_width, pp_travel_width, pp_gap,
2279
+ ]
2280
+ parallel_render_button.click(
2281
+ fn=render_parallel,
2282
+ inputs=parallel_render_inputs,
2283
+ outputs=[parallel_plot, parallel_status, parallel_anim_controls],
2284
+ )
2285
+ # Width and gap changes rebuild the tubes server-side.
2286
+ for _slider in (pp_filament_width, pp_travel_width, pp_gap):
2287
+ _slider.release(
2288
+ fn=render_parallel,
2289
+ inputs=parallel_render_inputs,
2290
+ outputs=[parallel_plot, parallel_status, parallel_anim_controls],
2291
+ )
2292
+ # Color changes recolor a part's print/travel/nozzle traces client-side.
2293
+ for _picker, _idx in ((pp_color_1, 1), (pp_color_2, 2), (pp_color_3, 3)):
2294
+ _picker.change(
2295
+ fn=None,
2296
+ inputs=[_picker],
2297
+ outputs=[],
2298
+ js="""(color) => {
2299
+ const c = document.getElementById("parallel_plot");
2300
+ if (!c) return [];
2301
+ const pd = c.querySelector(".js-plotly-plot");
2302
+ if (!pd || !pd.data) return [];
2303
+ pd.data.forEach((t, i) => {
2304
+ if (t.name === "Shape %d" || t.name === "Travel %d") {
2305
+ Plotly.restyle(pd, {"color": color}, [i]);
2306
+ } else if (t.name === "Nozzle %d") {
2307
+ Plotly.restyle(pd, {"marker.color": color}, [i]);
2308
+ }
2309
+ });
2310
+ return [];
2311
+ }""" % (_idx, _idx, _idx),
2312
+ )
2313
+ # Travel opacity changes apply to all travel meshes client-side.
2314
+ pp_travel_opacity.release(
2315
+ fn=None,
2316
+ inputs=[pp_travel_opacity],
2317
+ outputs=[],
2318
+ js="""(op) => {
2319
+ const c = document.getElementById("parallel_plot");
2320
+ if (!c) return [];
2321
+ const pd = c.querySelector(".js-plotly-plot");
2322
+ if (!pd || !pd.data) return [];
2323
+ pd.data.forEach((t, i) => {
2324
+ if (/^Travel \\d+$/.test(t.name)) Plotly.restyle(pd, {"opacity": op}, [i]);
2325
+ });
2326
+ return [];
2327
+ }""",
2328
+ )
2329
+
2330
  return demo
2331
 
2332
 
gcode_viewer.py CHANGED
@@ -512,6 +512,115 @@ def build_toolpath_figure(
512
  return fig
513
 
514
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
  def render_gcode_file(path: str | Path) -> tuple[go.Figure, dict]:
516
  text = Path(path).read_text()
517
  parsed = parse_gcode_path(text)
 
512
  return fig
513
 
514
 
515
+ def build_parallel_figure(
516
+ parts: list[dict],
517
+ gap: float = 5.0,
518
+ filament_width: float = 0.8,
519
+ travel_width: float = 0.2,
520
+ travel_opacity: float = 0.2,
521
+ print_opacity: float = 1.0,
522
+ ) -> go.Figure:
523
+ """Render several parsed shapes side by side as tubes, offset along X so they
524
+ don't overlap, with a shared-time animation timeline (cumulative path length).
525
+
526
+ `parts` is a list of {"idx": int, "color": str, "parsed": dict}. Each part's
527
+ print and travel tubes and nozzle marker are added as separate traces named
528
+ by idx so the client-side animation/recolor can address them.
529
+ """
530
+ fig = go.Figure()
531
+ anim_parts: list[dict] = []
532
+ total_length = 0.0
533
+ bx0 = by0 = bz0 = float("inf")
534
+ bx1 = by1 = bz1 = float("-inf")
535
+
536
+ running_x = 0.0
537
+ for part in parts:
538
+ idx = part["idx"]
539
+ color = part["color"]
540
+ parsed = part["parsed"]
541
+ moves = parsed.get("moves") or []
542
+ if not moves:
543
+ continue
544
+
545
+ (pxmin, pymin, pzmin), (pxmax, pymax, pzmax) = parsed["bounds"]
546
+ width = pxmax - pxmin
547
+ x_off = running_x - pxmin
548
+ running_x += width + gap
549
+
550
+ print_tube = _build_path_tube(moves, radius=max(filament_width, 0.05) / 2.0, kind="print")
551
+ travel_tube = _build_path_tube(moves, radius=max(travel_width, 0.05) / 2.0, kind="travel")
552
+ path = _path_arrays(moves)
553
+
554
+ px = [v + x_off for v in print_tube["x"]]
555
+ tx = [v + x_off for v in travel_tube["x"]]
556
+ path_x = [v + x_off for v in path["x"]]
557
+
558
+ if travel_tube["i"]:
559
+ fig.add_trace(
560
+ go.Mesh3d(
561
+ x=tx, y=travel_tube["y"], z=travel_tube["z"],
562
+ i=travel_tube["i"], j=travel_tube["j"], k=travel_tube["k"],
563
+ color=color, opacity=travel_opacity, name=f"Travel {idx}",
564
+ showlegend=False, hoverinfo="skip",
565
+ lighting=dict(ambient=0.6, diffuse=0.8, specular=0.1, roughness=0.6),
566
+ )
567
+ )
568
+ if print_tube["i"]:
569
+ fig.add_trace(
570
+ go.Mesh3d(
571
+ x=px, y=print_tube["y"], z=print_tube["z"],
572
+ i=print_tube["i"], j=print_tube["j"], k=print_tube["k"],
573
+ color=color, opacity=print_opacity, name=f"Shape {idx}",
574
+ showlegend=True, hoverinfo="skip",
575
+ lighting=dict(ambient=0.55, diffuse=0.8, specular=0.15, roughness=0.6),
576
+ )
577
+ )
578
+ fig.add_trace(
579
+ go.Scatter3d(
580
+ x=[path_x[-1]], y=[path["y"][-1]], z=[path["z"][-1]],
581
+ mode="markers", name=f"Nozzle {idx}",
582
+ marker=dict(size=4, color=color), showlegend=False, hoverinfo="skip",
583
+ )
584
+ )
585
+
586
+ part_total = path["t"][-1] if path["t"] else 0.0
587
+ total_length = max(total_length, part_total)
588
+ anim_parts.append({
589
+ "printName": f"Shape {idx}",
590
+ "travelName": f"Travel {idx}",
591
+ "nozzleName": f"Nozzle {idx}",
592
+ "print_face_t": print_tube["face_t"],
593
+ "travel_face_t": travel_tube["face_t"],
594
+ "path_x": path_x, "path_y": path["y"], "path_z": path["z"], "path_t": path["t"],
595
+ })
596
+
597
+ bx0 = min(bx0, pxmin + x_off); bx1 = max(bx1, pxmax + x_off)
598
+ by0 = min(by0, pymin); by1 = max(by1, pymax)
599
+ bz0 = min(bz0, pzmin); bz1 = max(bz1, pzmax)
600
+
601
+ if not anim_parts:
602
+ fig.update_layout(height=700)
603
+ return fig
604
+
605
+ pad = max(bx1 - bx0, by1 - by0, bz1 - bz0, 1.0) * 0.05
606
+ fig.update_layout(
607
+ meta={"animation": {"total_length": total_length, "parts": anim_parts}},
608
+ height=700,
609
+ uirevision="parallel",
610
+ scene=dict(
611
+ xaxis_title="X (mm)", yaxis_title="Y (mm)", zaxis_title="Z (mm)",
612
+ xaxis_range=[bx0 - pad, bx1 + pad],
613
+ yaxis_range=[by0 - pad, by1 + pad],
614
+ zaxis_range=[bz0 - pad, bz1 + pad],
615
+ aspectmode="data",
616
+ ),
617
+ margin=dict(l=0, r=0, t=30, b=0),
618
+ legend=dict(orientation="h", yanchor="bottom", y=1.0, xanchor="left", x=0.0),
619
+ title=f"Parallel print — {len(anim_parts)} part(s)",
620
+ )
621
+ return fig
622
+
623
+
624
  def render_gcode_file(path: str | Path) -> tuple[go.Figure, dict]:
625
  text = Path(path).read_text()
626
  parsed = parse_gcode_path(text)