MichaelRKessler Claude Opus 4.6 commited on
Commit
1daa1c6
·
1 Parent(s): c315fc4

Add 3D G-code tool-path viewer and wire layer_height through gcode pipeline

Browse files

- New gcode_viewer.py module: parses G91 relative G-code, accumulates
positions, and renders an interactive Plotly 3D scatter plot with
distinct print vs travel traces
- G-Code Visualization tab: radio toggle to use Shape 1 output or
upload a custom file, renders tool path on button click
- Layer height from slicer tab now flows into G-code Z moves
- Default layer height and pixel size set to 0.8
- Add plotly dependency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (4) hide show
  1. app.py +76 -0
  2. gcode_viewer.py +175 -0
  3. pyproject.toml +1 -0
  4. uv.lock +24 -0
app.py CHANGED
@@ -6,6 +6,7 @@ from typing import Any
6
  import gradio as gr
7
  from PIL import Image
8
 
 
9
  from stl_slicer import SliceStack, load_mesh, slice_stl_to_tiffs
10
  from tiff_to_gcode import generate_snake_path_gcode
11
 
@@ -324,6 +325,50 @@ def run_all_tiff_to_gcode(
324
  return outputs[0], outputs[1], outputs[2], "\n".join(messages)
325
 
326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  def shift_slice(state: ViewerState, index: float, delta: int) -> tuple[int, str, Image.Image | None]:
328
  tiff_paths = state.get("tiff_paths", [])
329
  if not tiff_paths:
@@ -607,6 +652,37 @@ def build_demo() -> gr.Blocks:
607
  outputs=[gcode_file_1, gcode_file_2, gcode_file_3, gcode_status],
608
  )
609
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
610
  return demo
611
 
612
 
 
6
  import gradio as gr
7
  from PIL import Image
8
 
9
+ from gcode_viewer import build_toolpath_figure, parse_gcode_path
10
  from stl_slicer import SliceStack, load_mesh, slice_stl_to_tiffs
11
  from tiff_to_gcode import generate_snake_path_gcode
12
 
 
325
  return outputs[0], outputs[1], outputs[2], "\n".join(messages)
326
 
327
 
328
+ GCODE_SOURCE_SHAPE1 = "Use Shape 1 G-Code"
329
+ GCODE_SOURCE_UPLOAD = "Upload G-Code file"
330
+
331
+
332
+ def toggle_gcode_source(source: str) -> dict[str, Any]:
333
+ return gr.update(visible=(source == GCODE_SOURCE_UPLOAD))
334
+
335
+
336
+ def render_toolpath(
337
+ source: str,
338
+ uploaded_path: str | None,
339
+ shape1_path: str | None,
340
+ ) -> tuple[Any, str]:
341
+ if source == GCODE_SOURCE_UPLOAD:
342
+ path = uploaded_path
343
+ if not path:
344
+ return None, "No G-code file uploaded yet."
345
+ else:
346
+ path = shape1_path
347
+ if not path:
348
+ return None, "No Shape 1 G-code available yet. Generate it on the TIFF Slices to GCode tab first."
349
+
350
+ try:
351
+ text = Path(path).read_text()
352
+ except OSError as exc:
353
+ return None, f"Failed to read G-code file: {exc}"
354
+
355
+ parsed = parse_gcode_path(text)
356
+ if parsed["point_count"] == 0:
357
+ return None, "No G0/G1 movement lines found in the file."
358
+
359
+ figure = build_toolpath_figure(parsed)
360
+ (x_min, y_min, z_min), (x_max, y_max, z_max) = parsed["bounds"]
361
+ summary = (
362
+ f"**{parsed['point_count']} moves parsed** — "
363
+ f"{len(parsed['print_segments'])} print segment(s), "
364
+ f"{len(parsed['travel_segments'])} travel segment(s). \n"
365
+ f"Bounds: X ∈ [{x_min:.2f}, {x_max:.2f}], "
366
+ f"Y ∈ [{y_min:.2f}, {y_max:.2f}], "
367
+ f"Z ∈ [{z_min:.2f}, {z_max:.2f}] mm."
368
+ )
369
+ return figure, summary
370
+
371
+
372
  def shift_slice(state: ViewerState, index: float, delta: int) -> tuple[int, str, Image.Image | None]:
