CoolFace
Apppublic

build-small-hackathon/microfactory-lab

sourceHugging Facemitupdated 3mo agoView on Hugging Face
2likes
widgets.py243 linesDownload Raw Back to core
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("'", "&#39;")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