891 lines
32 KiB
Python
891 lines
32 KiB
Python
"""Interactive 3D viewer for Beckhoff 5-axis metal DED NC toolpaths.
|
|
|
|
Reads an NC file via the existing ``nc_parser`` (imported, never rewritten) and
|
|
renders a single self-contained, offline-capable HTML file with:
|
|
|
|
* a 3D toolpath view (rapid moves, per-layer weld paths),
|
|
* an A/B tilt-angle chart with welding-active shading,
|
|
* a statistics table,
|
|
* a layer-isolation dropdown.
|
|
|
|
Mouse controls are Plotly's built-in scatter3d defaults:
|
|
LEFT-DRAG rotate, SCROLL zoom, RIGHT-DRAG pan, HOVER tooltip.
|
|
|
|
Units: X/Y/Z in millimetres, A/B in degrees, path lengths in mm, time in
|
|
seconds (feed rate F = 800 mm/min).
|
|
|
|
Run:
|
|
python nc_viewer.py <path_to_nc_file>
|
|
|
|
Dependencies: plotly, numpy. ``nc_parser.py`` must sit in the same folder.
|
|
The output HTML embeds plotly.js from CDN (``include_plotlyjs="cdn"``).
|
|
"""
|
|
|
|
import math
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import plotly.graph_objects as go
|
|
import plotly.io as pio
|
|
from plotly.subplots import make_subplots
|
|
|
|
from nc_parser import parse_nc, compute_tool_vector
|
|
|
|
|
|
# Machine feed rate used for the print-time estimate (mm/min).
|
|
FEED_RATE = 800.0
|
|
|
|
# Number of animation frames. Each frame reveals the toolpath up to one move
|
|
# (the trail itself is full-resolution — every move is drawn), so this only
|
|
# controls how chunky the growth looks and the HTML size, not completeness.
|
|
ANIM_TARGET_FRAMES = 160
|
|
|
|
# Default 3D camera: ORTHOGRAPHIC projection from an elevated isometric angle.
|
|
# Plotly normalises the scene to a unit cube before placing the camera, so this
|
|
# single fixed eye fits the whole part regardless of its real size — a "full
|
|
# view" of any part. Orthographic (no perspective foreshortening) gives a true-
|
|
# to-scale, CAD-style view; the eye is pulled back so nothing is clipped.
|
|
# Used as the initial view and the view that Reset snaps back to.
|
|
DEFAULT_CAMERA = dict(
|
|
projection=dict(type="orthographic"),
|
|
eye=dict(x=1.6, y=1.6, z=1.2),
|
|
center=dict(x=0.0, y=0.0, z=0.0),
|
|
up=dict(x=0.0, y=0.0, z=1.0),
|
|
)
|
|
|
|
# Dark theme palette.
|
|
SCENE_BG = "#1a1a2e"
|
|
FIG_BG = "#0f0f1a"
|
|
RAPID_COLOUR = "#888888"
|
|
ANGLE_A_COLOUR = "#00bfff"
|
|
ANGLE_B_COLOUR = "#ff4500"
|
|
|
|
|
|
def _slice_order(moves: list[dict]) -> list[int]:
|
|
"""Return unique slice_no values in first-seen order.
|
|
|
|
Inputs:
|
|
moves -- list of move dicts from ``parse_nc``.
|
|
Output:
|
|
list[int] of distinct ``slice_no`` values, ordered by appearance.
|
|
"""
|
|
seen = []
|
|
for m in moves:
|
|
if m["slice_no"] not in seen:
|
|
seen.append(m["slice_no"])
|
|
return seen
|
|
|
|
|
|
def _layer_colour(i: int, n: int) -> str:
|
|
"""Map a layer index to a distinct rainbow colour.
|
|
|
|
Inputs:
|
|
i -- zero-based layer index.
|
|
n -- total number of layers.
|
|
Output:
|
|
an "rgb(r,g,b)" string sampled across the hue wheel so each layer
|
|
(Z level) is visually separable.
|
|
"""
|
|
hue = (i / max(n, 1)) * 0.85 # 0..0.85 avoids wrapping red back to red
|
|
r, g, b = _hsv_to_rgb(hue, 0.85, 1.0)
|
|
return f"rgb({int(r * 255)},{int(g * 255)},{int(b * 255)})"
|
|
|
|
|
|
def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[float, float, float]:
|
|
"""Convert HSV (each 0..1) to RGB (each 0..1). Pure helper, no deps."""
|
|
i = int(h * 6.0)
|
|
f = h * 6.0 - i
|
|
p = v * (1.0 - s)
|
|
q = v * (1.0 - f * s)
|
|
t = v * (1.0 - (1.0 - f) * s)
|
|
i %= 6
|
|
return [
|
|
(v, t, p), (q, v, p), (p, v, t),
|
|
(p, q, v), (t, p, v), (v, p, q),
|
|
][i]
|
|
|
|
|
|
def _path_length(pts: list[tuple[float, float, float]]) -> float:
|
|
"""Sum Euclidean distances between consecutive 3D points (mm).
|
|
|
|
Inputs:
|
|
pts -- ordered list of (x, y, z) tuples in mm.
|
|
Output:
|
|
total path length in mm (0.0 for fewer than two points).
|
|
"""
|
|
total = 0.0
|
|
for (x0, y0, z0), (x1, y1, z1) in zip(pts, pts[1:]):
|
|
total += math.dist((x0, y0, z0), (x1, y1, z1))
|
|
return total
|
|
|
|
|
|
def compute_stats(moves: list[dict]) -> dict:
|
|
"""Compute all statistics shown in the stats table.
|
|
|
|
Inputs:
|
|
moves -- list of move dicts from ``parse_nc`` (x/y/z in mm, a/b in
|
|
degrees, plus move_type/weld_state/slice_no/fraction_no).
|
|
|
|
Output:
|
|
dict with these keys (values pre-formatted as strings for display
|
|
except where noted):
|
|
"part_name" -- set by caller, placeholder "" here
|
|
"total_layers" -- int count of unique slice_no
|
|
"total_fractions" -- int count of unique (slice_no, fraction_no)
|
|
"total_points" -- int len(moves)
|
|
"weld_points" -- int count where weld_state == "on"
|
|
"rapid_points" -- int count where move_type == "rapid"
|
|
"weld_length_mm" -- float, summed distance between consecutive
|
|
weld (cut) points, mm
|
|
"rapid_length_mm" -- float, summed distance between consecutive
|
|
rapid points, mm
|
|
"z_min"/"z_max" -- float, mm
|
|
"a_min"/"a_max" -- float, degrees
|
|
"b_min"/"b_max" -- float, degrees
|
|
"print_time_s" -- float, weld_length / FEED_RATE * 60, seconds
|
|
|
|
All numeric values are returned as floats/ints; formatting for the
|
|
table happens in ``build_stats_table``.
|
|
"""
|
|
total_points = len(moves)
|
|
layers = {m["slice_no"] for m in moves}
|
|
fractions = {(m["slice_no"], m["fraction_no"]) for m in moves}
|
|
weld_points = sum(1 for m in moves if m["weld_state"] == "on")
|
|
rapid_points = sum(1 for m in moves if m["move_type"] == "rapid")
|
|
|
|
weld_pts = [(m["x"], m["y"], m["z"]) for m in moves if m["move_type"] == "cut"]
|
|
rapid_pts = [(m["x"], m["y"], m["z"]) for m in moves if m["move_type"] == "rapid"]
|
|
weld_len = _path_length(weld_pts)
|
|
rapid_len = _path_length(rapid_pts)
|
|
|
|
zs = [m["z"] for m in moves] or [0.0]
|
|
as_ = [m["a"] for m in moves] or [0.0]
|
|
bs = [m["b"] for m in moves] or [0.0]
|
|
|
|
return {
|
|
"part_name": "",
|
|
"total_layers": len(layers),
|
|
"total_fractions": len(fractions),
|
|
"total_points": total_points,
|
|
"weld_points": weld_points,
|
|
"rapid_points": rapid_points,
|
|
"weld_length_mm": weld_len,
|
|
"rapid_length_mm": rapid_len,
|
|
"z_min": min(zs),
|
|
"z_max": max(zs),
|
|
"a_min": min(as_),
|
|
"a_max": max(as_),
|
|
"b_min": min(bs),
|
|
"b_max": max(bs),
|
|
"print_time_s": weld_len / FEED_RATE * 60.0,
|
|
}
|
|
|
|
|
|
def _customdata_row(m: dict) -> list:
|
|
"""Build the per-point customdata array used by the hover template.
|
|
|
|
Inputs:
|
|
m -- one move dict.
|
|
Output:
|
|
[x, y, z, a, b, weld_state, slice_no, fraction_no, u, v, w] where
|
|
(u, v, w) is the unit tool vector from ``compute_tool_vector``.
|
|
"""
|
|
u, v, w = compute_tool_vector(m["a"], m["b"])
|
|
return [
|
|
m["x"], m["y"], m["z"], m["a"], m["b"],
|
|
m["weld_state"], m["slice_no"], m["fraction_no"],
|
|
u, v, w,
|
|
]
|
|
|
|
|
|
# Shared hover template (units in the labels).
|
|
_HOVER = (
|
|
"<b>Position</b><br>"
|
|
"X: %{customdata[0]:.2f} mm<br>"
|
|
"Y: %{customdata[1]:.2f} mm<br>"
|
|
"Z: %{customdata[2]:.2f} mm<br>"
|
|
"<b>Extruder</b><br>"
|
|
"A: %{customdata[3]:.2f}°<br>"
|
|
"B: %{customdata[4]:.2f}°<br>"
|
|
"Vector: (%{customdata[8]:.3f}, %{customdata[9]:.3f}, %{customdata[10]:.3f})<br>"
|
|
"<b>Status:</b> %{customdata[5]}<br>"
|
|
"Slice: %{customdata[6]} Fraction: %{customdata[7]}"
|
|
"<extra></extra>"
|
|
)
|
|
|
|
|
|
def build_rapid_trace(moves: list[dict]) -> go.Scatter3d:
|
|
"""Build a single 3D trace for all rapid (non-welding) moves.
|
|
|
|
Inputs:
|
|
moves -- list of move dicts (mm for coords).
|
|
Output:
|
|
a ``go.Scatter3d`` line trace (grey dotted) covering every
|
|
``move_type == "rapid"`` point, with full hover customdata.
|
|
"""
|
|
rapids = [m for m in moves if m["move_type"] == "rapid"]
|
|
return go.Scatter3d(
|
|
x=[m["x"] for m in rapids],
|
|
y=[m["y"] for m in rapids],
|
|
z=[m["z"] for m in rapids],
|
|
mode="lines",
|
|
line=dict(color=RAPID_COLOUR, dash="dot", width=1),
|
|
name="Rapid moves",
|
|
customdata=[_customdata_row(m) for m in rapids],
|
|
hovertemplate=_HOVER,
|
|
showlegend=True,
|
|
)
|
|
|
|
|
|
def build_weld_traces(moves: list[dict]) -> list[go.Scatter3d]:
|
|
"""Build one 3D weld-path trace per slice (Z layer), colour-coded.
|
|
|
|
Inputs:
|
|
moves -- list of move dicts (mm for coords).
|
|
Output:
|
|
list of ``go.Scatter3d`` "lines+markers" traces, one per unique
|
|
``slice_no`` (in appearance order), each a distinct rainbow colour and
|
|
named ``"Layer N Z=..mm"``. Only ``move_type == "cut"`` points are
|
|
included. Each trace carries full hover customdata.
|
|
"""
|
|
order = _slice_order(moves)
|
|
n = len(order)
|
|
traces = []
|
|
for i, sno in enumerate(order):
|
|
cut = [m for m in moves if m["slice_no"] == sno and m["move_type"] == "cut"]
|
|
if not cut:
|
|
continue
|
|
z = cut[0]["z"]
|
|
colour = _layer_colour(i, n)
|
|
traces.append(go.Scatter3d(
|
|
x=[m["x"] for m in cut],
|
|
y=[m["y"] for m in cut],
|
|
z=[m["z"] for m in cut],
|
|
mode="lines+markers",
|
|
marker=dict(size=3, color=colour),
|
|
line=dict(color=colour, width=3),
|
|
name=f"Layer {sno} Z={z:.1f}mm",
|
|
customdata=[_customdata_row(m) for m in cut],
|
|
hovertemplate=_HOVER,
|
|
showlegend=True,
|
|
))
|
|
return traces
|
|
|
|
|
|
def build_angle_chart(moves: list[dict]):
|
|
"""Build the A/B tilt-angle line traces plus welding-active shading.
|
|
|
|
Inputs:
|
|
moves -- list of move dicts (angles in degrees).
|
|
Output:
|
|
(traces, y_range):
|
|
traces -- list of four ``go.Scatter`` (2D) traces over move index
|
|
0..N-1: a low baseline + a "Welding active" band that
|
|
together shade (green, semi-transparent) the FULL chart
|
|
height wherever ``weld_state == "on"``, then the A axis
|
|
(blue) and B axis (red) angle lines on top.
|
|
y_range -- [ymin, ymax] for the subplot's y-axis (padded angle range,
|
|
with a sensible minimum so a flat 0° trace still shows a
|
|
band). Returned so ``build_figure`` can pin the axis — the
|
|
band is drawn to these exact bounds.
|
|
These live in the 2D subplot, not the 3D scene.
|
|
"""
|
|
idx = list(range(len(moves)))
|
|
a_vals = [m["a"] for m in moves]
|
|
b_vals = [m["b"] for m in moves]
|
|
|
|
# Pinned y-range: padded angle extent, but never degenerate (so an all-0°
|
|
# part still gets a visible band). The band fills exactly these bounds.
|
|
lo = min(a_vals + b_vals + [0.0])
|
|
hi = max(a_vals + b_vals + [0.0])
|
|
pad = max((hi - lo) * 0.1, 1.0)
|
|
ymin, ymax = lo - pad, hi + pad
|
|
|
|
welding = [m["weld_state"] == "on" for m in moves]
|
|
band_low = [ymin if on else None for on in welding]
|
|
band_high = [ymax if on else None for on in welding]
|
|
|
|
# Two traces forming a filled band: invisible baseline at ymin, then the
|
|
# top edge at ymax filling down to it. connectgaps=False keeps the band
|
|
# broken across travel (non-welding) gaps.
|
|
base = go.Scatter(
|
|
x=idx, y=band_low, mode="lines",
|
|
line=dict(width=0), hoverinfo="skip",
|
|
showlegend=False, connectgaps=False,
|
|
)
|
|
band = go.Scatter(
|
|
x=idx, y=band_high, mode="lines",
|
|
line=dict(width=0), fill="tonexty",
|
|
fillcolor="rgba(0,255,128,0.18)",
|
|
name="Welding active", hoverinfo="skip",
|
|
connectgaps=False,
|
|
)
|
|
a_trace = go.Scatter(
|
|
x=idx, y=a_vals, mode="lines",
|
|
line=dict(color=ANGLE_A_COLOUR, width=2),
|
|
name="A axis (°)",
|
|
hovertemplate="move %{x}<br>A: %{y:.2f}°<extra></extra>",
|
|
)
|
|
b_trace = go.Scatter(
|
|
x=idx, y=b_vals, mode="lines",
|
|
line=dict(color=ANGLE_B_COLOUR, width=2),
|
|
name="B axis (°)",
|
|
hovertemplate="move %{x}<br>B: %{y:.2f}°<extra></extra>",
|
|
)
|
|
# Band first (base then fill) so the angle lines draw on top.
|
|
return [base, band, a_trace, b_trace], [ymin, ymax]
|
|
|
|
|
|
def build_stats_table(stats: dict) -> go.Table:
|
|
"""Build the formatted statistics table trace.
|
|
|
|
Inputs:
|
|
stats -- dict from ``compute_stats`` (with "part_name" filled in).
|
|
Output:
|
|
a ``go.Table`` trace with "Property"/"Value" columns, dark-blue header,
|
|
alternating dark row backgrounds, white 12pt text. Lengths shown in
|
|
mm, angles in degrees, time in seconds.
|
|
"""
|
|
rows = [
|
|
("Part name", stats["part_name"]),
|
|
("Total layers", f"{stats['total_layers']}"),
|
|
("Total fractions", f"{stats['total_fractions']}"),
|
|
("Total points", f"{stats['total_points']}"),
|
|
("Weld ON points", f"{stats['weld_points']}"),
|
|
("Rapid points", f"{stats['rapid_points']}"),
|
|
("Weld path length", f"{stats['weld_length_mm']:.1f} mm"),
|
|
("Rapid path length", f"{stats['rapid_length_mm']:.1f} mm"),
|
|
("Z range", f"{stats['z_min']:.2f} mm → {stats['z_max']:.2f} mm"),
|
|
("A angle range", f"{stats['a_min']:.2f}° → {stats['a_max']:.2f}°"),
|
|
("B angle range", f"{stats['b_min']:.2f}° → {stats['b_max']:.2f}°"),
|
|
("Est. print time", f"{stats['print_time_s']:.1f} s"),
|
|
]
|
|
props = [r[0] for r in rows]
|
|
vals = [r[1] for r in rows]
|
|
|
|
# Alternating row colours.
|
|
fill = [
|
|
["#1a1a2e" if i % 2 == 0 else "#16213e" for i in range(len(rows))]
|
|
] * 2
|
|
|
|
return go.Table(
|
|
header=dict(
|
|
values=["<b>Property</b>", "<b>Value</b>"],
|
|
fill_color="#0d47a1",
|
|
font=dict(color="white", size=12),
|
|
align="left",
|
|
),
|
|
cells=dict(
|
|
values=[props, vals],
|
|
fill_color=fill,
|
|
font=dict(color="white", size=12),
|
|
align="left",
|
|
height=24,
|
|
),
|
|
)
|
|
|
|
|
|
def build_layer_dropdown(weld_traces: list[go.Scatter3d], n_static_before: int,
|
|
total_traces: int) -> dict:
|
|
"""Build the updatemenus dropdown that isolates a single layer.
|
|
|
|
Inputs:
|
|
weld_traces -- the per-layer weld traces (to read names/order).
|
|
n_static_before -- number of traces placed BEFORE the weld traces in
|
|
the figure's trace list (e.g. the rapid trace),
|
|
so weld-trace indices can be computed.
|
|
total_traces -- total number of traces in the figure (for building
|
|
full-length opacity arrays via restyle).
|
|
Output:
|
|
a single ``updatemenus`` dict. "All layers" shows every weld trace at
|
|
full opacity; each "Layer N" option sets that layer's weld trace to
|
|
opacity 1.0 and all OTHER weld traces to 0.1. Non-weld traces (rapid
|
|
path, animated head cursor) are left untouched.
|
|
|
|
Note: opacity is restyled only on the weld-trace indices, so the rapid
|
|
path and the animated head cursor always stay fully visible.
|
|
"""
|
|
weld_indices = list(range(n_static_before, n_static_before + len(weld_traces)))
|
|
|
|
buttons = [dict(
|
|
label="All layers",
|
|
method="restyle",
|
|
args=[{"opacity": [1.0] * len(weld_indices)}, weld_indices],
|
|
)]
|
|
|
|
for i, tr in enumerate(weld_traces):
|
|
opac = [0.1] * len(weld_indices)
|
|
opac[i] = 1.0
|
|
buttons.append(dict(
|
|
label=tr.name,
|
|
method="restyle",
|
|
args=[{"opacity": opac}, weld_indices],
|
|
))
|
|
|
|
return dict(
|
|
buttons=buttons,
|
|
direction="down",
|
|
showactive=True,
|
|
x=0.01, xanchor="left",
|
|
y=1.12, yanchor="top",
|
|
bgcolor="#16213e",
|
|
font=dict(color="white", size=12),
|
|
bordercolor="#0d47a1",
|
|
)
|
|
|
|
|
|
def _stick_length(moves: list[dict]) -> float:
|
|
"""Pick a sensible length (mm) for the head 'stick' from the part size.
|
|
|
|
Inputs:
|
|
moves -- list of move dicts (mm coords).
|
|
Output:
|
|
10% of the largest bounding-box extent (min 5 mm), so the stick is
|
|
visible but not overwhelming regardless of part scale.
|
|
"""
|
|
if not moves:
|
|
return 5.0
|
|
xs = [m["x"] for m in moves]
|
|
ys = [m["y"] for m in moves]
|
|
zs = [m["z"] for m in moves]
|
|
ext = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs), 1.0)
|
|
return max(ext * 0.10, 5.0)
|
|
|
|
|
|
def _stick_points(m: dict, length: float):
|
|
"""Return the two endpoints of the head stick for one move.
|
|
|
|
Inputs:
|
|
m -- a move dict (x/y/z mm, a/b degrees).
|
|
length -- stick length in mm.
|
|
Output:
|
|
(xs, ys, zs) each a 2-element list: [contact_point, tip], where the tip
|
|
is the contact point offset by ``length`` along ``compute_tool_vector``.
|
|
Straight up (+Z) when A=B=0; tilts as A/B grow, so the inclination is
|
|
visible in 3D.
|
|
"""
|
|
u, v, w = compute_tool_vector(m["a"], m["b"])
|
|
bx, by, bz = m["x"], m["y"], m["z"]
|
|
return ([bx, bx + u * length],
|
|
[by, by + v * length],
|
|
[bz, bz + w * length])
|
|
|
|
|
|
def build_cursor_trace(moves: list[dict]) -> go.Scatter3d:
|
|
"""Build the animated head 'stick' that moves along the toolpath.
|
|
|
|
Inputs:
|
|
moves -- list of move dicts (mm coords, deg angles).
|
|
Output:
|
|
a two-point ``go.Scatter3d`` line+marker trace drawn as a short yellow
|
|
stick: a fat dot at the contact point and a thin line pointing along
|
|
the tool vector (straight up at A=B=0, tilted when the head is
|
|
inclined). Updated frame by frame during the play/pause animation.
|
|
"""
|
|
first = moves[0] if moves else {"x": 0.0, "y": 0.0, "z": 0.0,
|
|
"a": 0.0, "b": 0.0}
|
|
xs, ys, zs = _stick_points(first, _stick_length(moves))
|
|
return go.Scatter3d(
|
|
x=xs, y=ys, z=zs,
|
|
mode="lines+markers",
|
|
line=dict(color="#ffff00", width=6),
|
|
marker=dict(size=[7, 3], color="#ffff00",
|
|
line=dict(color="black", width=1)),
|
|
name="Head position",
|
|
showlegend=True,
|
|
hoverinfo="skip",
|
|
)
|
|
|
|
|
|
def build_trail_trace() -> go.Scatter3d:
|
|
"""Build the (initially empty) trail that is drawn progressively on Play.
|
|
|
|
Inputs:
|
|
none.
|
|
Output:
|
|
an empty ``go.Scatter3d`` line+markers trace. During the animation each
|
|
frame replaces it with EVERY coordinate visited so far (full resolution,
|
|
not sub-sampled), drawing a cyan line AND a dot at every point the head
|
|
has passed. Empty on load so nothing is drawn until Play is pressed.
|
|
"""
|
|
return go.Scatter3d(
|
|
x=[], y=[], z=[],
|
|
mode="lines+markers",
|
|
# Dim grey line so the layer-coloured dots (set per-frame) stand out.
|
|
line=dict(color="#5a6072", width=2),
|
|
marker=dict(size=2),
|
|
name="Printed trail",
|
|
showlegend=True,
|
|
hoverinfo="skip",
|
|
)
|
|
|
|
|
|
def _anim_indices(n_moves: int) -> list[int]:
|
|
"""Pick the move indices used as animation frames.
|
|
|
|
Inputs:
|
|
n_moves -- total number of moves.
|
|
Output:
|
|
ascending list of move indices sub-sampled to about
|
|
``ANIM_TARGET_FRAMES`` frames (always includes the final move), so the
|
|
animation stays smooth regardless of file size.
|
|
"""
|
|
if n_moves == 0:
|
|
return []
|
|
step = max(1, math.ceil(n_moves / ANIM_TARGET_FRAMES))
|
|
idxs = list(range(0, n_moves, step))
|
|
if idxs[-1] != n_moves - 1:
|
|
idxs.append(n_moves - 1)
|
|
return idxs
|
|
|
|
|
|
def build_animation(moves: list[dict], trail_index: int, cursor_index: int,
|
|
static_indices: list[int]):
|
|
"""Build the progressive-reveal animation, control buttons, and slider.
|
|
|
|
Inputs:
|
|
moves -- list of move dicts (mm coords, weld_state).
|
|
trail_index -- trace index of the growing "Printed trail".
|
|
cursor_index -- trace index of the head-position marker.
|
|
static_indices -- trace indices of the full static toolpath (rapid +
|
|
weld traces) that are hidden during playback and
|
|
restored on reset.
|
|
|
|
Output:
|
|
(frames, play_menu, slider):
|
|
frames -- list[go.Frame]. The animation frames (one per sub-sampled
|
|
move, named by move index) grow the trail up to that move
|
|
and advance the head cursor; the first frame also hides
|
|
every static toolpath trace so only the trail is drawn
|
|
while playing. A trailing "reset" frame clears the trail,
|
|
returns the head to the start, and makes the static
|
|
toolpath visible again.
|
|
play_menu -- an ``updatemenus`` dict with Play (~25 fps, restarts from
|
|
the beginning so the path always re-reveals), Pause
|
|
(halts immediately), and Reset buttons.
|
|
slider -- a ``sliders`` dict to scrub to any frame by move index.
|
|
Returns ([], None, None) when there are no moves.
|
|
|
|
Coordinates: mm. The trail/cursor positions are exact NC move coordinates;
|
|
only which moves get their own frame is sub-sampled (see ``_anim_indices``).
|
|
"""
|
|
idxs = _anim_indices(len(moves))
|
|
if not idxs:
|
|
return [], None, None
|
|
|
|
first = moves[idxs[0]]
|
|
stick_len = _stick_length(moves)
|
|
# Full-resolution coordinate arrays; each frame reveals a prefix of these
|
|
# so the real toolpath (every move, not just frame points) is drawn.
|
|
all_x = [m["x"] for m in moves]
|
|
all_y = [m["y"] for m in moves]
|
|
all_z = [m["z"] for m in moves]
|
|
# Per-point trail colour keyed to the move's layer, using the SAME mapping
|
|
# as the weld traces so each revealed dot matches its layer's rainbow hue.
|
|
order = _slice_order(moves)
|
|
n_layers = len(order)
|
|
layer_pos = {sno: i for i, sno in enumerate(order)}
|
|
all_colours = [_layer_colour(layer_pos[m["slice_no"]], n_layers) for m in moves]
|
|
|
|
# Hiding/showing the static toolpath = a tiny visibility update per static
|
|
# trace (re-used as frame data alongside the trail + cursor updates).
|
|
hide_static = [go.Scatter3d(visible=False) for _ in static_indices]
|
|
show_static = [go.Scatter3d(visible=True) for _ in static_indices]
|
|
|
|
frames = []
|
|
slider_steps = []
|
|
for k, i in enumerate(idxs):
|
|
# Reveal every move up to and including this frame's move index.
|
|
upto = i + 1
|
|
m = moves[i]
|
|
|
|
trail = go.Scatter3d(
|
|
x=all_x[:upto], y=all_y[:upto], z=all_z[:upto],
|
|
marker=dict(size=2, color=all_colours[:upto]),
|
|
)
|
|
cx, cy, cz = _stick_points(m, stick_len)
|
|
cursor = go.Scatter3d(x=cx, y=cy, z=cz)
|
|
|
|
if k == 0:
|
|
# First frame also hides the full static path so playback reveals
|
|
# it, and flips scene.uirevision to "play" so the next Reset (which
|
|
# uses a different value) is guaranteed to re-apply the camera.
|
|
data = [trail, cursor, *hide_static]
|
|
traces = [trail_index, cursor_index, *static_indices]
|
|
layout = dict(scene=dict(uirevision="play", camera=DEFAULT_CAMERA))
|
|
else:
|
|
data = [trail, cursor]
|
|
traces = [trail_index, cursor_index]
|
|
layout = None
|
|
|
|
frame_kwargs = dict(data=data, traces=traces, name=str(i))
|
|
if layout is not None:
|
|
frame_kwargs["layout"] = layout
|
|
frames.append(go.Frame(**frame_kwargs))
|
|
slider_steps.append(dict(
|
|
method="animate",
|
|
label=str(i),
|
|
args=[[str(i)], dict(
|
|
mode="immediate",
|
|
frame=dict(duration=0, redraw=True),
|
|
transition=dict(duration=0),
|
|
)],
|
|
))
|
|
|
|
anim_names = [str(i) for i in idxs]
|
|
|
|
# Reset frame: empty trail, head stick back at the start, static path
|
|
# visible, and the camera snapped back to the default zoomed-out view.
|
|
# Flipping scene.uirevision to "reset" (different from Play's "play") forces
|
|
# Plotly to re-apply DEFAULT_CAMERA even after the user has rotated.
|
|
rx, ry, rz = _stick_points(first, stick_len)
|
|
frames.append(go.Frame(
|
|
name="reset",
|
|
data=[
|
|
go.Scatter3d(x=[], y=[], z=[]),
|
|
go.Scatter3d(x=rx, y=ry, z=rz),
|
|
*show_static,
|
|
],
|
|
traces=[trail_index, cursor_index, *static_indices],
|
|
layout=dict(scene=dict(uirevision="reset", camera=DEFAULT_CAMERA)),
|
|
))
|
|
|
|
play_menu = dict(
|
|
type="buttons",
|
|
direction="left",
|
|
showactive=False,
|
|
x=0.01, xanchor="left",
|
|
y=0.05, yanchor="bottom",
|
|
bgcolor="#16213e",
|
|
bordercolor="#0d47a1",
|
|
font=dict(color="white", size=12),
|
|
pad=dict(l=4, r=4, t=4, b=4),
|
|
buttons=[
|
|
dict(
|
|
label="▶ Play",
|
|
method="animate",
|
|
# Explicit frame list (not None) so the trailing "reset" frame
|
|
# is excluded; fromcurrent=False restarts the reveal each time.
|
|
args=[anim_names, dict(
|
|
fromcurrent=False,
|
|
mode="immediate",
|
|
frame=dict(duration=40, redraw=True),
|
|
transition=dict(duration=0),
|
|
)],
|
|
),
|
|
dict(
|
|
label="❚❚ Pause",
|
|
method="animate",
|
|
args=[[None], dict(
|
|
mode="immediate",
|
|
frame=dict(duration=0, redraw=False),
|
|
transition=dict(duration=0),
|
|
)],
|
|
),
|
|
dict(
|
|
label="⟲ Reset",
|
|
method="animate",
|
|
args=[["reset"], dict(
|
|
mode="immediate",
|
|
frame=dict(duration=0, redraw=True),
|
|
transition=dict(duration=0),
|
|
)],
|
|
),
|
|
],
|
|
)
|
|
|
|
slider = dict(
|
|
active=0,
|
|
x=0.05, len=0.55,
|
|
y=0.0, yanchor="top",
|
|
pad=dict(t=10, b=10),
|
|
currentvalue=dict(prefix="Move ", font=dict(color="white", size=12)),
|
|
font=dict(color="white", size=10),
|
|
bgcolor="#16213e",
|
|
bordercolor="#0d47a1",
|
|
steps=slider_steps,
|
|
)
|
|
|
|
return frames, play_menu, slider
|
|
|
|
|
|
def _axis_ranges(moves: list[dict]):
|
|
"""Compute fixed [min, max] ranges for the X/Y/Z scene axes (mm).
|
|
|
|
Inputs:
|
|
moves -- list of move dicts (mm coords).
|
|
Output:
|
|
(x_range, y_range, z_range), each a 2-element [lo, hi] list padded by
|
|
5% of the largest extent so the part never touches the scene walls.
|
|
These are pinned on the scene so the view stays FIXED and fully framed
|
|
regardless of which traces are visible — without them the animation
|
|
(which hides the static toolpath and grows a 1-point trail) makes
|
|
``aspectmode="data"`` rescale the scene, causing the zoom to jump.
|
|
"""
|
|
if not moves:
|
|
return [-1, 1], [-1, 1], [-1, 1]
|
|
xs = [m["x"] for m in moves]
|
|
ys = [m["y"] for m in moves]
|
|
zs = [m["z"] for m in moves]
|
|
pad = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs), 1.0) * 0.05
|
|
return (
|
|
[min(xs) - pad, max(xs) + pad],
|
|
[min(ys) - pad, max(ys) + pad],
|
|
[min(zs) - pad, max(zs) + pad],
|
|
)
|
|
|
|
|
|
def build_figure(parsed: dict, source_file: str) -> go.Figure:
|
|
"""Assemble the complete two-column Plotly figure.
|
|
|
|
Inputs:
|
|
parsed -- dict from ``parse_nc`` ("part_name", "moves").
|
|
source_file -- path/name of the NC file (shown in the title).
|
|
Output:
|
|
a ``go.Figure`` with: a large 3D toolpath scene (rapid + per-layer weld
|
|
+ an animated head cursor) spanning both left rows, an A/B angle chart
|
|
top-right, a stats table bottom-right, a layer-isolation dropdown,
|
|
play/pause animation controls with a scrub slider, and a dark theme.
|
|
Calls every ``build_*`` helper.
|
|
"""
|
|
moves = parsed["moves"]
|
|
part_name = parsed["part_name"]
|
|
|
|
stats = compute_stats(moves)
|
|
stats["part_name"] = part_name
|
|
n_layers = stats["total_layers"]
|
|
n_points = stats["total_points"]
|
|
|
|
# Fixed scene bounds from the part's bounding box (see _axis_ranges).
|
|
x_range, y_range, z_range = _axis_ranges(moves)
|
|
|
|
fig = make_subplots(
|
|
rows=2, cols=2,
|
|
column_widths=[0.66, 0.34],
|
|
row_heights=[0.5, 0.5],
|
|
specs=[
|
|
[{"type": "scene", "rowspan": 2}, {"type": "xy"}],
|
|
[None, {"type": "table"}],
|
|
],
|
|
subplot_titles=("", "Extruder Tilt — A and B Angles", ""),
|
|
horizontal_spacing=0.06,
|
|
vertical_spacing=0.10,
|
|
)
|
|
|
|
# --- 3D scene traces (col 1) ---
|
|
rapid_trace = build_rapid_trace(moves)
|
|
weld_traces = build_weld_traces(moves)
|
|
|
|
fig.add_trace(rapid_trace, row=1, col=1) # index 0
|
|
for wt in weld_traces: # indices 1..n
|
|
fig.add_trace(wt, row=1, col=1)
|
|
|
|
# Head cursor (index 1+n) and growing trail (index 2+n) sit right after the
|
|
# weld traces so the layer dropdown (restyles only weld indices) never
|
|
# touches them. The static toolpath = rapid + weld traces (indices 0..n).
|
|
cursor_index = 1 + len(weld_traces)
|
|
trail_index = 2 + len(weld_traces)
|
|
static_indices = list(range(0, 1 + len(weld_traces)))
|
|
fig.add_trace(build_cursor_trace(moves), row=1, col=1)
|
|
fig.add_trace(build_trail_trace(), row=1, col=1)
|
|
|
|
# --- angle chart (row 1, col 2) ---
|
|
angle_traces, angle_yrange = build_angle_chart(moves)
|
|
for at in angle_traces:
|
|
fig.add_trace(at, row=1, col=2)
|
|
|
|
# --- stats table (row 2, col 2) ---
|
|
fig.add_trace(build_stats_table(stats), row=2, col=2)
|
|
|
|
# Total trace count (for dropdown bookkeeping): rapid + welds + cursor +
|
|
# trail + 4 angle traces + table.
|
|
total_traces = 1 + len(weld_traces) + 2 + 4 + 1
|
|
|
|
dropdown = build_layer_dropdown(
|
|
weld_traces, n_static_before=1, total_traces=total_traces,
|
|
)
|
|
|
|
# --- animation: frames + play/pause/reset + slider ---
|
|
frames, play_menu, slider = build_animation(
|
|
moves, trail_index, cursor_index, static_indices,
|
|
)
|
|
fig.frames = frames
|
|
menus = [dropdown] + ([play_menu] if play_menu else [])
|
|
sliders = [slider] if slider else []
|
|
|
|
title = (
|
|
f"NC Viewer — {part_name} | {n_layers} layers "
|
|
f"| {n_points} points | {Path(source_file).name}"
|
|
)
|
|
|
|
fig.update_layout(
|
|
title=dict(text=title, font=dict(color="white", size=18), x=0.5),
|
|
autosize=True,
|
|
paper_bgcolor=FIG_BG,
|
|
plot_bgcolor=FIG_BG,
|
|
font=dict(color="white"),
|
|
updatemenus=menus,
|
|
sliders=sliders,
|
|
# Constant uirevision keeps the user's camera across frame redraws, so
|
|
# the 3D scene can be rotated/zoomed/panned WHILE the animation plays.
|
|
uirevision="keep",
|
|
legend=dict(
|
|
x=0.62, y=0.99, xanchor="right", yanchor="top",
|
|
bgcolor="rgba(22,33,62,0.6)", font=dict(color="white", size=10),
|
|
),
|
|
scene=dict(
|
|
bgcolor=SCENE_BG,
|
|
# Pinned axis ranges (full part bounding box) keep the view fixed
|
|
# and fully framed — without them aspectmode="data" rescales the
|
|
# scene as the animation hides/reveals traces, making the zoom jump.
|
|
xaxis=dict(title="X (mm)", showgrid=True, gridcolor="#444",
|
|
color="white", range=x_range),
|
|
yaxis=dict(title="Y (mm)", showgrid=True, gridcolor="#444",
|
|
color="white", range=y_range),
|
|
zaxis=dict(title="Z (mm)", showgrid=True, gridcolor="#444",
|
|
color="white", range=z_range),
|
|
aspectmode="data",
|
|
camera=DEFAULT_CAMERA,
|
|
uirevision="keep",
|
|
),
|
|
)
|
|
|
|
# Angle chart axis labels (top-right subplot is xaxis2/yaxis2).
|
|
fig.update_xaxes(title_text="Move index", row=1, col=2,
|
|
color="white", gridcolor="#333")
|
|
# Pin the y-range so the welding band fills the full chart height exactly.
|
|
fig.update_yaxes(title_text="Angle (°)", row=1, col=2,
|
|
color="white", gridcolor="#333",
|
|
range=angle_yrange)
|
|
|
|
return fig
|
|
|
|
|
|
def main(nc_path: str) -> None:
|
|
"""Parse an NC file and write the interactive viewer HTML.
|
|
|
|
Inputs:
|
|
nc_path -- path to the .nc file to visualise.
|
|
Output:
|
|
None. Writes ``<stem>_viewer.html`` (self-contained, plotly.js from
|
|
CDN) next to where the script runs and prints "Saved: <filename>".
|
|
"""
|
|
parsed = parse_nc(nc_path)
|
|
fig = build_figure(parsed, nc_path)
|
|
|
|
out_name = f"{Path(nc_path).stem}_viewer.html"
|
|
pio.write_html(
|
|
fig, file=out_name, include_plotlyjs="cdn", full_html=True,
|
|
default_width="100%", default_height="100vh",
|
|
config={"responsive": True},
|
|
)
|
|
print(f"Saved: {out_name}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python nc_viewer.py <path_to_nc_file>")
|
|
sys.exit(1)
|
|
main(sys.argv[1])
|