373
  tiff_paths = state.get("tiff_paths", [])
374
  if not tiff_paths:
 
652
  outputs=[gcode_file_1, gcode_file_2, gcode_file_3, gcode_status],
653
  )
654
 
655
+ with gr.Tab("G-Code Visualization"):
656
+ gr.Markdown(
657
+ "### 3D Tool-Path Viewer\n"
658
+ "Choose a G-code source, then click **Render Tool Path** to visualize the nozzle path."
659
+ )
660
+ with gr.Row():
661
+ gcode_source = gr.Radio(
662
+ choices=[GCODE_SOURCE_SHAPE1, GCODE_SOURCE_UPLOAD],
663
+ value=GCODE_SOURCE_SHAPE1,
664
+ label="G-Code source",
665
+ )
666
+ gcode_upload = gr.File(
667
+ label="Upload G-Code",
668
+ file_types=[".txt", ".gcode", ".nc"],
669
+ visible=False,
670
+ )
671
+ render_button = gr.Button("Render Tool Path", variant="primary")
672
+ toolpath_plot = gr.Plot(label="Tool Path")
673
+ toolpath_status = gr.Markdown("")
674
+
675
+ gcode_source.change(
676
+ fn=toggle_gcode_source,
677
+ inputs=[gcode_source],
678
+ outputs=[gcode_upload],
679
+ )
680
+ render_button.click(
681
+ fn=render_toolpath,
682
+ inputs=[gcode_source, gcode_upload, gcode_file_1],
683
+ outputs=[toolpath_plot, toolpath_status],
684
+ )
685
+
686
  return demo
687
 
688
 
