Visualizer 2
This commit is contained in:
@@ -5,9 +5,14 @@ Modules
|
||||
parser
|
||||
NC-dialect reader. Standard library only — importable with zero installs.
|
||||
viewer
|
||||
Plotly figure builder and self-contained HTML writer (needs plotly, numpy).
|
||||
NC Plotly figure builder and self-contained HTML writer (needs plotly, numpy).
|
||||
server
|
||||
Drag-and-drop local web front end around the viewer.
|
||||
Drag-and-drop local web front end around the NC viewer.
|
||||
stl_viewer
|
||||
STL mesh figure builder + HTML writer, with per-coordinate direction
|
||||
vectors (needs plotly, numpy, trimesh).
|
||||
server2
|
||||
Drag-and-drop local web front end around the STL viewer ("server 2").
|
||||
|
||||
This top-level package deliberately imports nothing heavy: ``import
|
||||
wired3d_viewer`` stays cheap and ``wired3d_viewer.parser`` keeps working without
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""Command-line entry point for the wired3d viewer package.
|
||||
|
||||
Usage:
|
||||
python -m wired3d_viewer view <file.nc> # write a standalone HTML viewer
|
||||
python -m wired3d_viewer serve [port] # start the drag-and-drop server
|
||||
python -m wired3d_viewer view <file.nc> # write a standalone NC viewer
|
||||
python -m wired3d_viewer serve [port] # NC drag-and-drop server
|
||||
python -m wired3d_viewer view-stl <file.stl> # write a standalone STL viewer
|
||||
python -m wired3d_viewer serve-stl [port] # STL drag-and-drop server (port 8766)
|
||||
|
||||
After ``pip install -e .`` the same commands are available as::
|
||||
|
||||
wired3d view <file.nc>
|
||||
wired3d serve [port]
|
||||
wired3d view-stl <file.stl>
|
||||
wired3d serve-stl [port]
|
||||
|
||||
Submodules are imported lazily inside each handler so that ``view``/``serve``
|
||||
only pull in plotly/numpy when actually used.
|
||||
@@ -35,11 +39,21 @@ def main(argv: list[str] | None = None) -> None:
|
||||
p_view.add_argument("nc_file", help="path to the .nc file to visualise")
|
||||
|
||||
p_serve = sub.add_parser(
|
||||
"serve", help="start the drag-and-drop web server")
|
||||
"serve", help="start the NC drag-and-drop web server")
|
||||
p_serve.add_argument(
|
||||
"port", nargs="?", type=int, default=8765,
|
||||
help="TCP port to listen on (default 8765)")
|
||||
|
||||
p_view_stl = sub.add_parser(
|
||||
"view-stl", help="render an STL mesh to a standalone HTML viewer")
|
||||
p_view_stl.add_argument("stl_file", help="path to the .stl file to visualise")
|
||||
|
||||
p_serve_stl = sub.add_parser(
|
||||
"serve-stl", help="start the STL drag-and-drop web server")
|
||||
p_serve_stl.add_argument(
|
||||
"port", nargs="?", type=int, default=8766,
|
||||
help="TCP port to listen on (default 8766)")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "view":
|
||||
@@ -48,6 +62,12 @@ def main(argv: list[str] | None = None) -> None:
|
||||
elif args.command == "serve":
|
||||
from .server import main as serve_main
|
||||
serve_main(args.port)
|
||||
elif args.command == "view-stl":
|
||||
from .stl_viewer import main as view_stl_main
|
||||
view_stl_main(args.stl_file)
|
||||
elif args.command == "serve-stl":
|
||||
from .server2 import main as serve_stl_main
|
||||
serve_stl_main(args.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
"""Drag-and-drop launcher for the STL mesh viewer (the "server 2").
|
||||
|
||||
The sibling :mod:`server` serves sliced NC toolpaths; this one serves the raw
|
||||
**STL mesh** so you can drop a part and rotate/zoom it in 3D with the mouse
|
||||
*before* slicing. Drop an ``.stl`` file (or a folder of them) onto the page; the
|
||||
browser POSTs the file bytes and Python loads it with ``trimesh``, builds the
|
||||
Plotly figure (``stl_viewer.build_figure``) and returns ready-to-display HTML
|
||||
shown inline in an iframe.
|
||||
|
||||
**Slice & Visualize:** once a part is loaded, a toolbar button slices it with
|
||||
the repo-root ``stl_slicer`` (layer height + infill spacing from the toolbar
|
||||
inputs) and shows the resulting NC toolpath with the very same NC viewer — STL
|
||||
in, toolpath view out, on one page. The browser does this in two steps so the
|
||||
part is sliced only once: ``POST /slice-nc`` returns the raw NC program text
|
||||
(kept client-side), then ``POST /render-nc`` turns that text into the viewer
|
||||
HTML. "Back to mesh" flips back to the cached mesh view.
|
||||
|
||||
**Download NC:** after slicing, a ⤓ Download NC button saves the held NC text
|
||||
as ``<part>.nc`` straight from the browser (a Blob — no server round-trip).
|
||||
|
||||
Run (from ``visualizer/``):
|
||||
python -m wired3d_viewer serve-stl # http://127.0.0.1:8766
|
||||
python -m wired3d_viewer serve-stl 9000 # custom port
|
||||
# or, after `pip install -e .`: wired3d serve-stl [port]
|
||||
|
||||
Dependencies: trimesh, plotly, numpy. Imports the sibling ``stl_viewer`` module.
|
||||
Binary STL note: unlike the NC server, files are POSTed as raw **bytes**
|
||||
(an ArrayBuffer) because STL is commonly binary — never decoded as text.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import plotly.io as pio
|
||||
|
||||
from .server import render_nc_text
|
||||
from .stl_viewer import build_figure, load_stl
|
||||
from .viewer import logo_data_uri
|
||||
|
||||
|
||||
DEFAULT_PORT = 8766
|
||||
|
||||
# stl_slicer.py lives at the repository root (two levels above this package:
|
||||
# wired3d_viewer/ -> visualizer/ -> <repo root>). It is not part of the
|
||||
# installed package, so we load it by path on first use.
|
||||
_SLICER = None
|
||||
|
||||
|
||||
def _load_slicer():
|
||||
"""Import and cache the repo-root ``stl_slicer`` module.
|
||||
|
||||
Output:
|
||||
the loaded ``stl_slicer`` module (with ``slice_stl_to_nc`` /
|
||||
``SlicerConfig``).
|
||||
Raises:
|
||||
FileNotFoundError if ``stl_slicer.py`` cannot be located.
|
||||
"""
|
||||
global _SLICER
|
||||
if _SLICER is None:
|
||||
path = Path(__file__).resolve().parents[2] / "stl_slicer.py"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"stl_slicer.py not found at {path}")
|
||||
spec = importlib.util.spec_from_file_location("stl_slicer", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# Register before exec: @dataclass resolves the class's module via
|
||||
# sys.modules["stl_slicer"], which must exist while the file runs.
|
||||
sys.modules["stl_slicer"] = module
|
||||
spec.loader.exec_module(module)
|
||||
_SLICER = module
|
||||
return _SLICER
|
||||
|
||||
|
||||
# --- 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>STL Viewer</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 img.logo { height: 28px; width: auto; display: block; }
|
||||
#bar h1 { font-size: 15px; margin: 0; font-weight: 600; color: #fff; }
|
||||
#status { font-size: 13px; color: #9aa0b5; }
|
||||
#slicebar { display: flex; align-items: center; gap: 10px; margin-right: 14px; }
|
||||
#slicebar label { font-size: 12px; color: #9aa0b5; }
|
||||
#slicebar input { width: 56px; background: #0f1626; color: #e8e8f0;
|
||||
border: 1px solid #2a3a5e; border-radius: 6px; padding: 4px 6px; font-size: 13px; }
|
||||
#slicebar button { background: #0d47a1; color: #fff; border: 0; border-radius: 7px;
|
||||
padding: 7px 12px; font-size: 13px; cursor: pointer; }
|
||||
#slicebar button:hover { background: #1565c0; }
|
||||
#slicebar #meshBtn { background: #16213e; border: 1px solid #2a3a5e; }
|
||||
#slicebar #meshBtn:hover { background: #1d2b4d; }
|
||||
#slicebar #downloadBtn { background: #1b7f4b; }
|
||||
#slicebar #downloadBtn:hover { background: #239a5c; }
|
||||
#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(74,144,217,.12); border-color: #4a90d9; }
|
||||
#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: #4a90d9; text-decoration: none; cursor: pointer; }
|
||||
#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 .logo-big { height: 72px; width: auto; display: block;
|
||||
animation: pulse 1.3s ease-in-out infinite; }
|
||||
#loading .spinner { width: 56px; height: 56px; border-radius: 50%;
|
||||
border: 6px solid #1d2b4d; border-top-color: #4a90d9;
|
||||
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); } }
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: .45; transform: scale(.96); }
|
||||
50% { opacity: 1; transform: scale(1.05); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrap">
|
||||
<div id="bar">
|
||||
__LOGO_IMG__
|
||||
<h1>STL Viewer</h1>
|
||||
<span id="status">Drag an .stl file (or a folder of them) anywhere on this page.</span>
|
||||
<span style="flex:1"></span>
|
||||
<span id="slicebar" style="display:none">
|
||||
<label>layer height (mm) <input id="layerH" type="number" min="0.1" step="0.5" value="5"></label>
|
||||
<label>infill (mm) <input id="infillS" type="number" min="0.1" step="0.5" value="5"></label>
|
||||
<button id="sliceBtn">🔪 Slice & Visualize</button>
|
||||
<button id="downloadBtn" style="display:none">⤓ Download NC</button>
|
||||
<button id="meshBtn" style="display:none">⬔ Back to mesh</button>
|
||||
</span>
|
||||
<a class="file" id="another" style="display:none">⤓ Drop another</a>
|
||||
</div>
|
||||
<div id="drop">
|
||||
<div id="zone">
|
||||
<div class="big">⬇ Drop an STL file here</div>
|
||||
<div class="sub">a single <code>.stl</code> file or a whole folder — loaded locally, nothing is uploaded anywhere</div>
|
||||
<div class="buttons">
|
||||
<button id="browseFile">📄 Choose .stl file</button>
|
||||
<button id="browseFolder">📁 Choose folder</button>
|
||||
</div>
|
||||
<input id="fileInput" type="file" accept=".stl,.STL" multiple hidden>
|
||||
<input id="dirInput" type="file" webkitdirectory directory multiple hidden>
|
||||
<div id="picker"></div>
|
||||
</div>
|
||||
<iframe id="view"></iframe>
|
||||
<div id="loading">
|
||||
__LOGO_LOADING__
|
||||
<div class="spinner"></div>
|
||||
<div class="msg" id="loadingMsg">Rendering…</div>
|
||||
<div class="sub2">loading the mesh + direction vectors — 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');
|
||||
const slicebar = document.getElementById('slicebar');
|
||||
const sliceBtn = document.getElementById('sliceBtn');
|
||||
const downloadBtn = document.getElementById('downloadBtn');
|
||||
const meshBtn = document.getElementById('meshBtn');
|
||||
const layerH = document.getElementById('layerH');
|
||||
const infillS = document.getElementById('infillS');
|
||||
|
||||
let currentFile = null; // the loaded STL File, re-sent when slicing
|
||||
let lastMeshHtml = null; // cached mesh view HTML, for "Back to mesh"
|
||||
let currentNcText = null; // NC text from the last slice, for instant download
|
||||
|
||||
function showLoading(msg) {
|
||||
loadingMsg.textContent = msg;
|
||||
loading.classList.add('show');
|
||||
}
|
||||
function hideLoading() { loading.classList.remove('show'); }
|
||||
|
||||
function showZone() {
|
||||
view.style.display = 'none';
|
||||
zone.style.display = 'flex';
|
||||
another.style.display = 'none';
|
||||
slicebar.style.display = 'none';
|
||||
downloadBtn.style.display = 'none';
|
||||
currentNcText = null;
|
||||
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 .stl File objects.
|
||||
function readEntry(entry, out) {
|
||||
return new Promise(resolve => {
|
||||
if (entry.isFile) {
|
||||
entry.file(f => {
|
||||
if (f.name.toLowerCase().endsWith('.stl')) 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('.stl')) 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(stlOnly(fileInput.files)));
|
||||
dirInput.addEventListener('change', () => handleFiles(stlOnly(dirInput.files)));
|
||||
|
||||
function stlOnly(fileList) {
|
||||
return Array.from(fileList).filter(f => f.name.toLowerCase().endsWith('.stl'));
|
||||
}
|
||||
|
||||
// Render one .stl file, or show a picker when several were given.
|
||||
function handleFiles(files) {
|
||||
if (!files.length) {
|
||||
statusEl.textContent = 'No .stl files found in what you selected.';
|
||||
picker.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
if (files.length === 1) { render(files[0]); return; }
|
||||
statusEl.textContent = files.length + ' STL 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('Rendering ' + file.name + ' …');
|
||||
currentFile = file;
|
||||
currentNcText = null; // new part — no NC until it is sliced
|
||||
downloadBtn.style.display = 'none';
|
||||
try {
|
||||
// STL is commonly binary — POST the raw bytes, not decoded text.
|
||||
const buf = await file.arrayBuffer();
|
||||
const resp = await fetch('/render?name=' + encodeURIComponent(file.name),
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: buf });
|
||||
if (!resp.ok) {
|
||||
statusEl.textContent = 'Error: ' + (await resp.text());
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
lastMeshHtml = await resp.text();
|
||||
view.srcdoc = lastMeshHtml;
|
||||
zone.style.display = 'none';
|
||||
view.style.display = 'block';
|
||||
another.style.display = 'inline';
|
||||
slicebar.style.display = 'flex';
|
||||
meshBtn.style.display = 'none';
|
||||
statusEl.textContent = file.name + ' — mesh';
|
||||
view.onload = hideLoading; // keep spinner until the iframe paints
|
||||
} catch (err) {
|
||||
statusEl.textContent = 'Error: ' + err;
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// Slice the loaded STL server-side, keep the NC text, and show the toolpath.
|
||||
// Two steps so we slice ONCE: /slice-nc returns the NC program (held for the
|
||||
// Download button), then /render-nc turns that text into the viewer HTML.
|
||||
async function doSlice() {
|
||||
if (!currentFile) return;
|
||||
showLoading('Slicing ' + currentFile.name + ' …');
|
||||
statusEl.textContent = 'Slicing ' + currentFile.name + ' …';
|
||||
try {
|
||||
const buf = await currentFile.arrayBuffer();
|
||||
const params = new URLSearchParams({
|
||||
name: currentFile.name,
|
||||
layer_height: layerH.value || '5',
|
||||
infill_spacing: infillS.value || '5',
|
||||
});
|
||||
const ncResp = await fetch('/slice-nc?' + params.toString(),
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: buf });
|
||||
if (!ncResp.ok) {
|
||||
statusEl.textContent = 'Slice error: ' + (await ncResp.text());
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
currentNcText = await ncResp.text();
|
||||
|
||||
const renderResp = await fetch('/render-nc?name=' + encodeURIComponent(currentFile.name),
|
||||
{ method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: currentNcText });
|
||||
if (!renderResp.ok) {
|
||||
statusEl.textContent = 'Render error: ' + (await renderResp.text());
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
view.srcdoc = await renderResp.text();
|
||||
meshBtn.style.display = 'inline-block';
|
||||
downloadBtn.style.display = 'inline-block';
|
||||
statusEl.textContent = currentFile.name + ' — sliced toolpath';
|
||||
view.onload = hideLoading;
|
||||
} catch (err) {
|
||||
statusEl.textContent = 'Slice error: ' + err;
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// Download the sliced NC program (no server round-trip — we kept the text).
|
||||
function downloadNc() {
|
||||
if (!currentNcText) return;
|
||||
const fname = currentFile ? currentFile.name : 'part';
|
||||
const stem = fname.toLowerCase().endsWith('.stl') ? fname.slice(0, -4) : fname;
|
||||
const blob = new Blob([currentNcText], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = stem + '.nc';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Restore the cached mesh view without a server round-trip.
|
||||
function showMesh() {
|
||||
if (lastMeshHtml === null) return;
|
||||
view.srcdoc = lastMeshHtml;
|
||||
meshBtn.style.display = 'none';
|
||||
statusEl.textContent = (currentFile ? currentFile.name : '') + ' — mesh';
|
||||
}
|
||||
|
||||
sliceBtn.addEventListener('click', doSlice);
|
||||
downloadBtn.addEventListener('click', downloadNc);
|
||||
meshBtn.addEventListener('click', showMesh);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def render_stl_bytes(stl_bytes: bytes, name: str) -> str:
|
||||
"""Load STL bytes and return a full viewer HTML document.
|
||||
|
||||
Inputs:
|
||||
stl_bytes -- raw contents of an .stl file (binary or ASCII).
|
||||
name -- original file name (used for the title / part name).
|
||||
Output:
|
||||
a complete, self-contained HTML string (plotly.js from CDN), identical
|
||||
to what ``stl_viewer.write_html`` saves — returned for inline display.
|
||||
"""
|
||||
# trimesh loads from a path; write the bytes to a temp file, load, clean up.
|
||||
with tempfile.NamedTemporaryFile("wb", suffix=".stl", delete=False) as fh:
|
||||
fh.write(stl_bytes)
|
||||
tmp = fh.name
|
||||
try:
|
||||
mesh = load_stl(tmp)
|
||||
finally:
|
||||
Path(tmp).unlink(missing_ok=True)
|
||||
|
||||
fig = build_figure(mesh, Path(name).stem)
|
||||
return pio.to_html(
|
||||
fig, include_plotlyjs="cdn", full_html=True,
|
||||
default_width="100%", default_height="100vh",
|
||||
config={"responsive": True},
|
||||
)
|
||||
|
||||
|
||||
def slice_stl_to_nc_text(stl_bytes: bytes, name: str,
|
||||
layer_height: float, infill_spacing: float) -> str:
|
||||
"""Slice STL bytes with ``stl_slicer`` and return the raw NC program text.
|
||||
|
||||
Inputs:
|
||||
stl_bytes -- raw contents of an .stl file (binary or ASCII).
|
||||
name -- original file name (used for the part name).
|
||||
layer_height -- slicer layer height in mm (the Z spacing between
|
||||
slices — NOT a layer count; a 50 mm part at 15 mm
|
||||
yields ~3 layers).
|
||||
infill_spacing -- slicer concentric-infill spacing in mm.
|
||||
Output:
|
||||
the generated NC program as text (the downloadable .nc contents). The
|
||||
viewer HTML is produced separately from this text, so slicing happens
|
||||
once and the same NC powers both the on-page view and the download.
|
||||
"""
|
||||
slicer = _load_slicer()
|
||||
stem = Path(name).stem
|
||||
cfg = slicer.SlicerConfig(
|
||||
layer_height=layer_height,
|
||||
infill_spacing=infill_spacing,
|
||||
part_name=stem,
|
||||
)
|
||||
# Slicer reads/writes paths; stage the STL and the NC output in a temp dir.
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
stl_path = Path(tmpdir) / "in.stl"
|
||||
nc_path = Path(tmpdir) / "out.nc"
|
||||
stl_path.write_bytes(stl_bytes)
|
||||
slicer.slice_stl_to_nc(stl_path, nc_path, cfg)
|
||||
return nc_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def slice_stl_bytes(stl_bytes: bytes, name: str,
|
||||
layer_height: float, infill_spacing: float) -> str:
|
||||
"""Slice STL bytes and return the NC viewer HTML (slice + render in one).
|
||||
|
||||
Convenience wrapper kept for the ``/slice`` route: produces the NC text via
|
||||
:func:`slice_stl_to_nc_text` then renders it with the shared NC viewer
|
||||
(``server.render_nc_text``) so the toolpath view is identical to the NC
|
||||
drop server's.
|
||||
"""
|
||||
nc_text = slice_stl_to_nc_text(stl_bytes, name, layer_height, infill_spacing)
|
||||
return render_nc_text(nc_text, f"{Path(name).stem}.nc")
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
"""Serves the drop page (GET /), renders posted STL bytes (POST /render),
|
||||
and slices + visualises them (POST /slice)."""
|
||||
|
||||
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"):
|
||||
uri = logo_data_uri()
|
||||
bar_img = f'<img class="logo" src="{uri}" alt="wired3d">' if uri else ""
|
||||
load_img = f'<img class="logo-big" src="{uri}" alt="wired3d">' if uri else ""
|
||||
page = (INDEX_HTML
|
||||
.replace("__LOGO_IMG__", bar_img)
|
||||
.replace("__LOGO_LOADING__", load_img))
|
||||
self._send(200, page)
|
||||
else:
|
||||
self._send(404, "not found", "text/plain")
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
# Routes (longest-prefix first so "/slice-nc" isn't caught by "/slice"):
|
||||
# /slice-nc STL bytes -> raw NC program text (for download)
|
||||
# /render-nc NC text -> NC viewer HTML
|
||||
# /slice STL bytes -> NC viewer HTML (slice + render in one)
|
||||
# /render STL bytes -> STL mesh viewer HTML
|
||||
from urllib.parse import parse_qs, urlparse, unquote
|
||||
parsed_url = urlparse(self.path)
|
||||
route = next((r for r in ("/slice-nc", "/render-nc", "/slice", "/render")
|
||||
if parsed_url.path == r), None)
|
||||
if route is None:
|
||||
self._send(404, "not found", "text/plain")
|
||||
return
|
||||
|
||||
q = parse_qs(parsed_url.query)
|
||||
name = unquote(q["name"][0]) if "name" in q else "dropped.stl"
|
||||
layer_height = float(q.get("layer_height", ["5"])[0])
|
||||
infill_spacing = float(q.get("infill_spacing", ["5"])[0])
|
||||
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length)
|
||||
|
||||
try:
|
||||
if route == "/slice-nc":
|
||||
nc_text = slice_stl_to_nc_text(body, name, layer_height, infill_spacing)
|
||||
self._send(200, nc_text, "text/plain")
|
||||
elif route == "/render-nc":
|
||||
# body is NC program text; render it with the shared NC viewer.
|
||||
html = render_nc_text(body.decode("utf-8", errors="replace"),
|
||||
f"{Path(name).stem}.nc")
|
||||
self._send(200, html)
|
||||
elif route == "/slice":
|
||||
self._send(200, slice_stl_bytes(body, name, layer_height, infill_spacing))
|
||||
else: # /render
|
||||
self._send(200, render_stl_bytes(body, name))
|
||||
except Exception as exc: # noqa: BLE001 - report to the page
|
||||
self._send(500, f"Failed ({route}) {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 STL drop-zone server and open it in the browser.
|
||||
|
||||
Inputs:
|
||||
port -- TCP port to listen on (default 8766).
|
||||
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 -m wired3d_viewer serve-stl {port + 1}")
|
||||
return
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
print(f"STL Viewer drop zone running at {url}")
|
||||
print("Drag an .stl 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)
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Interactive 3D viewer for raw STL meshes (pre-slicing).
|
||||
|
||||
This is the mesh-side companion to ``viewer.py`` (which renders sliced NC
|
||||
toolpaths). It loads an STL with ``trimesh`` and builds a single self-contained
|
||||
Plotly HTML document showing the solid surface, so you can rotate/zoom the part
|
||||
in 3D *before* slicing.
|
||||
|
||||
A per-face **direction-vector** overlay (the surface normals, via
|
||||
:func:`build_normal_trace`) is kept in this module as the seed of the
|
||||
projectile-direction feature, but is **not drawn yet** — once slicing is wired
|
||||
in, the direction shown will be the tool-head orientation at every toolpath
|
||||
point rather than raw facet normals.
|
||||
|
||||
The styling deliberately mirrors ``viewer.py``: dark theme, perspective camera,
|
||||
axis ranges pinned to the part bounding box, the wired3d logo embedded top-left,
|
||||
and a stats table. It reuses :func:`viewer.logo_data_uri` and
|
||||
:data:`viewer.DEFAULT_CAMERA` so the two viewers stay visually consistent.
|
||||
|
||||
Run (from ``visualizer/``):
|
||||
python -m wired3d_viewer view-stl <file.stl> # writes <stem>_stl_viewer.html
|
||||
|
||||
Dependencies: trimesh, plotly, numpy.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import plotly.graph_objects as go
|
||||
import plotly.io as pio
|
||||
import trimesh
|
||||
|
||||
from .viewer import DEFAULT_CAMERA, FIG_BG, SCENE_BG, logo_data_uri
|
||||
|
||||
# Surface + direction-vector colours, in keeping with the NC viewer palette.
|
||||
MESH_COLOUR = "#4a90d9" # cool steel blue for the solid surface
|
||||
NORMAL_COLOUR = "#ffcc00" # amber direction vectors — pop against the blue
|
||||
EDGE_COLOUR = "#1b2a44"
|
||||
|
||||
# Cap on how many normal vectors we draw. A dense mesh can have 10k+ faces;
|
||||
# drawing one cone per face is both unreadable and slow, so we evenly sample
|
||||
# down to this many. The full mesh is always drawn — only the vector overlay is
|
||||
# decimated (and the count of dropped vectors is reported in the stats table).
|
||||
MAX_NORMALS = 1500
|
||||
|
||||
|
||||
def load_stl(filepath: str) -> trimesh.Trimesh:
|
||||
"""Load an STL file and return a single concrete ``Trimesh``.
|
||||
|
||||
Inputs:
|
||||
filepath -- path to a binary or ASCII ``.stl`` file.
|
||||
Output:
|
||||
a ``trimesh.Trimesh``. If the file is a scene (multiple bodies) it is
|
||||
concatenated into one mesh so the rest of the pipeline sees one object.
|
||||
Raises:
|
||||
ValueError if the file contains no triangulated geometry.
|
||||
"""
|
||||
loaded = trimesh.load(filepath, force="mesh")
|
||||
if isinstance(loaded, trimesh.Scene):
|
||||
loaded = trimesh.util.concatenate(loaded.dump())
|
||||
if not isinstance(loaded, trimesh.Trimesh) or len(loaded.faces) == 0:
|
||||
raise ValueError(f"No triangle mesh found in {filepath!r}")
|
||||
return loaded
|
||||
|
||||
|
||||
def _axis_ranges(mesh: trimesh.Trimesh) -> dict:
|
||||
"""Return padded, equal-aspect X/Y/Z scene ranges for the mesh bounds.
|
||||
|
||||
Inputs:
|
||||
mesh -- the loaded ``Trimesh``.
|
||||
Output:
|
||||
dict with ``xaxis``/``yaxis``/``zaxis`` range entries. A common padded
|
||||
cube edge is used for all three axes so the part is never distorted and
|
||||
the view does not zoom-jump (same approach as the NC viewer).
|
||||
"""
|
||||
lo, hi = mesh.bounds
|
||||
centre = (lo + hi) / 2.0
|
||||
half = float((hi - lo).max()) / 2.0
|
||||
half = half * 1.1 if half > 0 else 1.0 # 10% padding; guard a flat part
|
||||
return dict(
|
||||
xaxis=dict(range=[centre[0] - half, centre[0] + half]),
|
||||
yaxis=dict(range=[centre[1] - half, centre[1] + half]),
|
||||
zaxis=dict(range=[centre[2] - half, centre[2] + half]),
|
||||
)
|
||||
|
||||
|
||||
def build_mesh_trace(mesh: trimesh.Trimesh) -> go.Mesh3d:
|
||||
"""Build the solid-surface ``Mesh3d`` trace.
|
||||
|
||||
Inputs:
|
||||
mesh -- the loaded ``Trimesh``.
|
||||
Output:
|
||||
a lit ``go.Mesh3d`` using the mesh vertices/faces.
|
||||
"""
|
||||
v = mesh.vertices
|
||||
f = mesh.faces
|
||||
return go.Mesh3d(
|
||||
x=v[:, 0], y=v[:, 1], z=v[:, 2],
|
||||
i=f[:, 0], j=f[:, 1], k=f[:, 2],
|
||||
color=MESH_COLOUR,
|
||||
opacity=1.0,
|
||||
flatshading=True,
|
||||
name="STL surface",
|
||||
showlegend=True,
|
||||
lighting=dict(ambient=0.45, diffuse=0.85, specular=0.25, roughness=0.55),
|
||||
lightposition=dict(x=1000, y=1000, z=2000),
|
||||
hoverinfo="skip",
|
||||
)
|
||||
|
||||
|
||||
def _sample_faces(n_faces: int, cap: int) -> np.ndarray:
|
||||
"""Return indices of faces to draw a normal for (evenly sampled, capped).
|
||||
|
||||
Inputs:
|
||||
n_faces -- total number of faces.
|
||||
cap -- maximum number of vectors to draw.
|
||||
Output:
|
||||
a 1-D int array of face indices (all of them when ``n_faces <= cap``,
|
||||
otherwise an even stride across the range).
|
||||
"""
|
||||
if n_faces <= cap:
|
||||
return np.arange(n_faces)
|
||||
return np.linspace(0, n_faces - 1, cap).astype(int)
|
||||
|
||||
|
||||
def build_normal_trace(mesh: trimesh.Trimesh) -> tuple[go.Cone, int]:
|
||||
"""Build the per-face direction-vector overlay (surface normals as cones).
|
||||
|
||||
Inputs:
|
||||
mesh -- the loaded ``Trimesh``.
|
||||
Output:
|
||||
``(cone_trace, shown)`` where ``cone_trace`` is a ``go.Cone`` quiver
|
||||
placed at each sampled face centroid pointing along its outward normal,
|
||||
and ``shown`` is how many vectors were actually drawn (after sampling).
|
||||
The cones are length-normalised relative to the part size so they read as
|
||||
little arrows regardless of model scale.
|
||||
"""
|
||||
idx = _sample_faces(len(mesh.faces), MAX_NORMALS)
|
||||
origins = mesh.triangles_center[idx]
|
||||
normals = mesh.face_normals[idx]
|
||||
|
||||
# Scale the cone vectors to ~3% of the bounding-box diagonal so they are
|
||||
# visible but don't swamp the surface. go.Cone sizes cones by vector norm.
|
||||
diag = float(np.linalg.norm(mesh.bounds[1] - mesh.bounds[0])) or 1.0
|
||||
vec = normals * (diag * 0.03)
|
||||
|
||||
cone = go.Cone(
|
||||
x=origins[:, 0], y=origins[:, 1], z=origins[:, 2],
|
||||
u=vec[:, 0], v=vec[:, 1], w=vec[:, 2],
|
||||
colorscale=[[0, NORMAL_COLOUR], [1, NORMAL_COLOUR]],
|
||||
showscale=False,
|
||||
sizemode="absolute",
|
||||
sizeref=diag * 0.03,
|
||||
anchor="tail",
|
||||
name="Direction (face normals)",
|
||||
showlegend=True,
|
||||
hoverinfo="x+y+z",
|
||||
)
|
||||
return cone, len(idx)
|
||||
|
||||
|
||||
def compute_stats(mesh: trimesh.Trimesh) -> dict:
|
||||
"""Compute a small dictionary of human-readable mesh statistics.
|
||||
|
||||
Inputs:
|
||||
mesh -- the loaded ``Trimesh``.
|
||||
Output:
|
||||
ordered dict of ``label -> value`` strings for the stats table.
|
||||
"""
|
||||
lo, hi = mesh.bounds
|
||||
size = hi - lo
|
||||
stats = {
|
||||
"Vertices": f"{len(mesh.vertices):,}",
|
||||
"Faces": f"{len(mesh.faces):,}",
|
||||
"Size X×Y×Z (mm)": f"{size[0]:.1f} × {size[1]:.1f} × {size[2]:.1f}",
|
||||
"Bounds min (mm)": f"({lo[0]:.1f}, {lo[1]:.1f}, {lo[2]:.1f})",
|
||||
"Bounds max (mm)": f"({hi[0]:.1f}, {hi[1]:.1f}, {hi[2]:.1f})",
|
||||
"Watertight": "yes" if mesh.is_watertight else "no",
|
||||
"Volume (mm³)": f"{mesh.volume:,.1f}" if mesh.is_watertight else "n/a",
|
||||
"Surface area (mm²)": f"{mesh.area:,.1f}",
|
||||
}
|
||||
return stats
|
||||
|
||||
|
||||
def build_stats_table(stats: dict) -> go.Table:
|
||||
"""Build the bottom-right stats ``go.Table`` trace.
|
||||
|
||||
Inputs:
|
||||
stats -- the dict from :func:`compute_stats`.
|
||||
Output:
|
||||
a styled ``go.Table`` (dark theme, two columns).
|
||||
"""
|
||||
return go.Table(
|
||||
columnwidth=[1.1, 1.4],
|
||||
header=dict(
|
||||
values=["<b>Property</b>", "<b>Value</b>"],
|
||||
fill_color="#16213e", font=dict(color="#e8e8f0", size=13),
|
||||
align="left", height=26,
|
||||
),
|
||||
cells=dict(
|
||||
values=[list(stats.keys()), list(stats.values())],
|
||||
fill_color="#0f1626", font=dict(color="#cfd3e6", size=12),
|
||||
align="left", height=24,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_figure(mesh: trimesh.Trimesh, part_name: str) -> go.Figure:
|
||||
"""Assemble the full STL viewer figure (3D mesh + stats table).
|
||||
|
||||
Inputs:
|
||||
mesh -- the loaded ``Trimesh``.
|
||||
part_name -- title shown in the header.
|
||||
Output:
|
||||
a ``go.Figure`` with a large left 3D scene and a bottom-right stats
|
||||
table, dark themed, perspective camera, axis ranges pinned to bounds.
|
||||
"""
|
||||
from plotly.subplots import make_subplots
|
||||
|
||||
fig = make_subplots(
|
||||
rows=2, cols=2,
|
||||
column_widths=[0.74, 0.26], row_heights=[0.55, 0.45],
|
||||
specs=[[{"type": "scene", "rowspan": 2}, {"type": "table"}],
|
||||
[None, {"type": "table"}]],
|
||||
horizontal_spacing=0.02, vertical_spacing=0.04,
|
||||
)
|
||||
|
||||
# Direction vectors (build_normal_trace) are intentionally not drawn yet —
|
||||
# see the module docstring. Only the solid surface is shown for now.
|
||||
fig.add_trace(build_mesh_trace(mesh), row=1, col=1)
|
||||
|
||||
table = build_stats_table(compute_stats(mesh))
|
||||
fig.add_trace(table, row=1, col=2)
|
||||
|
||||
ranges = _axis_ranges(mesh)
|
||||
fig.update_layout(
|
||||
title=dict(text=f"STL Viewer — {part_name}", x=0.5,
|
||||
font=dict(color="#e8e8f0", size=18)),
|
||||
paper_bgcolor=FIG_BG,
|
||||
font=dict(color="#e8e8f0"),
|
||||
showlegend=True,
|
||||
legend=dict(x=0.0, y=1.0, bgcolor="rgba(22,33,62,0.6)",
|
||||
font=dict(size=12), traceorder="normal"),
|
||||
autosize=True,
|
||||
margin=dict(l=0, r=0, t=44, b=0),
|
||||
scene=dict(
|
||||
bgcolor=SCENE_BG,
|
||||
xaxis=dict(title="X", color="#9aa0b5", **ranges["xaxis"]),
|
||||
yaxis=dict(title="Y", color="#9aa0b5", **ranges["yaxis"]),
|
||||
zaxis=dict(title="Z", color="#9aa0b5", **ranges["zaxis"]),
|
||||
aspectmode="cube",
|
||||
camera=DEFAULT_CAMERA,
|
||||
uirevision="keep",
|
||||
),
|
||||
)
|
||||
|
||||
logo = logo_data_uri()
|
||||
if logo:
|
||||
fig.add_layout_image(dict(
|
||||
source=logo, xref="paper", yref="paper",
|
||||
x=0.01, y=0.99, sizex=0.12, sizey=0.12,
|
||||
xanchor="left", yanchor="top", layer="above",
|
||||
))
|
||||
return fig
|
||||
|
||||
|
||||
def write_html(mesh: trimesh.Trimesh, part_name: str, out_path: str) -> str:
|
||||
"""Build the figure and write a self-contained HTML file.
|
||||
|
||||
Inputs:
|
||||
mesh -- the loaded ``Trimesh``.
|
||||
part_name -- title / part name.
|
||||
out_path -- destination ``.html`` path.
|
||||
Output:
|
||||
``out_path`` (also written to disk). plotly.js is pulled from the CDN
|
||||
so the file is small but needs a network connection on first open.
|
||||
"""
|
||||
fig = build_figure(mesh, part_name)
|
||||
pio.write_html(
|
||||
fig, out_path, include_plotlyjs="cdn", full_html=True,
|
||||
default_width="100%", default_height="100vh",
|
||||
config={"responsive": True},
|
||||
)
|
||||
return out_path
|
||||
|
||||
|
||||
def main(stl_file: str) -> None:
|
||||
"""CLI entry: render one STL file to ``<stem>_stl_viewer.html``.
|
||||
|
||||
Inputs:
|
||||
stl_file -- path to the ``.stl`` file.
|
||||
Output:
|
||||
None. Prints the saved HTML path.
|
||||
"""
|
||||
path = Path(stl_file)
|
||||
mesh = load_stl(str(path))
|
||||
out = path.with_name(f"{path.stem}_stl_viewer.html")
|
||||
write_html(mesh, path.stem, str(out))
|
||||
print(f"Saved: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: python -m wired3d_viewer.stl_viewer <file.stl>")
|
||||
sys.exit(1)
|
||||
main(sys.argv[1])
|
||||
+134
-2
@@ -107,6 +107,42 @@ gd.on('plotly_legendclick', function(ev) {
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// --- Projectile toggle (custom HTML button, reliable single-click) ---------
|
||||
// Replaces the old Plotly updatemenu toggle, whose `active` default left the
|
||||
// button highlighted while the overlay was hidden (inverted/2-click state).
|
||||
// This button is the SOLE control for the projectile line + arrowheads; its
|
||||
// label and colour always reflect the real visibility.
|
||||
(function() {
|
||||
var lineIdx = -1, arrowIdx = -1;
|
||||
gd.data.forEach(function(t, i) {
|
||||
if (t.name === 'Projectile (tool direction)') lineIdx = i;
|
||||
if (t.name === 'Projectile arrowheads') arrowIdx = i;
|
||||
});
|
||||
if (lineIdx === -1) return; // no projectile in this figure
|
||||
var idxs = arrowIdx === -1 ? [lineIdx] : [lineIdx, arrowIdx];
|
||||
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
// Sits just BELOW the layer dropdown (top-left) so the two never overlap.
|
||||
btn.style.cssText = 'position:fixed;top:52px;left:14px;z-index:1000;' +
|
||||
'padding:7px 13px;font-size:13px;border-radius:7px;cursor:pointer;' +
|
||||
'border:1px solid #ff9d00;font-family:system-ui,-apple-system,sans-serif;';
|
||||
|
||||
function isOn() { return gd.data[lineIdx].visible === true; }
|
||||
function refresh() {
|
||||
var on = isOn();
|
||||
btn.textContent = on ? '🎯 Projectile: ON' : '🎯 Projectile: OFF';
|
||||
btn.style.background = on ? '#ff9d00' : '#16213e';
|
||||
btn.style.color = on ? '#1a1a2e' : '#ffffff';
|
||||
}
|
||||
btn.addEventListener('click', function() {
|
||||
Plotly.restyle(gd, {visible: isOn() ? 'legendonly' : true}, idxs)
|
||||
.then(refresh);
|
||||
});
|
||||
document.body.appendChild(btn);
|
||||
refresh();
|
||||
})();
|
||||
"""
|
||||
|
||||
# Dark theme palette.
|
||||
@@ -560,6 +596,85 @@ def build_cursor_trace(moves: list[dict]) -> go.Scatter3d:
|
||||
)
|
||||
|
||||
|
||||
def build_projectile_trace(moves: list[dict], length: float) -> go.Scatter3d:
|
||||
"""Build the per-point 'projectile' overlay: the tool direction at every move.
|
||||
|
||||
Inputs:
|
||||
moves -- list of move dicts (mm coords, A/B degrees).
|
||||
length -- length (mm) of each direction stick.
|
||||
Output:
|
||||
a single ``go.Scatter3d`` line trace containing one short segment per
|
||||
move — from the move's coordinate along its tool vector
|
||||
``compute_tool_vector(a, b)`` (straight up at A=B=0, tilted as the head
|
||||
inclines). Segments are separated by ``None`` so the whole field of
|
||||
directions is one lightweight trace. Hidden by default
|
||||
(``visible="legendonly"``, no legend entry) and revealed by the custom
|
||||
"Projectile" toggle button injected via the post-script. This is the
|
||||
projectile/direction-of-every-coordinate view.
|
||||
"""
|
||||
xs: list = []
|
||||
ys: list = []
|
||||
zs: list = []
|
||||
for m in moves:
|
||||
u, v, w = compute_tool_vector(m["a"], m["b"])
|
||||
xs += [m["x"], m["x"] + u * length, None]
|
||||
ys += [m["y"], m["y"] + v * length, None]
|
||||
zs += [m["z"], m["z"] + w * length, None]
|
||||
return go.Scatter3d(
|
||||
x=xs, y=ys, z=zs,
|
||||
mode="lines",
|
||||
line=dict(color="#ff9d00", width=2),
|
||||
name="Projectile (tool direction)",
|
||||
visible="legendonly",
|
||||
# Toggled only by the custom HTML "Projectile" button (no legend entry),
|
||||
# so the line and its arrowheads never get out of sync.
|
||||
showlegend=False,
|
||||
hoverinfo="skip",
|
||||
)
|
||||
|
||||
|
||||
def build_projectile_arrows(moves: list[dict], length: float) -> go.Cone:
|
||||
"""Build arrowheads (cones) at the tip of every projectile vector.
|
||||
|
||||
Inputs:
|
||||
moves -- list of move dicts (mm coords, A/B degrees).
|
||||
length -- length (mm) of each projectile stick (the cone sits at its end).
|
||||
Output:
|
||||
a ``go.Cone`` trace with one small cone per move, placed at the stick's
|
||||
TIP (``coord + length × tool_vector``) and pointing along the tool
|
||||
vector, so each projectile line reads as a directional arrow. Hidden by
|
||||
default (``visible="legendonly"``) and toggled together with the line by
|
||||
the Projectile button. ``go.Cone`` colours by vector norm, so the
|
||||
colorscale is pinned to the single projectile amber.
|
||||
"""
|
||||
xs: list = []
|
||||
ys: list = []
|
||||
zs: list = []
|
||||
us: list = []
|
||||
vs: list = []
|
||||
ws: list = []
|
||||
for m in moves:
|
||||
u, v, w = compute_tool_vector(m["a"], m["b"])
|
||||
xs.append(m["x"] + u * length)
|
||||
ys.append(m["y"] + v * length)
|
||||
zs.append(m["z"] + w * length)
|
||||
us.append(u)
|
||||
vs.append(v)
|
||||
ws.append(w)
|
||||
return go.Cone(
|
||||
x=xs, y=ys, z=zs, u=us, v=vs, w=ws,
|
||||
anchor="tip", # cone point sits at the stick tip
|
||||
sizemode="absolute",
|
||||
sizeref=length * 0.45,
|
||||
colorscale=[[0, "#ff9d00"], [1, "#ff9d00"]],
|
||||
showscale=False,
|
||||
name="Projectile arrowheads",
|
||||
showlegend=False, # one legend entry (the line) is enough
|
||||
visible="legendonly",
|
||||
hoverinfo="skip",
|
||||
)
|
||||
|
||||
|
||||
def build_trail_trace() -> go.Scatter3d:
|
||||
"""Build the (initially empty) trail that is drawn progressively on Play.
|
||||
|
||||
@@ -864,13 +979,30 @@ def build_figure(parsed: dict, source_file: str) -> go.Figure:
|
||||
# --- stats table (row 2, col 2) ---
|
||||
fig.add_trace(build_stats_table(stats), row=2, col=2)
|
||||
|
||||
# --- projectile overlay (3D scene) ---
|
||||
# Appended LAST so it never shifts the indices the frames/dropdown reference
|
||||
# (rapid/weld/cursor/trail/angles/table). It is a scene trace despite being
|
||||
# added after the table — add_trace order is just append order. Hidden until
|
||||
# the custom "Projectile" HTML button (post-script) toggles it on.
|
||||
stick = _stick_length(moves)
|
||||
projectile_index = len(fig.data)
|
||||
fig.add_trace(build_projectile_trace(moves, stick), row=1, col=1)
|
||||
arrow_index = len(fig.data)
|
||||
fig.add_trace(build_projectile_arrows(moves, stick), row=1, col=1)
|
||||
projectile_traces = [projectile_index, arrow_index]
|
||||
|
||||
# Total trace count (for dropdown bookkeeping): rapid + welds + cursor +
|
||||
# trail + 4 angle traces + table.
|
||||
total_traces = 1 + len(weld_traces) + 2 + 4 + 1
|
||||
# trail + 4 angle traces + table + projectile line + arrowheads.
|
||||
total_traces = 1 + len(weld_traces) + 2 + 4 + 1 + 2
|
||||
|
||||
dropdown = build_layer_dropdown(
|
||||
weld_traces, n_static_before=1, total_traces=total_traces,
|
||||
)
|
||||
# The projectile overlay is toggled by a custom HTML button injected via the
|
||||
# post-script (LEGEND_CLICK_JS), not a Plotly updatemenu — the updatemenu
|
||||
# toggle's default `active` left the button visually inverted/2-click. The
|
||||
# JS finds the two projectile traces by name, so no indices are passed here.
|
||||
_ = projectile_traces # indices kept for clarity; JS locates traces by name
|
||||
|
||||
# --- animation: frames + play/pause/reset + slider ---
|
||||
frames, play_menu, slider = build_animation(
|
||||
|
||||
Reference in New Issue
Block a user