Skill Tracer
Skills › Scientific Agent Skills › lab-hardware-cad › snapshot.py

snapshot.py

278 lines · skills/lab-hardware-cad/scripts/snapshot.py at 5f2bc03

1#!/usr/bin/env python3
2"""Render a part to a multi-view PNG for mandatory visual review.
3
4 python scripts/snapshot.py out/carrier.step --out out/carrier.png
5 python scripts/snapshot.py carrier_model.py --out out/carrier.png --views iso,front,top
6
7Renders offscreen through matplotlib's Agg backend, so it needs no display, no GPU,
8and no viewer application. Faces come from OpenCascade's tessellation; the outlines
9are the model's real BREP edges, drawn without hidden-line removal, so the render
10reads slightly x-ray.
12This step exists because ``is_valid`` and a correct bounding box are both fully
13consistent with a pocket cut on the wrong face, an inverted mold polarity, or a
14feature placed outside the body. Look at the image.
15"""
17from __future__ import annotations
19import argparse
20import sys
21from pathlib import Path
23sys.path.insert(0, str(Path(__file__).resolve().parent))
25from _common import ( # noqa: E402
26 LabCadError,
27 eprint,
28 load_shape,
29 main_guard,
32# elevation, azimuth
33VIEWS = {
34 "iso": (24.0, -58.0),
35 "front": (0.0, -90.0),
36 "back": (0.0, 90.0),
37 "right": (0.0, 0.0),
38 "left": (0.0, 180.0),
39 "top": (89.9, -90.0),
40 "bottom": (-89.9, -90.0),
42DEFAULT_VIEWS = ["iso", "front", "right", "top", "left", "bottom"]
45def _require_matplotlib():
46 try:
47 import matplotlib # noqa: PLC0415
48 except ImportError as exc: # pragma: no cover - environment dependent
49 raise LabCadError(
50 "matplotlib is not installed in this interpreter.\n"
51 ' uv pip install --python .venv-labcad/bin/python "matplotlib>=3.8"'
52 ) from exc
53 matplotlib.use("Agg")
54 return matplotlib
57def _to_rgb(color: str):
58 from matplotlib.colors import to_rgb # noqa: PLC0415
60 return to_rgb(color)
63def _shade(base, normals, elevation: float, azimuth: float):
64 """Flat-shade each triangle by its angle to the camera for this view.
66 Without this every face renders the same colour, which makes pockets,
67 steps, and bosses almost impossible to read - defeating the purpose of a
68 review render.
69 """
70 import numpy as np # noqa: PLC0415
72 elev_rad = np.radians(elevation)
73 azim_rad = np.radians(azimuth)
74 camera = np.array([
75 np.cos(elev_rad) * np.cos(azim_rad),
76 np.cos(elev_rad) * np.sin(azim_rad),
77 np.sin(elev_rad),
78 ])
79 # Offset the light from the camera so faces square-on to the viewer still
80 # separate from those angled away.
81 light = camera + np.array([0.35, 0.25, 0.55])
82 light /= np.linalg.norm(light)
84 intensity = np.abs(normals @ light)
85 scale = 0.55 + 0.45 * intensity
86 colors = np.clip(np.asarray(base)[None, :] * scale[:, None], 0.0, 1.0)
87 return colors
90def _view_label(name: str, bbox) -> str:
91 """In-plane extents for this view, in millimetres."""
92 size_x, size_y, size_z = float(bbox.size.X), float(bbox.size.Y), float(bbox.size.Z)
93 plane = {
94 "front": (size_x, size_z),
95 "back": (size_x, size_z),
96 "right": (size_y, size_z),
97 "left": (size_y, size_z),
98 "top": (size_x, size_y),
99 "bottom": (size_x, size_y),
100 }.get(name)
101 if plane is None:
102 return ""
103 return f"{plane[0]:.2f} x {plane[1]:.2f} mm"
106def _edge_polylines(shape, samples: int = 24) -> list:
107 """Sample the shape's real BREP edges as polylines.
109 Drawing triangle edges instead puts a diagonal across every flat rectangular
110 face -- pure tessellation noise that reads as a crease or a feature in a review
111 render. The BREP edges are the ones a machinist would see.
112 """
113 try:
114 edges = shape.edges()
115 except (AttributeError, TypeError): # pragma: no cover - kernel dependent
116 return []
118 polylines = []
119 for edge in edges:
120 try:
121 straight = "LINE" in str(edge.geom_type)
122 count = 2 if straight else samples
123 points = [edge @ (index / (count - 1)) for index in range(count)]
124 polylines.append([(float(p.X), float(p.Y), float(p.Z)) for p in points])
125 except Exception: # noqa: BLE001 - skip an edge the kernel cannot sample
126 continue
127 return polylines
130def _tessellate(shape, deviation: float):
131 """Return (vertices, triangles) as plain nested lists."""
132 bbox = shape.bounding_box()
133 diagonal = max(float(bbox.diagonal), 1.0)
134 tolerance = diagonal * deviation
135 vertices, triangles = shape.tessellate(tolerance)
136 if not triangles:
137 raise LabCadError(
138 "tessellation produced no triangles; the shape may be empty or invalid"
139 )
140 points = [[float(v.X), float(v.Y), float(v.Z)] for v in vertices]
141 return points, triangles
144def main() -> int:
145 parser = argparse.ArgumentParser(
146 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
147 )
148 parser.add_argument("target", type=Path, help="STEP, STL, or *_model.py")
149 parser.add_argument("--out", type=Path, required=True, help="output PNG path")
150 parser.add_argument("--views", default=",".join(DEFAULT_VIEWS),
151 help=f"comma-separated views from {', '.join(VIEWS)} "
152 f"(default: {','.join(DEFAULT_VIEWS)})")
153 parser.add_argument("--deviation", type=float, default=0.002,
154 help="tessellation deviation as a fraction of the bbox diagonal "
155 "(default: 0.002; lower is finer and slower)")
156 parser.add_argument("--dpi", type=int, default=150)
157 parser.add_argument("--color", default="#7ea9d4", help="base face colour")
158 parser.add_argument("--no-edges", action="store_true",
159 help="skip the BREP edge overlay; faster on parts with thousands "
160 "of edges, at the cost of feature outlines")
161 parser.add_argument("--show-axes", action="store_true",
162 help="draw mm axes and ticks; off by default because the "
163 "collapsed axis in an orthographic view overlaps its labels")
164 args = parser.parse_args()
166 requested = [name.strip() for name in args.views.split(",") if name.strip()]
167 unknown = [name for name in requested if name not in VIEWS]
168 if unknown:
169 raise LabCadError(
170 f"unknown view(s): {', '.join(unknown)}. Available: {', '.join(VIEWS)}"
171 )
173 _require_matplotlib()
174 import matplotlib.pyplot as plt # noqa: PLC0415
175 from mpl_toolkits.mplot3d.art3d import Line3DCollection, Poly3DCollection # noqa: PLC0415
177 eprint(f"loading {args.target} ...")
178 shape = load_shape(args.target)
180 eprint("tessellating ...")
181 points, triangles = _tessellate(shape, args.deviation)
182 outlines = [] if args.no_edges else _edge_polylines(shape)
183 eprint(f"{len(points)} vertices, {len(triangles)} triangles, {len(outlines)} edges")
185 import numpy as np # noqa: PLC0415
187 vertices = np.asarray(points, dtype=float)
188 index = np.asarray(triangles, dtype=int)
189 faces = vertices[index]
191 # Per-face normals, used to shade each view from its own camera direction.
192 normals = np.cross(faces[:, 1] - faces[:, 0], faces[:, 2] - faces[:, 0])
193 lengths = np.linalg.norm(normals, axis=1, keepdims=True)
194 normals = normals / np.where(lengths == 0.0, 1.0, lengths)
196 base = _to_rgb(args.color)
198 bbox = shape.bounding_box()
199 centre = [
200 (float(bbox.min.X) + float(bbox.max.X)) / 2.0,
201 (float(bbox.min.Y) + float(bbox.max.Y)) / 2.0,
202 (float(bbox.min.Z) + float(bbox.max.Z)) / 2.0,
203 ]
204 reach = max(float(bbox.size.X), float(bbox.size.Y), float(bbox.size.Z), 1e-6) / 2.0
205 reach *= 1.08
207 columns = min(3, len(requested))
208 rows = (len(requested) + columns - 1) // columns
209 figure = plt.figure(figsize=(4.2 * columns, 4.2 * rows))
211 for position, name in enumerate(requested, start=1):
212 elevation, azimuth = VIEWS[name]
213 axes = figure.add_subplot(rows, columns, position, projection="3d")
215 shaded = _shade(base, normals, elevation, azimuth)
216 collection = Poly3DCollection(
217 faces, facecolors=shaded, edgecolors="none", alpha=1.0,
218 )
219 axes.add_collection3d(collection)
220 if outlines:
221 # Matplotlib cannot hidden-line-remove across collections, so these
222 # include far-side edges. That reads as slightly x-ray, and is called
223 # out in the message below rather than hidden.
224 axes.add_collection3d(Line3DCollection(
225 outlines, colors=[(0.10, 0.16, 0.24, 0.7)], linewidths=0.5,
226 ))
227 axes.set_xlim(centre[0] - reach, centre[0] + reach)
228 axes.set_ylim(centre[1] - reach, centre[1] + reach)
229 axes.set_zlim(centre[2] - reach, centre[2] + reach)
230 # zoom fills the frame; without it, turning the axes off leaves the shape
231 # small in a mostly empty subplot.
232 try:
233 axes.set_box_aspect((1, 1, 1), zoom=1.0 if args.show_axes else 1.45)
234 except TypeError: # matplotlib < 3.6 has no zoom parameter
235 axes.set_box_aspect((1, 1, 1))
236 axes.view_init(elev=elevation, azim=azimuth)
237 # Matplotlib's 3D default is perspective, which bends a square part into a
238 # wedge and makes a straight wall look tapered - the exact kind of thing this
239 # render exists to rule out. Every view here is a true orthographic projection.
240 axes.set_proj_type("ortho")
241 axes.set_title(f"{name} {_view_label(name, bbox)}", fontsize=10)
243 if args.show_axes:
244 axes.set_xlabel("x (mm)", fontsize=7)
245 axes.set_ylabel("y (mm)", fontsize=7)
246 axes.set_zlabel("z (mm)", fontsize=7)
247 axes.tick_params(labelsize=6)
248 else:
249 # Orthographic views collapse one axis, which produces a stack of
250 # overlapping tick labels. Legibility of the shape is the point here;
251 # use `check.py facts` for numbers.
252 axes.set_axis_off()
254 figure.suptitle(
255 f"{args.target.name} "
256 f"{float(bbox.size.X):.2f} x {float(bbox.size.Y):.2f} x {float(bbox.size.Z):.2f} mm",
257 fontsize=12,
258 )
259 figure.tight_layout()
260 args.out.parent.mkdir(parents=True, exist_ok=True)
261 figure.savefig(args.out, dpi=args.dpi, bbox_inches="tight")
262 plt.close(figure)
264 eprint(f"wrote {args.out}")
265 print(
266 f"{args.out}\n"
267 "Now READ the image. Confirm: pockets on the intended face, mold polarity "
268 "correct, every port and boss present and inside the body, no feature "
269 "consumed by a fillet.\n"
270 "Reading it: views are true orthographic, and the outlines are the model's real "
271 "edges, hidden ones included. So a circle showing 'through' material is a "
272 "far-side bore, not a window - the part is not transparent."
273 )
274 return 0
277if __name__ == "__main__":
278 main_guard(main)