Files
visualizer/wired3d_viewer/stl_viewer.py
T
Vignesh Suresh 29dfbf99a5 Visualizer 2
2026-06-06 20:43:01 +02:00

308 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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])