first commit
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
+297
@@ -0,0 +1,297 @@
|
|||||||
|
"""NC file parser for the Beckhoff 5-axis metal DED slicer.
|
||||||
|
|
||||||
|
Parses the machine NC dialect emitted by ``stl_slicer.py`` (and the real
|
||||||
|
Beckhoff controller) into structured Python data. No visualisation — this
|
||||||
|
module only reads NC text and extracts motion + weld information.
|
||||||
|
|
||||||
|
The NC dialect understood here:
|
||||||
|
|
||||||
|
COMMENTS
|
||||||
|
(Part name: Hemisphere-v1) -> part name (header)
|
||||||
|
(New Slice of body X No.: 3, Z15.00) -> slice (Z layer) number + Z
|
||||||
|
(X, Slice 3, Fraction 2 Outer ...) -> fraction (contour) number
|
||||||
|
|
||||||
|
MOTION
|
||||||
|
G00 X.. Y.. Z.. [A..] [B..] -> rapid move (no welding)
|
||||||
|
G01 X.. Y.. Z.. [A..] [B..] [M61] -> cut move (M61 = laser ON)
|
||||||
|
|
||||||
|
WELD CONTROL
|
||||||
|
M60=1 / M60=11 -> weld start markers (tracked, not moves)
|
||||||
|
M61 -> laser ON (inline on a G01 line)
|
||||||
|
M62 -> laser OFF (standalone line)
|
||||||
|
M01 -> optional stop between slices (ignored)
|
||||||
|
|
||||||
|
IGNORED
|
||||||
|
#TRAFO ON, G54, F800.0, M63, M71, blank lines, ``;`` comments.
|
||||||
|
|
||||||
|
Units: X/Y/Z in millimetres, A/B in degrees.
|
||||||
|
|
||||||
|
Standard library only (re, math, pathlib) — importable with zero installs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# Regexes compiled once at import time.
|
||||||
|
_PART_NAME_RE = re.compile(r"\(Part name:\s*(.+?)\)")
|
||||||
|
_SLICE_RE = re.compile(r"\(New Slice of body .*?No\.:\s*(\d+)")
|
||||||
|
_FRACTION_RE = re.compile(r"Fraction\s+(\d+)")
|
||||||
|
# Axis word followed by an optional-sign float (incl. scientific notation).
|
||||||
|
_AXIS_RE = re.compile(r"\b([XYZAB])(-?\d+\.?\d*(?:[eE][-+]?\d+)?)")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_axes(line: str) -> dict:
|
||||||
|
"""Pull X/Y/Z/A/B axis words out of a motion line.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
line -- a single G00/G01 NC line, e.g. "G01 X-1.2 Y3.4 Z2.5 A0.0 B1.0".
|
||||||
|
|
||||||
|
Output:
|
||||||
|
dict mapping the upper-case axis letter ("X".."B") to its float value
|
||||||
|
(mm for X/Y/Z, degrees for A/B). Only axes present in the line appear.
|
||||||
|
"""
|
||||||
|
return {m.group(1): float(m.group(2)) for m in _AXIS_RE.finditer(line)}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nc(filepath: str) -> dict:
|
||||||
|
"""Parse a Beckhoff DED NC file into structured move data.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
filepath -- path to the .nc file to read.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
dict with two keys:
|
||||||
|
"part_name" : str -- from the "(Part name: ...)" header comment,
|
||||||
|
or the filename stem if that comment is absent.
|
||||||
|
"moves" : list[dict] -- one dict per G00/G01 motion line, each:
|
||||||
|
{
|
||||||
|
"line_no" : int, # 1-based line number in the source file
|
||||||
|
"x" : float, # mm
|
||||||
|
"y" : float, # mm
|
||||||
|
"z" : float, # mm
|
||||||
|
"a" : float, # degrees (0.0 if absent)
|
||||||
|
"b" : float, # degrees (0.0 if absent)
|
||||||
|
"move_type" : str, # "rapid" (G00) or "cut" (G01)
|
||||||
|
"weld_state" : str, # "on" or "off"
|
||||||
|
"slice_no" : int, # Z layer this move belongs to
|
||||||
|
"fraction_no" : int, # contour within the layer
|
||||||
|
}
|
||||||
|
|
||||||
|
Behaviour / error handling:
|
||||||
|
* Weld state starts "off"; M61 (inline) turns it "on", M62 turns it
|
||||||
|
"off". The state carries forward across G01 lines until M62.
|
||||||
|
* A G00/G01 line missing X, Y or Z prints a warning with the line
|
||||||
|
number and is skipped (never crashes).
|
||||||
|
* Missing A or B silently default to 0.0.
|
||||||
|
* The whole parse is wrapped in try/except; on error the file path is
|
||||||
|
reported and a dict with an empty move list is returned.
|
||||||
|
"""
|
||||||
|
part_name = None
|
||||||
|
moves = []
|
||||||
|
|
||||||
|
weld_state = "off"
|
||||||
|
slice_no = 0
|
||||||
|
fraction_no = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = Path(filepath).read_text()
|
||||||
|
lines = text.splitlines()
|
||||||
|
|
||||||
|
for idx, raw in enumerate(lines, start=1):
|
||||||
|
line = raw.strip()
|
||||||
|
|
||||||
|
# Skip blanks and ';' comments outright.
|
||||||
|
if not line or line.startswith(";"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parenthesised comments: extract part name / slice / fraction.
|
||||||
|
if line.startswith("("):
|
||||||
|
if part_name is None:
|
||||||
|
m = _PART_NAME_RE.search(line)
|
||||||
|
if m:
|
||||||
|
part_name = m.group(1).strip()
|
||||||
|
continue
|
||||||
|
m = _SLICE_RE.search(line)
|
||||||
|
if m:
|
||||||
|
slice_no = int(m.group(1))
|
||||||
|
continue
|
||||||
|
m = _FRACTION_RE.search(line)
|
||||||
|
if m:
|
||||||
|
fraction_no = int(m.group(1))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Weld control / stop markers.
|
||||||
|
if line.startswith("M62"):
|
||||||
|
weld_state = "off"
|
||||||
|
continue
|
||||||
|
if line.startswith("M60") or line.startswith("M01"):
|
||||||
|
# M60=1 / M60=11 = weld start markers, M01 = optional stop.
|
||||||
|
# Tracked by context only; not moves themselves.
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Motion lines.
|
||||||
|
if line.startswith("G00") or line.startswith("G01"):
|
||||||
|
move_type = "rapid" if line.startswith("G00") else "cut"
|
||||||
|
axes = _extract_axes(line)
|
||||||
|
|
||||||
|
missing = [ax for ax in ("X", "Y", "Z") if ax not in axes]
|
||||||
|
if missing:
|
||||||
|
print(
|
||||||
|
f"Warning: line {idx} missing {','.join(missing)} "
|
||||||
|
f"-> skipped: {line!r}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# M61 appears inline on a G01 line and turns the laser ON for
|
||||||
|
# this move and the ones that follow.
|
||||||
|
if "M61" in line:
|
||||||
|
weld_state = "on"
|
||||||
|
|
||||||
|
moves.append({
|
||||||
|
"line_no": idx,
|
||||||
|
"x": axes["X"],
|
||||||
|
"y": axes["Y"],
|
||||||
|
"z": axes["Z"],
|
||||||
|
"a": axes.get("A", 0.0),
|
||||||
|
"b": axes.get("B", 0.0),
|
||||||
|
"move_type": move_type,
|
||||||
|
"weld_state": weld_state,
|
||||||
|
"slice_no": slice_no,
|
||||||
|
"fraction_no": fraction_no,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Everything else (#TRAFO ON, G54, F800.0, M63, M71, ...) ignored.
|
||||||
|
|
||||||
|
except Exception as exc: # noqa: BLE001 - report and degrade gracefully
|
||||||
|
print(f"Error parsing NC file {filepath!r}: {exc}")
|
||||||
|
return {"part_name": Path(filepath).stem, "moves": moves}
|
||||||
|
|
||||||
|
if part_name is None:
|
||||||
|
part_name = Path(filepath).stem
|
||||||
|
|
||||||
|
return {"part_name": part_name, "moves": moves}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_tool_vector(a_deg: float, b_deg: float) -> tuple[float, float, float]:
|
||||||
|
"""Convert A/B axis angles to a unit tool-direction vector.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
a_deg -- A-axis angle in degrees.
|
||||||
|
b_deg -- B-axis angle in degrees.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
(u, v, w) -- a unit direction vector for where the extruder/welding
|
||||||
|
head is pointing, where:
|
||||||
|
u = X component
|
||||||
|
v = Y component
|
||||||
|
w = Z component
|
||||||
|
|
||||||
|
Formula:
|
||||||
|
u = sin(B)
|
||||||
|
v = -sin(A) * cos(B)
|
||||||
|
w = cos(A) * cos(B)
|
||||||
|
|
||||||
|
With A=0 and B=0 the result is (0.0, 0.0, 1.0) — the head points straight
|
||||||
|
up along +Z. The vector is already unit length for any A/B.
|
||||||
|
"""
|
||||||
|
a = math.radians(a_deg)
|
||||||
|
b = math.radians(b_deg)
|
||||||
|
u = math.sin(b)
|
||||||
|
v = -math.sin(a) * math.cos(b)
|
||||||
|
w = math.cos(a) * math.cos(b)
|
||||||
|
return (u, v, w)
|
||||||
|
|
||||||
|
|
||||||
|
def summarise(parsed: dict) -> None:
|
||||||
|
"""Print a clean human-readable summary of parsed NC data.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
parsed -- the dict returned by ``parse_nc`` (keys "part_name",
|
||||||
|
"moves").
|
||||||
|
|
||||||
|
Output:
|
||||||
|
None. Prints to the terminal: part name, total move count, number of
|
||||||
|
distinct Z layers (slices) and fractions, weld-ON vs rapid point
|
||||||
|
counts, and the Z (mm) / A (deg) / B (deg) ranges seen across moves.
|
||||||
|
"""
|
||||||
|
moves = parsed.get("moves", [])
|
||||||
|
part_name = parsed.get("part_name", "?")
|
||||||
|
|
||||||
|
total = len(moves)
|
||||||
|
layers = len({m["slice_no"] for m in moves})
|
||||||
|
fractions = len({(m["slice_no"], m["fraction_no"]) for m in moves})
|
||||||
|
weld_on = sum(1 for m in moves if m["weld_state"] == "on")
|
||||||
|
rapid = sum(1 for m in moves if m["move_type"] == "rapid")
|
||||||
|
|
||||||
|
print(f"Part name : {part_name}")
|
||||||
|
print(f"Total moves : {total}")
|
||||||
|
print(f"Layers : {layers}")
|
||||||
|
print(f"Fractions : {fractions}")
|
||||||
|
print(f"Weld ON pts : {weld_on}")
|
||||||
|
print(f"Rapid pts : {rapid}")
|
||||||
|
|
||||||
|
if moves:
|
||||||
|
zs = [m["z"] for m in moves]
|
||||||
|
as_ = [m["a"] for m in moves]
|
||||||
|
bs = [m["b"] for m in moves]
|
||||||
|
print(f"Z range : {min(zs):.2f} mm → {max(zs):.2f} mm")
|
||||||
|
print(f"A range : {min(as_):.2f}° → {max(as_):.2f}°")
|
||||||
|
print(f"B range : {min(bs):.2f}° → {max(bs):.2f}°")
|
||||||
|
else:
|
||||||
|
print("Z range : (no moves)")
|
||||||
|
print("A range : (no moves)")
|
||||||
|
print("B range : (no moves)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
TEST_NC = """(Part name: Hemisphere-v1)
|
||||||
|
#TRAFO ON
|
||||||
|
G54
|
||||||
|
M63
|
||||||
|
M71
|
||||||
|
(New Slice of body Hemisphere-v1 No.: 1, Z2.50)
|
||||||
|
(Change material to Steel)
|
||||||
|
(Hemisphere-v1, Slice 1, Fraction 1 Outer Perimeter 1)
|
||||||
|
F800.0
|
||||||
|
G00 X-48.09 Y-13.29 Z2.50 A0.00 B0.00
|
||||||
|
M60=1
|
||||||
|
G01 X-47.29 Y-15.96 Z2.50 A0.00 B0.00 M61
|
||||||
|
G01 X-46.78 Y-17.40 Z2.50 A0.00 B0.00
|
||||||
|
G01 X-45.71 Y-20.01 Z2.50 A2.50 B-1.20
|
||||||
|
M62
|
||||||
|
(Hemisphere-v1, Slice 1, Fraction 2 Outer Perimeter 2)
|
||||||
|
G00 X-31.23 Y-24.83 Z2.50 A0.00 B0.00
|
||||||
|
M60=11
|
||||||
|
G01 X-31.90 Y-23.97 Z2.50 A0.00 B0.00 M61
|
||||||
|
G01 X-32.64 Y-22.94 Z2.50 A1.80 B0.50
|
||||||
|
M62
|
||||||
|
M01
|
||||||
|
(New Slice of body Hemisphere-v1 No.: 2, Z7.50)
|
||||||
|
(Hemisphere-v1, Slice 2, Fraction 1 Outer Perimeter 1)
|
||||||
|
F800.0
|
||||||
|
G00 X-45.12 Y-10.55 Z7.50 A5.10 B-2.30
|
||||||
|
M60=11
|
||||||
|
G01 X-44.50 Y-12.80 Z7.50 A5.10 B-2.30 M61
|
||||||
|
G01 X-43.90 Y-15.10 Z7.50 A6.20 B-1.80
|
||||||
|
M62
|
||||||
|
M01
|
||||||
|
"""
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
"w", suffix=".nc", delete=False
|
||||||
|
) as fh:
|
||||||
|
fh.write(TEST_NC)
|
||||||
|
tmp_path = fh.name
|
||||||
|
|
||||||
|
parsed = parse_nc(tmp_path)
|
||||||
|
summarise(parsed)
|
||||||
|
|
||||||
|
# Quick sanity check on the tool-vector helper.
|
||||||
|
print()
|
||||||
|
print(f"tool vector @ A0 B0 : {compute_tool_vector(0.0, 0.0)}")
|
||||||
|
print(f"tool vector @ A5 B-2 : {compute_tool_vector(5.0, -2.0)}")
|
||||||
+359
@@ -0,0 +1,359 @@
|
|||||||
|
"""Drag-and-drop launcher for the NC toolpath viewer.
|
||||||
|
|
||||||
|
Starts a tiny local web server (Python standard-library ``http.server`` only —
|
||||||
|
no extra installs beyond what the viewer already needs) that serves a dark
|
||||||
|
full-screen **drop zone**. Drop an ``.nc`` file — or a whole folder of them —
|
||||||
|
onto the page and it is parsed with ``nc_parser`` and rendered with the exact
|
||||||
|
same Plotly viewer as ``nc_viewer.py`` (``build_figure``), shown inline in an
|
||||||
|
iframe. Drop another at any time; pick from a list when several are dropped.
|
||||||
|
|
||||||
|
Why a server (instead of pure client-side drag-drop): the parser and the viewer
|
||||||
|
already live in Python and must not be rewritten in JavaScript. The browser only
|
||||||
|
reads the dropped file's text and POSTs it; Python does the parsing + figure
|
||||||
|
building and returns ready-to-display HTML.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
python nc_server.py # serves on http://127.0.0.1:8765
|
||||||
|
python nc_server.py 9000 # custom port
|
||||||
|
|
||||||
|
Then open the printed URL and drag NC files onto it. Ctrl-C to stop.
|
||||||
|
|
||||||
|
Dependencies: plotly, numpy (same as the viewer). ``nc_parser.py`` and
|
||||||
|
``nc_viewer.py`` must sit in the same folder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import webbrowser
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import plotly.io as pio
|
||||||
|
|
||||||
|
from nc_parser import parse_nc
|
||||||
|
from nc_viewer import build_figure
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PORT = 8765
|
||||||
|
|
||||||
|
|
||||||
|
# --- the drop-zone page (served at "/") -------------------------------------
|
||||||
|
INDEX_HTML = """<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>wired3d visualizer</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: #0f0f1a; color: #e8e8f0;
|
||||||
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||||
|
#wrap { display: flex; flex-direction: column; height: 100vh; }
|
||||||
|
#bar { flex: 0 0 auto; padding: 10px 16px; display: flex; align-items: center;
|
||||||
|
gap: 14px; border-bottom: 1px solid #222; background: #16213e; }
|
||||||
|
#bar h1 { font-size: 15px; margin: 0; font-weight: 600; color: #fff; }
|
||||||
|
#status { font-size: 13px; color: #9aa0b5; }
|
||||||
|
#drop { flex: 1 1 auto; position: relative; }
|
||||||
|
#zone { position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; text-align: center;
|
||||||
|
border: 2px dashed #2a3a5e; margin: 18px; border-radius: 14px;
|
||||||
|
transition: background .15s, border-color .15s; }
|
||||||
|
#zone.hot { background: rgba(0,191,255,.10); border-color: #00bfff; }
|
||||||
|
#zone .big { font-size: 22px; color: #cfd3e6; }
|
||||||
|
#zone .sub { font-size: 13px; color: #7f86a0; margin-top: 8px; }
|
||||||
|
#zone .buttons { margin-top: 18px; display: flex; gap: 10px; }
|
||||||
|
#zone .buttons button { background: #0d47a1; color: #fff; border: 0;
|
||||||
|
border-radius: 8px; padding: 10px 16px; font-size: 14px; cursor: pointer; }
|
||||||
|
#zone .buttons button:hover { background: #1565c0; }
|
||||||
|
#picker { margin-top: 18px; display: flex; flex-wrap: wrap; gap: 8px;
|
||||||
|
justify-content: center; max-width: 80%; }
|
||||||
|
#picker button { background: #16213e; color: #e8e8f0; border: 1px solid #0d47a1;
|
||||||
|
border-radius: 8px; padding: 8px 12px; font-size: 13px; cursor: pointer; }
|
||||||
|
#picker button:hover { background: #1d2b4d; }
|
||||||
|
iframe { border: 0; width: 100%; height: 100%; display: none; background: #0f0f1a; }
|
||||||
|
a.file { color: #00bfff; text-decoration: none; cursor: pointer; }
|
||||||
|
/* Loading overlay shown while the server parses + builds the figure. */
|
||||||
|
#loading { position: absolute; inset: 0; display: none; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; gap: 22px;
|
||||||
|
background: rgba(15,15,26,.92); z-index: 5; }
|
||||||
|
#loading.show { display: flex; }
|
||||||
|
#loading .spinner { width: 64px; height: 64px; border-radius: 50%;
|
||||||
|
border: 6px solid #1d2b4d; border-top-color: #00bfff;
|
||||||
|
animation: spin 0.9s linear infinite; }
|
||||||
|
#loading .msg { font-size: 16px; color: #cfd3e6; }
|
||||||
|
#loading .sub2 { font-size: 12px; color: #7f86a0; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="wrap">
|
||||||
|
<div id="bar">
|
||||||
|
<h1>wired3d visualizer</h1>
|
||||||
|
<span id="status">Drag an .nc file (or a folder of them) anywhere on this page.</span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<a class="file" id="another" style="display:none">⤓ Drop another</a>
|
||||||
|
</div>
|
||||||
|
<div id="drop">
|
||||||
|
<div id="zone">
|
||||||
|
<div class="big">⬇ Drop an NC file here</div>
|
||||||
|
<div class="sub">a single <code>.nc</code> file or a whole folder — parsed locally, nothing is uploaded anywhere</div>
|
||||||
|
<div class="buttons">
|
||||||
|
<button id="browseFile">📄 Choose .nc file</button>
|
||||||
|
<button id="browseFolder">📁 Choose folder</button>
|
||||||
|
</div>
|
||||||
|
<input id="fileInput" type="file" accept=".nc,.NC" multiple hidden>
|
||||||
|
<input id="dirInput" type="file" webkitdirectory directory multiple hidden>
|
||||||
|
<div id="picker"></div>
|
||||||
|
</div>
|
||||||
|
<iframe id="view"></iframe>
|
||||||
|
<div id="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div class="msg" id="loadingMsg">Rendering…</div>
|
||||||
|
<div class="sub2">building the 3D toolpath — this can take a moment for large files</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const zone = document.getElementById('zone');
|
||||||
|
const picker = document.getElementById('picker');
|
||||||
|
const view = document.getElementById('view');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const another = document.getElementById('another');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const dirInput = document.getElementById('dirInput');
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
const loadingMsg = document.getElementById('loadingMsg');
|
||||||
|
|
||||||
|
function showLoading(name) {
|
||||||
|
loadingMsg.textContent = 'Rendering ' + name + ' …';
|
||||||
|
loading.classList.add('show');
|
||||||
|
}
|
||||||
|
function hideLoading() { loading.classList.remove('show'); }
|
||||||
|
|
||||||
|
function showZone() {
|
||||||
|
view.style.display = 'none';
|
||||||
|
zone.style.display = 'flex';
|
||||||
|
another.style.display = 'none';
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
another.addEventListener('click', showZone);
|
||||||
|
|
||||||
|
// --- drag & drop highlighting (preventDefault is required to allow a drop) ---
|
||||||
|
let dragDepth = 0;
|
||||||
|
document.addEventListener('dragenter', ev => {
|
||||||
|
ev.preventDefault(); dragDepth++; zone.classList.add('hot');
|
||||||
|
});
|
||||||
|
document.addEventListener('dragover', ev => { ev.preventDefault(); });
|
||||||
|
document.addEventListener('dragleave', ev => {
|
||||||
|
ev.preventDefault(); if (--dragDepth <= 0) zone.classList.remove('hot');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recurse a dropped directory entry, collecting .nc File objects.
|
||||||
|
function readEntry(entry, out) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (entry.isFile) {
|
||||||
|
entry.file(f => {
|
||||||
|
if (f.name.toLowerCase().endsWith('.nc')) out.push(f);
|
||||||
|
resolve();
|
||||||
|
}, () => resolve()); // resolve only AFTER the file callback
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
const reader = entry.createReader();
|
||||||
|
const all = [];
|
||||||
|
const readBatch = () => reader.readEntries(ents => {
|
||||||
|
if (!ents.length) {
|
||||||
|
Promise.all(all.map(e => readEntry(e, out))).then(resolve);
|
||||||
|
} else { all.push(...ents); readBatch(); }
|
||||||
|
}, () => resolve());
|
||||||
|
readBatch();
|
||||||
|
} else { resolve(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('drop', async ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
dragDepth = 0; zone.classList.remove('hot');
|
||||||
|
statusEl.textContent = 'Reading…';
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
const items = ev.dataTransfer.items;
|
||||||
|
let usedEntries = false;
|
||||||
|
if (items && items.length && items[0].webkitGetAsEntry) {
|
||||||
|
const entries = [];
|
||||||
|
for (const it of items) { const e = it.webkitGetAsEntry && it.webkitGetAsEntry(); if (e) entries.push(e); }
|
||||||
|
if (entries.length) { usedEntries = true; await Promise.all(entries.map(e => readEntry(e, files))); }
|
||||||
|
}
|
||||||
|
if (!usedEntries) { // fallback for browsers without entries API
|
||||||
|
for (const f of ev.dataTransfer.files)
|
||||||
|
if (f.name.toLowerCase().endsWith('.nc')) files.push(f);
|
||||||
|
}
|
||||||
|
handleFiles(files);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- "Choose file" / "Choose folder" buttons ---
|
||||||
|
document.getElementById('browseFile').addEventListener('click', () => fileInput.click());
|
||||||
|
document.getElementById('browseFolder').addEventListener('click', () => dirInput.click());
|
||||||
|
fileInput.addEventListener('change', () => handleFiles(ncOnly(fileInput.files)));
|
||||||
|
dirInput.addEventListener('change', () => handleFiles(ncOnly(dirInput.files)));
|
||||||
|
|
||||||
|
function ncOnly(fileList) {
|
||||||
|
return Array.from(fileList).filter(f => f.name.toLowerCase().endsWith('.nc'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render one .nc file, or show a picker when several were given.
|
||||||
|
function handleFiles(files) {
|
||||||
|
if (!files.length) {
|
||||||
|
statusEl.textContent = 'No .nc files found in what you selected.';
|
||||||
|
picker.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.length === 1) { render(files[0]); return; }
|
||||||
|
statusEl.textContent = files.length + ' NC files found — pick one:';
|
||||||
|
picker.innerHTML = '';
|
||||||
|
files.forEach(f => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.textContent = f.webkitRelativePath || f.name;
|
||||||
|
b.onclick = () => render(f);
|
||||||
|
picker.appendChild(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(file) {
|
||||||
|
statusEl.textContent = 'Rendering ' + file.name + ' …';
|
||||||
|
picker.innerHTML = '';
|
||||||
|
showLoading(file.name);
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const resp = await fetch('/render?name=' + encodeURIComponent(file.name),
|
||||||
|
{ method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: text });
|
||||||
|
if (!resp.ok) {
|
||||||
|
statusEl.textContent = 'Error: ' + (await resp.text());
|
||||||
|
hideLoading();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const html = await resp.text();
|
||||||
|
view.srcdoc = html;
|
||||||
|
zone.style.display = 'none';
|
||||||
|
view.style.display = 'block';
|
||||||
|
another.style.display = 'inline';
|
||||||
|
statusEl.textContent = file.name;
|
||||||
|
// Keep the spinner up until the iframe has actually painted the viewer.
|
||||||
|
view.onload = hideLoading;
|
||||||
|
} catch (err) {
|
||||||
|
statusEl.textContent = 'Error: ' + err;
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_nc_text(nc_text: str, name: str) -> str:
|
||||||
|
"""Parse NC source text and return a full viewer HTML document.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
nc_text -- raw contents of an .nc file.
|
||||||
|
name -- original file name (used for the title and as the part-name
|
||||||
|
fallback when the file has no "(Part name: ...)" comment).
|
||||||
|
Output:
|
||||||
|
a complete, self-contained HTML string (plotly.js from CDN), full
|
||||||
|
viewport, identical to what ``nc_viewer.py`` writes to disk — just
|
||||||
|
returned as a string for inline display instead of saved.
|
||||||
|
"""
|
||||||
|
# parse_nc reads from a path; write to a temp file, parse, then clean up.
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix=".nc", delete=False) as fh:
|
||||||
|
fh.write(nc_text)
|
||||||
|
tmp = fh.name
|
||||||
|
try:
|
||||||
|
parsed = parse_nc(tmp)
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp)
|
||||||
|
|
||||||
|
# Without a "(Part name: ...)" comment, parse_nc falls back to the temp
|
||||||
|
# file's random stem — restore the real dropped name instead.
|
||||||
|
if "(Part name:" not in nc_text:
|
||||||
|
parsed["part_name"] = Path(name).stem
|
||||||
|
|
||||||
|
fig = build_figure(parsed, name)
|
||||||
|
return pio.to_html(
|
||||||
|
fig, include_plotlyjs="cdn", full_html=True,
|
||||||
|
default_width="100%", default_height="100vh",
|
||||||
|
config={"responsive": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
|
"""Serves the drop page (GET /) and renders posted NC text (POST /render)."""
|
||||||
|
|
||||||
|
def _send(self, code: int, body: str, ctype: str = "text/html") -> None:
|
||||||
|
data = body.encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", f"{ctype}; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def do_GET(self): # noqa: N802 (http.server naming)
|
||||||
|
if self.path in ("/", "/index.html"):
|
||||||
|
self._send(200, INDEX_HTML)
|
||||||
|
else:
|
||||||
|
self._send(404, "not found", "text/plain")
|
||||||
|
|
||||||
|
def do_POST(self): # noqa: N802
|
||||||
|
if not self.path.startswith("/render"):
|
||||||
|
self._send(404, "not found", "text/plain")
|
||||||
|
return
|
||||||
|
# Original filename is passed as a query param (?name=...).
|
||||||
|
name = "dropped.nc"
|
||||||
|
if "?" in self.path:
|
||||||
|
from urllib.parse import parse_qs, urlparse, unquote
|
||||||
|
q = parse_qs(urlparse(self.path).query)
|
||||||
|
if "name" in q:
|
||||||
|
name = unquote(q["name"][0])
|
||||||
|
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
nc_text = self.rfile.read(length).decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
html = render_nc_text(nc_text, name)
|
||||||
|
self._send(200, html)
|
||||||
|
except Exception as exc: # noqa: BLE001 - report to the page
|
||||||
|
self._send(500, f"Failed to render {name}: {exc}", "text/plain")
|
||||||
|
|
||||||
|
def log_message(self, *_args):
|
||||||
|
pass # quiet; we print our own status line
|
||||||
|
|
||||||
|
|
||||||
|
def main(port: int = DEFAULT_PORT) -> None:
|
||||||
|
"""Start the drop-zone server and open it in the browser.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
port -- TCP port to listen on (default 8765).
|
||||||
|
Output:
|
||||||
|
None. Runs until Ctrl-C.
|
||||||
|
"""
|
||||||
|
ThreadingHTTPServer.allow_reuse_address = True
|
||||||
|
try:
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", port), _Handler)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Could not bind port {port}: {exc}")
|
||||||
|
print(f"Another server is likely already running. Try a different port: "
|
||||||
|
f"python nc_server.py {port + 1}")
|
||||||
|
return
|
||||||
|
url = f"http://127.0.0.1:{port}"
|
||||||
|
print(f"NC Viewer drop zone running at {url}")
|
||||||
|
print("Drag an .nc file (or a folder of them) onto the page. Ctrl-C to stop.")
|
||||||
|
try:
|
||||||
|
webbrowser.open(url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopped.")
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT
|
||||||
|
main(port)
|
||||||
+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])
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user