build-small-hackathon/microfactory-lab
2
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("<", "<")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 