gcode_viewer.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ import plotly.graph_objects as go
7
+
8
+
9
+ _MOVE_RE = re.compile(
10
+ r"^\s*(G0|G1)\s+X(-?\d+(?:\.\d+)?)\s+Y(-?\d+(?:\.\d+)?)"
11
+ r"(?:\s+Z(-?\d+(?:\.\d+)?))?",
12
+ re.IGNORECASE,
13
+ )
14
+
15
+
16
+ def parse_gcode_path(gcode_text: str) -> dict:
17
+ relative = True
18
+ x = y = z = 0.0
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
+
25
+ all_x: list[float] = []
26
+ all_y: list[float] = []
27
+ all_z: list[float] = []
28
+
29
+ def flush_segment() -> None:
30
+ nonlocal current_segment, current_kind
31
+ if current_segment and current_kind is not None:
32
+ target = print_segments if current_kind == "print" else travel_segments
33
+ target.append(current_segment)
34
+ current_segment = []
35
+ current_kind = None
36
+
37
+ for raw_line in gcode_text.splitlines():
38
+ line = raw_line.strip()
39
+ if not line:
40
+ flush_segment()
41
+ continue
42
+
43
+ upper = line.upper()
44
+ if upper.startswith("G90"):
45
+ relative = False
46
+ continue
47
+ if upper.startswith("G91"):
48
+ relative = True
49
+ continue
50
+
51
+ match = _MOVE_RE.match(line)
52
+ if not match:
53
+ flush_segment()
54
+ continue
55
+
56
+ gcmd = match.group(1).upper()
57
+ dx = float(match.group(2))
58
+ dy = float(match.group(3))
59
+ dz_match = match.group(4)
60
+ dz = float(dz_match) if dz_match is not None else 0.0
61
+
62
+ prev_pos = (x, y, z)
63
+
64
+ if relative:
65
+ x += dx
66
+ y += dy
67
+ z += dz
68
+ else:
69
+ x, y = dx, dy
70
+ if dz_match is not None:
71
+ z = dz
72
+
73
+ kind = "travel" if gcmd == "G1" else "print"
74
+
75
+ if kind != current_kind:
76
+ flush_segment()
77
+ current_kind = kind
78
+ current_segment = [prev_pos]
79
+
80
+ current_segment.append((x, y, z))
81
+ all_x.append(x)
82
+ all_y.append(y)
83
+ all_z.append(z)
84
+
85
+ flush_segment()
86
+
87
+ if all_x:
88
+ bounds = (
89
+ (min(all_x), min(all_y), min(all_z)),
90
+ (max(all_x), max(all_y), max(all_z)),
91
+ )
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]]:
106
+ xs: list[float | None] = []
107
+ ys: list[float | None] = []
108
+ zs: list[float | None] = []
109
+ for segment in segments:
110
+ for px, py, pz in segment:
111
+ xs.append(px)
112
+ ys.append(py)
113
+ zs.append(pz)
114
+ xs.append(None)
115
+ ys.append(None)
116
+ zs.append(None)
117
+ return xs, ys, zs
118
+
119
+
120
+ def build_toolpath_figure(parsed: dict) -> go.Figure:
121
+ print_xs, print_ys, print_zs = _segments_to_xyz(parsed["print_segments"])
122
+ travel_xs, travel_ys, travel_zs = _segments_to_xyz(parsed["travel_segments"])
123
+
124
+ fig = go.Figure()
125
+
126
+ if travel_xs:
127
+ fig.add_trace(
128
+ go.Scatter3d(
129
+ x=travel_xs,
130
+ y=travel_ys,
131
+ z=travel_zs,
132
+ mode="lines",
133
+ name="Travel (G0)",
134
+ line=dict(color="rgba(150,150,150,0.55)", width=2, dash="dot"),
135
+ hoverinfo="skip",
136
+ )
137
+ )
138
+
139
+ if print_xs:
140
+ fig.add_trace(
141
+ go.Scatter3d(
142
+ x=print_xs,
143
+ y=print_ys,
144
+ z=print_zs,
145
+ mode="lines",
146
+ name="Print (G1)",
147
+ line=dict(color="#1f77b4", width=4),
148
+ hovertemplate="X=%{x:.2f}<br>Y=%{y:.2f}<br>Z=%{z:.2f}<extra></extra>",
149
+ )
150
+ )
151
+
152
+ (x_min, y_min, z_min), (x_max, y_max, z_max) = parsed["bounds"]
153
+ fig.update_layout(
154
+ scene=dict(
155
+ xaxis_title="X (mm)",
156
+ yaxis_title="Y (mm)",
157
+ zaxis_title="Z (mm)",
158
+ aspectmode="data",
159
+ ),
160
+ margin=dict(l=0, r=0, t=30, b=0),
161
+ legend=dict(orientation="h", yanchor="bottom", y=1.0, xanchor="left", x=0.0),
162
+ title=(
163
+ f"Tool path — {len(parsed['print_segments'])} print / "
164
+ f"{len(parsed['travel_segments'])} travel segments "
165
+ f"X[{x_min:.1f},{x_max:.1f}] Y[{y_min:.1f},{y_max:.1f}] "
166
+ f"Z[{z_min:.1f},{z_max:.1f}]"
167
+ ),
168
+ )
169
+ return fig
170
+
171
+
172
+ def render_gcode_file(path: str | Path) -> tuple[go.Figure, dict]:
173
+ text = Path(path).read_text()
174
+ parsed = parse_gcode_path(text)
175
+ return build_toolpath_figure(parsed), parsed
pyproject.toml CHANGED
@@ -9,6 +9,7 @@ dependencies = [
9
  "networkx>=3.4.2",
10
  "numpy>=2.2.0",
11
  "pillow>=11.1.0",
 
12
  "scipy>=1.15.2",
13
  "shapely>=2.0.7",
14
  "trimesh>=4.6.5",
 
9
  "networkx>=3.4.2",
10
  "numpy>=2.2.0",
11
  "pillow>=11.1.0",
12
+ "plotly>=6.7.0",
13
  "scipy>=1.15.2",
14
  "shapely>=2.0.7",
15
  "trimesh>=4.6.5",
uv.lock CHANGED
@@ -523,6 +523,15 @@ wheels = [
523
  { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
524
  ]
525
 
 
 
 
 
 
 
 
 
 
526
  [[package]]
527
  name = "networkx"
528
  version = "3.6.1"
@@ -835,6 +844,19 @@ wheels = [
835
  { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
836
  ]
837
 
 
 
 
 
 
 
 
 
 
 
 
 
 
838
  [[package]]
839
  name = "pluggy"
840
  version = "1.6.0"
@@ -1279,6 +1301,7 @@ dependencies = [
1279
  { name = "networkx" },
1280
  { name = "numpy" },
1281
  { name = "pillow" },
 
1282
  { name = "scipy" },
1283
  { name = "shapely" },
1284
  { name = "trimesh" },
@@ -1295,6 +1318,7 @@ requires-dist = [
1295
  { name = "networkx", specifier = ">=3.4.2" },
1296
  { name = "numpy", specifier = ">=2.2.0" },
1297
  { name = "pillow", specifier = ">=11.1.0" },
 
1298
  { name = "scipy", specifier = ">=1.15.2" },
1299
  { name = "shapely", specifier = ">=2.0.7" },
1300
  { name = "trimesh", specifier = ">=4.6.5" },
 
523
  { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
524
  ]
525
 
526
+ [[package]]
527
+ name = "narwhals"
528
+ version = "2.19.0"
529
+ source = { registry = "https://pypi.org/simple" }
530
+ sdist = { url = "https://files.pythonhosted.org/packages/4e/1a/bd3317c0bdbcd9ffb710ddf5250b32898f8f2c240be99494fe137feb77a7/narwhals-2.19.0.tar.gz", hash = "sha256:14fd7040b5ff211d415a82e4827b9d04c354e213e72a6d0730205ffd72e3b7ff", size = 623698, upload-time = "2026-04-06T15:50:58.786Z" }
531
+ wheels = [
532
+ { url = "https://files.pythonhosted.org/packages/37/72/e61e3091e0e00fae9d3a8ef85ece9d2cd4b5966058e1f2901ce42679eebf/narwhals-2.19.0-py3-none-any.whl", hash = "sha256:1f8dfa4a33a6dbff878c3e9be4c3b455dfcaf2a9322f1357db00e4e92e95b84b", size = 446991, upload-time = "2026-04-06T15:50:57.046Z" },
533
+ ]
534
+
535
  [[package]]
536
  name = "networkx"
537
  version = "3.6.1"
 
844
  { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
845
  ]
846
 
847
+ [[package]]
848
+ name = "plotly"
849
+ version = "6.7.0"
850
+ source = { registry = "https://pypi.org/simple" }
851
+ dependencies = [
852
+ { name = "narwhals" },
853
+ { name = "packaging" },
854
+ ]
855
+ sdist = { url = "https://files.pythonhosted.org/packages/3a/7f/0f100df1172aadf88a929a9dbb902656b0880ba4b960fe5224867159d8f4/plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6", size = 6911286, upload-time = "2026-04-09T20:36:45.738Z" }
856
+ wheels = [
857
+ { url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" },
858
+ ]
859
+
860
  [[package]]
861
  name = "pluggy"
862
  version = "1.6.0"
 
1301
  { name = "networkx" },
1302
  { name = "numpy" },
1303
  { name = "pillow" },
1304
+ { name = "plotly" },
1305
  { name = "scipy" },
1306
  { name = "shapely" },
1307
  { name = "trimesh" },
 
1318
  { name = "networkx", specifier = ">=3.4.2" },
1319
  { name = "numpy", specifier = ">=2.2.0" },
1320
  { name = "pillow", specifier = ">=11.1.0" },
1321
+ { name = "plotly", specifier = ">=6.7.0" },
1322
  { name = "scipy", specifier = ">=1.15.2" },
1323
  { name = "shapely", specifier = ">=2.0.7" },
1324
  { name = "trimesh", specifier = ">=4.6.5" },