first commit
This commit is contained in:
+890
@@ -0,0 +1,890 @@
|
||||
"""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])
|
||||
Reference in New Issue
Block a user