build-small-hackathon/microfactory-lab
2
1"""Custom visual-beat widgets — live in-browser instruments rendered into gr.HTML.2 3The marquee one is the VIRTUAL PRINTER: the real mesh sliced into cross-sections4(reusing sim.virtual_printer) and animated *rising* on a <canvas>, client-side, so5it's a live instrument in the cockpit rather than a pre-rendered GIF. Astrometrics6palette; zero new deps; fully offline/Space-safe.7 8Gradio gotcha handled here: a <script> injected via gr.HTML does NOT execute. So9the animation logic lives once in VP_JS (loaded via the Blocks `js=`), exposing a10global that scans for `canvas.ce-vp` elements and animates any it hasn't started.11The HTML payload is just a <canvas> with the layer data in a data-attribute — no12inline script — and the global picks it up on the next tick.13"""14 15from __future__ import annotations16 17import json18from pathlib import Path19 20_ISO = 0.55 # oblique projection skew, matches sim/virtual_printer21 22 23def _projected_layers(mesh_path: str | Path, layer_height_mm: float = 0.2,24 max_layers: int = 44, max_segs_per_layer: int = 520) -> dict:25 """Slice the mesh and return projected 2D polylines per layer + bounds.26 27 {bounds:[xmin,xmax,ymin,ymax], layers:[[[x0,y0,x1,y1],...], ...]} — projected28 oblique screen space, rounded to keep the data-attribute payload small. Segments29 per layer are capped (down-sampled) so a high-res mesh doesn't bloat the page.30 """31 from sim.virtual_printer import slice_segments32 33 layers = slice_segments(mesh_path, layer_height_mm, max_layers=max_layers)34 out_layers, xs, ys = [], [], []35 for _z, segs in layers:36 if len(segs) > max_segs_per_layer: # down-sample for payload37 step = len(segs) // max_segs_per_layer + 138 segs = segs[::step]39 L = []40 for seg in segs:41 (x0, y0, z0), (x1, y1, z1) = seg[0], seg[1]42 px0, py0 = x0 + _ISO * y0, z0 + _ISO * y043 px1, py1 = x1 + _ISO * y1, z1 + _ISO * y144 L.append([round(px0, 1), round(py0, 1), round(px1, 1), round(py1, 1)])45 xs += [px0, px1]; ys += [py0, py1]46 if L:47 out_layers.append(L)48 if not out_layers:49 return {}50 return {"bounds": [min(xs), max(xs), min(ys), max(ys)], "layers": out_layers}51 52 53# ── layer scrubber: full-fidelity single-layer cross-section, rendered server-side ──54_TRIS_CACHE: dict = {}55 56 57def _tris_for(mesh_path: str):58 """Load + cache (triangles, bounds) for a mesh so scrubbing doesn't reload it."""59 p = str(mesh_path)60 if p not in _TRIS_CACHE:61 import trimesh62 m = trimesh.load(p, force="mesh")63 _TRIS_CACHE[p] = (m.vertices[m.faces], m.bounds.copy())64 return _TRIS_CACHE[p]65 66 67SCRUB_LAYERS = 40 # fixed layer count for the scrubber slider (independent of the animation)68 69 70def _fill_or_outline(d, segs, px) -> str:71 """Draw a layer as FILLED solid regions (shapely polygonize on snap-rounded72 segments — like a real slicer) with bright perimeters and holes cut out. Falls73 back to plain outlines if shapely is absent or the rings don't close. Returns a74 short label describing what was drawn."""75 try:76 from shapely.geometry import LineString77 from shapely.ops import polygonize, unary_union78 lines = [LineString([(round(float(sg[0][0]), 2), round(float(sg[0][1]), 2)),79 (round(float(sg[1][0]), 2), round(float(sg[1][1]), 2))]) for sg in segs]80 polys = list(polygonize(unary_union(lines)))81 if polys:82 for p in polys: # solid body83 d.polygon([px(x, y) for x, y in p.exterior.coords], fill=(110, 70, 20))84 for p in polys: # holes → background85 for r in p.interiors:86 d.polygon([px(x, y) for x, y in r.coords], fill=(10, 12, 20))87 for p in polys: # bright perimeters88 d.line([px(x, y) for x, y in p.exterior.coords], fill=(255, 150, 40), width=2)89 for r in p.interiors:90 d.line([px(x, y) for x, y in r.coords], fill=(255, 150, 40), width=1)91 return f"{len(polys)} filled regions"92 except Exception:93 pass94 for sg in segs: # fallback: outline95 d.line([px(sg[0][0], sg[0][1]), px(sg[1][0], sg[1][1])], fill=(255, 150, 40), width=1)96 return f"{len(segs)} perimeter segments"97 98 99def layer_image(mesh_path: str | Path | None, idx: int, n: int = SCRUB_LAYERS,100 size: tuple[int, int] = (560, 380)):101 """Top-down cross-section of layer `idx` at FULL mesh fidelity (no payload cap —102 one layer at a time), drawn as a filled solid layer like a real slicer. Stable XY103 bounds so the part doesn't jump while scrubbing. Returns an RGB numpy array."""104 import numpy as np105 from PIL import Image, ImageDraw106 from sim.virtual_printer import _slice_at107 108 W, H, pad = size[0], size[1], 26109 img = Image.new("RGB", (W, H), (10, 12, 20))110 d = ImageDraw.Draw(img)111 if not mesh_path or not Path(str(mesh_path)).exists():112 d.text((pad, H // 2), "run ANALYZE to slice the part", fill=(150, 160, 180))113 return np.asarray(img)114 115 tris, bounds = _tris_for(mesh_path)116 zmin, zmax = float(bounds[0, 2]), float(bounds[1, 2])117 h = max(zmax - zmin, 1e-6)118 zs = np.linspace(zmin + h * 0.01, zmax - h * 0.01, n)119 idx = max(1, min(n, int(idx)))120 z = float(zs[idx - 1])121 segs = _slice_at(tris, z)122 123 xmin, ymin = float(bounds[0, 0]), float(bounds[0, 1])124 xmax, ymax = float(bounds[1, 0]), float(bounds[1, 1])125 s = min((W - 2 * pad) / max(xmax - xmin, 1e-6), (H - 2 * pad) / max(ymax - ymin, 1e-6))126 127 def px(x, y):128 return (pad + (x - xmin) * s, H - (pad + (y - ymin) * s))129 130 drawn = _fill_or_outline(d, segs, px)131 d.text((pad, 8), f"LAYER {idx}/{n} · z={z:.1f} mm · {drawn}", fill=(210, 220, 235))132 return np.asarray(img)133 134 135def virtual_printer_html(mesh_path: str | Path | None,136 settings: "PrintSettings | None" = None,137 caption: str = "") -> str:138 """A live virtual-printer canvas for the cockpit (animated by VP_JS).139 140 Uses the layer height from the proposed PrintSettings so the preview is tied141 to the actual recommendation rather than a hard-coded default. Canvas size is142 chosen from the projected mesh aspect ratio so the preview is not squished.143 """144 if not mesh_path or not Path(str(mesh_path)).exists():145 return ("<div class='ce-rule'>VIRTUAL PRINT</div>"146 "<div class='ce-sub'>run a recommendation to slice the part →</div>")147 layer_height = settings.layer_height if settings else 0.2148 data = _projected_layers(mesh_path, layer_height_mm=layer_height)149 if not data:150 return ("<div class='ce-rule'>VIRTUAL PRINT</div>"151 "<div class='ce-sub'>mesh too thin to slice.</div>")152 payload = json.dumps(data).replace("'", "'")153 lh_note = f"{layer_height:.2f} mm layers"154 155 # Canvas size derived from projected bounds so the preview is not squished.156 # Max width is clamped to the column; height follows aspect ratio.157 xmin, xmax, ymin, ymax = data["bounds"]158 proj_w = max(xmax - xmin, 1)159 proj_h = max(ymax - ymin, 1)160 aspect = proj_h / proj_w161 max_css_w = 560162 css_h = min(round(max_css_w * aspect), 340)163 canvas_w, canvas_h = max_css_w, css_h164 165 return (166 "<div class='ce-rule'>VIRTUAL PRINT · MOTION PREVIEW</div>"167 "<div class='ce-vp-wrap'>"168 f"<canvas class='ce-vp' width='{canvas_w}' height='{canvas_h}' data-vp='{payload}' "169 f"data-aspect='{aspect:.4f}' style='width:{max_css_w}px;height:{css_h}px;display:block;'></canvas>"170 "</div>"171 f"<div class='ce-sub ce-vp-caption'>{caption} · {lh_note} · real cross-sections of "172 "this part, rising layer by layer (motion preview — not a slicer). Click REPLAY to restart.</div>"173 "<button class='ce-pillbtn ce-vp-replay' type='button' "174 "onclick=\"const cv=this.parentNode.querySelector('canvas.ce-vp');"175 "if(window.__vp_replay) window.__vp_replay(cv);\">REPLAY</button>"176 )177 178 179# One-time client animator, injected as a real <script> via launch(head=...).180# (launch(js=...) proved unreliable for setting up a persistent scan loop; a head181# script runs on load deterministically and is CSP-friendly on a Space.)182VP_HEAD = r"""183<script>184(function(){185 function start(){186 // --- LCARS clock ---187 const tickClock = () => {188 const el = document.getElementById('ce-clock');189 if (el) el.textContent = new Date().toISOString().slice(11,19) + ' UTC';190 };191 tickClock(); setInterval(tickClock, 1000);192 193 // --- virtual-printer animator ---194 const DONE='#46627f', CUR='#ff9c00', NOZ='#ffe6b4', BG='#0a0c14';195 window.__vp_replay = function(cv){196 if(cv._raf){ cancelAnimationFrame(cv._raf); cv._raf=null; }197 cv.removeAttribute('data-init');198 const ctx = cv.getContext('2d'); ctx.fillStyle=BG; ctx.fillRect(0,0,cv.width,cv.height);199 scan();200 };201 function animate(cv){202 let data; try { data = JSON.parse(cv.getAttribute('data-vp')); } catch(e){ return; }203 if(!data || !data.layers || !data.layers.length) return;204 const ctx = cv.getContext('2d'); const W=cv.width, H=cv.height, pad=22;205 const [xmin,xmax,ymin,ymax] = data.bounds;206 const sx = (W-2*pad)/Math.max(xmax-xmin,1e-6), sy=(H-2*pad)/Math.max(ymax-ymin,1e-6);207 const s = Math.min(sx,sy);208 const X = x => pad + (x-xmin)*s, Y = y => H - (pad + (y-ymin)*s);209 const N = data.layers.length;210 let upto = 0, hold = 0;211 // bigger hold count = slower animation. default 6; override with window.__vp_speed.212 const speed = Math.max(1, Math.min(20, Number(window.__vp_speed || 6)));213 function frame(){214 ctx.fillStyle=BG; ctx.fillRect(0,0,W,H);215 for(let li=0; li<=upto && li<N; li++){216 const segs = data.layers[li];217 ctx.strokeStyle = (li===upto)?CUR:DONE; ctx.lineWidth=(li===upto)?1.6:1;218 ctx.beginPath();219 for(const [x0,y0,x1,y1] of segs){ ctx.moveTo(X(x0),Y(y0)); ctx.lineTo(X(x1),Y(y1)); }220 ctx.stroke();221 }222 // nozzle: centroid of current layer223 const cur = data.layers[Math.min(upto,N-1)];224 let cx=0,cy=0,n=0; for(const s2 of cur){ cx+=s2[0]+s2[2]; cy+=s2[1]+s2[3]; n+=2; }225 if(n){ ctx.fillStyle=NOZ; ctx.beginPath(); ctx.arc(X(cx/n),Y(cy/n),3.2,0,7); ctx.fill(); }226 // HUD227 ctx.fillStyle='#9fb0c8'; ctx.font='10px ui-monospace,monospace';228 ctx.fillText('LAYER '+(upto+1)+'/'+N+' · '+Math.round(100*(upto+1)/N)+'%', pad, 14);229 if(upto < N-1){ if(++hold>=speed){ hold=0; upto++; } cv._raf = requestAnimationFrame(frame); }230 // else: full part drawn — hold final frame231 }232 frame();233 }234 function scan(){ document.querySelectorAll('canvas.ce-vp:not([data-init])').forEach(cv=>{235 cv.setAttribute('data-init','1'); animate(cv); }); }236 setInterval(scan, 400); scan();237 }238 if (document.readyState !== 'loading') start();239 else document.addEventListener('DOMContentLoaded', start);240})();241</script>242"""243 