MichaelRKessler Claude Fable 5 commited on
Commit
f5d1acb
·
1 Parent(s): 57b46fb

Add animated tube-plot G-code visualization with playback controls

Browse files

G-Code Visualization tab overhaul:
- Two render modes: fast line plot, and tube plot with build animation
- Print and travel paths rendered as solid circular tubes with physical
mm diameters that scale with zoom (filament-like appearance)
- Client-side playback: play/pause, 0.25x-10x speed, scrub bar, per-move
frame stepping, nozzle marker, and layer/progress readout
- Width sliders default to layer height and cap at 150% of it
- Travel opacity default lowered to 25%; travel lines made solid
- Side-by-side layout (controls left, chart right); animation controls
and width sliders only shown after a tube render
- Upload box hidden unless "Upload G-Code file" source is selected

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

Files changed (2) hide show
  1. app.py +493 -50
  2. gcode_viewer.py +324 -21
app.py CHANGED
@@ -81,6 +81,54 @@ APP_CSS = """
81
  #load-sample-stls-button button:focus-visible {
82
  box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.35) !important;
83
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  """
85
 
86
  # Gradio 6.10's gr.Model3D leaves the Undo (reset view) button permanently
@@ -126,6 +174,254 @@ APP_HEAD = """
126
  </script>
127
  """
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  def _read_slice_preview(path: str) -> Image.Image:
131
  with Image.open(path) as image:
@@ -691,17 +987,20 @@ GCODE_SOURCE_UPLOAD = "Upload G-Code file"
691
 
692
 
693
  def toggle_gcode_source(source: str) -> dict[str, Any]:
694
- return gr.update(interactive=(source == GCODE_SOURCE_UPLOAD))
695
 
696
 
697
  def render_toolpath(
698
  source: str,
699
  uploaded_path: str | None,
700
  shape1_path: str | None,
701
- travel_opacity: float = 0.55,
702
  print_opacity: float = 1.0,
703
  travel_color: str = "#969696",
704
  print_color: str = "#ff7f0e",
 
 
 
705
  ) -> tuple[Any, str, dict]:
706
  if source == GCODE_SOURCE_UPLOAD:
707
  path = uploaded_path
