CoolFace
Apppublic

build-small-hackathon/microfactory-lab

sourceHugging Facemitupdated 3mo agoView on Hugging Face
2likes
viewer.py372 linesDownload Raw Back to core
1"""3D preview helpers + G-code readout.2 3gr.Model3D gives orbit/zoom/pan for free. We keep trimesh use minimal: pick a4sample mesh for the geometry_type (or use an uploaded one) and, where it helps,5locate the steepest overhang so a risk callout anchors to something real. Risk6regions render as labeled callouts beside the interactive model — robust, and7the model stays interactive. No slicing, no simulation.8"""9 10from __future__ import annotations11 12from pathlib import Path13 14from .models import PrintSettings, RiskRegion15from .theme import icon16 17ASSETS = Path(__file__).resolve().parent.parent / "assets"18DATA = Path(__file__).resolve().parent.parent / "data"19_SAMPLE = {20    "overhang": "overhang.glb",21    "bridge": "bridge.glb",22    "vase": "vase.glb",23    "stringing": "cube.glb",24    "adhesion": "cube.glb",25}26 27 28def sample_mesh(geometry_type: str) -> str | None:29    path = ASSETS / _SAMPLE.get(geometry_type, "cube.glb")30    return str(path) if path.exists() else None31 32 33PART_LABEL = {34    "overhang": "OVERHANG TEST", "bridge": "BRIDGE TEST", "vase": "VASE (THIN WALL)",35    "stringing": "STRINGING TOWER", "adhesion": "ADHESION CUBE",36}37 38 39def benchy_mesh() -> str | None:40    """The CC0 3DBenchy, IF dropped into assets/benchy.glb (can't fetch on a41    locked Space; Kyle adds it locally). None → caller shows a 'add the file' hint."""42    p = ASSETS / "benchy.glb"43    return str(p) if p.exists() else None44 45 46_PRIMITIVES = ("box", "cylinder", "cone", "sphere")47# which geometry_type (the model's reasoning class) each primitive maps to48_PRIM_GEO = {"box": "adhesion", "cylinder": "vase", "cone": "overhang", "sphere": "overhang"}49 50 51def generate_primitive(kind: str, size_mm: float = 30.0) -> tuple[str, str]:52    """Generate a parametric primitive with trimesh → (mesh_path, geometry_type).53    Offline, zero new deps. Saved to data/_generated.glb for the preview/slicer."""54    import trimesh55 56    s = max(5.0, float(size_mm))57    kind = kind if kind in _PRIMITIVES else "box"58    if kind == "box":59        m = trimesh.creation.box(extents=(s, s, s))60    elif kind == "cylinder":61        m = trimesh.creation.cylinder(radius=s / 2, height=s)62    elif kind == "cone":63        m = trimesh.creation.cone(radius=s / 2, height=s)64    else:65        m = trimesh.creation.icosphere(radius=s / 2)66    m.apply_translation(-m.bounds[0])  # sit on the bed (z ≥ 0)67    DATA.mkdir(exist_ok=True)68    out = DATA / "_generated.glb"69    m.export(out)70    return str(out), _PRIM_GEO[kind]71 72 73# one-line human read for each inferred class — surfaced read-only ("the engineer74# reads this as …"), never a control the user sets.75GEO_READS = {76    "overhang": "overhang-dominant",77    "bridge": "has unsupported spans (bridging)",78    "vase": "tall thin-wall (vase-like)",79    "adhesion": "wide flat base (adhesion-critical)",80    "stringing": "many travel moves (stringing-prone)",81}82 83 84def infer_geometry(mesh_path: str | None) -> tuple[str, str]:85    """Classify the failure-mode the engineer should reason about, straight from86    the mesh — the user never picks it (the system figures it out). Returns87    (geometry_type, one-line read). Falls back to 'overhang' (the most common88    torture-test failure) when the mesh can't be read."""89    if not mesh_path or not Path(mesh_path).exists():90        return "overhang", GEO_READS["overhang"]91    try:92        import math93 94        import trimesh95 96        mesh = trimesh.load(mesh_path, force="mesh")97        w, d, h = (float(x) for x in mesh.bounding_box.extents)98        base = max(w, d, 1e-6)99        footprint = max(1e-6, w * d)100        downward = -mesh.face_normals[:, 2]            # +1 → horizontal ceiling (downward-facing)101        steep = float(downward.max()) if len(downward) else 0.0102        angle = math.degrees(math.asin(min(1.0, max(0.0, steep))))   # overhang angle from vertical103        ceiling = float(mesh.area_faces[downward > 0.94].sum()) if len(downward) else 0.0104        try:105            solidity = float(mesh.volume) / max(1e-6, w * d * h)106        except Exception:107            solidity = 1.0108 109        if h > 2.0 * base and solidity < 0.35:         # tall + mostly hollow → thin-wall shell110            return "vase", GEO_READS["vase"]111        if ceiling > 0.10 * footprint:                 # flat unsupported span → bridging112            return "bridge", GEO_READS["bridge"]113        if angle >= 45:                                # steep angled face → overhang114            return "overhang", GEO_READS["overhang"]115        if h < 0.5 * base:                             # wide + low → big bed contact116            return "adhesion", GEO_READS["adhesion"]117        return "stringing", GEO_READS["stringing"]118    except Exception:119        return "overhang", GEO_READS["overhang"]120 121 122def settings_panel_html(settings: PrintSettings, material: str) -> str:123    """Render proposed settings as an LCARS instrument readout (not raw JSON)."""124    rows = [125        ("NOZZLE", f"{settings.nozzle_temp:.0f}", "°C"),126        ("BED", f"{settings.bed_temp:.0f}", "°C"),127        ("RETRACTION", f"{settings.retraction_mm:.1f}", "mm"),128        ("FAN", f"{settings.fan_pct:.0f}", "%"),129        ("FIRST-LAYER FAN", f"{settings.first_layer_fan_pct:.0f}", "%"),130    ]131    cells = "".join(132        "<div style='display:flex;justify-content:space-between;align-items:baseline;"133        "border-bottom:1px solid var(--ao-outline-dim);padding:5px 2px;'>"134        f"<span style='color:var(--ao-orange);letter-spacing:1.5px;font-size:10px;'>{name}</span>"135        f"<span style='color:var(--ao-text);font-size:15px;font-weight:700;'>{val}"136        f"<span style='color:var(--ao-outline);font-size:10px;'> {unit}</span></span></div>"137        for name, val, unit in rows138    )139    return (140        "<div style='font-family:ui-monospace,monospace;background:var(--ao-void);"141        "border:1px solid var(--ao-outline-dim);border-left:3px solid var(--ao-orange);padding:8px 12px;'>"142        f"<div style='color:var(--ao-orange);font-weight:700;letter-spacing:2px;font-size:11px;"143        f"margin-bottom:4px;'>PROPOSED SETTINGS · {material} <span style='color:var(--ao-outline);"144        "font-weight:400;'>(SPINE-VALIDATED)</span></div>" + cells + "</div>"145    )146 147 148def gcode_panel_html(settings: PrintSettings, material: str) -> str:149    """The g-code readout as a styled terminal panel (not gr.Code)."""150    body = gcode_readout(settings, material).replace("<", "&lt;")151    return (152        "<div style='font-family:ui-monospace,monospace;background:var(--ao-void);"153        "border:1px solid var(--ao-outline-dim);padding:8px 12px;'>"154        "<div style='color:var(--ao-orange);font-weight:700;letter-spacing:2px;font-size:11px;'>"155        "START G-CODE <span style='color:var(--ao-outline);font-weight:400;'>(HEADER TIED TO SETTINGS)</span></div>"156        f"<pre style='color:var(--ao-blue);font-size:11px;margin:6px 0 0;white-space:pre-wrap;'>{body}</pre></div>"157    )158 159 160def steepest_overhang_hint(mesh_path: str | None) -> str | None:161    """Optional: report where the steepest downward-facing face sits (minimal trimesh)."""162    if not mesh_path or not Path(mesh_path).exists():163        return None164    try:165        import numpy as np166        import trimesh167 168        mesh = trimesh.load(mesh_path, force="mesh")169        import math170        normals = mesh.face_normals171        downward = normals[:, 2]               # -1 = fully downward-facing172        idx = int(downward.argmin())173        steep = -float(downward[idx])          # 0..1; 1 = horizontal ceiling174        if steep > 0.30:                       # meaningfully overhanging175            angle = math.degrees(math.asin(min(1.0, steep)))   # overhang angle from vertical176            c = mesh.triangles_center[idx]177            note = f"steepest overhang ~{angle:.0f}° near (x={c[0]:.0f}, y={c[1]:.0f}, z={c[2]:.0f}) mm"178            if angle >= 50:                    # past the usual support threshold179                note += " — likely needs supports (or reorient to reduce it)"180            return note181    except Exception:182        return None183    return None184 185 186def risk_callouts_html(risks: list[RiskRegion], geo_hint: str | None = None) -> str:187    if not risks:188        body = "<div style='color:var(--ao-green);'>No failure regions flagged.</div>"189    else:190        rows = []191        for r in risks:192            anchor = f" · {r.anchor_hint}" if r.anchor_hint else ""193            rows.append(194                f"<div style='border-left:3px solid var(--ao-red);background:var(--ao-surface);"195                f"padding:6px 10px;margin:5px 0;font-family:ui-monospace,monospace;font-size:12px;'>"196                f"<span style='color:var(--ao-red);font-weight:700;'>{icon('alert')} {r.risk.upper()}</span> "197                f"<span style='color:var(--ao-text);'>@ {r.location}{anchor}</span>"198                f"<div style='color:var(--ao-outline);'>{r.why}</div></div>"199            )200        body = "".join(rows)201    if geo_hint:202        body += f"<div style='color:var(--ao-outline);font-size:11px;font-family:ui-monospace,monospace;'>↳ {geo_hint}</div>"203    return f"<div><div style='color:var(--ao-orange);font-family:ui-monospace,monospace;font-size:11px;'>PREDICTED FAILURE REGIONS</div>{body}</div>"204 205 206_VERDICT = {"failed_sag": "sagged", "failed_stringing": "strung", "success": "printed clean"}207 208 209def precedent_eval_html(retrieved, env) -> str:210    """The load-bearing moment, narrated deterministically from the env delta.211 212    Makes the "humidity is higher than the job that sagged" framing reliable on213    screen even before the model's prose — the model's reasoning then adds to it.214    """215    if not retrieved:216        return (217            "<div style='border-left:3px solid var(--ao-purple);background:var(--ao-surface);"218            "padding:10px 14px;font-family:ui-monospace,monospace;'>"219            "<div style='color:var(--ao-purple);font-weight:700;letter-spacing:1px;'>NO CLOSE PRECEDENT</div>"220            "<div style='color:var(--ao-text);'>Nothing in the ledger matches this material + geometry. "221            "Reasoning from material properties — and saying so. Knowing what it doesn't know is the point.</div></div>"222        )223    e, dist = retrieved[0]224    dt = env.temp - e.env_temp225    dh = env.humidity - e.env_humidity226 227    def phrase(delta, unit, hi, lo):228        if abs(delta) < 1:229            return f"about the same {unit}"230        return f"{abs(delta):.0f}{unit} {hi if delta > 0 else lo}"231 232    t_ph = phrase(dt, "°C", "warmer", "cooler")233    h_ph = phrase(dh, " pts", "more humid", "drier")234    verdict = _VERDICT.get(e.outcome, e.outcome)235 236    failed = e.outcome.startswith("failed")237    worse = (e.geometry_type in ("overhang", "bridge") and dt > 1) or ("string" in e.geometry_type and dh > 1)238    if failed and worse:239        impl = "Conditions are <b>worse</b> than that failure — expect the same risk and adjusting to prevent it."240        col = "var(--ao-red)"241    elif failed and not worse:242        impl = "Conditions are <b>better</b> than that failure — the original cause is less likely now."243        col = "var(--ao-orange)"244    else:245        impl = "That job succeeded under similar conditions — leaning on what worked."246        col = "var(--ao-green)"247 248    return (249        f"<div style='border-left:3px solid {col};background:var(--ao-surface);"250        f"padding:10px 14px;font-family:ui-monospace,monospace;'>"251        f"<div style='color:var(--ao-orange);font-weight:700;letter-spacing:1px;'>PRECEDENT EVALUATION</div>"252        f"<div style='color:var(--ao-text);'>Nearest prior job <b>{e.job_id}</b> ({e.source}) — "253        f"{e.material}/{e.geometry_type} <b>{verdict}</b> at {e.env_temp:.0f}°C / {e.env_humidity:.0f}% RH.<br>"254        f"Right now it's <b>{t_ph}</b> and <b>{h_ph}</b> (env-distance {dist:.2f}).<br>{impl}</div></div>"255    )256 257 258_POS_SHRINK = {"ABS": "high", "PETG": "moderate", "PLA": "low", "TPU": "low"}259 260 261def placement_callout(material: str, bed_position: str) -> str:262    """Deterministic build-plate placement risk + suggested alignment. Bed edges/263    corners run cooler + draftier → warp/adhesion risk, worst for high-shrink264    materials. Returns an HTML block (or '' when centered/low-risk)."""265    pos = (bed_position or "center").lower()266    if pos == "center":267        return ""268    shrink = _POS_SHRINK.get(material.upper(), "moderate")269    risky = shrink in ("high", "moderate")270    col = "var(--ao-red)" if (pos == "corner" and risky) else (271        "var(--ao-orange)" if risky else "var(--ao-outline)")272    sev = "corner" if pos == "corner" else "edge"273    body = (f"{material} has <b>{shrink}</b> shrink; a {sev} of the heated bed runs cooler and "274            f"draftier, so the first layer can lift and the part can warp.")275    fix = ("Center the part on the bed" + (276        ", add a brim, and an enclosure if you have one." if shrink == "high"277        else " and add a brim." if risky else "; minor risk for this material."))278    return (279        f"<div style='border-left:3px solid {col};background:var(--ao-surface);padding:6px 10px;"280        f"margin:5px 0;font-family:ui-monospace,monospace;font-size:12px;'>"281        f"<span style='color:{col};font-weight:700;'>{icon('target')} PLACEMENT · {sev.upper()}</span> "282        f"<span style='color:var(--ao-text);'>{body}</span>"283        f"<div style='color:var(--ao-orange-soft);'>↳ suggested: {fix}</div></div>"284    )285 286 287def gcode_readout(settings: PrintSettings, material: str) -> str:288    """Short snippet whose header lines come from the proposed settings."""289    return "\n".join([290        f"; Chief Engineer — start g-code for {material} (header tied to recommendation)",291        f"; layer height {settings.layer_height:.2f} mm",292        f"M140 S{settings.bed_temp:.0f}      ; set bed",293        f"M104 S{settings.nozzle_temp:.0f}   ; set nozzle",294        f"M190 S{settings.bed_temp:.0f}      ; wait for bed",295        f"M109 S{settings.nozzle_temp:.0f}   ; wait for nozzle",296        "G28               ; home all axes",297        "G92 E0            ; reset extruder",298        f"M106 S{settings.first_layer_fan_pct * 2.55:.0f}   ; first-layer fan {settings.first_layer_fan_pct:.0f}%",299        f"; retraction {settings.retraction_mm:.1f} mm   ; cruise fan {settings.fan_pct:.0f}%",300        "; … (toolpath generated by your slicer, never by the model)",301    ])302 303 304# --- learning-loop renderers (the primary demo surface) --------------------305 306def quality_curve_html(trajectory: list[float], threshold: float = 0.7) -> str:307    """Astrometrics bar chart of quality per iteration — the 'it gets better' shot."""308    if not trajectory:309        return "<div style='color:var(--ao-outline);font-family:ui-monospace,monospace;'>run the loop →</div>"310    bars = []311    for i, q in enumerate(trajectory, 1):312        h = max(4, int(q * 120))313        col = "var(--ao-green)" if q >= threshold else ("var(--ao-orange)" if q >= 0.5 else "var(--ao-red)")314        bars.append(315            f"<div style='display:flex;flex-direction:column;justify-content:flex-end;align-items:center;'>"316            f"<div style='color:var(--ao-text);font-size:9px;'>{q:.2f}</div>"317            f"<div style='width:22px;height:{h}px;background:{col};'></div>"318            f"<div style='color:var(--ao-outline);font-size:9px;'>{i}</div></div>"319        )320    line_top = int((1 - threshold) * 120) + 14321    return (322        "<div style='font-family:ui-monospace,monospace;'>"323        "<div style='color:var(--ao-orange);font-weight:700;letter-spacing:1px;'>PRINT QUALITY PER ITERATION "324        f"<span style='color:var(--ao-outline);font-weight:400;'>(green = clean ≥ {threshold:.2f})</span></div>"325        "<div style='position:relative;display:flex;gap:6px;align-items:flex-end;padding:16px 4px 0;"326        "background:var(--ao-surface);border-left:3px solid var(--ao-orange);'>"327        f"<div style='position:absolute;left:0;right:0;top:{line_top}px;border-top:1px dashed var(--ao-outline-dim);'></div>"328        + "".join(bars) + "</div></div>"329    )330 331 332def iteration_log_html(records, verdicts=None, timings=None) -> str:333    """Per-iteration log. `verdicts` (optional, aligned to records) adds the QA334    Inspector's terse grade on each run — the second voice in the loop.335    `timings` (optional, aligned to records) adds per-iteration elapsed ms."""336    rows = []337    for i, r in enumerate(records):338        clean = r.result.outcome == "success"339        col = "var(--ao-green)" if clean else "var(--ao-red)"340        insp = ""341        if verdicts and i < len(verdicts) and verdicts[i] is not None:342            v = verdicts[i]343            insp = (f"<br><span style='color:{v.color};'>{icon('search')} inspector [{v.stance}]: {v.headline}</span>")344        timing = ""345        if timings and i < len(timings):346            timing = (f"<span style='float:right;color:var(--ao-outline);'>"347                      f"+{timings[i]*1000:.0f}ms</span>")348        rows.append(349            f"<div style='font-family:ui-monospace,monospace;font-size:11px;border-left:3px solid {col};"350            f"background:var(--ao-surface);padding:5px 10px;margin:3px 0;color:var(--ao-text);'>"351            f"<b style='color:{col};'>#{r.n} {r.result.outcome}</b> · q={r.result.quality:.2f} · "352            f"noz {r.settings.nozzle_temp:.0f}°C bed {r.settings.bed_temp:.0f}°C fan {r.settings.fan_pct:.0f}% "353            f"ret {r.settings.retraction_mm:.1f}mm"354            + (" · <span style='color:var(--ao-orange);'>spine-clamped</span>" if r.clamped else "")355            + f"{timing}<br><span style='color:var(--ao-outline);'>↳ {r.learned}</span>{insp}</div>"356        )357    return "".join(rows)358 359 360def policy_cell_html(cell, key: str) -> str:361    if cell is None or not getattr(cell, "offsets", None):362        return ("<div style='color:var(--ao-outline);font-family:ui-monospace,monospace;font-size:11px;'>"363                f"POLICY CELL {key}: untrained (baseline only).</div>")364    deltas = "  ".join(f"{k} {v:+g}" for k, v in cell.offsets.items())365    return (366        f"<div style='border-left:3px solid var(--ao-purple);background:var(--ao-surface);"367        f"padding:8px 12px;font-family:ui-monospace,monospace;font-size:11px;'>"368        f"<div style='color:var(--ao-purple);font-weight:700;'>LEARNED POLICY CELL · {key}</div>"369        f"<div style='color:var(--ao-text);'>offsets vs baseline: <b>{deltas}</b></div>"370        f"<div style='color:var(--ao-outline);'>{cell.trials} runs · {cell.success_rate*100:.0f}% clean</div></div>"371    )372