308 lines
11 KiB
Python
308 lines
11 KiB
Python
"""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])
|