microfactory-lab / sim /virtual_printer.py
kylebrodeur's picture
Upload folder using huggingface_hub
6b09b49 verified
Raw
History Blame Contribute Delete
7.66 kB
"""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.