Spaces:
Runtime error
Runtime error
| """Virtual printer — a clean-room, dependency-light visual of a part building up | |
| layer by layer. The "more legit in that direction" visual (Kyle's call), done | |
| from the REAL mesh, not a canned animation. | |
| Honest about what it is: it slices the actual uploaded/sample mesh into real | |
| horizontal cross-sections at the chosen layer height and animates them rising — | |
| so it's the genuine geometry of *this* part, layer by layer. It is NOT a slicer | |
| (no infill/supports/true toolpaths) and NOT a physics sim; the failure | |
| prediction stays in sim/outcome.py. This is the motion/visual legitimacy layer; | |
| that's the failure legitimacy layer. Keep the claim exactly that narrow. | |
| Zero new deps: numpy + Pillow + trimesh (all already required). No scipy/shapely | |
| (trimesh's high-level sectioning needs them — we do triangle-plane intersection | |
| ourselves). Fully permissive; runs on a CPU Space. Isolated + removable: nothing | |
| in the demo path imports this yet (see integration snippet at the bottom). | |
| CLI: uv run python -m sim.virtual_printer assets/overhang.glb out.gif | |
| """ | |
| from __future__ import annotations | |
| import sys | |
| from pathlib import Path | |
| import numpy as np | |
| # Astrometrics palette (ties to the Off-Brand look) | |
| _BG = (10, 12, 20) | |
| _DONE = (70, 95, 130) # already-printed layers (cool, receding) | |
| _CUR = (255, 150, 40) # the layer printing now (orange) | |
| _NOZZLE = (255, 230, 180) | |
| _TEXT = (210, 220, 235) | |
| _ISO = 0.55 # oblique projection skew (fakes 3D) | |
| def slice_segments(mesh_path: str | Path, layer_height_mm: float = 0.6, | |
| max_layers: int = 140) -> list[tuple[float, np.ndarray]]: | |
| """Return [(z, segments)] where segments is (N,2,3) float — real cross-section | |
| line segments of the mesh at each layer Z. Pure numpy triangle-plane slicing.""" | |
| import trimesh | |
| mesh = trimesh.load(str(mesh_path), force="mesh") | |
| tris = mesh.vertices[mesh.faces] # (F,3,3) | |
| zmin, zmax = float(mesh.bounds[0, 2]), float(mesh.bounds[1, 2]) | |
| height = max(zmax - zmin, 1e-6) | |
| n = min(max_layers, max(2, int(height / max(layer_height_mm, 1e-3)))) | |
| zs = np.linspace(zmin + height * 0.01, zmax - height * 0.01, n) | |
| out: list[tuple[float, np.ndarray]] = [] | |
| for z in zs: | |
| segs = _slice_at(tris, float(z)) | |
| if len(segs): | |
| out.append((float(z), segs)) | |
| return out | |
| _OTHERS = np.array([[1, 2], [0, 2], [0, 1]]) # the two non-odd vertices, by odd index | |
| def _slice_at(tris: np.ndarray, z: float) -> np.ndarray: | |
| """Intersect all triangles with plane Z=z at once (vectorized) → (S,2,3) segments. | |
| A straddling triangle has exactly one vertex on the minority side ('odd'); the | |
| two intersection points lie on the edges from the odd vertex to the other two. | |
| """ | |
| below = tris[:, :, 2] < z # (F,3) | |
| cnt = below.sum(1) | |
| strad = (cnt > 0) & (cnt < 3) | |
| if not strad.any(): | |
| return np.empty((0, 2, 3)) | |
| T = tris[strad] # (S,3,3) | |
| b = below[strad] | |
| maj_below = cnt[strad] >= 2 # majority side | |
| odd_i = (b != maj_below[:, None]).argmax(1) # the lone-side vertex, (S,) | |
| ar = np.arange(len(T)) | |
| o = _OTHERS[odd_i] # (S,2) | |
| V0 = T[ar, odd_i] # (S,3) odd vertex | |
| Va, Vb = T[ar, o[:, 0]], T[ar, o[:, 1]] | |
| def interp(A, B): | |
| dz = B[:, 2] - A[:, 2] | |
| t = (z - A[:, 2]) / np.where(dz == 0, 1e-9, dz) | |
| return A + t[:, None] * (B - A) | |
| return np.stack([interp(V0, Va), interp(V0, Vb)], axis=1) # (S,2,3) | |
| def _project(p: np.ndarray) -> tuple[float, float]: | |
| """Oblique 3D→2D: x picks up some y (depth); screen-height tracks z + some y.""" | |
| x, y, z = p[..., 0], p[..., 1], p[..., 2] | |
| return x + _ISO * y, z + _ISO * y | |
| def render_gif(layers: list[tuple[float, np.ndarray]], out_path: str | Path, | |
| caption: str | None = None, size: tuple[int, int] = (520, 420), | |
| max_frames: int = 60, fps: int = 14) -> Path: | |
| """Animate the layers rising into a looping GIF (Pillow only).""" | |
| from PIL import Image, ImageDraw | |
| if not layers: | |
| raise ValueError("no layers to render") | |
| # Global projected bounds for a stable camera across all frames. | |
| allpts = np.concatenate([s.reshape(-1, 3) for _, s in layers], axis=0) | |
| sx, sy = _project(allpts) | |
| xmin, xmax, ymin, ymax = sx.min(), sx.max(), sy.min(), sy.max() | |
| W, H = size | |
| pad = 36 | |
| spanx, spany = max(xmax - xmin, 1e-6), max(ymax - ymin, 1e-6) | |
| scale = min((W - 2 * pad) / spanx, (H - 2 * pad) / spany) | |
| def to_px(seg_xy): | |
| x, y = seg_xy | |
| px = pad + (x - xmin) * scale | |
| py = H - (pad + (y - ymin) * scale) # invert: higher z → higher on screen | |
| return px, py | |
| # Sample layer indices down to max_frames; each frame adds the next layer. | |
| idxs = list(range(len(layers))) | |
| if len(idxs) > max_frames: | |
| idxs = [idxs[int(i)] for i in np.linspace(0, len(idxs) - 1, max_frames)] | |
| frames = [] | |
| for upto in idxs: | |
| img = Image.new("RGB", (W, H), _BG) | |
| d = ImageDraw.Draw(img) | |
| for li, (z, segs) in enumerate(layers[: upto + 1]): | |
| color = _CUR if li == upto else _DONE | |
| width = 2 if li == upto else 1 | |
| for seg in segs: | |
| p0 = to_px(_project(seg[0])) | |
| p1 = to_px(_project(seg[1])) | |
| d.line([p0, p1], fill=color, width=width) | |
| # nozzle marker at the centroid of the current layer | |
| _, cur = layers[upto] | |
| cx, cy = to_px(_project(cur.reshape(-1, 3).mean(axis=0))) | |
| d.ellipse([cx - 4, cy - 4, cx + 4, cy + 4], fill=_NOZZLE) | |
| pct = int(100 * (upto + 1) / len(layers)) | |
| d.text((pad, 10), f"VIRTUAL PRINT · layer {upto + 1}/{len(layers)} · {pct}%", | |
| fill=_TEXT) | |
| if caption: | |
| d.text((pad, H - 22), caption[:70], fill=_CUR) | |
| frames.append(img) | |
| # Hold the final frame a beat. | |
| frames += [frames[-1]] * max(1, fps // 2) | |
| out = Path(out_path) | |
| frames[0].save(out, save_all=True, append_images=frames[1:], | |
| duration=int(1000 / fps), loop=0, optimize=True) | |
| return out | |
| def build_print_gif(mesh_path: str | Path, out_path: str | Path, | |
| layer_height_mm: float = 0.6, caption: str | None = None) -> Path: | |
| """One-call convenience: slice a mesh and render the build animation.""" | |
| return render_gif(slice_segments(mesh_path, layer_height_mm), out_path, caption=caption) | |
| if __name__ == "__main__": | |
| src = sys.argv[1] if len(sys.argv) > 1 else "assets/overhang.glb" | |
| dst = sys.argv[2] if len(sys.argv) > 2 else "virtual_print.gif" | |
| cap = sys.argv[3] if len(sys.argv) > 3 else "PLA overhang · watch the underside" | |
| layers = slice_segments(src) | |
| p = build_print_gif(src, dst, caption=cap) | |
| print(f"sliced {len(layers)} layers from {src} → {p} ({p.stat().st_size//1024} KB)") | |
| # --- integration snippet (wire AFTER the Branch-A baseline is submitted) ------- | |
| # In app.py, add a removable component to the Cockpit and populate it on recommend: | |
| # | |
| # from sim.virtual_printer import build_print_gif | |
| # vprint = gr.Image(label="VIRTUAL PRINT", height=300) # beside gr.Model3D | |
| # # in get_recommendation(), after you know the mesh + a risk caption: | |
| # gif = build_print_gif(mesh_path, "data/_vprint.gif", | |
| # caption=f"{material} {geometry_type} · {top_risk}") | |
| # return (..., gif) | |
| # | |
| # It reads the SAME mesh the 3D preview uses; if no mesh, fall back to a sample | |
| # (viewer.sample_mesh(geometry_type)). Pure offline; safe on a CPU Space. | |