@@ -721,7 +1020,7 @@ def render_toolpath(
721
  if parsed["point_count"] == 0:
722
  return None, "No G0/G1 movement lines found in the file.", {}
723
 
724
- figure = build_toolpath_figure(parsed, travel_opacity=travel_opacity, print_opacity=print_opacity, travel_color=travel_color, print_color=print_color)
725
  (x_min, y_min, z_min), (x_max, y_max, z_max) = parsed["bounds"]
726
  summary = (
727
  f"**{parsed['point_count']} moves parsed** — "
@@ -734,6 +1033,63 @@ def render_toolpath(
734
  return figure, summary, parsed
735
 
736
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
737
  def update_toolpath_opacity(
738
  parsed: dict,
739
  travel_opacity: float,
@@ -870,7 +1226,7 @@ def generate_reference_stack(
870
  return ref_state, slider, label, preview
871
 
872
  def build_demo() -> gr.Blocks:
873
- with gr.Blocks(title="STL TIFF Slicer", css=APP_CSS, head=APP_HEAD) as demo:
874
  with gr.Tab("STL to TIFF Slicer"):
875
  gr.Markdown(
876
  """
@@ -1227,60 +1583,145 @@ def build_demo() -> gr.Blocks:
1227
  "### 3D Tool-Path Viewer\n"
1228
  "Choose a G-code source, then click **Render Tool Path** to visualize the nozzle path."
1229
  )
 
1230
  with gr.Row():
1231
  gcode_source = gr.Radio(
1232
  choices=[GCODE_SOURCE_SHAPE1, GCODE_SOURCE_UPLOAD],
1233
  value=GCODE_SOURCE_SHAPE1,
1234
  label="G-Code source",
1235
  )
1236
- gcode_upload = gr.File(
1237
- label="Upload G-Code",
1238
- file_types=[".txt", ".gcode", ".nc"],
1239
- interactive=False,
1240
- )
1241
- render_button = gr.Button("Render Tool Path", variant="primary")
 
 
 
 
 
1242
  with gr.Row():
1243
- travel_opacity_slider = gr.Slider(
1244
- label="Travel (G0) opacity",
1245
- minimum=0.0,
1246
- maximum=1.0,
1247
- value=0.55,
1248
- step=0.05,
1249
- )
1250
- travel_color_picker = gr.Dropdown(
1251
- label="Travel (G0) color",
1252
- choices=[("Grey", "#969696"), ("Orange", "#ff7f0e"), ("Green", "#2ca02c"), ("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"), ("Black", "#000000"), ("White", "#ffffff")],
1253
- value="#969696",
1254
- allow_custom_value=False,
1255
- )
1256
- print_opacity_slider = gr.Slider(
1257
- label="Print (G1) opacity",
1258
- minimum=0.0,
1259
- maximum=1.0,
1260
- value=1.0,
1261
- step=0.05,
1262
- )
1263
- print_color_picker = gr.Dropdown(
1264
- label="Print (G1) color",
1265
- choices=[("Blue", "#1f77b4"), ("Orange", "#ff7f0e"), ("Green", "#2ca02c"), ("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"), ("Black", "#000000"), ("White", "#ffffff")],
1266
- value="#ff7f0e",
1267
- allow_custom_value=False,
1268
- )
1269
- toolpath_plot = gr.Plot(label="Tool Path", elem_id="toolpath_plot")
1270
- toolpath_status = gr.Markdown("")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1271
  parsed_state = gr.State({})
 
1272
 
1273
  gcode_source.change(
1274
  fn=toggle_gcode_source,
1275
  inputs=[gcode_source],
1276
- outputs=[gcode_upload],
1277
  queue=False,
1278
  )
1279
- render_button.click(
1280
- fn=render_toolpath,
1281
- inputs=[gcode_source, gcode_upload, gcode_file_1, travel_opacity_slider, print_opacity_slider, travel_color_picker, print_color_picker],
 
 
 
 
 
 
 
 
 
 
 
 
1282
  outputs=[toolpath_plot, toolpath_status, parsed_state],
1283
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1284
  travel_opacity_slider.release(
1285
  fn=None,
1286
  inputs=[travel_opacity_slider],
@@ -1322,10 +1763,11 @@ def build_demo() -> gr.Blocks:
1322
  if (!container) return [];
1323
  const plotDiv = container.querySelector(".js-plotly-plot");
1324
  if (!plotDiv || !plotDiv.data) return [];
1325
- const indices = plotDiv.data
1326
- .map((t, i) => t.name === "Travel (G0)" ? i : -1)
1327
- .filter(i => i >= 0);
1328
- if (indices.length > 0) Plotly.restyle(plotDiv, {"line.color": color}, indices);
 
1329
  return [];
1330
  }"""
1331
  )
@@ -1338,10 +1780,11 @@ def build_demo() -> gr.Blocks:
1338
  if (!container) return [];
1339
  const plotDiv = container.querySelector(".js-plotly-plot");
1340
  if (!plotDiv || !plotDiv.data) return [];
1341
- const indices = plotDiv.data
1342
- .map((t, i) => t.name === "Print (G1)" ? i : -1)
1343
- .filter(i => i >= 0);
1344
- if (indices.length > 0) Plotly.restyle(plotDiv, {"line.color": color}, indices);
 
1345
  return [];
1346
  }"""
1347
  )
 
81
  #load-sample-stls-button button:focus-visible {
82
  box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.35) !important;
83
  }
84
+
85
+ #toolpath-anim-controls {
86
+ display: flex;
87
+ align-items: center;
88
+ gap: 0.6rem;
89
+ flex-wrap: wrap;
90
+ padding: 0.4rem 0.2rem;
91
+ }
92
+
93
+ #toolpath-anim-controls button,
94
+ #toolpath-anim-controls select {
95
+ background: var(--button-secondary-background-fill);
96
+ color: var(--button-secondary-text-color);
97
+ border: 1px solid var(--border-color-primary);
98
+ border-radius: 0.4rem;
99
+ padding: 0.25rem 0.7rem;
100
+ font-size: 0.85rem;
101
+ cursor: pointer;
102
+ }
103
+
104
+ #toolpath-anim-controls button:hover,
105
+ #toolpath-anim-controls select:hover {
106
+ border-color: #f97316;
107
+ }
108
+
109
+ #tp-scrub {
110
+ flex: 1 1 140px;
111
+ min-width: 120px;
112
+ accent-color: #f97316;
113
+ }
114
+
115
+ #tp-readout {
116
+ font-size: 0.85rem;
117
+ color: var(--body-text-color-subdued);
118
+ flex-basis: 100%;
119
+ }
120
+
121
+ #tp-hint {
122
+ font-size: 0.78rem;
123
+ color: var(--body-text-color-subdued);
124
+ padding: 0 0.2rem 0.3rem;
125
+ }
126
+
127
+ #tube-render-warning {
128
+ font-size: 0.8rem;
129
+ color: var(--body-text-color-subdued);
130
+ margin-top: -0.3rem !important;
131
+ }
132
  """
133
 
134
  # Gradio 6.10's gr.Model3D leaves the Undo (reset view) button permanently
 
174
  </script>
175
  """
176
 
177
+ # Client-side build animation for the G-Code Visualization tab. The rendered
178
+ # Plotly figure carries per-point timestamps (cumulative path length) in
179
+ # layout.meta.animation; this script reveals the print/travel traces up to a
180
+ # moving time cutoff via Plotly.restyle, entirely in the browser. Event
181
+ # listeners are delegated from document so they survive Gradio re-renders.
182
+ TOOLPATH_ANIM_HEAD = """
183
+ <script>
184
+ (function () {
185
+ var anim = {
186
+ gd: null, meta: null, cache: null,
187
+ playing: false, scrubbing: false,
188
+ cutoff: 0, speed: 1,
189
+ lastTick: null, lastDraw: 0, raf: null
190
+ };
191
+
192
+ function findPlot() {
193
+ var container = document.getElementById('toolpath_plot');
194
+ return container ? container.querySelector('.js-plotly-plot') : null;
195
+ }
196
+
197
+ function ensureInit() {
198
+ var gd = findPlot();
199
+ if (!gd || !gd.data || !gd.layout || !gd.layout.meta || !gd.layout.meta.animation) {
200
+ return false;
201
+ }
202
+ var meta = gd.layout.meta.animation;
203
+ if (gd === anim.gd && meta === anim.meta && anim.cache) return true;
204
+
205
+ var cache = { printIdx: -1, travelIdx: -1, nozzleIdx: -1 };
206
+ for (var i = 0; i < gd.data.length; i++) {
207
+ var name = gd.data[i].name;
208
+ if (gd.data[i].type === 'mesh3d' && name === 'Print (G1)') cache.printIdx = i;
209
+ else if (gd.data[i].type === 'mesh3d' && name === 'Travel (G0)') cache.travelIdx = i;
210
+ else if (name === 'Nozzle') cache.nozzleIdx = i;
211
+ }
212
+ function snapMesh(idx, t) {
213
+ if (idx < 0 || !t || !t.length) return null;
214
+ var tr = gd.data[idx];
215
+ return {
216
+ i: Array.from(tr.i),
217
+ j: Array.from(tr.j),
218
+ k: Array.from(tr.k),
219
+ t: t
220
+ };
221
+ }
222
+ cache.printMesh = snapMesh(cache.printIdx, meta.print_face_t);
223
+ cache.travelMesh = snapMesh(cache.travelIdx, meta.travel_face_t);
224
+ cache.path = (meta.path_t && meta.path_t.length)
225
+ ? { x: meta.path_x, y: meta.path_y, z: meta.path_z, t: meta.path_t }
226
+ : null;
227
+
228
+ // Deduplicated move-boundary timestamps for frame stepping.
229
+ var times = cache.path ? cache.path.t : [];
230
+ cache.times = times.filter(function (v, j) { return j === 0 || v !== times[j - 1]; });
231
+
232
+ anim.gd = gd;
233
+ anim.meta = meta;
234
+ anim.cache = cache;
235
+ anim.cutoff = meta.total_length;
236
+ anim.playing = false;
237
+ return true;
238
+ }
239
+
240
+ function upperBound(arr, v) {
241
+ var lo = 0, hi = arr.length;
242
+ while (lo < hi) {
243
+ var mid = (lo + hi) >> 1;
244
+ if (arr[mid] <= v) lo = mid + 1; else hi = mid;
245
+ }
246
+ return lo;
247
+ }
248
+
249
+ function nozzlePos(path, cutoff) {
250
+ var n = upperBound(path.t, cutoff);
251
+ if (n <= 0) return [path.x[0], path.y[0], path.z[0]];
252
+ if (n >= path.t.length) {
253
+ var m = path.t.length - 1;
254
+ return [path.x[m], path.y[m], path.z[m]];
255
+ }
256
+ var t0 = path.t[n - 1], t1 = path.t[n];
257
+ var f = t1 > t0 ? (cutoff - t0) / (t1 - t0) : 1;
258
+ return [
259
+ path.x[n - 1] + (path.x[n] - path.x[n - 1]) * f,
260
+ path.y[n - 1] + (path.y[n] - path.y[n - 1]) * f,
261
+ path.z[n - 1] + (path.z[n] - path.z[n - 1]) * f
262
+ ];
263
+ }
264
+
265
+ function applyCutoff() {
266
+ if (!ensureInit()) return;
267
+ var c = anim.cache, cutoff = anim.cutoff;
268
+
269
+ if (c.nozzleIdx >= 0 && c.path) {
270
+ var pos = nozzlePos(c.path, cutoff);
271
+ Plotly.restyle(anim.gd, { x: [[pos[0]]], y: [[pos[1]]], z: [[pos[2]]] }, [c.nozzleIdx]);
272
+ }
273
+
274
+ var idxs = [], fis = [], fjs = [], fks = [];
275
+ [['travelMesh', c.travelIdx], ['printMesh', c.printIdx]].forEach(function (pair) {
276
+ var mesh = c[pair[0]], idx = pair[1];
277
+ if (!mesh || idx < 0) return;
278
+ var nf = upperBound(mesh.t, cutoff);
279
+ idxs.push(idx);
280
+ fis.push(mesh.i.slice(0, nf));
281
+ fjs.push(mesh.j.slice(0, nf));
282
+ fks.push(mesh.k.slice(0, nf));
283
+ });
284
+ if (idxs.length) Plotly.restyle(anim.gd, { i: fis, j: fjs, k: fks }, idxs);
285
+ syncUI();
286
+ }
287
+
288
+ function syncUI() {
289
+ if (!anim.meta) return;
290
+ var total = anim.meta.total_length || 1;
291
+ var frac = Math.max(0, Math.min(1, anim.cutoff / total));
292
+
293
+ var scrub = document.getElementById('tp-scrub');
294
+ if (scrub && !anim.scrubbing) scrub.value = String(Math.round(frac * 1000));
295
+
296
+ var readout = document.getElementById('tp-readout');
297
+ if (readout) {
298
+ var text = Math.round(frac * 100) + '% of path';
299
+ var ends = anim.meta.layer_t_end || [];
300
+ if (ends.length) {
301
+ var k = upperBound(ends, anim.cutoff);
302
+ if (k >= ends.length) k = ends.length - 1;
303
+ var start = k > 0 ? ends[k - 1] : 0;
304
+ var span = ends[k] - start;
305
+ var lp = span > 0 ? Math.round(((anim.cutoff - start) / span) * 100) : 100;
306
+ lp = Math.max(0, Math.min(100, lp));
307
+ text = 'Layer ' + (k + 1) + '/' + ends.length + ' \\u00b7 ' + lp + '% \\u2014 ' + text;
308
+ }
309
+ readout.textContent = text;
310
+ }
311
+ }
312
+
313
+ function setPlaying(on) {
314
+ anim.playing = on;
315
+ var btn = document.getElementById('tp-play');
316
+ if (btn) btn.innerHTML = on ? '&#9208; Pause' : '&#9654; Play';
317
+ if (on) {
318
+ anim.lastTick = null;
319
+ anim.raf = requestAnimationFrame(tick);
320
+ } else if (anim.raf) {
321
+ cancelAnimationFrame(anim.raf);
322
+ anim.raf = null;
323
+ }
324
+ }
325
+
326
+ function tick(ts) {
327
+ if (!anim.playing) return;
328
+ if (!ensureInit()) { setPlaying(false); return; }
329
+ if (anim.lastTick == null) anim.lastTick = ts;
330
+ var dt = (ts - anim.lastTick) / 1000;
331
+ anim.lastTick = ts;
332
+
333
+ // 1x speed plays the full build in 60 seconds.
334
+ var rate = (anim.meta.total_length / 60) * anim.speed;
335
+ anim.cutoff = Math.min(anim.meta.total_length, anim.cutoff + dt * rate);
336
+
337
+ if (anim.cutoff >= anim.meta.total_length) {
338
+ applyCutoff();
339
+ setPlaying(false);
340
+ return;
341
+ }
342
+ if (ts - anim.lastDraw >= 33) { // cap redraws at ~30 fps
343
+ anim.lastDraw = ts;
344
+ applyCutoff();
345
+ }
346
+ anim.raf = requestAnimationFrame(tick);
347
+ }
348
+
349
+ document.addEventListener('click', function (e) {
350
+ var target = e.target.closest
351
+ ? e.target.closest('#tp-play, #tp-restart, #tp-step-back, #tp-step-fwd')
352
+ : null;
353
+ if (!target) return;
354
+ if (!ensureInit()) return;
355
+ var times = anim.cache.times || [];
356
+ if (target.id === 'tp-play') {
357
+ if (!anim.playing && anim.cutoff >= anim.meta.total_length) anim.cutoff = 0;
358
+ setPlaying(!anim.playing);
359
+ } else if (target.id === 'tp-step-fwd') {
360
+ setPlaying(false);
361
+ var i = upperBound(times, anim.cutoff);
362
+ if (i < times.length) { anim.cutoff = times[i]; applyCutoff(); }
363
+ } else if (target.id === 'tp-step-back') {
364
+ setPlaying(false);
365
+ // First index with times[i] >= cutoff, then step to the boundary before it.
366
+ var lo = 0, hi = times.length;
367
+ while (lo < hi) {
368
+ var mid = (lo + hi) >> 1;
369
+ if (times[mid] < anim.cutoff) lo = mid + 1; else hi = mid;
370
+ }
371
+ anim.cutoff = lo > 0 ? times[lo - 1] : 0;
372
+ applyCutoff();
373
+ } else {
374
+ setPlaying(false);
375
+ anim.cutoff = 0;
376
+ applyCutoff();
377
+ }
378
+ });
379
+
380
+ document.addEventListener('input', function (e) {
381
+ if (e.target && e.target.id === 'tp-scrub') {
382
+ if (!ensureInit()) return;
383
+ setPlaying(false);
384
+ anim.scrubbing = true;
385
+ anim.cutoff = (parseFloat(e.target.value) / 1000) * anim.meta.total_length;
386
+ applyCutoff();
387
+ anim.scrubbing = false;
388
+ }
389
+ });
390
+
391
+ document.addEventListener('change', function (e) {
392
+ if (e.target && e.target.id === 'tp-speed') {
393
+ anim.speed = parseFloat(e.target.value) || 1;
394
+ }
395
+ });
396
+ })();
397
+ </script>
398
+ """
399
+
400
+ TOOLPATH_CONTROLS_HTML = """
401
+ <div id="toolpath-anim-controls">
402
+ <button id="tp-restart" type="button" title="Back to start">&#9198;</button>
403
+ <button id="tp-step-back" type="button" title="Step back one move">&#9204;</button>
404
+ <button id="tp-play" type="button">&#9654; Play</button>
405
+ <button id="tp-step-fwd" type="button" title="Step forward one move">&#9205;</button>
406
+ <select id="tp-speed" title="Playback speed">
407
+ <option value="0.25">0.25&times;</option>
408
+ <option value="0.5">0.5&times;</option>
409
+ <option value="1" selected>1&times;</option>
410
+ <option value="2">2&times;</option>
411
+ <option value="5">5&times;</option>
412
+ <option value="10">10&times;</option>
413
+ </select>
414
+ <input id="tp-scrub" type="range" min="0" max="1000" value="1000" step="1"
415
+ title="Build progress">
416
+ <span id="tp-readout">Render a tool path, then press Play.</span>
417
+ </div>
418
+ <div id="tp-hint">
419
+ Tip: drag the plot to rotate and scroll to zoom &mdash; easiest while the
420
+ animation is paused. The &#9204; / &#9205; buttons step through the path
421
+ one move at a time.
422
+ </div>
423
+ """
424
+
425
 
426
  def _read_slice_preview(path: str) -> Image.Image:
427
  with Image.open(path) as image:
 
987
 
988
 
989
  def toggle_gcode_source(source: str) -> dict[str, Any]:
990
+ return gr.update(visible=(source == GCODE_SOURCE_UPLOAD))
991
 
992
 
993
  def render_toolpath(
994
  source: str,
995
  uploaded_path: str | None,
996
  shape1_path: str | None,
997
+ travel_opacity: float = 0.25,
998
  print_opacity: float = 1.0,
999
  travel_color: str = "#969696",
1000
  print_color: str = "#ff7f0e",
1001
+ print_width: float = 0.8,
1002
+ travel_width: float = 0.8,
1003
+ tube: bool = True,
1004
  ) -> tuple[Any, str, dict]:
1005
  if source == GCODE_SOURCE_UPLOAD:
1006
  path = uploaded_path
 
1020
  if parsed["point_count"] == 0:
1021
  return None, "No G0/G1 movement lines found in the file.", {}
1022
 
1023
+ figure = build_toolpath_figure(parsed, travel_opacity=travel_opacity, print_opacity=print_opacity, travel_color=travel_color, print_color=print_color, print_width=print_width, travel_width=travel_width, tube=tube)
1024
  (x_min, y_min, z_min), (x_max, y_max, z_max) = parsed["bounds"]
1025
  summary = (
1026
  f"**{parsed['point_count']} moves parsed** — "
 
1033
  return figure, summary, parsed
1034
 
1035
 
1036
+ def render_toolpath_lines(
1037
+ source: str,
1038
+ uploaded_path: str | None,
1039
+ shape1_path: str | None,
1040
+ travel_opacity: float,
1041
+ print_opacity: float,
1042
+ travel_color: str,
1043
+ print_color: str,
1044
+ print_width: float,
1045
+ travel_width: float,
1046
+ ) -> tuple[Any, str, dict, str, dict[str, Any], dict[str, Any]]:
1047
+ figure, status, parsed = render_toolpath(
1048
+ source, uploaded_path, shape1_path, travel_opacity, print_opacity,
1049
+ travel_color, print_color, print_width, travel_width, tube=False,
1050
+ )
1051
+ return figure, status, parsed, "line", gr.update(visible=False), gr.update(visible=False)
1052
+
1053
+
1054
+ def render_toolpath_tubes(
1055
+ source: str,
1056
+ uploaded_path: str | None,
1057
+ shape1_path: str | None,
1058
+ travel_opacity: float,
1059
+ print_opacity: float,
1060
+ travel_color: str,
1061
+ print_color: str,
1062
+ print_width: float,
1063
+ travel_width: float,
1064
+ ) -> tuple[Any, str, dict, str, dict[str, Any], dict[str, Any]]:
1065
+ figure, status, parsed = render_toolpath(
1066
+ source, uploaded_path, shape1_path, travel_opacity, print_opacity,
1067
+ travel_color, print_color, print_width, travel_width, tube=True,
1068
+ )
1069
+ # Playback controls and mm width sliders only apply to the tube figure.
1070
+ has_animation = bool(parsed.get("point_count"))
1071
+ return figure, status, parsed, "tube", gr.update(visible=has_animation), gr.update(visible=has_animation)
1072
+
1073
+
1074
+ def rerender_toolpath_current_mode(
1075
+ mode: str,
1076
+ source: str,
1077
+ uploaded_path: str | None,
1078
+ shape1_path: str | None,
1079
+ travel_opacity: float,
1080
+ print_opacity: float,
1081
+ travel_color: str,
1082
+ print_color: str,
1083
+ print_width: float,
1084
+ travel_width: float,
1085
+ ) -> tuple[Any, str, dict]:
1086
+ return render_toolpath(
1087
+ source, uploaded_path, shape1_path, travel_opacity, print_opacity,
1088
+ travel_color, print_color, print_width, travel_width,
1089
+ tube=(mode != "line"),
1090
+ )
1091
+
1092
+
1093
  def update_toolpath_opacity(
1094
  parsed: dict,
1095
  travel_opacity: float,
 
1226
  return ref_state, slider, label, preview
1227
 
1228
  def build_demo() -> gr.Blocks:
1229
+ with gr.Blocks(title="STL TIFF Slicer", css=APP_CSS, head=APP_HEAD + TOOLPATH_ANIM_HEAD) as demo:
1230
  with gr.Tab("STL to TIFF Slicer"):
1231
  gr.Markdown(
1232
  """
 
1583
  "### 3D Tool-Path Viewer\n"
1584
  "Choose a G-code source, then click **Render Tool Path** to visualize the nozzle path."
1585
  )
1586
+ # --- G-code source selection, above the controls/chart area ---
1587
  with gr.Row():
1588
  gcode_source = gr.Radio(
1589
  choices=[GCODE_SOURCE_SHAPE1, GCODE_SOURCE_UPLOAD],
1590
  value=GCODE_SOURCE_SHAPE1,
1591
  label="G-Code source",
1592
  )
1593
+ # Visibility is toggled on this wrapper column rather than the
1594
+ # File itself: a File that is the output of the toggle event
1595
+ # gets stuck showing a progress overlay when first revealed.
1596
+ with gr.Column(visible=False) as upload_col:
1597
+ gcode_upload = gr.File(
1598
+ label="Upload G-Code",
1599
+ file_types=[".txt", ".gcode", ".nc"],
1600
+ interactive=True,
1601
+ height=110,
1602
+ )
1603
+
1604
  with gr.Row():
1605
+ # --- Left column: render buttons and plot controls ---
1606
+ with gr.Column(scale=1, min_width=340):
1607
+ render_line_button = gr.Button(
1608
+ "Render Tool Path - Line Plot", variant="primary"
1609
+ )
1610
+ render_tube_button = gr.Button(
1611
+ "Render Tool Path - Tube Plot with Animation", variant="primary"
1612
+ )
1613
+ gr.Markdown(
1614
+ "&#9888;&#65039; For high-resolution models (small layer "
1615
+ "heights), the tube plot can take a while to build and render.",
1616
+ elem_id="tube-render-warning",
1617
+ )
1618
+ anim_controls = gr.HTML(TOOLPATH_CONTROLS_HTML, visible=False)
1619
+ with gr.Row():
1620
+ travel_opacity_slider = gr.Slider(
1621
+ label="Travel (G0) opacity",
1622
+ minimum=0.0,
1623
+ maximum=1.0,
1624
+ value=0.25,
1625
+ step=0.05,
1626
+ min_width=150,
1627
+ )
1628
+ print_opacity_slider = gr.Slider(
1629
+ label="Print (G1) opacity",
1630
+ minimum=0.0,
1631
+ maximum=1.0,
1632
+ value=1.0,
1633
+ step=0.05,
1634
+ min_width=150,
1635
+ )
1636
+ with gr.Row():
1637
+ travel_color_picker = gr.Dropdown(
1638
+ label="Travel (G0) color",
1639
+ choices=[("Grey", "#969696"), ("Orange", "#ff7f0e"), ("Green", "#2ca02c"), ("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"), ("Black", "#000000"), ("White", "#ffffff")],
1640
+ value="#969696",
1641
+ allow_custom_value=False,
1642
+ min_width=150,
1643
+ )
1644
+ print_color_picker = gr.Dropdown(
1645
+ label="Print (G1) color",
1646
+ choices=[("Blue", "#1f77b4"), ("Orange", "#ff7f0e"), ("Green", "#2ca02c"), ("Red", "#d62728"), ("Purple", "#9467bd"), ("Pink", "#e377c2"), ("Black", "#000000"), ("White", "#ffffff")],
1647
+ value="#ff7f0e",
1648
+ allow_custom_value=False,
1649
+ min_width=150,
1650
+ )
1651
+ with gr.Row(visible=False) as width_row:
1652
+ travel_width_slider = gr.Slider(
1653
+ label="Travel width (mm)",
1654
+ minimum=0.1,
1655
+ maximum=1.2,
1656
+ value=0.8,
1657
+ step=0.05,
1658
+ min_width=150,
1659
+ )
1660
+ print_width_slider = gr.Slider(
1661
+ label="Filament width (mm)",
1662
+ minimum=0.1,
1663
+ maximum=1.2,
1664
+ value=0.8,
1665
+ step=0.05,
1666
+ min_width=150,
1667
+ )
1668
+ toolpath_status = gr.Markdown("")
1669
+
1670
+ # --- Right column: the chart ---
1671
+ with gr.Column(scale=3, min_width=500):
1672
+ toolpath_plot = gr.Plot(label="Tool Path", elem_id="toolpath_plot")
1673
+
1674
  parsed_state = gr.State({})
1675
+ render_mode = gr.State("tube")
1676
 
1677
  gcode_source.change(
1678
  fn=toggle_gcode_source,
1679
  inputs=[gcode_source],
1680
+ outputs=[upload_col],
1681
  queue=False,
1682
  )
1683
+ render_inputs = [gcode_source, gcode_upload, gcode_file_1, travel_opacity_slider, print_opacity_slider, travel_color_picker, print_color_picker, print_width_slider, travel_width_slider]
1684
+ render_line_button.click(
1685
+ fn=render_toolpath_lines,
1686
+ inputs=render_inputs,
1687
+ outputs=[toolpath_plot, toolpath_status, parsed_state, render_mode, anim_controls, width_row],
1688
+ )
1689
+ render_tube_button.click(
1690
+ fn=render_toolpath_tubes,
1691
+ inputs=render_inputs,
1692
+ outputs=[toolpath_plot, toolpath_status, parsed_state, render_mode, anim_controls, width_row],
1693
+ )
1694
+ # Changing the travel width rebuilds the figure in the last-used mode.
1695
+ travel_width_slider.release(
1696
+ fn=rerender_toolpath_current_mode,
1697
+ inputs=[render_mode] + render_inputs,
1698
  outputs=[toolpath_plot, toolpath_status, parsed_state],
1699
  )
1700
+ # Changing the filament width rebuilds the figure in the last-used mode.
1701
+ print_width_slider.release(
1702
+ fn=rerender_toolpath_current_mode,
1703
+ inputs=[render_mode] + render_inputs,
1704
+ outputs=[toolpath_plot, toolpath_status, parsed_state],
1705
+ )
1706
+ # Keep the filament and travel width sliders in sync with the
1707
+ # Layer Height chosen on the slicing tab: default value equals the
1708
+ # layer height, maximum is 50% above it.
1709
+ def sync_width_sliders(v: float):
1710
+ height = float(v or 0.8)
1711
+ def width_update():
1712
+ return gr.update(
1713
+ value=height,
1714
+ minimum=min(0.1, height),
1715
+ maximum=height * 1.5,
1716
+ )
1717
+ return width_update(), width_update()
1718
+
1719
+ layer_height.change(
1720
+ fn=sync_width_sliders,
1721
+ inputs=[layer_height],
1722
+ outputs=[print_width_slider, travel_width_slider],
1723
+ queue=False,
1724
+ )
1725
  travel_opacity_slider.release(
1726
  fn=None,
1727
  inputs=[travel_opacity_slider],
 
1763
  if (!container) return [];
1764
  const plotDiv = container.querySelector(".js-plotly-plot");
1765
  if (!plotDiv || !plotDiv.data) return [];
1766
+ plotDiv.data.forEach((t, i) => {
1767
+ if (t.name !== "Travel (G0)") return;
1768
+ const attr = t.type === "mesh3d" ? {"color": color} : {"line.color": color};
1769
+ Plotly.restyle(plotDiv, attr, [i]);
1770
+ });
1771
  return [];
1772
  }"""
1773
  )
 
1780
  if (!container) return [];
1781
  const plotDiv = container.querySelector(".js-plotly-plot");
1782
  if (!plotDiv || !plotDiv.data) return [];
1783
+ plotDiv.data.forEach((t, i) => {
1784
+ if (t.name !== "Print (G1)") return;
1785
+ const attr = t.type === "mesh3d" ? {"color": color} : {"line.color": color};
1786
+ Plotly.restyle(plotDiv, attr, [i]);
1787
+ });
1788
  return [];
1789
  }"""
1790
  )
gcode_viewer.py CHANGED
@@ -1,8 +1,10 @@
1
  from __future__ import annotations
2
 
 
3
  import re
4
  from pathlib import Path
5
 
 
6
  import plotly.graph_objects as go
7
 
8
 
@@ -19,6 +21,7 @@ def parse_gcode_path(gcode_text: str) -> dict:
19
 
20
  print_segments: list[list[tuple[float, float, float]]] = []
21
  travel_segments: list[list[tuple[float, float, float]]] = []
 
22
  current_kind: str | None = None
23
  current_segment: list[tuple[float, float, float]] = []
24
 
@@ -71,6 +74,7 @@ def parse_gcode_path(gcode_text: str) -> dict:
71
  z = dz
72
 
73
  kind = "print" if gcmd == "G1" else "travel"
 
74
 
75
  if kind != current_kind:
76
  flush_segment()
@@ -92,14 +96,243 @@ def parse_gcode_path(gcode_text: str) -> dict:
92
  else:
93
  bounds = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
94
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  return {
96
  "print_segments": print_segments,
97
  "travel_segments": travel_segments,
 
 
98
  "bounds": bounds,
99
  "point_count": len(all_x),
100
  }
101
 
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  def _segments_to_xyz(
104
  segments: list[list[tuple[float, float, float]]],
105
  ) -> tuple[list[float | None], list[float | None], list[float | None]]:
@@ -119,46 +352,116 @@ def _segments_to_xyz(
119
 
120
  def build_toolpath_figure(
121
  parsed: dict,
122
- travel_opacity: float = 0.55,
123
  print_opacity: float = 1.0,
124
  travel_color: str = "#969696",
125
  print_color: str = "#1f77b4",
 
 
 
126
  ) -> go.Figure:
127
- print_xs, print_ys, print_zs = _segments_to_xyz(parsed["print_segments"])
128
- travel_xs, travel_ys, travel_zs = _segments_to_xyz(parsed["travel_segments"])
129
 
130
  fig = go.Figure()
 
131
 
132
- if travel_xs:
133
  fig.add_trace(
134
- go.Scatter3d(
135
- x=travel_xs,
136
- y=travel_ys,
137
- z=travel_zs,
138
- mode="lines",
139
- name="Travel (G0)",
140
- opacity=travel_opacity,
141
- line=dict(color=travel_color, width=2, dash="dot"),
 
 
 
142
  hoverinfo="skip",
 
143
  )
144
  )
145
 
146
- if print_xs:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  fig.add_trace(
148
  go.Scatter3d(
149
- x=print_xs,
150
- y=print_ys,
151
- z=print_zs,
152
- mode="lines",
153
- name="Print (G1)",
154
- opacity=print_opacity,
155
- line=dict(color=print_color, width=4),
156
- hovertemplate="X=%{x:.2f}<br>Y=%{y:.2f}<br>Z=%{z:.2f}<extra></extra>",
157
  )
158
  )
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  (x_min, y_min, z_min), (x_max, y_max, z_max) = parsed["bounds"]
161
  fig.update_layout(
 
 
162
  uirevision="toolpath",
163
  scene=dict(
164
  xaxis_title="X (mm)",
 
1
  from __future__ import annotations
2
 
3
+ import math
4
  import re
5
  from pathlib import Path
6
 
7
+ import numpy as np
8
  import plotly.graph_objects as go
9
 
10
 
 
21
 
22
  print_segments: list[list[tuple[float, float, float]]] = []
23
  travel_segments: list[list[tuple[float, float, float]]] = []
24
+ moves: list[dict] = []
25
  current_kind: str | None = None
26
  current_segment: list[tuple[float, float, float]] = []
27
 
 
74
  z = dz
75
 
76
  kind = "print" if gcmd == "G1" else "travel"
77
+ moves.append({"kind": kind, "start": prev_pos, "end": (x, y, z)})
78
 
79
  if kind != current_kind:
80
  flush_segment()
 
96
  else:
97
  bounds = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
98
 
99
+ # Assign a layer index to every move. Layers are the distinct Z heights at
100
+ # which printing (G1) happens; travel moves (including the Z lift between
101
+ # layers) are attributed to the layer of the next print move so a layer's
102
+ # timeline starts with the approach travel and ends with its last print.
103
+ print_z = sorted({round(m["end"][2], 6) for m in moves if m["kind"] == "print"})
104
+ z_to_layer = {z: i for i, z in enumerate(print_z)}
105
+ next_print_layer = len(print_z) - 1 if print_z else 0
106
+ for move in reversed(moves):
107
+ if move["kind"] == "print":
108
+ next_print_layer = z_to_layer[round(move["end"][2], 6)]
109
+ move["layer"] = next_print_layer
110
+
111
  return {
112
  "print_segments": print_segments,
113
  "travel_segments": travel_segments,
114
+ "moves": moves,
115
+ "layer_count": len(print_z),
116
  "bounds": bounds,
117
  "point_count": len(all_x),
118
  }
119
 
120
 
121
+ def _move_length(move: dict) -> float:
122
+ (x0, y0, z0), (x1, y1, z1) = move["start"], move["end"]
123
+ return math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2 + (z1 - z0) ** 2)
124
+
125
+
126
+ def _chronological_trace_arrays(moves: list[dict]) -> dict:
127
+ """Build per-kind polyline arrays with a shared time axis for animation.
128
+
129
+ Each point gets a timestamp equal to the cumulative path length (print +
130
+ travel) at which the nozzle reaches it, so the browser can reveal both
131
+ traces in lockstep by slicing at a time cutoff. Gap markers (None) close a
132
+ trace's polyline whenever the move kind switches and carry the timestamp
133
+ of the segment they terminate.
134
+ """
135
+ arrays: dict[str, dict[str, list]] = {
136
+ "print": {"x": [], "y": [], "z": [], "t": []},
137
+ "travel": {"x": [], "y": [], "z": [], "t": []},
138
+ }
139
+ cum = 0.0
140
+ prev_kind: str | None = None
141
+ layer_end: dict[int, float] = {}
142
+
143
+ for move in moves:
144
+ trace = arrays[move["kind"]]
145
+ if move["kind"] != prev_kind:
146
+ if prev_kind is not None:
147
+ prev_trace = arrays[prev_kind]
148
+ prev_trace["x"].append(None)
149
+ prev_trace["y"].append(None)
150
+ prev_trace["z"].append(None)
151
+ prev_trace["t"].append(cum)
152
+ sx, sy, sz = move["start"]
153
+ trace["x"].append(sx)
154
+ trace["y"].append(sy)
155
+ trace["z"].append(sz)
156
+ trace["t"].append(cum)
157
+ prev_kind = move["kind"]
158
+ cum += _move_length(move)
159
+ ex, ey, ez = move["end"]
160
+ trace["x"].append(ex)
161
+ trace["y"].append(ey)
162
+ trace["z"].append(ez)
163
+ trace["t"].append(cum)
164
+ layer_end[move["layer"]] = cum
165
+
166
+ layer_count = (max(layer_end) + 1) if layer_end else 0
167
+ layer_t_end: list[float] = []
168
+ last = 0.0
169
+ for i in range(layer_count):
170
+ last = max(last, layer_end.get(i, last))
171
+ layer_t_end.append(last)
172
+
173
+ return {
174
+ "print": arrays["print"],
175
+ "travel": arrays["travel"],
176
+ "total_length": cum,
177
+ "layer_t_end": layer_t_end,
178
+ }
179
+
180
+
181
+ def _path_arrays(moves: list[dict]) -> dict:
182
+ """Chronological nozzle positions with cumulative-length timestamps."""
183
+ xs = [moves[0]["start"][0]]
184
+ ys = [moves[0]["start"][1]]
185
+ zs = [moves[0]["start"][2]]
186
+ ts = [0.0]
187
+ cum = 0.0
188
+ for move in moves:
189
+ cum += _move_length(move)
190
+ ex, ey, ez = move["end"]
191
+ xs.append(ex)
192
+ ys.append(ey)
193
+ zs.append(ez)
194
+ ts.append(cum)
195
+ return {"x": xs, "y": ys, "z": zs, "t": ts}
196
+
197
+
198
+ def _build_path_tube(
199
+ moves: list[dict],
200
+ radius: float,
201
+ kind: str = "print",
202
+ sides: int = 12,
203
+ max_rings: int = 5000,
204
+ ) -> dict:
205
+ """Extrude a tube of physical radius along the moves of the given kind.
206
+
207
+ Returns Mesh3d-ready vertex and face arrays. Rings are laid down in
208
+ chronological order and long moves are subdivided, so faces can be
209
+ revealed progressively by slicing the (sorted) per-face timestamps.
210
+ """
211
+ # Group consecutive moves of this kind into continuous runs (global times).
212
+ runs: list[tuple[list, list]] = []
213
+ cur_pts: list | None = None
214
+ cur_ts: list | None = None
215
+ cum = 0.0
216
+ for move in moves:
217
+ length = _move_length(move)
218
+ if move["kind"] == kind:
219
+ if cur_pts is None:
220
+ cur_pts = [move["start"]]
221
+ cur_ts = [cum]
222
+ cur_pts.append(move["end"])
223
+ cur_ts.append(cum + length)
224
+ elif cur_pts is not None:
225
+ runs.append((cur_pts, cur_ts))
226
+ cur_pts = cur_ts = None
227
+ cum += length
228
+ if cur_pts is not None:
229
+ runs.append((cur_pts, cur_ts))
230
+
231
+ total_print = sum(ts[-1] - ts[0] for _pts, ts in runs)
232
+ step = max(radius * 2.0, total_print / max_rings) if total_print > 0 else radius
233
+
234
+ xs: list[float] = []
235
+ ys: list[float] = []
236
+ zs: list[float] = []
237
+ fi: list[int] = []
238
+ fj: list[int] = []
239
+ fk: list[int] = []
240
+ face_t: list[float] = []
241
+ angles = np.linspace(0.0, 2.0 * np.pi, sides, endpoint=False)
242
+ cos_a, sin_a = np.cos(angles), np.sin(angles)
243
+
244
+ for pts, ts in runs:
245
+ # Subdivide long moves so the tube grows smoothly during playback.
246
+ sub_p = [np.asarray(pts[0], dtype=float)]
247
+ sub_t = [ts[0]]
248
+ for a in range(len(pts) - 1):
249
+ p0 = np.asarray(pts[a], dtype=float)
250
+ p1 = np.asarray(pts[a + 1], dtype=float)
251
+ seg = float(np.linalg.norm(p1 - p0))
252
+ pieces = max(1, math.ceil(seg / step))
253
+ for s in range(1, pieces + 1):
254
+ f = s / pieces
255
+ sub_p.append(p0 + (p1 - p0) * f)
256
+ sub_t.append(ts[a] + (ts[a + 1] - ts[a]) * f)
257
+
258
+ points = np.vstack(sub_p)
259
+ n_rings = len(points)
260
+ if n_rings < 2:
261
+ continue
262
+
263
+ # Per-ring tangents (averaged at interior points) and a perpendicular
264
+ # frame; vertical tangents fall back to the X axis for the side vector.
265
+ tangents = np.zeros_like(points)
266
+ tangents[1:-1] = points[2:] - points[:-2]
267
+ tangents[0] = points[1] - points[0]
268
+ tangents[-1] = points[-1] - points[-2]
269
+ norms = np.linalg.norm(tangents, axis=1, keepdims=True)
270
+ norms[norms == 0] = 1.0
271
+ tangents /= norms
272
+
273
+ side_vec = np.cross(tangents, np.array([0.0, 0.0, 1.0]))
274
+ side_norm = np.linalg.norm(side_vec, axis=1)
275
+ vertical = side_norm < 1e-6
276
+ if vertical.any():
277
+ side_vec[vertical] = np.cross(tangents[vertical], np.array([1.0, 0.0, 0.0]))
278
+ side_vec /= np.maximum(np.linalg.norm(side_vec, axis=1, keepdims=True), 1e-12)
279
+ up_vec = np.cross(side_vec, tangents)
280
+
281
+ base = len(xs)
282
+ rings = (
283
+ points[:, None, :]
284
+ + radius * (cos_a[None, :, None] * side_vec[:, None, :]
285
+ + sin_a[None, :, None] * up_vec[:, None, :])
286
+ )
287
+ flat = np.round(rings.reshape(-1, 3), 4)
288
+ xs.extend(flat[:, 0].tolist())
289
+ ys.extend(flat[:, 1].tolist())
290
+ zs.extend(flat[:, 2].tolist())
291
+
292
+ # Center vertices for the end caps that close the tube.
293
+ cap_start = len(xs)
294
+ xs.append(round(float(points[0][0]), 4))
295
+ ys.append(round(float(points[0][1]), 4))
296
+ zs.append(round(float(points[0][2]), 4))
297
+ cap_end = len(xs)
298
+ xs.append(round(float(points[-1][0]), 4))
299
+ ys.append(round(float(points[-1][1]), 4))
300
+ zs.append(round(float(points[-1][2]), 4))
301
+
302
+ t_start = round(sub_t[0], 4)
303
+ t_end = round(sub_t[-1], 4)
304
+
305
+ # Start cap (fan around the first ring).
306
+ for k in range(sides):
307
+ k_next = (k + 1) % sides
308
+ fi.append(cap_start)
309
+ fj.append(base + k_next)
310
+ fk.append(base + k)
311
+ face_t.append(t_start)
312
+
313
+ for r in range(n_rings - 1):
314
+ r0 = base + r * sides
315
+ r1 = r0 + sides
316
+ t_face = round(sub_t[r + 1], 4)
317
+ for k in range(sides):
318
+ k_next = (k + 1) % sides
319
+ fi.extend((r0 + k, r0 + k))
320
+ fj.extend((r1 + k, r1 + k_next))
321
+ fk.extend((r1 + k_next, r0 + k_next))
322
+ face_t.extend((t_face, t_face))
323
+
324
+ # End cap (fan around the last ring).
325
+ last_ring = base + (n_rings - 1) * sides
326
+ for k in range(sides):
327
+ k_next = (k + 1) % sides
328
+ fi.append(cap_end)
329
+ fj.append(last_ring + k)
330
+ fk.append(last_ring + k_next)
331
+ face_t.append(t_end)
332
+
333
+ return {"x": xs, "y": ys, "z": zs, "i": fi, "j": fj, "k": fk, "face_t": face_t}
334
+
335
+
336
  def _segments_to_xyz(
337
  segments: list[list[tuple[float, float, float]]],
338
  ) -> tuple[list[float | None], list[float | None], list[float | None]]:
 
352
 
353
  def build_toolpath_figure(
354
  parsed: dict,
355
+ travel_opacity: float = 0.25,
356
  print_opacity: float = 1.0,
357
  travel_color: str = "#969696",
358
  print_color: str = "#1f77b4",
359
+ print_width: float = 0.8,
360
+ travel_width: float = 0.8,
361
+ tube: bool = True,
362
  ) -> go.Figure:
363
+ moves = parsed.get("moves") or []
 
364
 
365
  fig = go.Figure()
366
+ meta = None
367
 
368
+ def add_tube_trace(tube: dict, name: str, color: str, opacity: float) -> None:
369
  fig.add_trace(
370
+ go.Mesh3d(
371
+ x=tube["x"],
372
+ y=tube["y"],
373
+ z=tube["z"],
374
+ i=tube["i"],
375
+ j=tube["j"],
376
+ k=tube["k"],
377
+ color=color,
378
+ opacity=opacity,
379
+ name=name,
380
+ showlegend=True,
381
  hoverinfo="skip",
382
+ lighting=dict(ambient=0.55, diffuse=0.8, specular=0.15, roughness=0.6),
383
  )
384
  )
385
 
386
+ if moves and tube:
387
+ chrono = _chronological_trace_arrays(moves)
388
+
389
+ # Physical-width tubes along both paths: filament-like rendering whose
390
+ # thickness scales with zoom (widths are diameters in mm).
391
+ travel_tube = _build_path_tube(
392
+ moves, radius=max(travel_width, 0.05) / 2.0, kind="travel"
393
+ )
394
+ if travel_tube["i"]:
395
+ add_tube_trace(travel_tube, "Travel (G0)", travel_color, travel_opacity)
396
+
397
+ print_tube = _build_path_tube(
398
+ moves, radius=max(print_width, 0.05) / 2.0, kind="print"
399
+ )
400
+ if print_tube["i"]:
401
+ add_tube_trace(print_tube, "Print (G1)", print_color, print_opacity)
402
+
403
+ # Nozzle position marker, driven client-side during playback.
404
+ end_x, end_y, end_z = moves[-1]["end"]
405
  fig.add_trace(
406
  go.Scatter3d(
407
+ x=[end_x],
408
+ y=[end_y],
409
+ z=[end_z],
410
+ mode="markers",
411
+ name="Nozzle",
412
+ marker=dict(size=5, color="#d62728"),
413
+ showlegend=False,
414
+ hoverinfo="skip",
415
  )
416
  )
417
 
418
+ path = _path_arrays(moves)
419
+ meta = {
420
+ "animation": {
421
+ "travel_face_t": travel_tube["face_t"],
422
+ "print_face_t": print_tube["face_t"],
423
+ "path_x": path["x"],
424
+ "path_y": path["y"],
425
+ "path_z": path["z"],
426
+ "path_t": path["t"],
427
+ "layer_t_end": chrono["layer_t_end"],
428
+ "total_length": chrono["total_length"],
429
+ }
430
+ }
431
+ else:
432
+ travel_xs, travel_ys, travel_zs = _segments_to_xyz(parsed["travel_segments"])
433
+ if travel_xs:
434
+ fig.add_trace(
435
+ go.Scatter3d(
436
+ x=travel_xs,
437
+ y=travel_ys,
438
+ z=travel_zs,
439
+ mode="lines",
440
+ name="Travel (G0)",
441
+ opacity=travel_opacity,
442
+ line=dict(color=travel_color, width=2),
443
+ hoverinfo="skip",
444
+ )
445
+ )
446
+ print_xs, print_ys, print_zs = _segments_to_xyz(parsed["print_segments"])
447
+ if print_xs:
448
+ fig.add_trace(
449
+ go.Scatter3d(
450
+ x=print_xs,
451
+ y=print_ys,
452
+ z=print_zs,
453
+ mode="lines",
454
+ name="Print (G1)",
455
+ opacity=print_opacity,
456
+ line=dict(color=print_color, width=4),
457
+ hovertemplate="X=%{x:.2f}<br>Y=%{y:.2f}<br>Z=%{z:.2f}<extra></extra>",
458
+ )
459
+ )
460
+
461
  (x_min, y_min, z_min), (x_max, y_max, z_max) = parsed["bounds"]
462
  fig.update_layout(
463
+ meta=meta,
464
+ height=700,
465
  uirevision="toolpath",
466
  scene=dict(
467
  xaxis_title="X (mm)",