Spaces:
Runtime error
Runtime error
File size: 7,659 Bytes
6b09b49 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 | """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.
|