← Commit history

Upload full source (MIT) — open source under adom org

John Lauer ·ca088ebc52 ·4mo ago ·parent 24352b3
40 files changed +7565
vendor/adom-step/.gitignore+6vendor/adom-step/Cargo.toml+32vendor/adom-step/SKILL.md+35vendor/adom-step/VERSION+1vendor/adom-step/publish.sh+165vendor/adom-step/src/assets/adom.css+1189vendor/adom-step/src/assets/icon.svg+1vendor/adom-step/src/assets/shell.html+4029vendor/adom-step/src/cli/console.rs+84vendor/adom-step/src/cli/eval.rs+67vendor/adom-step/src/cli/frame.rs+30vendor/adom-step/src/cli/health.rs+26vendor/adom-step/src/cli/hide.rs+22vendor/adom-step/src/cli/install.rs+27vendor/adom-step/src/cli/isolate.rs+25vendor/adom-step/src/cli/list_components.rs+42vendor/adom-step/src/cli/measure.rs+38vendor/adom-step/src/cli/mod.rs+45vendor/adom-step/src/cli/open.rs+100vendor/adom-step/src/cli/pins.rs+60vendor/adom-step/src/cli/screenshot.rs+89vendor/adom-step/src/cli/show.rs+22vendor/adom-step/src/cli/show_all.rs+18vendor/adom-step/src/cli/status.rs+25vendor/adom-step/src/cli/stop.rs+19vendor/adom-step/src/cli/view.rs+116vendor/adom-step/src/cli/view_cube.rs+31vendor/adom-step/src/cli/view_library.rs+90vendor/adom-step/src/main.rs+93vendor/adom-step/src/server/cache.rs+40vendor/adom-step/src/server/convert.rs+176vendor/adom-step/src/server/mod.rs+486vendor/adom-step/vendor/3d-viewer-dist/chunk--sWvO0qh.js+18vendor/adom-step/vendor/3d-viewer-dist/chunk-3sHAV2QP.js+11vendor/adom-step/vendor/3d-viewer-dist/chunk-B8Nx8N9J.js+53vendor/adom-step/vendor/3d-viewer-dist/chunk-BLb1yjq-.js+31vendor/adom-step/vendor/3d-viewer-dist/chunk-BwiAhjYz.js+90vendor/adom-step/vendor/3d-viewer-dist/chunk-CA4IW-nR.js+17vendor/adom-step/vendor/3d-viewer-dist/chunk-DGq4Rr9r.js+52vendor/adom-step/vendor/build-viewer.sh+64
vendor/adom-step/.gitignoreadded+6
@@ -0,0 +1,6 @@+/target/+/dist-standalone/+Cargo.lock+*.swp+.DS_Store+/tmp/
vendor/adom-step/Cargo.tomladded+32
@@ -0,0 +1,32 @@+[package]+name = "adom-step"+version = "0.1.1"+edition = "2021"+description = "STEP file 3D viewer with Fusion 360-feel components browser, measure tool, and section planes — Hydrogen webview app + AI-drivable CLI"+authors = ["Adom <[email protected]>"]+license = "MIT"+repository = "https://github.com/adom-inc/adom-step"++[[bin]]+name = "adom-step"+path = "src/main.rs"++[dependencies]+clap = { version = "4", features = ["derive"] }+clap_complete = "4"+tiny_http = "0.12"+ureq = { version = "2", features = ["json"] }+serde = { version = "1", features = ["derive"] }+serde_json = "1"+anyhow = "1"+ctrlc = "3"+colored = "2"+sha2 = "0.10"+hex = "0.4"+rust-embed = { version = "8", features = ["compression"] }++[profile.release]+opt-level = 3+lto = true+codegen-units = 1+strip = true
vendor/adom-step/SKILL.mdadded+35
@@ -0,0 +1,35 @@+---+name: adom-step+description: STEP / STP 3D file viewer. Fusion 360-feel components outline, smart-pick measure tool (mm + mils + angle), hover-inspect, geometry-based pin/contact detection for LGA, BGA, QFN, SOIC, through-hole. Hydrogen webview + AI-drivable CLI. Loads via service-step2glb, preserves assembly hierarchy + named nodes + per-face materials. Trigger words: adom-step, step viewer, view step, open step, step file, fusion 360 viewer, components browser, isolate component cad, step assembly tree, measure step, step 3d viewer, cad viewer, lga pads, qfn pins, bga balls, through-hole pins.+---++# adom-step — STEP file viewer++Fusion 360-style viewer for `.step` / `.stp` on the canonical Adom Babylon9 viewer.++## Open++```bash+adom-step view /path/to/design.step+adom-step view-library Package_QFP/LQFP-64_10x10mm_P0.5mm+```++Opens a Hydrogen tab on port 8851. service-step2glb caches by SHA-256 — repeat opens sub-second.++## CLI++```bash+adom-step list-components+adom-step isolate U1            # hide others + frame+adom-step show / hide / show-all / frame NAME+adom-step measure A B           # mm + mils+adom-step pins list / isolate N / strobe+adom-step view-cube iso+adom-step screenshot --out /tmp/x.png+adom-step eval "return Object.keys(window.adomStep)"+adom-step console -f+```++## JS API++`window.adomStep.{listComponents, isolate, hide, show, showAll, frame, measure, screenshot, setView, pinCount, pinList, isolatePin, strobePins}`.
vendor/adom-step/VERSIONadded+1
@@ -0,0 +1 @@+0.1.1
vendor/adom-step/publish.shadded+165
@@ -0,0 +1,165 @@+#!/usr/bin/env bash+# adom-step release publisher.+# Wiki-first: bumping wiki page metadata + uploading the binary IS the+# release. GitHub repo is private, so the wiki binary is the canonical+# fetch path for users on other Adom containers.+#+# Idempotent: safe to re-run for the same version (asset upload replaces+# the file; page publish updates the existing row).++set -euo pipefail+cd "$(dirname "$0")"++VERSION=$(cat VERSION | tr -d '[:space:]')+if [ -z "$VERSION" ]; then+  echo "ERROR: VERSION file is empty" >&2; exit 1+fi++if [ -t 1 ]; then C_OK='\033[32m'; C_ERR='\033[31m'; C_STEP='\033[36m'; C_RESET='\033[0m'+else C_OK=''; C_ERR=''; C_STEP=''; C_RESET=''+fi+ok()   { printf '%sOK:%s %s\n' "$C_OK" "$C_RESET" "$1"; }+fail() { printf '%sERROR:%s %s\n' "$C_ERR" "$C_RESET" "$1" >&2; exit 1; }+step() { printf '\n%s== %s ==%s\n' "$C_STEP" "$1" "$C_RESET"; }++# ── 0. Sanity: VERSION == Cargo.toml == wiki/page.json version ─────+step "0. version sanity"+CARGO_VERSION=$(grep -m1 '^version =' Cargo.toml | sed -E 's/version = "([^"]+)"/\1/')+if [ "$VERSION" != "$CARGO_VERSION" ]; then+  fail "VERSION ($VERSION) != Cargo.toml ($CARGO_VERSION). Run: sed -i 's/^version = .*/version = \"$VERSION\"/' Cargo.toml"+fi+WIKI_VERSION=$(jq -r '.version' wiki/page.json)+if [ "$VERSION" != "$WIKI_VERSION" ]; then+  fail "VERSION ($VERSION) != wiki/page.json ($WIKI_VERSION). Update wiki/page.json: { version, metadata.version, metadata.releases.adom_docker.binary_url, install_hint }"+fi+ok "VERSION + Cargo.toml + wiki/page.json all at $VERSION"++# ── 1. Build + verify ──────────────────────────────────────────────+step "1. cargo build --release"+cargo build --release+BIN=target/release/adom-step+BIN_VER=$("$BIN" --version | awk '{print $NF}')+if [ "$BIN_VER" != "$VERSION" ]; then+  fail "binary --version ($BIN_VER) != VERSION ($VERSION)"+fi+ok "binary prints adom-step $VERSION"++# ── 2. Local install ───────────────────────────────────────────────+step "2. local install"+sudo install -m 0755 "$BIN" /usr/local/bin/adom-step+rm -f "$HOME/.local/bin/adom-step"+install -m 0755 "$BIN" "$HOME/.local/bin/adom-step"+adom-step install >/dev/null+ok "installed locally + skills deployed"++# ── 3. Optional: git tag ───────────────────────────────────────────+step "3. git tag (optional)"+if git remote get-url origin >/dev/null 2>&1; then+  git add -A+  if ! git diff --cached --quiet; then+    git commit -m "release v$VERSION" || true+    ok "committed"+  else+    ok "nothing to commit"+  fi+  git tag -f "v$VERSION" 2>/dev/null || true+  if git push origin main 2>/dev/null && git push origin "v$VERSION" --force 2>/dev/null; then+    ok "pushed main + v$VERSION tag"+  else+    ok "skipped git push (no auth or offline)"+  fi+else+  ok "no git remote configured; skipping"+fi++# ── 4. Wiki page publish ───────────────────────────────────────────+step "4. adom-wiki page publish apps/adom-step"+TITLE=$(jq -r '.title' wiki/page.json)+BRIEF=$(jq -r '.brief' wiki/page.json)++build_changelog() {+  if [ -n "${CHANGELOG:-}" ]; then+    printf '%s' "$CHANGELOG"+    return+  fi+  if [ -f CHANGELOG.md ]; then+    local section+    section=$(awk -v v="v$VERSION" '+      /^## / { if (in_section) exit; if ($0 ~ "## "v"($| )") { in_section=1; next } }+      in_section { print }+    ' CHANGELOG.md | sed '/^[[:space:]]*$/d' | head -20)+    if [ -n "$section" ]; then+      printf 'v%s:\n%s' "$VERSION" "$section"+      return+    fi+  fi+  local prev_tag log_lines+  prev_tag=$(git tag --sort=-v:refname 2>/dev/null | grep -v "^v$VERSION$" | head -1)+  if [ -n "$prev_tag" ]; then+    log_lines=$(git log "$prev_tag..HEAD" --oneline --no-merges 2>/dev/null \+      | grep -viE '^\w+\s+(wip|merge|typo|fmt|fix typo)\b' \+      | sed 's/^\w\+ /- /' \+      | head -15)+  fi+  if [ -n "${log_lines:-}" ]; then+    printf 'v%s — changes since %s:\n%s' "$VERSION" "$prev_tag" "$log_lines"+    return+  fi+  printf 'release v%s — initial wiki publish' "$VERSION"+}+CHANGELOG_TEXT=$(build_changelog)+echo "── changelog ──"+printf '%s\n' "$CHANGELOG_TEXT" | head -20+echo "──────────────"++adom-wiki page publish apps/adom-step \+  --title "$TITLE" \+  --brief "$BRIEF" \+  --body-md wiki/body.md \+  --version "$VERSION" \+  --changelog "$CHANGELOG_TEXT" \+  --sample-prompt "Open a STEP file|view this .step file in adom-step" \+  --sample-prompt "Measure two faces|measure between two faces and show mm + mils + angle" \+  --sample-prompt "Isolate a part|isolate U1 in the components outline" \+  --sample-prompt "Strobe pins|strobe each detected pin / contact" \+  --sample-prompt "List pins|list all pin clusters with their centroids"+ok "wiki page published apps/adom-step v$VERSION"++# ── 5. Wiki asset upload (binary + SKILL.md) ───────────────────────+step "5. adom-wiki asset upload"+adom-wiki asset upload apps/adom-step --asset-type docker-binary --file "$BIN" \+  --caption "adom-step Linux x86_64 binary v$VERSION — Fusion 360-style STEP file viewer." \+  --changelog "binary v$VERSION"+ok "uploaded binary"+adom-wiki asset upload apps/adom-step --asset-type file --file SKILL.md \+  --caption "adom-step user-facing skill (Claude / AI usage docs)" \+  --changelog "SKILL.md v$VERSION" 2>&1 | tail -2 || true+ok "uploaded SKILL.md"++# ── 6. Push metadata (discovery_triggers, install_hint) ────────────+step "6. adom-wiki page edit --field metadata"+TMP_META=$(mktemp)+jq --arg v "$VERSION" '.metadata | .version = $v' wiki/page.json > "$TMP_META"+adom-wiki page edit apps/adom-step --field metadata --body-md "$TMP_META" \+  --changelog "v$VERSION metadata bump"+rm -f "$TMP_META"+ok "metadata updated"++# ── 7. Verify wiki binary actually serves v$VERSION ───────────────+step "7. verify wiki binary --version"+BINARY_URL=$(jq -r '.metadata.releases.adom_docker.binary_url' wiki/page.json)+CHECK=$(mktemp)+if ! curl -fsSL "$BINARY_URL" -o "$CHECK"; then+  rm -f "$CHECK"+  fail "wiki binary download failed at $BINARY_URL (may need a minute; re-run step 7)"+fi+chmod +x "$CHECK"+WIKI_VER=$("$CHECK" --version | awk '{print $NF}')+rm -f "$CHECK"+if [ "$WIKI_VER" != "$VERSION" ]; then+  fail "wiki binary --version ($WIKI_VER) != VERSION ($VERSION) — propagation lag, retry in 30s"+fi+ok "wiki binary at $BINARY_URL prints v$VERSION"++step "DONE — adom-step v$VERSION published"+echo "Auto-discover users will see the upgrade nag on their next claude-code session."
vendor/adom-step/src/assets/adom.cssadded+1189
@@ -0,0 +1,1189 @@+/* adom-step — Fusion 360-feel STEP viewer styles */++/* Adom brand fonts — Familjen Grotesk (headlines), Satoshi (body),+   JetBrains Mono (code / brand row). Per gallia/skills/brand:+   "Do NOT use Inter, Arial, Helvetica, or system fonts as primary."+   Without these @font-face blocks the browser silently falls back+   to system sans-serif and the app looks off-brand. */+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');+  font-weight: 700; font-display: swap;+}+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2');+  font-weight: 600; font-display: swap;+}+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');+  font-weight: 500; font-display: swap;+}+@font-face {+  font-family: 'Satoshi';+  src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');+  font-weight: 400; font-display: swap;+}+@font-face {+  font-family: 'Satoshi';+  src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');+  font-weight: 500; font-display: swap;+}+@font-face {+  font-family: 'JetBrains Mono';+  src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2');+  font-weight: 400; font-display: swap;+}+@font-face {+  font-family: 'JetBrains Mono';+  src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-500-normal.woff2') format('woff2');+  font-weight: 500; font-display: swap;+}+@font-face {+  font-family: 'JetBrains Mono';+  src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-600-normal.woff2') format('woff2');+  font-weight: 600; font-display: swap;+}++:root {+  /* Brand-skill palette: Adom Teal #00B8B1 is the primary accent, never cyan. */+  --bg: #1a1d22;+  --bg-soft: #20242b;+  --bg-elev: #272c34;+  --border: #353b45;+  --border-soft: #2c313a;+  --text: #e6e8eb;+  --text-dim: #9aa0a8;+  --accent: #00b8b1;                       /* brand teal */+  --accent-bright: #8fd3d4;                /* brand teal-light, hover */+  --accent-cta: #0061ef;                   /* brand blue-dark, CTAs */+  --accent-soft: rgba(0, 184, 177, 0.14);+  --measure: #00b8b1;                      /* unified with brand */+  --inspect: #f5be50;                      /* amber, distinct from accent */+  --warn: #ff7043;+  --shadow: 0 6px 24px rgba(0, 0, 0, 0.45);+  --row-h: 24px;++  /* z-index ladder per human-ui-patterns §1d. Use these tokens, never raw numbers. */+  --z-hud:           50;     /* draggable HUDs (measure, section, inspect) */+  --z-toolbar:       80;     /* fixed header / footer */+  --z-floating-menu: 200;    /* topbar, context menu, dropdowns */+  --z-toast:         400;+  --z-modal:         600;+  --z-tooltip:       99999;  /* always on top */+}++* { box-sizing: border-box; }++html, body {+  margin: 0;+  padding: 0;+  height: 100%;+  background: var(--bg);+  color: var(--text);+  /* Body text = Satoshi (Adom brand standard). System fonts are+     fallbacks for the brief moment before WOFF2 fonts swap in. */+  font: 13px/1.4 'Satoshi', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;+  overflow: hidden;+}++/* Babylon Inspector mounts in non-embed mode (its own resizable side+   panel using stock Babylon UI). We don't position it ourselves —+   forcing widths/heights via CSS breaks the section-header click+   targets and chevron icons. Just let the Inspector own its real estate. */++/* Canonical Adom app-header — see gallia/skills/adom-app-header/SKILL.md.+   Single row, 44 px tall. Identical across every Adom app. */+header {+  position: fixed;+  top: 0;+  left: 0;+  right: 0;+  height: 44px;+  padding: 0 16px;+  display: flex;+  align-items: center;+  gap: 12px;+  background: var(--bg);+  border-bottom: 1px solid var(--border-muted);+  z-index: var(--z-floating-menu);+  user-select: none;+  font-family: 'Familjen Grotesk', sans-serif;+}+header .brand {+  display: flex;+  align-items: center;+  gap: 8px;+  color: var(--accent);+  font-size: 13px;+  font-weight: 600;+  letter-spacing: 0.01em;+  cursor: default;+  flex-shrink: 0;+  outline: none;+}+header .brand:hover .app-name { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; }+header .brand:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }+header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }+header .brand .app-name { color: var(--accent); }+header .brand .app-subtitle { color: var(--text2, #7d8590); font-weight: 500; }+header .brand .sep { color: var(--text3, #484f58); font-weight: 400; }+header .vsep {+  width: 1px;+  height: 18px;+  background: var(--border);+  flex-shrink: 0;+}+header .subject {+  display: flex;+  align-items: center;+  gap: 8px;+  min-width: 0;+  flex: 1 1 auto;+  cursor: default;+}+header .subject-name {+  color: var(--text);+  font-family: 'Familjen Grotesk', sans-serif;+  font-size: 14px;+  font-weight: 600;+  white-space: nowrap;+  overflow: hidden;+  text-overflow: ellipsis;+  min-width: 0;+}+header .subject-meta {+  display: flex;+  align-items: center;+  gap: 6px;+  color: var(--text2, #7d8590);+  font-family: 'JetBrains Mono', monospace;+  font-size: 11px;+  white-space: nowrap;+  flex-shrink: 0;+}+header .subject-meta .sep { color: var(--text3, #484f58); }+@media (max-width: 720px) {+  header .subject-meta { display: none; }+}+header .state-pill {+  display: inline-flex;+  align-items: center;+  gap: 6px;+  padding: 2px 8px;+  border-radius: 10px;+  font-family: 'Familjen Grotesk', sans-serif;+  font-size: 10px;+  font-weight: 500;+  letter-spacing: 0.3px;+  text-transform: none;+  flex-shrink: 0;+  cursor: default;+  background: var(--elevated, #1c2128);+  color: var(--text2, #7d8590);+  border: 1px solid var(--border);+}+header .state-pill[data-source-type="kicad-library"] {+  background: rgba(0, 184, 177, 0.15);+  color: var(--accent);+  border-color: var(--accent);+}+header .state-pill[data-source-type="local-file"] {+  background: rgba(245, 190, 80, 0.12);+  color: var(--inspect);+  border-color: var(--inspect);+}+header .state-pill[data-source-type="upload"] {+  background: rgba(143, 211, 212, 0.15);+  color: var(--accent-bright);+  border-color: var(--accent-bright);+}++/* --- Interactive hover-popover (movable into; carries clickable links) --- */+header .hover-popover {+  position: absolute;+  top: calc(100% + 6px);+  left: 16px;+  background: var(--elevated, #1c2128);+  border: 1px solid var(--border);+  border-radius: 8px;+  box-shadow: 0 8px 24px rgba(0,0,0,0.5);+  padding: 12px 14px;+  min-width: 300px;+  max-width: 420px;+  font-family: 'Satoshi', sans-serif;+  font-size: 12px;+  color: var(--text);+  z-index: 99999;+  opacity: 0;+  transform: translateY(-2px);+  pointer-events: none;+  transition: opacity 120ms ease, transform 120ms ease;+}+header .hover-popover.show {+  opacity: 1;+  transform: none;+  pointer-events: auto;+}+header .hover-popover.compact { min-width: 220px; left: 120px; right: auto; }+header .hover-popover .popover-title {+  font-family: 'Familjen Grotesk', sans-serif;+  font-size: 14px;+  font-weight: 700;+  color: var(--text);+  margin-bottom: 8px;+}+header .hover-popover .popover-row {+  display: flex;+  gap: 12px;+  padding: 3px 0;+  align-items: baseline;+}+header .hover-popover .popover-row .k {+  color: var(--text3, #484f58);+  font-family: 'JetBrains Mono', monospace;+  font-size: 10px;+  text-transform: lowercase;+  width: 64px;+  flex-shrink: 0;+}+header .hover-popover .popover-row .v {+  color: var(--text);+  font-family: 'Satoshi', sans-serif;+  font-size: 12px;+  flex: 1;+  word-break: break-word;+}+header .hover-popover .popover-actions {+  margin-top: 10px;+  padding-top: 10px;+  border-top: 1px solid var(--border-muted, #21262d);+  display: flex;+  gap: 12px;+}+header .hover-popover .popover-link {+  color: var(--accent);+  text-decoration: none;+  font-family: 'Familjen Grotesk', sans-serif;+  font-size: 12px;+  font-weight: 600;+}+header .hover-popover .popover-link:hover { text-decoration: underline; }++.app {+  position: fixed;+  top: 44px;+  left: 0;+  right: 0;+  bottom: 0;+  /* Single column — canvas fills the entire viewport so the outline can+     float on top of it, Fusion 360 Browser-style. */+  display: grid;+  grid-template-columns: minmax(0, 1fr);+  grid-template-rows: 1fr;+  background: var(--bg);+}++/* ---- Outline (Fusion 360 Browser-style floating panel) ----+   Translucent dark glass that sits over the canvas. The 3D scene shows+   through underneath so the outline never feels like it's stealing real+   estate from the model. Collapsible via the chevron in the header+   (slides off-screen) — collapsed state reveals a slim vertical+   expand-tab at the canvas's left edge. */++.sidebar {+  position: absolute;+  top: 0;+  left: 0;+  bottom: 0;+  width: 280px;+  /* Light Fusion 360-feel — nearly transparent. The 3D scene reads+     clearly through the panel; outline rows stay legible via the+     text-shadow rule below. No solid panel, no heavy border. */+  background: transparent;+  border-right: none;+  display: flex;+  flex-direction: column;+  min-width: 0;+  min-height: 0;+  overflow: hidden;+  z-index: 50;  /* above the canvas, below modals/tooltips */+  transition: transform 200ms cubic-bezier(0.4, 0.0, 0.2, 1);+}+/* Outline row legibility against any scene — soft dark text-shadow on+   every row label so light pads / silver caps don't wash out the text.+   Cheap (no full-screen blur) and matches Fusion's approach. */+.sidebar .sb-tree .tree-row,+.sidebar .sb-title,+.sidebar .sb-source,+.sidebar .sb-search,+.sidebar .sb-chip {+  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85), 0 0 4px rgba(0, 0, 0, 0.55);+}+.sidebar.collapsed {+  transform: translateX(-100%);+  pointer-events: none;+}++/* Expand tab — only shown when the outline is collapsed. Sits at the+   canvas's left edge as a slim vertical strip with a chevron pointing+   inward (❯). Same translucent glass treatment as the panel itself so+   the visual language stays consistent. */+.outline-expander {+  position: absolute;+  top: 50%;+  left: 0;+  transform: translateY(-50%);+  width: 22px;+  height: 64px;+  background: rgba(20, 24, 30, 0.82);+  backdrop-filter: blur(8px) saturate(1.2);+  -webkit-backdrop-filter: blur(8px) saturate(1.2);+  border: 1px solid var(--border);+  border-left: none;+  border-radius: 0 6px 6px 0;+  color: var(--text-dim);+  cursor: pointer;+  font-size: 14px;+  font-family: inherit;+  display: none;+  align-items: center;+  justify-content: center;+  z-index: 51;+  padding: 0;+  transition: background 120ms ease, color 120ms ease;+}+.outline-expander:hover {+  background: rgba(0, 184, 177, 0.18);+  color: var(--accent);+}+body.outline-collapsed .outline-expander {+  display: flex;+}++.sidebar .sb-header {+  padding: 10px 12px;+  border-bottom: 1px solid rgba(255, 255, 255, 0.08);+  display: flex;+  flex-direction: column;+  gap: 8px;+}++.sidebar .sb-title-row {+  display: flex;+  align-items: center;+  justify-content: space-between;+  gap: 6px;+}++.sidebar .sb-title {+  font-weight: 600;+  letter-spacing: 0.02em;+  display: flex;+  align-items: center;+  gap: 6px;+  font-size: 12px;+  color: var(--text-dim);+}++/* Collapse chevron — sits at the right end of the title row. Same hit-zone+   target sizing as the tree-row icons so it feels consistent. */+.sidebar .sb-collapse {+  width: 22px;+  height: 22px;+  background: transparent;+  border: 1px solid transparent;+  color: var(--text-dim);+  cursor: pointer;+  font-size: 12px;+  font-family: inherit;+  border-radius: 4px;+  padding: 0;+  display: inline-flex;+  align-items: center;+  justify-content: center;+  line-height: 1;+}+.sidebar .sb-collapse:hover {+  background: var(--accent-soft);+  color: var(--accent);+  border-color: var(--accent);+}+.sidebar .sb-title .count {+  font-weight: 400;+  color: var(--text-dim);+  font-size: 11px;+}++.sidebar .sb-search {+  width: 100%;+  /* Translucent input so the scene still shows behind it. Faint focus+     accent + text shadow keeps the placeholder legible. */+  background: rgba(20, 24, 30, 0.45);+  border: 1px solid rgba(255, 255, 255, 0.10);+  color: var(--text);+  padding: 5px 8px;+  border-radius: 4px;+  font-size: 12px;+  outline: none;+}+.sidebar .sb-search:focus {+  background: rgba(20, 24, 30, 0.65);+  border-color: var(--accent);+}++.sidebar .sb-filters {+  display: flex;+  flex-wrap: wrap;+  gap: 4px;+  margin-top: 2px;+}+.sidebar .sb-chip {+  background: rgba(20, 24, 30, 0.45);+  border: 1px solid rgba(255, 255, 255, 0.10);+  color: var(--text-dim);+  padding: 3px 8px;+  font-size: 11px;+  border-radius: 10px;+  cursor: pointer;+  font-family: inherit;+  display: inline-flex;+  align-items: center;+  gap: 4px;+}+.sidebar .sb-chip:hover {+  background: var(--accent-soft);+  color: var(--accent);+  border-color: var(--accent);+}+.sidebar .sb-chip.active {+  background: var(--accent-soft);+  color: var(--accent);+  border-color: var(--accent);+}+.sidebar .sb-chip .count {+  font-size: 10px;+  opacity: 0.7;+  font-weight: 400;+}++.sidebar .sb-source {+  font-size: 11px;+  color: var(--text-dim);+  text-overflow: ellipsis;+  overflow: hidden;+  white-space: nowrap;+}++.sidebar .sb-tree {+  flex: 1 1 auto;+  overflow-y: overlay;       /* WebKit: thumb floats over content, no width loss */+  overflow-y: auto;          /* fallback for browsers without `overlay` */+  padding: 4px 0;+  min-height: 0;+  /* Firefox thin overlay-style scrollbar — track stays invisible. */+  scrollbar-width: thin;+  scrollbar-color: rgba(255, 255, 255, 0.18) transparent;+}+/* WebKit / Blink: 6px-wide overlay scrollbar that fades in on hover/scroll.+   Tracks are transparent so the canvas keeps showing through; the thumb is+   a soft white tint that mirrors Fusion 360's Browser scrollbar. */+.sidebar .sb-tree::-webkit-scrollbar {+  width: 6px;+  height: 6px;+  background: transparent;+}+.sidebar .sb-tree::-webkit-scrollbar-track {+  background: transparent;+}+.sidebar .sb-tree::-webkit-scrollbar-thumb {+  background: rgba(255, 255, 255, 0.10);+  border-radius: 3px;+  transition: background 150ms ease;+}+.sidebar:hover .sb-tree::-webkit-scrollbar-thumb,+.sidebar .sb-tree:hover::-webkit-scrollbar-thumb {+  background: rgba(255, 255, 255, 0.28);+}+.sidebar .sb-tree::-webkit-scrollbar-thumb:active {+  background: rgba(255, 255, 255, 0.42);+}+.sidebar .sb-tree::-webkit-scrollbar-corner {+  background: transparent;+}++.tree-row {+  display: flex;+  align-items: center;+  height: 28px;             /* bumped from 24 — bigger hit targets */+  padding-right: 8px;+  user-select: none;+  cursor: default;+  font-size: 12px;+  color: var(--text);+  white-space: nowrap;+}+.tree-row:hover {+  background: var(--accent-soft);+}+.tree-row.selected {+  background: var(--accent-soft);+}+.tree-row.dimmed {+  opacity: 0.35;+}++/* "Find in outline" flash — used after right-click in the 3D viewer to+   draw the user's eye to the row that just got revealed. */+@keyframes treeRowFlash {+  0%   { background: rgba(0, 184, 177, 0); }+  20%  { background: rgba(0, 184, 177, 0.55); }+  100% { background: rgba(0, 184, 177, 0); }+}+.tree-row.flash {+  animation: treeRowFlash 1.4s ease-out;+}+.tree-row.hidden-vis {+  color: var(--text-dim);+}++/* Tree-row icons: bigger hit targets, always-visible, brand-teal active.+   Per human-ui-patterns §1a all three carry data-tooltip explaining what+   they do; per the same skill, hit zones are at least 24×24 so a real+   pointer (not a sub-pixel mouse) can land them. */+.tree-row .caret,+.tree-row .eye,+.tree-row .iso {+  width: 26px;+  height: 26px;+  display: inline-flex;+  align-items: center;+  justify-content: center;+  cursor: pointer;+  flex-shrink: 0;+  border-radius: 4px;+  margin-right: 1px;+}+.tree-row .caret {+  color: var(--text-dim);+  font-size: 12px;       /* VS-Code-style ❯ chevron — slim glyph, rotates 90° via .open */+  font-weight: 600;+  line-height: 1;+  transition: transform 120ms ease;+  transform-origin: 50% 50%;+}+.tree-row .caret.has-children {+  cursor: pointer;+}+.tree-row .caret.has-children.open {+  transform: rotate(90deg);  /* expanded → chevron points down, matching VS Code Explorer */+}+.tree-row .caret.has-children:hover {+  background: var(--accent-soft);+  color: var(--accent);+}+.tree-row .caret.leaf {+  cursor: default;        /* no fake-button affordance for leaf rows */+  visibility: hidden;     /* keep the slot for alignment, hide the glyph */+}+.tree-row .eye {+  color: var(--accent);+  font-size: 14px;+}+.tree-row .eye:hover {+  background: var(--accent-soft);+}+.tree-row.hidden-vis .eye { color: var(--text-dim); }+.tree-row .eye.mixed { color: var(--inspect); }++.tree-row .iso {+  color: var(--text-dim);+  font-size: 12px;+  /* always visible (not hover-only) so users discover the affordance */+}+.tree-row .iso:hover {+  color: var(--accent);+  background: var(--accent-soft);+}++.tree-row .label {+  margin-left: 4px;+  text-overflow: ellipsis;+  overflow: hidden;+  flex: 1 1 auto;+  min-width: 0;+}++.tree-row .child-count {+  font-size: 10px;+  color: var(--text-dim);+  margin-left: 6px;+  flex-shrink: 0;+}+.tree-row.material-group .label {+  font-weight: 500;+}+.tree-row .mat-swatch {+  display: inline-block;+  width: 10px;+  height: 10px;+  border-radius: 2px;+  margin-right: 6px;+  border: 1px solid rgba(255, 255, 255, 0.18);+  flex-shrink: 0;+}++.tree-row .pin-side {+  font-size: 10px;+  padding: 1px 6px;+  border-radius: 8px;+  margin-left: 6px;+  flex-shrink: 0;+  font-family: ui-monospace, monospace;+  letter-spacing: 0.02em;+}+.tree-row .pin-side[data-side="front"] { background: rgba(143, 211, 212, 0.18); color: var(--accent-bright); }+.tree-row .pin-side[data-side="right"] { background: rgba(0, 184, 177, 0.18);   color: var(--accent); }+.tree-row .pin-side[data-side="back"]  { background: rgba(100, 171, 255, 0.18); color: #64ABFF; }+.tree-row .pin-side[data-side="left"]  { background: rgba(245, 190, 80, 0.18);  color: var(--inspect); }++/* Tree row badges — small monochrome SVG status icons that sit between+   the label and the child-count. Each badge inherits its tint from a+   per-kind data-attribute so a row can carry several at once (pin-1+   star + chipsmith-synthesized sparkle, for example). Per+   gallia/skills/human-ui-patterns playbooks/icons-and-displays.md §4b+   we use SVGs not Unicode emoji: same stroke weight, same 12px box,+   currentColor so the row's text color drives them.++   Hover/tooltip on each badge is set via data-tooltip in JS so users+   discover what they mean without a legend. */+.tree-row .tree-badges {+  display: inline-flex;+  align-items: center;+  gap: 3px;+  margin-left: 4px;+  flex-shrink: 0;+}+.tree-row .tree-badge {+  display: inline-flex;+  align-items: center;+  justify-content: center;+  width: 14px;+  height: 14px;+  border-radius: 3px;+  line-height: 0;+}+.tree-row .tree-badge svg {+  width: 12px;+  height: 12px;+  display: block;+}+.tree-row .tree-badge[data-kind="pin1"]   { color: var(--inspect); background: rgba(245, 190, 80, 0.20); }+.tree-row .tree-badge[data-kind="synth"]  { color: var(--accent);  background: rgba(0, 184, 177, 0.18); }+.tree-row .tree-badge[data-kind="locked"] { color: var(--text-dim); background: rgba(255,255,255,0.08); }+.tree-row .tree-badge[data-kind="redet"]  { color: #64ABFF;        background: rgba(100,171,255,0.18); }++/* ---- Canvas + HUD ---- */++.canvas-wrap {+  position: relative;+  min-width: 0;+  min-height: 0;+  overflow: hidden;+}++#renderContainer {+  width: 100%;+  height: 100%;+  position: absolute;+  inset: 0;+  outline: none;+}+#renderContainer canvas {+  width: 100% !important;+  height: 100% !important;+  display: block;+  outline: none;+}++.toolbar {+  position: absolute;+  top: 10px;+  /* Outline floats over the left 280px of the canvas; nudge the toolbar+     past it. When the outline is collapsed (body.outline-collapsed), drop+     back to the canvas edge. The transition matches the outline's slide. */+  left: 290px;+  display: flex;+  gap: 6px;+  align-items: center;+  z-index: var(--z-toolbar);+  transition: left 200ms cubic-bezier(0.4, 0.0, 0.2, 1);+}+body.outline-collapsed .toolbar {+  left: 36px;  /* clear of the expand-tab (22px wide) + breathing room */+}+.tb-sep {+  width: 1px;+  height: 22px;+  background: var(--border);+  margin: 0 2px;+}+.tb-input {+  width: 64px;+  background: var(--bg);+  border: 1px solid var(--border);+  color: var(--text);+  padding: 5px 6px;+  font-size: 12px;+  border-radius: 4px;+  font-family: ui-monospace, monospace;+  outline: none;+}+.tb-input:focus { border-color: var(--accent); }+.tb-input.disabled,+.tb-input[aria-disabled="true"] { opacity: 0.4; color: var(--text-dim); }+.tb-btn {+  background: rgba(39, 44, 52, 0.92);+  border: 1px solid var(--border);+  color: var(--text);+  padding: 6px 10px;+  font-size: 12px;+  border-radius: 4px;+  cursor: pointer;+  display: inline-flex;+  align-items: center;+  gap: 6px;+  font-family: inherit;+}+.tb-btn:hover { background: var(--bg-elev); }+/* Icon-only variant — square 30x30 button. Inline custom SVG (not+   Unicode emoji per human-ui-patterns icons-and-displays §4b). Tooltips+   carry the label + behaviour. */+.tb-btn.icon {+  width: 30px;+  height: 30px;+  padding: 0;+  line-height: 1;+  justify-content: center;+  gap: 0;+}+.tb-btn.icon svg {+  width: 18px;+  height: 18px;+  display: block;+}+.tb-btn.active {+  background: var(--accent-soft);+  border-color: var(--accent);+  color: var(--accent);+}+.tb-btn.measure.active { border-color: var(--measure); color: var(--measure); background: rgba(29, 233, 182, 0.12); }+.tb-btn.section.active { border-color: var(--warn); color: var(--warn); background: rgba(255, 112, 67, 0.12); }+.tb-btn.disabled,+.tb-btn[aria-disabled="true"] {+  opacity: 0.4;+  cursor: not-allowed;+  color: var(--text-dim);+}+.tb-btn.disabled:hover,+.tb-btn[aria-disabled="true"]:hover { background: rgba(39, 44, 52, 0.92); }++.tb-corner {+  position: absolute;+  bottom: 10px;+  right: 10px;+  display: flex;+  gap: 6px;+  z-index: var(--z-toolbar);+  align-items: center;+}++/* ---- Drop zone overlay ---- */++.dropzone {+  position: absolute;+  inset: 0;+  background: rgba(26, 29, 34, 0.85);+  display: none;+  align-items: center;+  justify-content: center;+  z-index: var(--z-modal);+  pointer-events: none;+}+body.dragging .dropzone { display: flex; }+.dropzone .dz-card {+  border: 2px dashed var(--accent);+  border-radius: 12px;+  padding: 32px 64px;+  font-size: 18px;+  color: var(--accent);+  background: rgba(79, 195, 247, 0.06);+  text-align: center;+  letter-spacing: 0.02em;+}+.dropzone .dz-sub {+  font-size: 13px;+  color: var(--text-dim);+  margin-top: 8px;+}++/* ---- Measure HUD ---- */++.measure-hud {+  position: absolute;+  top: 10px;+  right: 10px;+  width: 280px;+  background: rgba(32, 36, 43, 0.96);+  border: 1px solid var(--measure);+  border-radius: 6px;+  box-shadow: var(--shadow);+  z-index: var(--z-hud);+  display: none;+  font-size: 12px;+  overflow: hidden;+}+.measure-hud.open { display: block; }+.measure-hud .mh-head {+  padding: 8px 10px;+  border-bottom: 1px solid var(--border);+  display: flex;+  align-items: center;+  justify-content: space-between;+  background: rgba(29, 233, 182, 0.08);+}+.measure-hud .mh-title {+  color: var(--measure);+  font-weight: 600;+  letter-spacing: 0.04em;++  font-size: 11px;+}+.measure-hud .mh-close {+  cursor: pointer;+  color: var(--text-dim);+  font-size: 14px;+  padding: 0 4px;+}+.measure-hud .mh-close:hover { color: var(--text); }+.measure-hud .mh-snap {+  padding: 6px 10px;+  border-bottom: 1px solid var(--border);+  display: flex;+  align-items: center;+  gap: 8px;+  font-size: 11px;+  background: rgba(0, 184, 177, 0.04);+}+.measure-hud .mh-snap-label {+  color: var(--text-dim);+}+.measure-hud .mh-snap-kind {+  font-family: ui-monospace, monospace;+  font-weight: 600;+  color: var(--text-dim);+  text-transform: capitalize;+}+.measure-hud .mh-snap-kind[data-kind="edge"]   { color: var(--measure); }+.measure-hud .mh-snap-kind[data-kind="face"]   { color: var(--measure); }+.measure-hud .mh-snap-kind[data-kind="vertex"] { color: var(--measure); }+.measure-hud .mh-snap-kind[data-kind="body"]   { color: var(--measure); }+.measure-hud .mh-snap-kind[data-kind="point"]  { color: var(--text-dim); }++.measure-hud .mh-filters {+  padding: 6px 8px;+  border-bottom: 1px solid var(--border);+  display: flex;+  gap: 4px;+  flex-wrap: wrap;+}+.measure-hud .mb-filter {+  background: var(--bg);+  border: 1px solid var(--border);+  color: var(--text);+  padding: 3px 8px;+  font-size: 11px;+  border-radius: 3px;+  cursor: pointer;+}+.measure-hud .mb-filter.active {+  background: rgba(29, 233, 182, 0.16);+  border-color: var(--measure);+  color: var(--measure);+}+.measure-hud .mh-body {+  padding: 8px 10px;+}+.measure-hud .mh-row {+  display: grid;+  grid-template-columns: 70px 1fr;+  gap: 4px 10px;+  padding: 3px 0;+  align-items: baseline;+}+.measure-hud .mh-row .lbl {+  color: var(--text-dim);+  font-size: 11px;+}+.measure-hud .mh-row .val {+  font-family: ui-monospace, monospace;+  font-size: 12px;+}+.measure-hud .mh-sep {+  border-top: 1px solid var(--border);+  margin: 6px 0;+}+.measure-hud .mh-distance {+  color: var(--measure);+  font-weight: 600;+  font-size: 14px;+}+.measure-hud .mh-secondary {+  color: var(--text-dim);+  font-size: 11px;+}+.measure-hud .mh-hint {+  padding: 6px 10px;+  font-size: 11px;+  color: var(--text-dim);+  border-top: 1px solid var(--border);+}++/* ---- Inspect HUD (amber) ---- */++.inspect-card {+  position: fixed;+  background: rgba(32, 36, 43, 0.97);+  border: 1px solid var(--inspect);+  border-radius: 6px;+  box-shadow: var(--shadow);+  z-index: calc(var(--z-hud) + 10);+  display: none;+  width: 260px;+  pointer-events: none;+  font-size: 12px;+  overflow: hidden;+}+.inspect-card.open { display: block; }+.inspect-card.pinned {+  pointer-events: auto;+  border-color: var(--warn);+  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6);+}+.inspect-card .ic-head {+  padding: 8px 10px;+  border-bottom: 1px solid var(--border);+  background: rgba(245, 190, 80, 0.08);+}+.inspect-card .ic-name {+  color: var(--inspect);+  font-weight: 600;+  font-size: 12px;+  white-space: nowrap;+  overflow: hidden;+  text-overflow: ellipsis;+}+.inspect-card .ic-path {+  font-size: 10px;+  color: var(--text-dim);+  margin-top: 2px;+  white-space: nowrap;+  overflow: hidden;+  text-overflow: ellipsis;+}+.inspect-card .ic-body {+  padding: 6px 10px;+  display: grid;+  grid-template-columns: 70px 1fr;+  gap: 3px 10px;+  font-size: 11px;+}+.inspect-card .ic-body .lbl { color: var(--text-dim); }+.inspect-card .ic-body .val { font-family: ui-monospace, monospace; }+.inspect-card .swatch {+  display: inline-block;+  width: 14px;+  height: 14px;+  border-radius: 2px;+  border: 1px solid rgba(255, 255, 255, 0.2);+  vertical-align: middle;+  margin-right: 6px;+}+.inspect-card .ic-foot {+  padding: 4px 10px;+  font-size: 10px;+  color: var(--text-dim);+  border-top: 1px solid var(--border);+  text-align: center;+}++/* ---- Section HUD ---- */++.section-hud {+  position: absolute;+  top: 10px;+  right: 10px;+  width: 260px;+  background: rgba(32, 36, 43, 0.96);+  border: 1px solid var(--warn);+  border-radius: 6px;+  box-shadow: var(--shadow);+  z-index: var(--z-hud);+  display: none;+  font-size: 12px;+  overflow: hidden;+}+.section-hud.open { display: block; }+.section-hud .sh-head {+  padding: 8px 10px;+  border-bottom: 1px solid var(--border);+  display: flex;+  align-items: center;+  justify-content: space-between;+  background: rgba(255, 112, 67, 0.08);+}+.section-hud .sh-title {+  color: var(--warn);+  font-weight: 600;+  letter-spacing: 0.04em;++  font-size: 11px;+}+.section-hud .sh-close {+  cursor: pointer;+  color: var(--text-dim);+  font-size: 14px;+  padding: 0 4px;+}+.section-hud .sh-row {+  display: grid;+  grid-template-columns: 24px 24px 1fr 24px 24px;+  gap: 4px;+  padding: 6px 8px;+  align-items: center;+  border-bottom: 1px solid var(--border-soft);+}+.section-hud .sh-row .axis {+  display: inline-flex;+  align-items: center;+  justify-content: center;+  font-weight: 600;+  font-size: 11px;+  border-radius: 3px;+  padding: 2px 6px;+}+.section-hud .sh-row .axis.x { background: rgba(255, 80, 80, 0.18); color: #ff8080; }+.section-hud .sh-row .axis.y { background: rgba(80, 200, 80, 0.18); color: #80e080; }+.section-hud .sh-row .axis.z { background: rgba(80, 140, 255, 0.18); color: #80a8ff; }+.section-hud .sh-row input[type=number] {+  width: 100%;+  background: var(--bg);+  border: 1px solid var(--border);+  color: var(--text);+  padding: 2px 4px;+  font-size: 11px;+  border-radius: 3px;+  font-family: ui-monospace, monospace;+}+.section-hud .sh-row .x-btn,+.section-hud .sh-row .flip-btn {+  cursor: pointer;+  color: var(--text-dim);+  background: transparent;+  border: 1px solid var(--border);+  border-radius: 3px;+  padding: 2px 4px;+  font-size: 11px;+}+.section-hud .sh-row .x-btn:hover { color: var(--warn); }+.section-hud .sh-actions {+  padding: 6px 8px;+  display: flex;+  gap: 6px;+  border-top: 1px solid var(--border);+}+.section-hud .sh-actions .btn {+  flex: 1;+  background: var(--bg);+  border: 1px solid var(--border);+  color: var(--text);+  padding: 4px 8px;+  font-size: 11px;+  border-radius: 3px;+  cursor: pointer;+}+.section-hud .sh-actions .btn:hover { background: var(--bg-elev); }++/* ---- Toast / status footer ---- */++.toast {+  position: absolute;+  bottom: 10px;+  left: 10px;+  z-index: var(--z-toast);+  background: rgba(32, 36, 43, 0.96);+  border: 1px solid var(--border);+  border-radius: 4px;+  padding: 6px 10px;+  font-size: 12px;+  color: var(--text-dim);+  box-shadow: var(--shadow);+  display: none;+}+.toast.open { display: block; }+.toast.ok { border-color: var(--measure); color: var(--measure); }+.toast.err { border-color: var(--warn); color: var(--warn); }++/* ---- Loading splash ---- */++.splash {+  position: absolute;+  inset: 0;+  display: flex;+  flex-direction: column;+  align-items: center;+  justify-content: center;+  background: var(--bg);+  z-index: 1;+  color: var(--text-dim);+  font-size: 14px;+  gap: 12px;+}+.splash .spinner {+  width: 28px;+  height: 28px;+  border: 3px solid var(--border);+  border-top-color: var(--accent);+  border-radius: 50%;+  animation: spin 1s linear infinite;+}+@keyframes spin {+  to { transform: rotate(360deg); }+}++/* ---- Context menu ---- */++.ctx-menu {+  position: fixed;+  background: rgba(32, 36, 43, 0.97);+  border: 1px solid var(--border);+  border-radius: 4px;+  box-shadow: var(--shadow);+  z-index: var(--z-floating-menu);+  min-width: 160px;+  padding: 4px 0;+  font-size: 12px;+  display: none;+}+.ctx-menu.open { display: block; }+.ctx-menu .ctx-item {+  padding: 5px 14px;+  cursor: pointer;+  white-space: nowrap;+}+.ctx-menu .ctx-item:hover { background: var(--accent-soft); color: var(--accent); }+.ctx-menu .ctx-sep {+  border-top: 1px solid var(--border);+  margin: 4px 0;+}
vendor/adom-step/src/assets/icon.svgadded+1
@@ -0,0 +1 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="M3.27 6.96 12 12.01l8.73-5.05"/><path d="M12 22.08V12"/></svg>
vendor/adom-step/src/assets/shell.htmladded+4029
@@ -0,0 +1,4029 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="UTF-8" />+<meta name="viewport" content="width=device-width, initial-scale=1" />+<title>STEP Viewer</title>+<base href="./" />+<link rel="icon" href="favicon.svg" type="image/svg+xml" />+<link rel="stylesheet" href="adom.css" />+</head>+<body>+<script>+// SANITY: prove the page reached the parser. If you don't see "page loaded"+// in /console, the proxy or webview is the culprit, not the boot script.+fetch('console', {+  method: 'POST',+  headers: {'Content-Type': 'application/json'},+  body: JSON.stringify({level: 'log', text: 'page loaded ua=' + navigator.userAgent.slice(0, 80) + ' href=' + location.href})+}).catch(()=>{});+</script>+<!-- Canonical Adom app-header — see gallia/skills/adom-app-header/SKILL.md.+     Single row: brand (icon + name + subtitle) | subject (file + meta) | actions.+     Brand block hovers a popover with version / build / engine / wiki link.+     Subject-meta hides on narrow viewports; subject-name then carries the+     stats tooltip. Total height: 44 px. -->+<header>+  <div class="brand" data-tooltip-target="app-info" tabindex="0">+    <svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">+      <path d="M3 7l9-4 9 4-9 4-9-4zm0 5l9 4 9-4M3 17l9 4 9-4M16 18.5v3M16 21.5h-1.5"/>+    </svg>+    <span class="app-name">adom-step</span>+    <span class="sep">·</span>+    <span class="app-subtitle">STEP file viewer</span>+  </div>++  <div class="vsep" aria-hidden="true"></div>++  <div class="subject" data-tooltip-target="subject-info">+    <span class="subject-name" id="topSource">No model loaded</span>+    <span class="state-pill" id="topProvenance" data-tooltip="Where the loaded STEP came from. KiCad library = fetched from service-kicad. Local file = your file passed via `adom-step view`. Drop = a STEP file you dropped onto the canvas.">unknown</span>+    <span class="subject-meta" id="topMeta"></span>+  </div>++  <!-- App-info popover (hover the brand block) -->+  <div class="hover-popover" id="app-info" role="dialog" aria-label="App information">+    <div class="popover-title">adom-step</div>+    <div class="popover-row"><span class="k">about</span>+      <span class="v">Fusion 360-style STEP file viewer with measure tool, components browser, and AI-driven inspection.</span></div>+    <div class="popover-row"><span class="k">version</span><span class="v">v0.1.0</span></div>+    <div class="popover-row"><span class="k">engine</span><span class="v">Adom 3D Viewer · Babylon.js 9.5</span></div>+    <div class="popover-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>+    <div class="popover-actions">+      <a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-step" target="_blank" rel="noopener">Open wiki ↗</a>+      <a class="popover-link" href="https://github.com/adom-inc/adom-step" target="_blank" rel="noopener">GitHub ↗</a>+    </div>+  </div>++  <!-- Subject-meta tooltip (hover the subject — surfaces meta when row is narrow) -->+  <div class="hover-popover compact" id="subject-info" role="dialog">+    <div class="popover-row"><span class="k">file</span><span class="v" id="tipFile">—</span></div>+    <div class="popover-row"><span class="k">source</span><span class="v" id="tipSource">—</span></div>+    <div class="popover-row"><span class="k">nodes</span><span class="v" id="tipNodes">—</span></div>+    <div class="popover-row"><span class="k">size</span><span class="v" id="tipSize">—</span></div>+  </div>+</header>++<div class="app">+  <!-- ===== OUTLINE (left-side floating panel — Fusion 360 Browser style) ===== -->+  <aside class="sidebar" id="outlinePanel">+    <div class="sb-header">+      <div class="sb-title-row">+        <div class="sb-title"><span>Outline</span> <span class="count" id="compCount">—</span></div>+        <button class="sb-collapse" id="outlineCollapse" aria-label="Collapse outline" data-tooltip="Collapse the outline (O) — slides the panel away so the 3D view fills the canvas. Click the tab at the left edge to expand.">❮</button>+      </div>+      <input class="sb-search" id="sbSearch" type="text" placeholder="Search outline" data-tooltip="Filter the assembly tree. Matching nodes stay opaque, non-matches dim. Ancestors of matches auto-expand." />+      <div class="sb-filters" id="sbFilters">+        <button class="sb-chip active" data-filter-type="all" data-tooltip="Show every part type (default).">All</button>+      </div>+      <div class="sb-source" id="sbSource">No model loaded</div>+    </div>+    <div class="sb-tree" id="sbTree"></div>+  </aside>+  <!-- Expand tab — only shown when the outline is collapsed. Fusion-style+       slim vertical strip at the canvas's left edge with a chevron pointing+       inward. Click expands the outline back. -->+  <button class="outline-expander" id="outlineExpander" aria-label="Expand outline" data-tooltip="Expand the outline (O).">❯</button>++  <!-- ===== CANVAS + HUD ===== -->+  <div class="canvas-wrap">+    <!-- Container for the canonical Babylon9 viewer's Svelte-mounted canvas.+         Adom3DViewerBabylon9.init() takes a CONTAINER element (not a canvas)+         and creates its own <canvas> inside, so we give it a plain div. -->+    <div id="renderContainer" tabindex="0"></div>++    <!-- Top-left toolbar — icon-only (Home / Front / Top / Iso are handled+         by the canonical viewer's ViewCube top-right). Tooltips carry the+         label + behaviour, so the canvas stays clean. Per+         gallia/skills/human-ui-patterns/playbooks/icons-and-displays.md+         §4b — every glyph is a custom-drawn monochrome SVG (currentColor)+         at viewBox 0 0 24 24, stroke 1.6. NEVER Unicode emoji. -->+    <div class="toolbar">+      <button class="tb-btn icon measure" id="tbMeasure" aria-label="Measure" data-tooltip="Measure (M) — Fusion 360-style smart pick. Shows mm + mils.">+        <!-- Calipers — two jaws + beam, the canonical EE measure tool. -->+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9 v3 a2 2 0 0 0 2 2 h12 a2 2 0 0 0 2 -2 v-3"/><path d="M4 9 h16"/><path d="M7 14 v3 M11 14 v4 M15 14 v3"/></svg>+      </button>+      <span class="tb-sep"></span>+      <button class="tb-btn icon" id="tbGround" aria-label="Ground plane" data-tooltip="Ground plane + cast shadows. Off by default — flip on when you want a contact shadow under the part.">+        <!-- Ground line + diagonal hatching beneath. -->+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 14 h18"/><path d="M6 14 l-2 4 M10 14 l-2 4 M14 14 l-2 4 M18 14 l-2 4 M22 14 l-2 4"/></svg>+      </button>+      <button class="tb-btn icon" id="tbBottomLight" aria-label="Bottom light" data-tooltip="Bottom-fill light (B) — illuminate the underside of the part. Useful for inspecting BGA balls / QFN pads / through-hole leg bottoms.">+        <!-- Sun with rays pointing UP (light source below, rays going up+             into the chip). -->+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="17" r="3"/><path d="M12 12 v-2 M12 22 v0 M7 17 h-2 M19 17 h2 M8.5 13.5 l-1.5 -1.5 M15.5 13.5 l1.5 -1.5"/></svg>+      </button>+      <button class="tb-btn icon active" id="tbAxes" aria-label="World axes" data-tooltip="World-origin axes (X red / Y green / Z blue). 3d-viewer-design §8a.">+        <!-- 3-axis triad — Z up, X back-left, Y back-right (canonical+             pseudo-3D origin marker). -->+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 14 v-9"/><path d="M12 14 l-7 4"/><path d="M12 14 l7 4"/><circle cx="12" cy="14" r="1.4" fill="currentColor"/></svg>+      </button>+      <button class="tb-btn icon" id="tbMeshAxes" aria-label="Local axes" data-tooltip="Per-mesh local-origin axes. 3d-viewer-design §8b — useful for spotting Y-up vs Z-up bugs.">+        <!-- Triad inside a small mesh-body box (LOCAL frame = bound to+             a single mesh, not the world). -->+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="13" width="8" height="8" rx="1"/><path d="M7 17 v-9 M7 17 l8 4 M7 17 l8 -4"/></svg>+      </button>+      <span class="tb-sep"></span>+      <button class="tb-btn icon" id="tbInspect" aria-label="Babylon Inspector" data-tooltip="Babylon Inspector — open the materials / scene-graph / debug panel.">+        <!-- Magnifier with a small data dot inside — chosen over the+             generic gear because chipfit / chiplinter readers know+             "inspect" reads as a magnifier. -->+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="10.5" cy="10.5" r="5.5"/><path d="M14.5 14.5 L20 20"/><circle cx="10.5" cy="10.5" r="1.4" fill="currentColor"/></svg>+      </button>+    </div>++    <!-- View presets handled by the canonical viewer's ViewCube (top-right+         of canvas). No app-level Front/Top/Iso/Home buttons needed. -->++    <!-- Drop zone overlay -->+    <div class="dropzone">+      <div class="dz-card">+        Drop a .step / .stp file+        <div class="dz-sub">Converted via service-step2glb · cached on disk by content hash</div>+      </div>+    </div>++    <!-- Measure HUD -->+    <div class="measure-hud" id="measureHud">+      <div class="mh-head">+        <span class="mh-title">Measure</span>+        <span class="mh-close" id="measureClose" data-tooltip="Close (Esc)">✕</span>+      </div>+      <div class="mh-filters">+        <button class="mb-filter active" data-filter="smart" data-tooltip="Smart pick — snaps to the nearest vertex, then edge, then face, in priority order. Fusion 360 default.">Smart</button>+        <button class="mb-filter" data-filter="vertex" data-tooltip="Vertex — snaps your click to the closest mesh vertex within ~1.5 mm.">Vertex</button>+        <button class="mb-filter" data-filter="edge" data-tooltip="Edge — snaps to the midpoint of the nearest edge of the picked face.">Edge</button>+        <button class="mb-filter" data-filter="face" data-tooltip="Face — uses the raw click point on whatever face you picked.">Face</button>+        <button class="mb-filter" data-filter="body" data-tooltip="Body — snaps to the bounding-box center of the whole picked mesh.">Body</button>+      </div>+      <div class="mh-snap" id="measureSnap" data-tooltip="Live indicator of what the next click will snap to. Smart mode tries edges first, then faces, then vertexes.">+        <span class="mh-snap-label">Snap:</span>+        <span class="mh-snap-kind" id="measureSnapKind">—</span>+      </div>+      <div class="mh-body" id="measureBody">+        <div class="mh-hint">Click two points on the model.</div>+      </div>+    </div>++<!-- Section feature was removed (May 2026): user wanted a Fusion 360-grade+     section (visible plane gizmo + draggable + cap fill) which is a much+     bigger refactor. Rather than ship a half-baked clip-plane HUD, the+     feature is gone entirely until that proper implementation arrives. -->++    <!-- Inspect HUD (amber, follows cursor) -->+    <div class="inspect-card" id="inspectCard">+      <div class="ic-head">+        <div class="ic-name" id="icName">—</div>+        <div class="ic-path" id="icPath"></div>+      </div>+      <div class="ic-body" id="icBody"></div>+      <div class="ic-foot">Click to pin · Esc to dismiss</div>+    </div>++    <!-- Splash while the model loads -->+    <div class="splash" id="splash">+      <div class="spinner"></div>+      <div id="splashMsg">Loading 3D viewer…</div>+    </div>++    <div class="toast" id="toast"></div>++    <!-- Right-click context menu for tree rows AND for the 3D canvas. -->+    <div class="ctx-menu" id="ctxMenu">+      <div class="ctx-item" data-act="reveal" data-tooltip="Find this item in the Outline panel and flash it. Useful when you've hidden something and need to find its row to restore visibility.">Reveal in Outline</div>+      <div class="ctx-sep"></div>+      <div class="ctx-item" data-act="isolate" data-tooltip="Hide everything except this item. Click again to un-isolate. Ctrl+Z to undo.">Isolate</div>+      <div class="ctx-item" data-act="hide-others" data-tooltip="Hide every other top-level item. Ctrl+Z to undo.">Hide Others</div>+      <div class="ctx-item" data-act="show" data-tooltip="Show this subtree. Ctrl+Z to undo.">Show</div>+      <div class="ctx-item" data-act="hide" data-tooltip="Hide this subtree. The row will flash in the outline so you can find it again to restore. Ctrl+Z also undoes.">Hide</div>+      <div class="ctx-sep"></div>+      <div class="ctx-item" data-act="zoom" data-tooltip="Frame the camera tight on this item.">Zoom To</div>+      <div class="ctx-item" data-act="copy-name" data-tooltip="Copy the item's name to clipboard.">Copy Name</div>+      <div class="ctx-item" data-act="copy-json" data-tooltip="Copy the item's full metadata as a JSON blob — id, kind, depth, mesh count, bbox, role tags, overlay group (if any). Paste-able into bug reports.">Copy as JSON</div>+      <div class="ctx-sep"></div>+      <div class="ctx-item" data-act="properties" data-tooltip="Pin the inspect card showing this item's bbox, material, and geometry stats.">Properties</div>+    </div>+  </div>+</div>++<!-- =====================================================================+     The canonical Adom Babylon9 viewer is loaded as an ES module.+     It exposes window.Adom3DViewerBabylon9 + window.BABYLON.+     ===================================================================== -->+<script type="module" src="viewer/adom-3d-viewer-babylon9.esm.js"></script>++<script>+'use strict';++// =====================================================================+// 0. Tooltip system — body-appended position:fixed div per+//    human-ui-patterns §1d. Replaces title= attributes (which §1a bans).+//    500ms reveal delay (§1b), cursor-anchored (§1d), suppresses+//    ancestor tooltips when inner element has its own (§1f), never+//    ALL CAPS (§1e — handled in CSS via text-transform: none).+// =====================================================================+(function setupTooltips() {+  const tip = document.createElement('div');+  tip.id = 'adom-tooltip';+  tip.style.cssText = [+    'position: fixed',+    'z-index: 99999',                 /* literal — above any --z-tooltip token */+    'pointer-events: none',+    'background: rgba(32, 36, 43, 0.97)',+    'color: #e6e8eb',+    'border: 1px solid #00b8b1',      /* brand teal */+    'border-radius: 4px',+    'padding: 6px 9px',+    'font-size: 11.5px',+    'line-height: 1.35',+    'max-width: min(320px, calc(100vw - 40px))',+    'box-shadow: 0 6px 18px rgba(0,0,0,0.5)',+    'opacity: 0',+    'transition: opacity 120ms ease-out',+    'text-transform: none',           /* §1e — never inherit uppercase */+    'letter-spacing: 0',+    'font-family: inherit',+    'white-space: pre-line',          /* honor \n in data-tooltip strings as line breaks */+  ].join(';');+  document.body.appendChild(tip);++  let showTimer = 0;+  let lastTrigger = null;++  function position(x, y) {+    const off = 14;+    const rect = tip.getBoundingClientRect();+    const W = window.innerWidth, H = window.innerHeight;+    let left = x + off, top = y + off;+    if (left + rect.width > W - 8) left = x - rect.width - off;+    if (top + rect.height > H - 8) top = y - rect.height - off;+    if (left < 8) left = 8;+    if (top < 8) top = 8;+    tip.style.left = left + 'px';+    tip.style.top = top + 'px';+  }++  function hide() {+    clearTimeout(showTimer);+    showTimer = 0;+    tip.style.opacity = '0';+    lastTrigger = null;+  }++  document.addEventListener('mousemove', (e) => {+    const el = e.target.closest && e.target.closest('[data-tooltip]');+    if (!el) { hide(); return; }+    const text = el.getAttribute('data-tooltip');+    if (!text) { hide(); return; }+    if (el !== lastTrigger) {+      lastTrigger = el;+      tip.textContent = text;+      tip.style.opacity = '0';+      clearTimeout(showTimer);+      showTimer = setTimeout(() => {+        position(e.clientX, e.clientY);+        tip.style.opacity = '1';+      }, 500);+    } else if (tip.style.opacity === '1') {+      position(e.clientX, e.clientY);+    }+  });+  document.addEventListener('mouseleave', hide);+  document.addEventListener('mousedown', hide);+  window.addEventListener('blur', hide);+})();++// =====================================================================+// 1. Console forwarder — every log goes to the server's ring buffer so+//    `adom-step console -f` can tail it. (Pattern from adom-tsci.)+// =====================================================================+(function setupConsoleForwarder() {+  const origLog = console.log.bind(console);+  const origWarn = console.warn.bind(console);+  const origErr = console.error.bind(console);+  function send(level, args) {+    const text = args.map(a => {+      if (typeof a === 'string') return a;+      try { return JSON.stringify(a); } catch (e) { return String(a); }+    }).join(' ');+    fetch('console', {+      method: 'POST',+      headers: { 'Content-Type': 'application/json' },+      body: JSON.stringify({ level, text }),+      keepalive: true,+    }).catch(() => {});+  }+  console.log = (...a) => { send('log', a); origLog(...a); };+  console.warn = (...a) => { send('warn', a); origWarn(...a); };+  console.error = (...a) => { send('error', a); origErr(...a); };+  window.addEventListener('error', (e) => { send('error', [e.message, e.filename, e.lineno]); });+  window.addEventListener('unhandledrejection', (e) => { send('error', ['unhandled rejection:', String(e.reason)]); });+})();++// =====================================================================+// 2. Boot — wait for the viewer module, init the scene, kick off the+//    initial model load, register all UI behaviors.+// =====================================================================++function setSourceLabel(source, sourceType) {+  const txt = source || 'No model loaded';+  document.getElementById('sbSource').textContent = txt;+  // The header subject-name shows the basename only — the full source path+  // moves into the subject-info popover.+  const basename = txt.includes('/') ? txt.split('/').pop() : txt;+  document.getElementById('topSource').textContent = basename;+  const tipFile = document.getElementById('tipFile');+  const tipSource = document.getElementById('tipSource');+  if (tipFile) tipFile.textContent = basename;+  if (tipSource) tipSource.textContent = txt;+  const pill = document.getElementById('topProvenance');+  const t = sourceType || 'unknown';+  pill.dataset.sourceType = t;+  // Friendly label per type.+  const labels = {+    'kicad-library': 'KiCad library',+    'local-file': 'Local file',+    'upload': 'Drop',+    'unknown': 'unknown',+  };+  pill.textContent = labels[t] || t;+}++// Update the header subject-meta + tooltip rows with current scene stats.+// Called after a model finishes loading.+function setSubjectMeta(stats) {+  const meta = document.getElementById('topMeta');+  const tipNodes = document.getElementById('tipNodes');+  const tipSize = document.getElementById('tipSize');+  if (!stats) {+    if (meta) meta.innerHTML = '';+    if (tipNodes) tipNodes.textContent = '—';+    if (tipSize) tipSize.textContent = '—';+    return;+  }+  const parts = [];+  if (stats.nodes != null) parts.push(`${stats.nodes} nodes`);+  if (stats.size) parts.push(stats.size);+  if (meta) {+    meta.innerHTML = parts.map((p, i) => i === 0 ? `<span class="sep">·</span><span>${p}</span>` : `<span class="sep">·</span><span>${p}</span>`).join('');+  }+  if (tipNodes) tipNodes.textContent = stats.nodes != null ? String(stats.nodes) : '—';+  if (tipSize) tipSize.textContent = stats.size || '—';+}++// Interactive hover-popover. SHOW_DELAY = 500ms (human-ui-patterns §1b+// — intentional hovers get the popover, fly-bys don't); HIDE_GRACE =+// 220ms so the user can cross the gap into the popover and click the+// wiki link without dismissing it.+(function attachHeaderPopovers(){+  if (typeof document === 'undefined') return;+  const SHOW_DELAY = 500;+  const HIDE_GRACE = 220;+  const wire = () => {+    document.querySelectorAll('header [data-tooltip-target]').forEach(trigger => {+      if (trigger.__popoverWired) return;+      trigger.__popoverWired = true;+      const target = document.getElementById(trigger.dataset.tooltipTarget);+      if (!target) return;+      let showTimer = null, hideTimer = null;+      const show = () => {+        clearTimeout(hideTimer);+        if (target.classList.contains('show')) return;+        clearTimeout(showTimer);+        showTimer = setTimeout(() => target.classList.add('show'), SHOW_DELAY);+      };+      const queueHide = () => {+        clearTimeout(showTimer);+        clearTimeout(hideTimer);+        hideTimer = setTimeout(() => target.classList.remove('show'), HIDE_GRACE);+      };+      trigger.addEventListener('mouseenter', show);+      trigger.addEventListener('mouseleave', queueHide);+      trigger.addEventListener('focus', show);+      trigger.addEventListener('blur', queueHide);+      target.addEventListener('mouseenter', show);+      target.addEventListener('mouseleave', queueHide);+      trigger.addEventListener('click', e => {+        clearTimeout(showTimer);+        if (target.classList.contains('show')) target.classList.remove('show');+        else target.classList.add('show');+        e.stopPropagation();+      });+    });+    document.addEventListener('click', e => {+      document.querySelectorAll('header .hover-popover.show').forEach(p => {+        if (!p.contains(e.target)) {+          const trigger = document.querySelector(`[data-tooltip-target="${p.id}"]`);+          if (!trigger || !trigger.contains(e.target)) p.classList.remove('show');+        }+      });+    }, true);+  };+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);+  else wire();+})();++function showToast(msg, kind) {+  const t = document.getElementById('toast');+  t.className = 'toast open' + (kind ? ' ' + kind : '');+  t.textContent = msg;+  clearTimeout(t._timer);+  t._timer = setTimeout(() => { t.classList.remove('open'); }, 3500);+}++function setSplash(msg) {+  const s = document.getElementById('splash');+  if (msg === null) { s.style.display = 'none'; return; }+  s.style.display = 'flex';+  document.getElementById('splashMsg').textContent = msg;+}++async function waitForViewer() {+  const t0 = performance.now();+  while (!window.Adom3DViewerBabylon9 || !window.BABYLON) {+    if (performance.now() - t0 > 15000) {+      throw new Error('Adom3DViewerBabylon9 module never loaded after 15s');+    }+    await new Promise(r => setTimeout(r, 50));+  }+}++let viewer = null;       // ViewerInstance from Adom3DViewerBabylon9.init()+let scene = null;+let BABYLON = null;+let modelRoots = [];     // top-level TransformNodes from loaded GLBs+let assemblyTree = [];   // flat list of {id, name, depth, parent, children, meshes, visible}+let nodeById = new Map();+let materialsCache = []; // from /state.materials+let visibilityHistory = []; // undo stack of {prev, next, label}+let visibilityRedo = [];++async function boot() {+  setSplash('Loading 3D viewer…');+  await waitForViewer();+  BABYLON = window.BABYLON;++  const container = document.getElementById('renderContainer');+  // App-level override of 3d-viewer-design §4d: skill says ground + shadow+  // default ON for PCB viewers. For a single-component STEP viewer the+  // ground dominates the iso frame at default zoom and washes the §3b+  // gradient, so we ship it OFF and let the user toggle it on via the+  // Ground button when they want a contact shadow. The toggle still works.+  //+  // NOTE: Adom3DViewerBabylon9.init() takes a CONTAINER element. Passing+  // a <canvas> would nest a 0×0 canvas inside it that nobody can see.+  viewer = window.Adom3DViewerBabylon9.init(container, {+    zUp: true,+    showViewCube: true,+    showGround: false,+    initialViewMode: 'perspective',+  });+  scene = viewer.getScene();++  // The actual rendering canvas is now a child of #renderContainer; we+  // keep a handle to it for sizing + DOM-overlay measurements.+  const canvas = container.querySelector('canvas');++  applyGradientBackground(scene);++  function resizeCanvas() {+    const w = container.clientWidth;+    const h = container.clientHeight;+    if (canvas) {+      canvas.style.width = w + 'px';+      canvas.style.height = h + 'px';+    }+    if (viewer.getEngine) {+      const eng = viewer.getEngine();+      if (eng && eng.resize) eng.resize();+    }+  }+  window.addEventListener('resize', resizeCanvas);+  resizeCanvas();++  // Try to load the initial model from /state.+  await loadInitialModel();++  // Wire all UI.+  setupSidebar();+  setupContextMenu();+  setupToolbar();+  // View buttons removed — ViewCube handles all view presets.+  setupDropzone();+  setupMeasureTool();+  setupInspectTool();+  // (Section tool removed.)+  setupKeyboard();+  setupEvalPolling();+  setupCommandPolling();++  setSplash(null);+  showToast('Ready', 'ok');+}++function applyGradientBackground(scene) {+  // Build the gradient texture: 4×512 vertical strip, top → bottom.+  const W = 4, H = 512;+  const tex = new BABYLON.DynamicTexture('bgGradient', { width: W, height: H }, scene, false);+  const ctx = tex.getContext();+  const grad = ctx.createLinearGradient(0, 0, 0, H);+  grad.addColorStop(0, '#5a6b7e');  // §3b top — medium steel blue-gray+  grad.addColorStop(1, '#2a3340');  // §3b bottom — darker steel+  ctx.fillStyle = grad;+  ctx.fillRect(0, 0, W, H);+  tex.update();+  // Render-behind layer (drawn before the scene meshes).+  const layer = new BABYLON.Layer('bgGradientLayer', null, scene, true /* isBackground */);+  layer.texture = tex;++  // Hide the canonical viewer's BackgroundHelper + BackgroundSkybox so the+  // gradient is visible. The env texture (scene.environmentTexture) still+  // lights materials via PBR — we're only suppressing the *visible* skybox.+  // Re-applies on every frame because the canonical viewer recreates these+  // meshes inside frameModel().+  scene.onBeforeRenderObservable.add(() => {+    for (const m of scene.meshes) {+      if (m.name === 'BackgroundHelper' || m.name === 'BackgroundSkybox') {+        if (m.isEnabled()) m.setEnabled(false);+      }+    }+  });+}++async function loadInitialModel() {+  setSplash('Loading model…');+  let state;+  try {+    state = await fetch('state').then(r => r.json());+  } catch (e) {+    setSplash('Failed to fetch /state — is the server up?');+    return;+  }+  setSourceLabel(state.source, state.source_type);+  materialsCache = state.materials || [];+  if (state.has_model) {+    await loadModelUrl('model.glb?cb=' + Date.now());+  } else {+    setSplash('Drop a .step / .stp file onto the canvas');+  }+}++async function loadModelUrl(url) {+  setSplash('Loading model…');+  // Clear previous content+  if (modelRoots.length) {+    viewer.clearScene();+    modelRoots = [];+    assemblyTree = [];+    nodeById = new Map();+  }+  // Snapshot the scene before load so we can diff in the new nodes only.+  // This is the reliable way to identify the user's model — any name-based+  // filter falls over the moment a STEP file legitimately names a part+  // "arrow" or "cylinder" (e.g. mechanical hardware libraries).+  const beforeIds = new Set();+  for (const n of scene.transformNodes) beforeIds.add(n.uniqueId);+  for (const m of scene.meshes) beforeIds.add(m.uniqueId);++  try {+    await viewer.loadModel(url);+  } catch (e) {+    console.error('loadModel failed:', e);+    setSplash('Failed to load model — see console');+    showToast('Load failed: ' + e.message, 'err');+    return;+  }++  // Babylon's GLB loader inserts a synthetic `__root__` whose children are+  // the GLB's true top-level nodes. Find every fresh `__root__` (not in the+  // before-snapshot) and lift its direct children as our model roots.+  // This is reliable across viewer-internal nodes (axis helpers, indicator+  // sphere, viewcube) that may also be (re)created during loadModel.+  modelRoots = [];+  const allNodes = scene.transformNodes.concat(scene.meshes);+  for (const r of allNodes) {+    if (r.name !== '__root__') continue;+    if (beforeIds.has(r.uniqueId)) continue;+    for (const c of allNodes) {+      if (c.parent === r && !beforeIds.has(c.uniqueId)) modelRoots.push(c);+    }+  }+  // Fallback: if no __root__ was created (some loaders skip it), fall back+  // to "any new node with no new ancestor" — same diff approach as before.+  if (!modelRoots.length) {+    const newNodes = allNodes.filter(n => !beforeIds.has(n.uniqueId));+    const newIds = new Set(newNodes.map(n => n.uniqueId));+    modelRoots = newNodes.filter(n => {+      let p = n.parent;+      while (p) {+        if (newIds.has(p.uniqueId)) return false;+        p = p.parent;+      }+      return true;+    });+  }++  rebuildAssemblyTree();+  renderTree();+  postComponentsToServer();+  setSplash(null);+}++// =====================================================================+// 3. Assembly tree — walk scene.transformNodes recursively.+// =====================================================================++function rebuildAssemblyTree() {+  assemblyTree = [];+  nodeById = new Map();++  function friendlyName(node) {+    if (node.name && node.name !== '__root__' && !node.name.startsWith('node_')) return node.name;+    if (node.metadata && node.metadata.gltf && node.metadata.gltf.extras && node.metadata.gltf.extras.name) {+      return node.metadata.gltf.extras.name;+    }+    const meshes = node.getChildMeshes ? node.getChildMeshes(true) : [];+    if (meshes.length && meshes[0].name) return meshes[0].name;+    return 'Component_' + node.uniqueId;+  }++  function isMeshWithGeometry(n) {+    return n && n.getTotalVertices && n.getTotalVertices() > 0;+  }++  function walk(node, depth, parentEntry) {+    const id = node.uniqueId;+    const directMeshes = (node.getChildMeshes ? node.getChildMeshes(true) : []).filter(m => m.parent === node);+    const allDescendantMeshes = (node.getChildMeshes ? node.getChildMeshes(false) : []);+    // If the node ITSELF is a Mesh with geometry, include it. Babylon 9's+    // Mesh extends TransformNode, so leaf primitives walked by+    // getChildTransformNodes are nodes whose own geometry needs to count.+    if (isMeshWithGeometry(node) && !allDescendantMeshes.includes(node)) {+      allDescendantMeshes.push(node);+    }+    const entry = {+      id,+      node,+      name: friendlyName(node),+      depth,+      parentId: parentEntry ? parentEntry.id : null,+      children: [],+      directMeshes,+      allMeshes: allDescendantMeshes,+      visible: true,+      expanded: depth < 1, // root expanded by default+    };+    nodeById.set(id, entry);+    assemblyTree.push(entry);+    if (parentEntry) parentEntry.children.push(entry);++    const childTNs = (node.getChildTransformNodes ? node.getChildTransformNodes(true) : []);+    for (const c of childTNs) {+      if (c.parent !== node) continue;+      walk(c, depth + 1, entry);+    }+  }++  for (const root of modelRoots) {+    walk(root, 0, null);+  }++  // Post-process: many GLBs (especially STEPs from kicad) ship as one+  // top-level node with hundreds of material-split leaf primitives. That+  // floods the tree with noise. Replace any "many leaves under one parent"+  // with material-grouped synthetic entries (Body / Pins / Contacts / ...).+  // Real assemblies with named sub-parts are left alone.+  collapseLeafPrimitives();+}++function inferMaterialType(name, baseColor) {+  const n = (name || '').toLowerCase();+  if (baseColor && baseColor.length >= 3) {+    const [r, g, b] = baseColor;+    const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;+    // Gold / copper / brass: red > green > blue, fairly saturated+    if (r > 0.55 && g > 0.35 && b < 0.45 && r > b + 0.15) return { type: 'contact', label: 'Contacts' };+    // Silver / chrome / tin: near-equal RGB and bright+    if (Math.abs(r - g) < 0.12 && Math.abs(g - b) < 0.12 && lum > 0.5) return { type: 'pin', label: 'Pins' };+    // Bright red marker dot+    if (r > 0.55 && g < 0.3 && b < 0.3) return { type: 'marker', label: 'Markers' };+    // Dark plastic body+    if (lum < 0.22) return { type: 'body', label: 'Body' };+  }+  if (/pin|lead|leg|contact/.test(n)) return { type: 'pin', label: 'Pins' };+  if (/body|case|plastic|epoxy|mold/.test(n)) return { type: 'body', label: 'Body' };+  if (/gold|copper|brass|pad/.test(n)) return { type: 'contact', label: 'Contacts' };+  if (/mark|dot|text|silk/.test(n)) return { type: 'marker', label: 'Markers' };+  return { type: 'other', label: 'Other' };+}++let synthIdCounter = -1;++function meshCentroid(m) {+  if (!m.getBoundingInfo) return null;+  m.computeWorldMatrix(true);+  const bb = m.getBoundingInfo().boundingBox;+  return {+    x: (bb.minimumWorld.x + bb.maximumWorld.x) * 0.5,+    y: (bb.minimumWorld.y + bb.maximumWorld.y) * 0.5,+    z: (bb.minimumWorld.z + bb.maximumWorld.z) * 0.5,+  };+}++// Cluster primitives into individual pins. KiCad gull-wing leads have a+// body-side junction at one Z and a bent foot at a lower Z, with the foot+// shifted outward — so XY-distance clustering sees them as 2 separate pins+// per real pin. Correct approach: classify each primitive by the side of+// the body it lives on, then 1D-cluster along the pitch axis (perpendicular+// to the side normal). Returns clusters with { id, meshes, centroid, side }.+function clusterPinsByCentroid(meshes) {+  if (!meshes || meshes.length < 2) return [];+  const items = meshes.map((m, i) => ({ idx: i, mesh: m, c: meshCentroid(m) })).filter(x => x.c);+  if (!items.length) return [];++  // Body bounds from centroid spread (good enough since pins surround the body).+  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;+  for (const it of items) {+    if (it.c.x < minX) minX = it.c.x; if (it.c.x > maxX) maxX = it.c.x;+    if (it.c.y < minY) minY = it.c.y; if (it.c.y > maxY) maxY = it.c.y;+  }+  const bodyCx = (minX + maxX) * 0.5;+  const bodyCy = (minY + maxY) * 0.5;++  // Classify side of each primitive by which axis-distance from center is larger.+  // Front/back pins live on -Y/+Y face — pitch axis is X.+  // Left/right pins live on -X/+X face — pitch axis is Y.+  const bySide = { front: [], back: [], left: [], right: [] };+  for (const it of items) {+    const dx = it.c.x - bodyCx, dy = it.c.y - bodyCy;+    let side;+    if (Math.abs(dx) > Math.abs(dy)) side = dx > 0 ? 'right' : 'left';+    else side = dy > 0 ? 'back' : 'front';+    bySide[side].push(it);+  }++  // For each side, cluster along the pitch axis only.+  function clusterAlongAxis(list, axis /* 'x' or 'y' */) {+    if (!list.length) return [];+    // Estimate pitch: sort by axis, find smallest gap that's "real" (not within-pin).+    const sorted = list.slice().sort((a, b) => a.c[axis] - b.c[axis]);+    // First clue at pitch: median gap between adjacent CENTROIDS that differ enough.+    const gaps = [];+    for (let i = 1; i < sorted.length; i++) {+      const g = sorted[i].c[axis] - sorted[i - 1].c[axis];+      if (g > 0) gaps.push(g);+    }+    gaps.sort((a, b) => a - b);+    // Pitch ≈ median of upper half of gaps (within-pin gaps cluster near zero;+    // between-pin gaps cluster at the real pitch).+    const upper = gaps.slice(Math.floor(gaps.length / 2));+    const pitchEst = upper.length ? upper[Math.floor(upper.length / 2)] : 0;+    const eps = pitchEst > 0 ? pitchEst * 0.4 : 0.0003; // 40% of pitch++    // 1D clustering: walk sorted list, start a new cluster when gap > eps.+    const clusters = [];+    let cur = [sorted[0]];+    for (let i = 1; i < sorted.length; i++) {+      const g = sorted[i].c[axis] - sorted[i - 1].c[axis];+      if (g > eps) { clusters.push(cur); cur = []; }+      cur.push(sorted[i]);+    }+    if (cur.length) clusters.push(cur);+    return clusters;+  }++  const allClusters = [];+  function addClusters(list, axis, sideName) {+    const cs = clusterAlongAxis(list, axis);+    for (const list2 of cs) {+      const meshes2 = list2.map(it => it.mesh);+      let cx = 0, cy = 0, cz = 0;+      for (const it of list2) { cx += it.c.x; cy += it.c.y; cz += it.c.z; }+      cx /= list2.length; cy /= list2.length; cz /= list2.length;+      allClusters.push({ meshes: meshes2, centroid: { x: cx, y: cy, z: cz }, side: sideName });+    }+  }+  addClusters(bySide.front, 'x', 'front');+  addClusters(bySide.right, 'y', 'right');+  addClusters(bySide.back,  'x', 'back');+  addClusters(bySide.left,  'y', 'left');++  // Order pins counterclockwise starting at front-left (pin1 conventional location).+  const sideOrder = { front: 0, right: 1, back: 2, left: 3 };+  allClusters.sort((a, b) => {+    if (a.side !== b.side) return sideOrder[a.side] - sideOrder[b.side];+    if (a.side === 'front') return a.centroid.x - b.centroid.x;+    if (a.side === 'right') return a.centroid.y - b.centroid.y;+    if (a.side === 'back')  return b.centroid.x - a.centroid.x;+    return b.centroid.y - a.centroid.y;+  });++  allClusters.forEach((c, i) => { c.id = i + 1; });+  return allClusters;+}++let pinClusters = [];++// 2D grid clustering for LGA / BGA / module contacts arranged on a flat+// face (NOT side-mounted leads). Each contact becomes one cluster. Uses+// union-find with eps ≈ pad_pitch/2 estimated from bbox extent and+// primitive count. Reused by the strobe / isolatePin / pins-list APIs so+// the same UX applies to leaded chips and grid-pad chips alike.+function clusterContactsByGrid(meshes) {+  if (!meshes || meshes.length < 2) return [];+  const items = meshes.map((m, i) => ({ idx: i, mesh: m, c: meshCentroid(m) })).filter(x => x.c);+  if (!items.length) return [];++  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;+  for (const it of items) {+    if (it.c.x < minX) minX = it.c.x; if (it.c.x > maxX) maxX = it.c.x;+    if (it.c.y < minY) minY = it.c.y; if (it.c.y > maxY) maxY = it.c.y;+  }+  const bodyCx = (minX + maxX) * 0.5;+  const bodyCy = (minY + maxY) * 0.5;+  // Pitch estimation in 3D using BIMODAL SPLIT of nearest-neighbor+  // distances. Within-pad NNs cluster at the small end; between-pad NNs+  // at the larger end. Find the largest ratio-gap between consecutive+  // sorted NN values — that's the natural split. Set eps just above the+  // small group's max, just below the large group's min.+  const nnDists = [];+  for (let i = 0; i < items.length; i++) {+    let best = Infinity;+    for (let j = 0; j < items.length; j++) {+      if (i === j) continue;+      const dx = items[i].c.x - items[j].c.x;+      const dy = items[i].c.y - items[j].c.y;+      const dz = items[i].c.z - items[j].c.z;+      const d2 = dx * dx + dy * dy + dz * dz;+      if (d2 < best) best = d2;+    }+    if (best < Infinity && best > 1e-18) nnDists.push(Math.sqrt(best));+  }+  nnDists.sort((a, b) => a - b);+  let eps;+  if (nnDists.length < 2) {+    eps = (nnDists[0] || 0.001) * 0.5;+  } else {+    // Find the largest RATIO gap. A 1.5x or larger jump between adjacent+    // sorted NN distances is a strong signal of "within-pad ends here,+    // between-pad starts here".+    let bestRatio = 1, splitIdx = -1;+    for (let i = 1; i < nnDists.length; i++) {+      if (nnDists[i - 1] < 1e-12) continue;+      const ratio = nnDists[i] / nnDists[i - 1];+      if (ratio > bestRatio) { bestRatio = ratio; splitIdx = i; }+    }+    if (bestRatio >= 1.5 && splitIdx > 0) {+      // eps = midpoint between the last within-pad NN and the first+      // between-pad NN, on a log scale (geometric mean).+      eps = Math.sqrt(nnDists[splitIdx - 1] * nnDists[splitIdx]);+    } else {+      // No clear gap — fall back to 80th percentile × 0.5 (safer than 60).+      const p = nnDists[Math.floor(nnDists.length * 0.8)];+      eps = p * 0.5;+    }+  }+  eps = Math.max(eps, 0.00005);+  const eps2 = eps * eps;+  const cellSize = eps * 2;+  const buckets = new Map();+  function key(x, y, z) {+    return Math.floor(x / cellSize) + ',' + Math.floor(y / cellSize) + ',' + Math.floor(z / cellSize);+  }+  items.forEach((it, i) => {+    const k = key(it.c.x, it.c.y, it.c.z);+    if (!buckets.has(k)) buckets.set(k, []);+    buckets.get(k).push(i);+  });+  const parent = items.map((_, i) => i);+  function find(i) { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; }+  function union(a, b) { a = find(a); b = find(b); if (a !== b) parent[a] = b; }+  for (let i = 0; i < items.length; i++) {+    const a = items[i];+    const cx = Math.floor(a.c.x / cellSize),+          cy = Math.floor(a.c.y / cellSize),+          cz = Math.floor(a.c.z / cellSize);+    for (let dx = -1; dx <= 1; dx++)+      for (let dy = -1; dy <= 1; dy++)+        for (let dz = -1; dz <= 1; dz++) {+          const k = (cx + dx) + ',' + (cy + dy) + ',' + (cz + dz);+          const list = buckets.get(k);+          if (!list) continue;+          for (const j of list) {+            if (j <= i) continue;+            const b = items[j];+            const ddx = a.c.x - b.c.x, ddy = a.c.y - b.c.y, ddz = a.c.z - b.c.z;+            if (ddx * ddx + ddy * ddy + ddz * ddz <= eps2) union(i, j);+          }+        }+  }+  const groups = new Map();+  for (let i = 0; i < items.length; i++) {+    const r = find(i);+    if (!groups.has(r)) groups.set(r, []);+    groups.get(r).push(items[i]);+  }+  const clusters = [];+  for (const list of groups.values()) {+    const meshes = list.map(it => it.mesh);+    let cx = 0, cy = 0, cz = 0;+    for (const it of list) { cx += it.c.x; cy += it.c.y; cz += it.c.z; }+    cx /= list.length; cy /= list.length; cz /= list.length;+    // Side classification for grid contacts: 'bottom' is conventional+    // for LGA/BGA, but if the centroids span both top and bottom Z layers+    // we tag each cluster with whichever face it's nearest to.+    clusters.push({ meshes, centroid: { x: cx, y: cy, z: cz }, side: 'bottom' });+  }+  // Row-major order from front-left → back-right (similar to common+  // BGA pad numbering A1 at top-left going right then down).+  clusters.sort((a, b) => {+    const dy = a.centroid.y - b.centroid.y;+    if (Math.abs(dy) > 0.00001) return dy;+    return a.centroid.x - b.centroid.x;+  });+  clusters.forEach((c, i) => { c.id = i + 1; });+  return clusters;+}++// =====================================================================+// Geometry-based THT pin discovery — Mode B.+//+// Color-based clustering (Mode A) misses pins on STEPs where the+// manufacturer modeled the pin legs in the same material as the body+// (e.g. WAGO terminal blocks: 50 silver "spring contact" meshes ≠ the+// 5 actual through-hole solder legs, which sit in the gray body+// material). Mode B walks ALL meshes and finds tall-thin sub-meshes+// poking BELOW the part's seating plane. Material color is irrelevant.+//+// Pin candidate filter (units = scene meters):+//   • Mesh's bbox-min Z is within 0.8 mm of the model's bottom plane+//     (bottommost mesh in the scene).+//   • Cross-section ≤ 1.5 mm: max(width_x, depth_y) ≤ 0.0015.+//   • Height ≥ 0.8 mm: bbox_z extent ≥ 0.0008.+//   • Aspect ratio: height / max(w, d) ≥ 1.2 (taller than wide).+//+// Sub-meshes of the same pin (chamfer + shaft + tip) get merged when+// their XY centroids are within 1.0 mm — typical sub-mesh jitter for+// a single THT lead is 0.2-0.3 mm; 1.0 mm is well below 2.0 mm pitch+// (the smallest standard THT pitch found on connectors).+// =====================================================================+function discoverThruHolePins() {+  // Skip viewer's helper meshes (axis arrows, viewcube faces, gizmos).+  const allMeshes = scene.meshes.filter(m =>+    m.geometry && m.getTotalVertices() > 0 && m.material &&+    !/Axis|sphere|Sky|grid|axis|viewcube|gizmo/i.test(m.material.name || '') &&+    !/__viewer__/i.test(m.name || '')+  );+  if (allMeshes.length < 3) return [];++  // Find the part's seating plane = lowest Z of any real mesh.+  let modelMinZ = Infinity;+  for (const m of allMeshes) {+    const z = m.getBoundingInfo().boundingBox.minimumWorld.z;+    if (z < modelMinZ) modelMinZ = z;+  }++  // Filter to pin-shaped meshes near the seating plane.+  const PIN_FOOT_MAX = 0.0015;   // 1.5 mm cross-section ceiling+  const PIN_HEIGHT_MIN = 0.0008; // 0.8 mm tall minimum+  const PIN_ASPECT_MIN = 1.2;+  const SEAT_TOLERANCE = 0.0008; // 0.8 mm — bottom of pin must be near bottom of part+  const candidates = [];+  for (const m of allMeshes) {+    const bb = m.getBoundingInfo().boundingBox;+    const bottomZ = bb.minimumWorld.z;+    if (bottomZ > modelMinZ + SEAT_TOLERANCE) continue;+    const w = bb.maximumWorld.x - bb.minimumWorld.x;+    const d = bb.maximumWorld.y - bb.minimumWorld.y;+    const h = bb.maximumWorld.z - bb.minimumWorld.z;+    const foot = Math.max(w, d);+    if (foot > PIN_FOOT_MAX) continue;+    if (h < PIN_HEIGHT_MIN) continue;+    if (h / Math.max(foot, 1e-6) < PIN_ASPECT_MIN) continue;+    candidates.push({+      mesh: m,+      c: { x: bb.centerWorld.x, y: bb.centerWorld.y, z: bb.centerWorld.z },+    });+  }+  if (candidates.length < 2) return [];++  // Union-find merge by XY proximity. eps = 1.0 mm.+  const eps = 0.0010;+  const eps2 = eps * eps;+  const parent = candidates.map((_, i) => i);+  function find(i) { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; }+  for (let i = 0; i < candidates.length; i++) {+    for (let j = i + 1; j < candidates.length; j++) {+      const ddx = candidates[i].c.x - candidates[j].c.x;+      const ddy = candidates[i].c.y - candidates[j].c.y;+      if (ddx * ddx + ddy * ddy <= eps2) {+        const a = find(i), b = find(j);+        if (a !== b) parent[a] = b;+      }+    }+  }+  const groups = new Map();+  for (let i = 0; i < candidates.length; i++) {+    const r = find(i);+    if (!groups.has(r)) groups.set(r, []);+    groups.get(r).push(candidates[i]);+  }++  // Build clusters. The "side" tag is used downstream to render side+  // badges in the outline; for THT inline parts we tag every cluster+  // 'bottom' (matches Mode B's grid behavior) — the per-side semantics+  // of leaded chips don't apply.+  const clusters = [];+  for (const list of groups.values()) {+    let cx = 0, cy = 0, cz = 0;+    for (const it of list) { cx += it.c.x; cy += it.c.y; cz += it.c.z; }+    cx /= list.length; cy /= list.length; cz /= list.length;+    clusters.push({+      meshes: list.map(it => it.mesh),+      centroid: { x: cx, y: cy, z: cz },+      side: 'bottom',+    });+  }++  // Number along the dominant pitch axis (X if X-spread > Y-spread).+  // For a 1×N inline connector, pin 1 conventionally sits at the lowest+  // index along the long axis.+  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;+  for (const c of clusters) {+    if (c.centroid.x < minX) minX = c.centroid.x; if (c.centroid.x > maxX) maxX = c.centroid.x;+    if (c.centroid.y < minY) minY = c.centroid.y; if (c.centroid.y > maxY) maxY = c.centroid.y;+  }+  const xSpread = maxX - minX, ySpread = maxY - minY;+  if (xSpread >= ySpread) {+    clusters.sort((a, b) => a.centroid.x - b.centroid.x);+  } else {+    clusters.sort((a, b) => a.centroid.y - b.centroid.y);+  }+  clusters.forEach((c, i) => { c.id = i + 1; });+  return clusters;+}++function collapseLeafPrimitives() {+  const before = assemblyTree.slice();+  const newTree = [];+  const newById = new Map();++  function visit(entry, parent) {+    const cloned = Object.assign({}, entry, { children: [] });+    newById.set(cloned.id, cloned);+    newTree.push(cloned);+    if (parent) parent.children.push(cloned);++    const leafChildren = entry.children.filter(c => c.children.length === 0);+    const nonLeafChildren = entry.children.filter(c => c.children.length > 0);++    // Group leaves by material if the count is "noisy" (>= 6 leaves).+    if (leafChildren.length >= 6 && !nonLeafChildren.length) {+      const groups = new Map();+      for (const c of leafChildren) {+        const mesh = c.allMeshes[0];+        const mat = mesh && mesh.material;+        const matName = mat ? (mat.name || 'unnamed') : 'no-material';+        let color = null;+        if (mat) {+          const c3 = mat.albedoColor || mat.diffuseColor;+          if (c3) color = [c3.r, c3.g, c3.b];+        }+        if (!color) {+          const m = materialsCache.find(x => x.name === matName);+          if (m && m.baseColor) color = m.baseColor;+        }+        const inf = inferMaterialType(matName, color);+        const key = matName + '|' + inf.type;+        if (!groups.has(key)) {+          groups.set(key, {+            id: synthIdCounter--,+            node: null,+            isMaterialGroup: true,+            name: inf.label,+            matName,+            type: inf.type,+            baseColor: color,+            depth: cloned.depth + 1,+            parentId: cloned.id,+            children: [],+            directMeshes: [],+            allMeshes: [],+            visible: true,+            expanded: false,+            primitiveCount: 0,+          });+        }+        const g = groups.get(key);+        g.allMeshes.push(...c.allMeshes);+        g.directMeshes.push(...c.directMeshes);+        g.primitiveCount += 1;+      }+      // Order groups: Body, Pins, Contacts, Markers, Other (visual hierarchy)+      const order = { body: 0, pin: 1, contact: 2, marker: 3, other: 4 };+      const sorted = Array.from(groups.values()).sort((a, b) => (order[a.type] - order[b.type]) || a.matName.localeCompare(b.matName));+      for (const g of sorted) {+        newById.set(g.id, g);+        newTree.push(g);+        cloned.children.push(g);+      }+    } else {+      for (const c of entry.children) visit(c, cloned);+    }+  }++  const roots = before.filter(e => e.depth === 0);+  for (const r of roots) visit(r, null);+  assemblyTree = newTree;+  nodeById = newById;++  // After grouping, cluster the electrical contacts (leads OR pads) into+  // individual pin clusters. Two paths:+  //   (a) Leaded chips (QFP/QFN/SOIC/DIP/SOT): per-side 1D clustering on+  //       the Pins material group. Pin pitch comes from gap analysis.+  //   (b) Grid chips (LGA/BGA/module): 2D union-find on the Contacts+  //       material group. Pad pitch ≈ bbox / sqrt(N).+  // Whichever produces clusters becomes the strobe / isolatePin / per-pin+  // outline source. Both share the same `pinClusters` array so downstream+  // code (CLI, JS API, sidebar entries) doesn't care about the source.+  pinClusters = [];+  let clusterHostGroup = null;+  const pinsGroup = assemblyTree.find(e => e.isMaterialGroup && e.type === 'pin');+  const contactsGroup = assemblyTree.find(e => e.isMaterialGroup && e.type === 'contact');+  if (pinsGroup && pinsGroup.allMeshes.length > 0) {+    pinClusters = clusterPinsByCentroid(pinsGroup.allMeshes);+    clusterHostGroup = pinsGroup;+  }+  if (!pinClusters.length && contactsGroup && contactsGroup.allMeshes.length >= 4) {+    // No leaded pins detected — try grid clustering on the Contacts group.+    pinClusters = clusterContactsByGrid(contactsGroup.allMeshes);+    clusterHostGroup = contactsGroup;+  }+  // Mode B fallback: geometry-based THT pin discovery. Runs when material-+  // based clustering came up short (≤ 4 clusters) — that's the threshold+  // where we'd rather trust geometry. WAGO 2601-3105 is the canonical+  // case: 50 silver "spring" meshes group into 4 sites, but the 5 actual+  // THT solder legs are gray and only Mode B sees them. Mode B wins ONLY+  // if it finds strictly more clusters than Mode A — never regress a+  // good Mode A result on a leaded SMD.+  if (pinClusters.length <= 4) {+    const geom = discoverThruHolePins();+    if (geom.length > pinClusters.length) {+      pinClusters = geom;+      // Host the geometry-discovered pins under the existing Pins group+      // if there is one; otherwise host them under the body group so the+      // outline still has a parent row to expand into.+      const bodyGroup = assemblyTree.find(e => e.isMaterialGroup && e.type === 'body');+      clusterHostGroup = pinsGroup || contactsGroup || bodyGroup || clusterHostGroup;+    }+  }+  if (clusterHostGroup) {++    // Re-number so pin 1 is the IPC-convention pin near the marker. The+    // marker sits at a corner of the package; pin 1 is the FIRST pin you+    // encounter going counterclockwise FROM that corner — which means+    // pin 1 is on the side that's CCW-adjacent to the marker corner, not+    // on the side perpendicular to it. Going CCW from each corner:+    //   back-left   → LEFT side, back-most pin+    //   front-left  → FRONT side, left-most pin+    //   front-right → RIGHT side, front-most pin+    //   back-right  → BACK side, right-most pin+    // Then number CCW from there.+    const markerCentroid = findPin1MarkerCentroid();+    if (markerCentroid && pinClusters.length > 1) {+      // Body center estimated from the cluster centroids' bounds.+      let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;+      for (const c of pinClusters) {+        if (c.centroid.x < minX) minX = c.centroid.x; if (c.centroid.x > maxX) maxX = c.centroid.x;+        if (c.centroid.y < minY) minY = c.centroid.y; if (c.centroid.y > maxY) maxY = c.centroid.y;+      }+      const bodyCx = (minX + maxX) * 0.5, bodyCy = (minY + maxY) * 0.5;+      const dx = markerCentroid.x - bodyCx;+      const dy = markerCentroid.y - bodyCy;+      let pin1Side;+      if      (dx < 0 && dy > 0) pin1Side = 'left';   // back-left  corner → LEFT side+      else if (dx < 0 && dy < 0) pin1Side = 'front';  // front-left corner → FRONT side+      else if (dx > 0 && dy < 0) pin1Side = 'right';  // front-right corner → RIGHT side+      else                       pin1Side = 'back';   // back-right corner → BACK side++      // Find the cluster on pin1Side closest to the marker corner.+      let bestIdx = -1;+      let bestD = Infinity;+      for (let i = 0; i < pinClusters.length; i++) {+        if (pinClusters[i].side !== pin1Side) continue;+        const c = pinClusters[i].centroid;+        const ddx = c.x - markerCentroid.x;+        const ddy = c.y - markerCentroid.y;+        const d2 = ddx * ddx + ddy * ddy;+        if (d2 < bestD) { bestD = d2; bestIdx = i; }+      }+      // Fallback: if no cluster on pin1Side (shouldn't happen with QFP), use overall nearest.+      if (bestIdx < 0) {+        bestIdx = 0; bestD = Infinity;+        for (let i = 0; i < pinClusters.length; i++) {+          const c = pinClusters[i].centroid;+          const ddx = c.x - markerCentroid.x, ddy = c.y - markerCentroid.y;+          const d2 = ddx * ddx + ddy * ddy;+          if (d2 < bestD) { bestD = d2; bestIdx = i; }+        }+      }+      pinClusters = pinClusters.slice(bestIdx).concat(pinClusters.slice(0, bestIdx));+      pinClusters.forEach((c, i) => { c.id = i + 1; });+    }++    // Inject each detected pin as a child entry of the Pins material+    // group so the user can see, isolate, and toggle visibility per-pin+    // from the components outline. Per-pin centroid (mm) is in the row+    // tooltip — feeds footprint matching workflows.+    if (pinClusters.length) {+      // Naming: leaded chips show "Pin N", grid-pad chips show "Contact N"+      // so the row label matches the source material group's category.+      const isContactSource = clusterHostGroup.type === 'contact';+      const nameKind = isContactSource ? 'Contact' : 'Pin';+      clusterHostGroup.children = [];   // replace any leaf children+      for (const c of pinClusters) {+        const pinEntry = {+          id: synthIdCounter--,+          node: null,+          isPinEntry: true,+          pinId: c.id,+          pinSide: c.side,+          pinKind: nameKind,            // 'Pin' or 'Contact'+          pinCentroid: c.centroid,+          name: nameKind + ' ' + c.id,+          parentId: clusterHostGroup.id,+          depth: clusterHostGroup.depth + 1,+          children: [],+          directMeshes: c.meshes,+          allMeshes: c.meshes,+          visible: true,+          expanded: false,+        };+        nodeById.set(pinEntry.id, pinEntry);+        assemblyTree.push(pinEntry);+        clusterHostGroup.children.push(pinEntry);+      }+      clusterHostGroup.primitiveCount = pinClusters.length;+    }+  }+}++function findPin1MarkerCentroid() {+  // Prefer a 'marker'-typed group; fall back to a small 'other' group+  // (often the pin-1 dot misclassified by color heuristic — KiCad's brown+  // marker tone doesn't match the bright-red threshold).+  const candidates = assemblyTree.filter(e =>+    e.isMaterialGroup && (e.type === 'marker' || (e.type === 'other' && e.primitiveCount <= 3))+  );+  if (!candidates.length) return null;+  // Use the one with the fewest primitives (most likely to be the actual+  // marker dot, not random small features).+  candidates.sort((a, b) => a.primitiveCount - b.primitiveCount);+  const meshes = candidates[0].allMeshes;+  if (!meshes.length) return null;+  let cx = 0, cy = 0, cz = 0, n = 0;+  for (const m of meshes) {+    const c = meshCentroid(m);+    if (!c) continue;+    cx += c.x; cy += c.y; cz += c.z; n += 1;+  }+  if (!n) return null;+  return { x: cx / n, y: cy / n, z: cz / n };+}++function postComponentsToServer() {+  const flat = assemblyTree.map(e => ({+    id: e.id,+    name: e.name,+    depth: e.depth,+    parentId: e.parentId,+    visible: e.visible,+    childCount: e.children.length,+    meshCount: e.allMeshes.length,+  }));+  fetch('api/components', {+    method: 'POST',+    headers: { 'Content-Type': 'application/json' },+    body: JSON.stringify(flat),+  }).catch(() => {});+}++// =====================================================================+// 4. Sidebar render — depth-padded rows with caret + eye + isolate.+// =====================================================================++let activeTypeFilter = 'all';  // 'all' | 'body' | 'pin' | 'contact' | 'marker' | 'other'++function renderFilterChips() {+  const wrap = document.getElementById('sbFilters');+  wrap.innerHTML = '';+  const counts = { body: 0, pin: 0, contact: 0, marker: 0, other: 0 };+  for (const e of assemblyTree) {+    if (e.isMaterialGroup) counts[e.type] = (counts[e.type] || 0) + (e.primitiveCount || 1);+  }+  const total = Object.values(counts).reduce((a, b) => a + b, 0);+  const chips = [+    { type: 'all', label: 'All', count: total, tip: 'Show every part type (default).' },+    { type: 'body', label: 'Body', count: counts.body || 0, tip: 'Body / case / plastic — dark molded compound.' },+    { type: 'pin', label: 'Pins', count: counts.pin || 0, tip: 'Leads / legs — silver or tin metallic surfaces.' },+    { type: 'contact', label: 'Contacts', count: counts.contact || 0, tip: 'Gold / copper pads — solderable contact metal.' },+    { type: 'marker', label: 'Markers', count: counts.marker || 0, tip: 'Pin-1 dots, marking text, silkscreen ID.' },+    { type: 'other', label: 'Other', count: counts.other || 0, tip: 'Materials that didn\'t match a known category.' },+  ];+  for (const c of chips) {+    if (c.type !== 'all' && c.count === 0) continue;+    const btn = document.createElement('button');+    btn.className = 'sb-chip' + (activeTypeFilter === c.type ? ' active' : '');+    btn.dataset.filterType = c.type;+    btn.setAttribute('data-tooltip', c.tip);+    btn.innerHTML = c.label + ' <span class="count">' + c.count + '</span>';+    btn.onclick = () => { activeTypeFilter = c.type; renderTree(); };+    wrap.appendChild(btn);+  }+}++function passesTypeFilter(entry) {+  if (activeTypeFilter === 'all') return true;+  // A material group matches if its type matches.+  if (entry.isMaterialGroup) return entry.type === activeTypeFilter;+  // A non-group node passes if any of its descendants match.+  let p = entry;+  const queue = [entry];+  while (queue.length) {+    const cur = queue.shift();+    if (cur.isMaterialGroup && cur.type === activeTypeFilter) return true;+    queue.push(...cur.children);+  }+  return false;+}++function renderTree() {+  renderFilterChips();+  const treeEl = document.getElementById('sbTree');+  treeEl.innerHTML = '';+  document.getElementById('compCount').textContent = assemblyTree.length + ' nodes';+  // Mirror node count into the canonical header subject-meta + tooltip.+  setSubjectMeta({ nodes: assemblyTree.length, size: window.__lastModelSizeStr || null });+  function appendRow(entry) {+    if (!isAncestorChainExpanded(entry)) return; // hidden under collapsed parent+    if (!passesTypeFilter(entry)) return;        // hidden by chip filter+    const row = document.createElement('div');+    row.className = 'tree-row';+    if (!entry.visible) row.classList.add('hidden-vis');+    if (entry.isMaterialGroup) row.classList.add('material-group');+    row.dataset.id = entry.id;+    row.style.paddingLeft = (4 + entry.depth * 14) + 'px';++    const caret = document.createElement('span');+    caret.className = 'caret';+    if (entry.children.length) {+      caret.classList.add('has-children');+      if (entry.expanded) caret.classList.add('open');+      caret.textContent = '❯';  // VS-Code-style chevron ❯ — CSS rotates 90° when .open+      caret.setAttribute('data-tooltip',+        entry.expanded+          ? 'Collapse — hide this node\'s ' + entry.children.length + ' children in the tree.'+          : 'Expand — show this node\'s ' + entry.children.length + ' children in the tree.');+      caret.onclick = (e) => { e.stopPropagation(); entry.expanded = !entry.expanded; renderTree(); };+    } else {+      // Leaf row — no caret glyph (CSS visibility:hidden), just an empty+      // slot so the alignment matches the parent rows. Not clickable.+      caret.classList.add('leaf');+      caret.textContent = '';+    }+    row.appendChild(caret);++    const eye = document.createElement('span');+    eye.className = 'eye';+    const triState = computeTriState(entry);+    if (triState === 'mixed') eye.classList.add('mixed');+    eye.textContent = triState === 'visible' ? '●' : (triState === 'hidden' ? '○' : '◐');+    const eyeTip = triState === 'visible'+      ? 'Visible — click to hide this node and its descendants.'+      : (triState === 'hidden'+        ? 'Hidden — click to show this node and its descendants.'+        : 'Mixed visibility — some descendants hidden. Click to show all.');+    eye.setAttribute('data-tooltip', eyeTip);+    eye.onclick = (e) => {+      e.stopPropagation();+      const willHide = triState !== 'hidden';+      pushVisibilityHistory(() => setSubtreeVisibility(entry, !willHide));+    };+    row.appendChild(eye);++    const iso = document.createElement('span');+    iso.className = 'iso';+    iso.textContent = '◇';+    iso.setAttribute('data-tooltip',+      'Isolate — hide every other top-level subtree, leave only this node visible. Camera stays where it is — double-click the row or use right-click → Zoom To if you want to re-frame.');+    iso.onclick = (e) => { e.stopPropagation(); pushVisibilityHistory(() => isolateEntry(entry, { frame: false })); };+    row.appendChild(iso);++    if (entry.isMaterialGroup && entry.baseColor) {+      const swatch = document.createElement('span');+      swatch.className = 'mat-swatch';+      const [r, g, b] = entry.baseColor;+      const css = `rgb(${Math.round(r*255)},${Math.round(g*255)},${Math.round(b*255)})`;+      swatch.style.background = css;+      swatch.setAttribute('data-tooltip',+        'Material color: ' + css + ' (from the GLB material baseColor). All ' ++        (entry.primitiveCount || entry.children.length) + ' primitives in this ' ++        entry.type + ' group share this material.');+      row.appendChild(swatch);+    }++    const label = document.createElement('span');+    label.className = 'label';+    label.textContent = entry.name;+    row.appendChild(label);++    // Per-row status badges. Derived from entry.kind + entry.isOverlay+    // + future entry.locked / entry.reDetected flags. SVG monochrome+    // icons (no Unicode emoji) per human-ui-patterns icon rules.+    const badges = computeRowBadges(entry);+    if (badges.length) {+      const wrap = document.createElement('span');+      wrap.className = 'tree-badges';+      for (const b of badges) {+        const el = document.createElement('span');+        el.className = 'tree-badge';+        el.dataset.kind = b.kind;+        el.setAttribute('data-tooltip', b.tip);+        el.innerHTML = TREE_BADGE_SVG[b.kind] || '';+        wrap.appendChild(el);+      }+      row.appendChild(wrap);+    }++    // Always set a row-level tooltip with the FULL name + metadata so+    // that ellipsis-truncated rows reveal their full label on hover.+    // Pin/material-group rows extend this with the existing detail.+    row.setAttribute('data-tooltip', buildRowTooltip(entry));++    if (entry.isPinEntry) {+      // Side badge ("front" / "right" / "back" / "left").+      const side = document.createElement('span');+      side.className = 'pin-side';+      side.dataset.side = entry.pinSide;+      side.textContent = entry.pinSide;+      row.appendChild(side);+    } else if (entry.isMaterialGroup) {+      const cnt = document.createElement('span');+      cnt.className = 'child-count';+      // If pin clustering produced entries under THIS group (Pins or+      // Contacts), show the cluster count + the kind name. Otherwise show+      // the raw primitive count from material grouping.+      const hostsClusters = (entry.type === 'pin' || entry.type === 'contact') && pinClusters.length;+      const num = hostsClusters ? pinClusters.length : entry.primitiveCount;+      const kind = entry.type === 'contact' ? 'contact' : 'pin';+      const unit = hostsClusters+        ? (num === 1 ? ' ' + kind : ' ' + kind + 's')+        : (num === 1 ? ' part' : ' parts');+      cnt.textContent = num + unit;+      // Tooltip explaining what the number means. Specific so a user who+      // says "what does 30 mean?" gets the answer on hover.+      cnt.setAttribute('data-tooltip', hostsClusters+        ? num + ' individual ' + kind + 's detected by clustering this material’s mesh primitives by spatial position. Each is selectable + isolatable in the outline below.'+        : num + ' mesh primitives in this material group. Primitives are how the GLB exporter splits geometry by material/face — they’re not semantic objects.');+      row.appendChild(cnt);+    } else if (entry.children.length) {+      const cnt = document.createElement('span');+      cnt.className = 'child-count';+      cnt.textContent = entry.children.length;+      cnt.setAttribute('data-tooltip',+        entry.children.length + ' direct child entr' ++        (entry.children.length === 1 ? 'y' : 'ies') ++        ' below this node. Expand the caret to see them.');+      row.appendChild(cnt);+    }++    // Mirror the 3D-viewport gentle highlight when the user hovers this+    // row. The sidebar hover targets THIS exact entry (no second-level+    // demotion) so a Pin N row highlights just that pin, not the whole+    // Pins group.+    row.onmouseenter = () => setHoverHighlight(entry);+    row.onmouseleave = () => setHoverHighlight(null);+    row.ondblclick = () => frameOnEntry(entry);+    row.oncontextmenu = (e) => {+      e.preventDefault();+      openContextMenu(e.clientX, e.clientY, entry);+    };++    treeEl.appendChild(row);+    for (const c of entry.children) appendRow(c);+  }+  for (const e of assemblyTree) {+    if (e.depth === 0) appendRow(e);+  }+  applySearchFilter();+}++// Per-row status badge map. Each kind maps to a 12px monochrome SVG+// using stroke-currentColor so the per-kind tint comes from CSS. Kept+// as inline SVGs (not Unicode emoji) per gallia human-ui-patterns+// icons-and-displays.md §4b — emoji renders inconsistently across+// platforms, never aligns to icon stroke weight, and Apple-specific+// emoji on Linux looks misplaced.+const TREE_BADGE_SVG = {+  // Pin-1 star — 5-pointed outline. Marks the user-placed pin-1+  // indicator (entries with kind containing 'pin1').+  pin1:+    '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' ++    '<path d="M8 1.8 9.85 5.55 14 6.16l-3 2.92L11.7 13.2 8 11.24 4.3 13.2 5 9.08 2 6.16 6.15 5.55 8 1.8z"/>' ++    '</svg>',+  // Synthesized — 4-pointed sparkle. Marks chipsmith-added overlays+  // (footprint pads, faux PCB, vias, solder-jet domes, user-placed+  // pin-1 — anything outside the original GLB hierarchy).+  synth:+    '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' ++    '<path d="M8 1.5 V5.5 M8 10.5 V14.5 M1.5 8 H5.5 M10.5 8 H14.5"/>' ++    '<path d="M3.5 3.5 L5.5 5.5 M10.5 10.5 L12.5 12.5 M3.5 12.5 L5.5 10.5 M10.5 5.5 L12.5 3.5" stroke-opacity="0.55"/>' ++    '</svg>',+  // Locked — padlock. Set when the entry came from a baked+  // .chipsmith.step (post-sign-off, not editable until re-opened).+  locked:+    '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' ++    '<rect x="3" y="7" width="10" height="7" rx="1.2"/>' ++    '<path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/>' ++    '</svg>',+  // Re-detected after Y-up auto-rotate. Sub-tree gets this badge when+  // detection ran a second time post-rotate. (v1.1 — flag wired but+  // detection currently disabled.)+  redet:+    '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' ++    '<path d="M3 8a5 5 0 0 1 8.5-3.5L13 6"/>' ++    '<path d="M13 3v3h-3"/>' ++    '<path d="M13 8a5 5 0 0 1-8.5 3.5L3 10"/>' ++    '<path d="M3 13v-3h3"/>' ++    '</svg>',+};++// Decide which badges (if any) a tree row should carry. Single source+// of truth; called from appendRow. The chipsmith overlay system tags+// every overlay entry with kind=`overlay-*`, isOverlay=true; we map+// those to the synth + (for pin-1) star icons. Locked + re-detected+// flags come later from sign-off / detection state — wired here so+// they light up automatically once the upstream state lands.+function computeRowBadges(entry) {+  const out = [];+  const kind = entry.kind || '';+  const k = kind.toLowerCase();+  if (k.includes('pin1')) {+    out.push({+      kind: 'pin1',+      tip: 'Pin-1 indicator. Marks the chip-side polarity reference — every downstream Adom tool reads this entry to align footprints, silkscreen, and BOM rows. Survives sign-off bake.',+    });+  }+  if (entry.isOverlay && !entry.isMaterialGroup) {+    out.push({+      kind: 'synth',+      tip: 'Chipsmith-synthesized overlay (not part of the manufacturer STEP). Toggleable, removable, and baked into the .chipsmith.step at sign-off so the synthesized geometry survives every future load.',+    });+  }+  if (entry.locked) {+    out.push({+      kind: 'locked',+      tip: 'Locked — loaded from a baked .chipsmith.step. Re-open the chip for editing to clear locks.',+    });+  }+  if (entry.reDetected) {+    out.push({+      kind: 'redet',+      tip: 'Re-detected after Y-up auto-rotate. The original orientation was wrong; chipsmith rotated 90° about X and re-classified this feature.',+    });+  }+  return out;+}++// Build the hover-tooltip string for a tree row. Always includes the+// FULL entry name (so ellipsis-truncated rows reveal what they are on+// hover) plus a second line of structured metadata: depth, kind,+// mesh + child counts, overlay group, and pin-specific centroid info+// when applicable. Per the human-ui-patterns skill: tooltip on every+// interactive element, 600 ms reveal delay, never ALL CAPS — handled+// by the existing tooltip CSS.+function buildRowTooltip(entry) {+  // Overlay rows can carry a rich tooltip set by the overlay plugin+  // (chipsmith injects provenance explanations here so the user+  // understands where each footprint source came from).+  if (entry.overlayTooltip) return entry.overlayTooltip;+  const lines = [];+  // Line 1: the full name. Use the entry.name verbatim — that's what+  // got truncated visually and what the user wants to see.+  lines.push(entry.name || '<unnamed>');++  // Line 2: structured metadata. Compose only the fields that apply+  // so the tooltip stays compact.+  const meta = [];+  if (typeof entry.depth === 'number') meta.push('depth ' + entry.depth);+  if (entry.kind && !entry.isOverlay)  meta.push(entry.kind);+  if (entry.isOverlay)                 meta.push('overlay');+  if (entry.overlayGroup)              meta.push('group "' + entry.overlayGroup + '"');+  const meshN  = (entry.allMeshes || []).length;+  if (meshN)                           meta.push(meshN + ' mesh' + (meshN === 1 ? '' : 'es'));+  const childN = (entry.children || []).length;+  if (childN)                          meta.push(childN + ' child' + (childN === 1 ? '' : 'ren'));+  if (entry.isMaterialGroup)           meta.push('material group');+  if (meta.length) lines.push(meta.join('  ·  '));++  // Pin entries: append the centroid + side line that the legacy+  // tooltip exposed. Same data; just one line down.+  if (entry.isPinEntry && entry.pinCentroid) {+    const c = entry.pinCentroid;+    const SCENE_TO_MM = 1000;+    lines.push('pin ' + entry.pinId + ' · ' + entry.pinSide ++      ' side · centroid ' ++      (c.x * SCENE_TO_MM).toFixed(3) + ', ' ++      (c.y * SCENE_TO_MM).toFixed(3) + ', ' ++      (c.z * SCENE_TO_MM).toFixed(3) + ' mm');+  }++  // Material-group entries: explain what the count number means so a+  // hover reveals "30 parts" → "30 mesh primitives". Same wording as+  // the inline child-count tooltip, lifted to the row.+  if (entry.isMaterialGroup) {+    const num = entry.primitiveCount || 0;+    lines.push(num + ' mesh primitives — how the GLB exporter split this material\'s geometry by face.');+  }++  return lines.join('\n');+}++function isAncestorChainExpanded(entry) {+  let p = entry.parentId == null ? null : nodeById.get(entry.parentId);+  while (p) {+    if (!p.expanded) return false;+    p = p.parentId == null ? null : nodeById.get(p.parentId);+  }+  return true;+}++function computeTriState(entry) {+  if (!entry.children.length) return entry.visible ? 'visible' : 'hidden';+  const states = entry.children.map(computeTriState);+  if (states.every(s => s === 'visible') && entry.visible) return 'visible';+  if (states.every(s => s === 'hidden') && !entry.visible) return 'hidden';+  return 'mixed';+}++function setSubtreeVisibility(entry, visible) {+  function walk(e) {+    e.visible = visible;+    for (const m of e.allMeshes) m.isVisible = visible;+    // Synthetic material-group entries have no Babylon node — skip setEnabled.+    if (e.node && e.node.setEnabled) e.node.setEnabled(visible);+    for (const c of e.children) walk(c);+  }+  walk(entry);+  renderTree();+  postVisibilityToServer();+}++// Tracks which entry is currently isolated (null = nothing isolated). When+// the user clicks the isolate icon a second time on the same entry, we+// un-isolate by restoring everything that was visible at the time of the+// FIRST click. Both the isolate AND the un-isolate go through+// pushVisibilityHistory so Ctrl+Z works on both.+let isolatedEntryId = null;++function isolateEntry(entry, opts) {+  // Toggle: second click on the same entry un-isolates (restores all).+  if (isolatedEntryId === entry.id) {+    showAllEntries();+    isolatedEntryId = null;+    return;+  }+  // ISOLATE: show only the meshes that belong to entry's subtree, hide+  // everything else. Works at any depth (Pin 1 / Pins group / chip top+  // level all behave correctly). Done at the per-mesh level (using+  // mesh.isVisible) instead of per-entry setEnabled so we don't get+  // tangled in parent-child setEnabled propagation.+  const visibleMeshIds = new Set();+  function collect(e) {+    for (const m of e.allMeshes) if (m && m.uniqueId != null) visibleMeshIds.add(m.uniqueId);+    for (const c of e.children) collect(c);+  }+  collect(entry);++  // Pass 1: per-mesh visibility from the visible set. Walks BOTH+  // the assemblyTree entries AND all scene meshes (in case the GLB+  // loader added meshes that aren't tracked in any tree entry).+  for (const e of assemblyTree) {+    for (const m of e.allMeshes) {+      if (!m || m.uniqueId == null) continue;+      m.isVisible = visibleMeshIds.has(m.uniqueId);+    }+  }+  const scene = viewer?.getScene?.();+  if (scene) {+    for (const m of scene.meshes) {+      if (!m || m.uniqueId == null) continue;+      if (['skyBox','ground','shadowGround'].includes(m.name)) continue;+      if ((m.name || '').startsWith('viewCube')) continue;+      if (!visibleMeshIds.has(m.uniqueId)) m.isVisible = false;+    }+  }+  // Pass 2: derive entry.visible (true if any of its meshes are visible).+  for (const e of assemblyTree) {+    let any = false;+    for (const m of e.allMeshes) {+      if (m && m.isVisible) { any = true; break; }+    }+    e.visible = any;+  }+  // Re-enable the host TransformNodes along the ancestor chain (in case+  // a previous setEnabled(false) is still hanging on them) so the picked+  // mesh gets actually rendered. Babylon's setEnabled cascades, so we+  // walk up from the entry and re-enable each.+  let cur = entry;+  while (cur) {+    if (cur.node && cur.node.setEnabled) cur.node.setEnabled(true);+    cur = cur.parentId == null ? null : nodeById.get(cur.parentId);+  }++  renderTree();+  postVisibilityToServer();+  isolatedEntryId = entry.id;+  if (opts && opts.frame) frameOnEntry(entry);+}++function showAllEntries() {+  for (const e of assemblyTree) {+    if (e.depth === 0) setSubtreeVisibility(e, true);+  }+  const scene = viewer?.getScene?.();+  if (scene) {+    for (const m of scene.meshes) {+      if (m && m.uniqueId != null) m.isVisible = true;+    }+  }+}++function postVisibilityToServer() {+  const map = {};+  for (const e of assemblyTree) map[e.name] = e.visible;+  fetch('api/visibility', {+    method: 'POST',+    headers: { 'Content-Type': 'application/json' },+    body: JSON.stringify(map),+  }).catch(() => {});+}++let highlightLayer = null;+function getHighlight() {+  if (!highlightLayer) {+    highlightLayer = new BABYLON.HighlightLayer('hover-hl', scene, { mainTextureRatio: 1 });+    highlightLayer.innerGlow = false;+  }+  return highlightLayer;+}+function highlightEntry(entry, on) {+  const hl = getHighlight();+  for (const m of entry.allMeshes) {+    if (!m.material) continue;+    if (on) hl.addMesh(m, BABYLON.Color3.FromHexString('#4fc3f7'));+    else hl.removeMesh(m);+  }+}++function frameOnEntry(entry) {+  if (!entry.allMeshes.length) return;+  // Compute combined bbox.+  const bb = combinedBoundingBox(entry.allMeshes);+  if (!bb) return;+  const cam = scene.activeCamera;+  if (!cam) return;+  const center = bb.center;+  const radius = bb.radius * 1.3;+  if (cam.target) cam.target.copyFrom(center);+  if (typeof cam.radius === 'number') cam.radius = Math.max(radius, 0.05);+  // Use built-in zoomOn if available.+  if (cam.zoomOn) {+    try { cam.zoomOn(entry.allMeshes, true); } catch (e) { /* fall back to target/radius */ }+  }+}++function combinedBoundingBox(meshes) {+  let minV = null, maxV = null;+  for (const m of meshes) {+    if (!m.getBoundingInfo) continue;+    m.computeWorldMatrix(true);+    const bi = m.getBoundingInfo();+    const lo = bi.boundingBox.minimumWorld;+    const hi = bi.boundingBox.maximumWorld;+    if (!minV) { minV = lo.clone(); maxV = hi.clone(); }+    else {+      minV.x = Math.min(minV.x, lo.x); minV.y = Math.min(minV.y, lo.y); minV.z = Math.min(minV.z, lo.z);+      maxV.x = Math.max(maxV.x, hi.x); maxV.y = Math.max(maxV.y, hi.y); maxV.z = Math.max(maxV.z, hi.z);+    }+  }+  if (!minV) return null;+  const center = minV.add(maxV).scale(0.5);+  const radius = maxV.subtract(minV).length() * 0.5;+  return { min: minV, max: maxV, center, radius };+}++// =====================================================================+// 5. Search — dim non-matching rows + auto-expand ancestors of matches.+// =====================================================================++function applySearchFilter() {+  const q = (document.getElementById('sbSearch').value || '').trim().toLowerCase();+  const rows = document.querySelectorAll('.tree-row');+  if (!q) { rows.forEach(r => r.classList.remove('dimmed')); return; }+  const matchIds = new Set();+  for (const e of assemblyTree) {+    if (e.name.toLowerCase().includes(q)) {+      matchIds.add(e.id);+      // walk up+      let p = e.parentId == null ? null : nodeById.get(e.parentId);+      while (p) {+        matchIds.add(p.id);+        p.expanded = true;+        p = p.parentId == null ? null : nodeById.get(p.parentId);+      }+    }+  }+  rows.forEach(r => {+    const id = parseInt(r.dataset.id, 10);+    if (matchIds.has(id)) r.classList.remove('dimmed');+    else r.classList.add('dimmed');+  });+}++function setupSidebar() {+  const search = document.getElementById('sbSearch');+  let timer = null;+  search.addEventListener('input', () => {+    clearTimeout(timer);+    timer = setTimeout(() => { renderTree(); }, 80);+  });++  // Outline collapse — Fusion 360 Browser-style. The outline floats over the+  // canvas with a translucent bg; collapsing slides it off-screen and reveals+  // a slim vertical expand-tab at the canvas's left edge. State persists in+  // localStorage so a user who likes the canvas-fills-viewport feel keeps it+  // across reloads. Keyboard: 'o' toggles.+  const panel = document.getElementById('outlinePanel');+  const expander = document.getElementById('outlineExpander');+  const STORAGE_KEY = 'adom-step.outline.collapsed';+  function setCollapsed(collapsed) {+    panel.classList.toggle('collapsed', collapsed);+    document.body.classList.toggle('outline-collapsed', collapsed);+    try { localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0'); } catch (_) {}+  }+  // Restore last state on load — default expanded for first-time users.+  try {+    if (localStorage.getItem(STORAGE_KEY) === '1') setCollapsed(true);+  } catch (_) {}+  document.getElementById('outlineCollapse').onclick = () => setCollapsed(true);+  expander.onclick = () => setCollapsed(false);+  // Keyboard shortcut — 'o' toggles. Don't intercept while a text input is+  // focused (search box, inspect HUD inputs, measure HUD).+  document.addEventListener('keydown', (e) => {+    if (e.key !== 'o' && e.key !== 'O') return;+    if (e.ctrlKey || e.metaKey || e.altKey) return;+    const t = e.target;+    if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;+    setCollapsed(!panel.classList.contains('collapsed'));+  });+}++// =====================================================================+// 6. Visibility undo / redo+// =====================================================================++function snapshotVisibility() {+  const snap = {};+  for (const e of assemblyTree) snap[e.id] = e.visible;+  return snap;+}+function applyVisibilitySnapshot(snap) {+  for (const e of assemblyTree) {+    if (snap[e.id] !== undefined && snap[e.id] !== e.visible) {+      e.visible = snap[e.id];+      for (const m of e.allMeshes) m.isVisible = snap[e.id];+      if (e.node && e.node.setEnabled) e.node.setEnabled(snap[e.id]);+    }+  }+  renderTree();+  postVisibilityToServer();+}+function pushVisibilityHistory(mutate) {+  const before = snapshotVisibility();+  mutate();+  const after = snapshotVisibility();+  visibilityHistory.push({ before, after });+  if (visibilityHistory.length > 50) visibilityHistory.shift();+  visibilityRedo = [];+}+function undoVisibility() {+  const last = visibilityHistory.pop();+  if (!last) return;+  applyVisibilitySnapshot(last.before);+  visibilityRedo.push(last);+}+function redoVisibility() {+  const last = visibilityRedo.pop();+  if (!last) return;+  applyVisibilitySnapshot(last.after);+  visibilityHistory.push(last);+}++// =====================================================================+// 7. Context menu+// =====================================================================++let ctxEntry = null;+function openContextMenu(x, y, entry) {+  ctxEntry = entry;+  const m = document.getElementById('ctxMenu');+  // Sync downstream-app extras (chipsmith adds "Move to decorative+  // branch" here). Extras are reset every open so toggle-state labels+  // stay current with the entry under the cursor.+  syncContextMenuExtras(entry);+  m.classList.add('open');+  m.style.left = x + 'px';+  m.style.top = y + 'px';+}++// Downstream apps register extra context-menu items via+// window.adomStepCtxMenuExtras = [{+//   id: 'mark-decorative',                  // unique within the menu+//   label: (entry) => 'Move to decorative branch on bake',+//   tooltip: (entry) => '…',+//   visibleFor: (entry) => true,            // optional filter+//   onClick: (entry) => { … },+// }, …]+// Items are appended (with a separator) at the bottom of the menu the+// first time the menu opens; on subsequent opens, only labels ++// visibility are updated. The host shell never has to know what the+// downstream app's verbs mean.+function syncContextMenuExtras(entry) {+  const m = document.getElementById('ctxMenu');+  const extras = (window.adomStepCtxMenuExtras || []);+  // Drop previous extras so we don't accumulate duplicates.+  m.querySelectorAll('.ctx-extra, .ctx-extra-sep').forEach(el => el.remove());+  if (!extras.length) return;+  const sep = document.createElement('div');+  sep.className = 'ctx-sep ctx-extra-sep';+  m.appendChild(sep);+  for (const ex of extras) {+    if (typeof ex.visibleFor === 'function' && !ex.visibleFor(entry)) continue;+    const it = document.createElement('div');+    it.className = 'ctx-item ctx-extra';+    it.dataset.act = 'extra:' + ex.id;+    it.textContent = (typeof ex.label === 'function' ? ex.label(entry) : ex.label);+    if (typeof ex.tooltip === 'function') it.setAttribute('data-tooltip', ex.tooltip(entry) || '');+    else if (ex.tooltip)                   it.setAttribute('data-tooltip', ex.tooltip);+    m.appendChild(it);+  }+}+function closeContextMenu() {+  document.getElementById('ctxMenu').classList.remove('open');+  ctxEntry = null;+}+function setupContextMenu() {+  document.addEventListener('click', closeContextMenu);+  const m = document.getElementById('ctxMenu');+  m.addEventListener('click', (e) => {+    e.stopPropagation();+    const act = e.target.dataset.act;+    if (!act || !ctxEntry) { closeContextMenu(); return; }+    const entry = ctxEntry;+    closeContextMenu();+    // Visibility-mutating actions go through pushVisibilityHistory so Ctrl+Z+    // can undo them. Non-mutating actions (reveal, zoom, copy, properties)+    // run directly to keep the undo stack clean.+    switch (act) {+      case 'reveal':+        findInOutline(entry);+        break;+      case 'isolate':+        pushVisibilityHistory(() => isolateEntry(entry, { frame: true }));+        break;+      case 'hide-others':+        pushVisibilityHistory(() => {+          for (const e of assemblyTree) {+            if (e.depth === 0 && e.id !== ancestorAt0(entry).id) setSubtreeVisibility(e, false);+          }+        });+        break;+      case 'show':+        pushVisibilityHistory(() => setSubtreeVisibility(entry, true));+        break;+      case 'hide':+        pushVisibilityHistory(() => setSubtreeVisibility(entry, false));+        // Flash the row in the outline so the user can find it again to+        // restore — without this, a hidden item visually disappears from+        // the canvas and the user has no breadcrumb back to its row.+        // (Ctrl+Z also undoes; this is the find-by-eye fallback.)+        findInOutline(entry);+        showToast('Hid ' + entry.name + ' — flashed in outline (Ctrl+Z undoes)', 'ok');+        break;+      case 'zoom':+        frameOnEntry(entry);+        break;+      case 'copy-name':+        navigator.clipboard.writeText(entry.name).catch(() => {});+        showToast('Copied: ' + entry.name, 'ok');+        break;+      case 'copy-json': {+        // Serialize a paste-able snapshot of the entry. Skip mesh refs+        // + circular children list — keep id/name/depth/parent/kind ++        // computed bbox + overlay tags so a bug report has all the+        // useful identity info.+        const blob = {+          id: entry.id,+          name: entry.name,+          depth: entry.depth,+          parentId: entry.parentId,+          kind: entry.kind || null,+          visible: entry.visible,+          isOverlay: !!entry.isOverlay,+          overlayGroup: entry.overlayGroup || null,+          meshCount: (entry.allMeshes || []).length,+          childCount: (entry.children || []).length,+        };+        try {+          const bb = combinedBoundingBox(entry.allMeshes || []);+          if (bb) {+            const SCENE_TO_MM = 1000;+            blob.bbox_mm = {+              min: [bb.min.x, bb.min.y, bb.min.z].map(v => v * SCENE_TO_MM),+              max: [bb.max.x, bb.max.y, bb.max.z].map(v => v * SCENE_TO_MM),+              size: [(bb.max.x - bb.min.x), (bb.max.y - bb.min.y), (bb.max.z - bb.min.z)].map(v => v * SCENE_TO_MM),+            };+          }+        } catch (_) {}+        const json = JSON.stringify(blob, null, 2);+        navigator.clipboard.writeText(json).catch(() => {});+        showToast('Copied JSON for ' + entry.name, 'ok');+        break;+      }+      case 'properties':+        pinInspectCard(entry);+        break;+      default:+        if (act && act.startsWith('extra:')) {+          const id = act.slice('extra:'.length);+          const ex = (window.adomStepCtxMenuExtras || []).find(e => e.id === id);+          if (ex && typeof ex.onClick === 'function') ex.onClick(entry);+        }+        break;+    }+  });+}+function ancestorAt0(entry) {+  let cur = entry;+  while (cur && cur.depth > 0) cur = cur.parentId == null ? null : nodeById.get(cur.parentId);+  return cur || entry;+}++// =====================================================================+// 8. Toolbar+// =====================================================================++function setupToolbar() {+  document.getElementById('tbMeasure').onclick = () => toggleMeasure();++  // Toggle starts OFF (app-level override of 3d-viewer-design §4d default-on,+  // see boot() for rationale). User flips it on when they want contact shadow.+  let groundOn = false;+  const groundBtn = document.getElementById('tbGround');+  groundBtn.classList.remove('active');+  groundBtn.onclick = (e) => {+    groundOn = !groundOn;+    viewer.setGroundVisible(groundOn);+    e.currentTarget.classList.toggle('active', groundOn);+  };++  // Per §8a: world-origin axes toggle, default ON.+  // Per §8b: mesh-local axes toggle, default OFF.+  // Bottom-fill light — toggles a HemisphericLight under the model so the+  // user can see BGA balls / QFN pads / through-hole leg bottoms. Persists+  // via localStorage. Keyboard 'B' toggles (skipped while a text input is+  // focused).+  const bottomBtn = document.getElementById('tbBottomLight');+  const BOTTOM_LIGHT_KEY = 'adom-step.bottomLight';+  function applyBottomLight(on) {+    viewer.setBottomLight(on);+    bottomBtn.classList.toggle('active', on);+    try { localStorage.setItem(BOTTOM_LIGHT_KEY, on ? '1' : '0'); } catch (_) {}+  }+  // Restore last state on init.+  try {+    if (localStorage.getItem(BOTTOM_LIGHT_KEY) === '1') applyBottomLight(true);+  } catch (_) {}+  bottomBtn.onclick = () => applyBottomLight(!viewer.getBottomLightState());+  document.addEventListener('keydown', (e) => {+    if (e.key !== 'b' && e.key !== 'B') return;+    if (e.ctrlKey || e.metaKey || e.altKey) return;+    const t = e.target;+    if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;+    applyBottomLight(!viewer.getBottomLightState());+  });++  document.getElementById('tbAxes').onclick = (e) => {+    const next = !viewer.getAxesState().world;+    viewer.toggleAxes('world', next);+    e.currentTarget.classList.toggle('active', next);+  };+  document.getElementById('tbMeshAxes').onclick = (e) => {+    const next = !viewer.getAxesState().meshLocal;+    viewer.toggleAxes('mesh-local', next);+    e.currentTarget.classList.toggle('active', next);+  };++  // Babylon Inspector — use the canonical viewer's showDebugLayer /+  // hideDebugLayer wrappers. They `await import('@babylonjs/inspector')`+  // from the bundled dependency BEFORE calling scene.debugLayer.show(),+  // which avoids the CDN-load + frozen-BABYLON-namespace mess. (This+  // is the same ESM-load path adom-tsci / chiplinter use.)+  let inspectorOpen = false;+  document.getElementById('tbInspect').onclick = async (e) => {+    const btn = e.currentTarget;+    try {+      if (inspectorOpen) {+        await viewer.hideDebugLayer();+        inspectorOpen = false;+        btn.classList.remove('active');+        showToast('Inspector closed', 'ok');+      } else {+        btn.classList.add('active');+        showToast('Loading Babylon Inspector…', 'ok');+        await viewer.showDebugLayer();+        inspectorOpen = true;+        showToast('Inspector ready', 'ok');+      }+    } catch (err) {+      console.error('Inspector failed:', err);+      showToast('Inspector error: ' + (err && err.message ? err.message : err), 'err');+      btn.classList.remove('active');+    }+  };++  // Strobe is removed from adom-step (out of scope for a STEP file+  // viewer — strobe-each-pin is a chip-fetcher / chiplinter concept).+  // Pin clustering itself stays for the components-outline rows.+}++function setupViewButtons() {+  // Toolbar ViewCube clicks: preserve the user's current orbit center ++  // radius per 3d-viewer-design §7. Don't pass frameWhole.+  document.querySelectorAll('[data-view]').forEach(btn => {+    btn.onclick = () => setView(btn.dataset.view);+  });+}++function setView(name, opts) {+  // ViewCube preset for the named direction. Per 3d-viewer-design §7,+  // toolbar ViewCube clicks should preserve the current orbit center ++  // radius (just rotate). CLI/AI calls (opts.frameWhole = true) re-frame+  // the whole model first so an AI driving the viewer always sees the+  // full part regardless of prior isolate/measure state.+  const cam = scene.activeCamera;+  if (!cam || typeof cam.alpha !== 'number') return;++  if (opts && opts.frameWhole) {+    showAllEntries();        // restore visibility from any prior isolate+    viewer.goHome();         // re-frame on full bounds+  }++  const presets = {+    front:  { alpha: -Math.PI / 2, beta: Math.PI / 2 },+    back:   { alpha: Math.PI / 2,  beta: Math.PI / 2 },+    left:   { alpha: Math.PI,      beta: Math.PI / 2 },+    right:  { alpha: 0,            beta: Math.PI / 2 },+    top:    { alpha: -Math.PI / 2, beta: 0.001 },+    bottom: { alpha: -Math.PI / 2, beta: Math.PI - 0.001 },+    iso:    { alpha: -Math.PI / 4, beta: Math.PI / 3 },+    home:   null,+  };+  const p = presets[name];+  if (name === 'home' || !p) { viewer.goHome(); return; }+  cam.alpha = p.alpha;+  cam.beta = p.beta;+}++// =====================================================================+// 9. Drop zone+// =====================================================================++function setupDropzone() {+  ['dragenter', 'dragover'].forEach(ev => {+    window.addEventListener(ev, (e) => {+      e.preventDefault();+      document.body.classList.add('dragging');+    });+  });+  ['dragleave', 'drop'].forEach(ev => {+    window.addEventListener(ev, (e) => {+      if (ev === 'dragleave' && e.target !== document && e.relatedTarget) return;+      document.body.classList.remove('dragging');+    });+  });+  window.addEventListener('drop', async (e) => {+    e.preventDefault();+    const file = e.dataTransfer.files[0];+    if (!file) return;+    if (!/\.(step|stp)$/i.test(file.name)) {+      showToast('Not a .step / .stp file', 'err');+      return;+    }+    setSplash('Converting ' + file.name + '...');+    const buf = await file.arrayBuffer();+    try {+      const r = await fetch('api/load', {+        method: 'POST',+        headers: { 'X-Filename': file.name },+        body: buf,+      });+      const body = await r.json();+      if (!r.ok || body.error) {+        setSplash(null);+        showToast('Load failed: ' + (body.error || r.statusText), 'err');+        return;+      }+      setSourceLabel(file.name, 'upload');+      materialsCache = body.materials || [];+      await loadModelUrl('model.glb?cb=' + Date.now());+      showToast(file.name + (body.cache_hit ? ' (cached)' : ' converted'), 'ok');+    } catch (err) {+      setSplash(null);+      showToast('Load error: ' + err.message, 'err');+    }+  });+}++// =====================================================================+// 10. Measure tool+// =====================================================================++let measureActive = false;+let measureFilter = 'smart';+let measurePicks = []; // [{point, mesh, marker, type}]++function toggleMeasure() {+  measureActive = !measureActive;+  document.getElementById('tbMeasure').classList.toggle('active', measureActive);+  document.getElementById('measureHud').classList.toggle('open', measureActive);+  if (!measureActive) {+    clearMeasure();+  } else {+    renderMeasureHud();+  }+}++function clearMeasure() {+  for (const p of measurePicks) {+    if (p.markers) for (const m of p.markers) m.dispose();+    else if (p.marker) p.marker.dispose();   // legacy single-mesh shape+  }+  if (measureLine) { measureLine.dispose(); measureLine = null; }+  if (measureLabel) { measureLabel.dispose(); measureLabel = null; }+  measurePicks = [];+  clearMeasurePreview();+  renderMeasureHud();+}++function setupMeasureTool() {+  document.getElementById('measureClose').onclick = () => toggleMeasure();+  document.querySelectorAll('.mb-filter').forEach(btn => {+    btn.onclick = () => {+      measureFilter = btn.dataset.filter;+      document.querySelectorAll('.mb-filter').forEach(b => b.classList.toggle('active', b === btn));+    };+  });++  scene.onPointerObservable.add((pi) => {+    if (!measureActive) return;++    // Click commits a measurement at the current snap point.+    if (pi.type === BABYLON.PointerEventTypes.POINTERTAP && pi.event.button === 0) {+      const pick = scene.pick(scene.pointerX, scene.pointerY, (m) => isPickableMesh(m));+      if (!pick || !pick.hit || !pick.pickedPoint) return;+      addMeasurePick(pick);+      return;+    }++    // Hover renders a translucent preview of what would commit on click.+    // Required by human-ui-patterns §2 (click previews) and matches Fusion 360+    // / SolidWorks / Onshape behavior.+    if (pi.type === BABYLON.PointerEventTypes.POINTERMOVE) {+      const pick = scene.pick(scene.pointerX, scene.pointerY, (m) => isPickableMesh(m));+      if (!pick || !pick.hit || !pick.pickedPoint) {+        clearMeasurePreview();+        return;+      }+      const snap = computeMeasureSnap(pick, measureFilter);+      drawMeasurePreview(snap);+    }+  });+}++function isPickableMesh(m) {+  if (!m) return false;+  if (m.name && (m.name.startsWith('measure-') || m.name.startsWith('section-') || m.name.startsWith('viewCube'))) return false;+  if (m.name === 'shadowGround' || m.name === 'ground' || m.name === 'skyBox') return false;+  return m.isEnabled() && m.isVisible !== false;+}++// Compute the snap point + auxiliary geometry for a hover/click pick under+// the current filter. Returns: { kind, point, mesh, edgeA?, edgeB?, faceTri? }+// Smart-mode priority is edges > faces > vertexes (per user feedback) — edges+// are the most semantically meaningful pick on a mechanical part, faces are+// next, and vertexes are last because they're often dense on tessellated+// curves and snap to noise.+function computeMeasureSnap(pick, filter) {+  const point = pick.pickedPoint.clone();+  const mesh = pick.pickedMesh;+  // Snap radius scales with scene extent so it feels right on a 2 mm SOT-23+  // and a 200 mm board alike. ~2% of scene extent in scene-meters.+  const ext = sceneExtentMeters();+  const snapRadius = Math.max(ext * 0.015, 0.0003);++  function tryEdge() {+    return nearestEdgeFull(mesh, pick, snapRadius);+  }+  function tryFace() {+    // 1° tolerance for "coplanar" — same threshold most CAD tools use.+    return findConnectedFace(mesh, pick, 1);+  }+  function tryVertex() {+    const v = nearestVertex(mesh, point, snapRadius);+    return v ? { point: v } : null;+  }++  if (filter === 'body') {+    const bb = combinedBoundingBox([mesh]);+    if (bb) return { kind: 'body', point: bb.center.clone(), mesh };+    return { kind: 'point', point, mesh };+  }++  if (filter === 'edge') {+    const e = tryEdge();+    if (e) return { kind: 'edge', point: e.point, mesh, edgeA: e.a, edgeB: e.b };+    return { kind: 'point', point, mesh };+  }+  if (filter === 'face') {+    const f = tryFace();+    return { kind: 'face', point: f ? f.point : point, mesh, face: f, faceTri: f && f.tri };+  }+  if (filter === 'vertex') {+    const v = tryVertex();+    if (v) return { kind: 'vertex', point: v.point, mesh };+    return { kind: 'point', point, mesh };+  }++  // Smart: edge > face > vertex priority (per user feedback).+  const e = tryEdge();+  if (e) return { kind: 'edge', point: e.point, mesh, edgeA: e.a, edgeB: e.b };+  const f = tryFace();+  if (f) return { kind: 'face', point: f.point, mesh, face: f, faceTri: f.tri };+  const v = tryVertex();+  if (v) return { kind: 'vertex', point: v.point, mesh };+  return { kind: 'point', point, mesh };+}++function addMeasurePick(pick) {+  if (measurePicks.length >= 2) clearMeasure();+  const snap = computeMeasureSnap(pick, measureFilter);+  const markerMeshes = makeMeasureMarker(snap, measurePicks.length + 1);+  // Preserve the snap's auxiliary geometry (edgeA/edgeB/faceTri) so+  // computeMeasureEndpoints can do edge-edge shortest-segment, etc.+  measurePicks.push({+    point: snap.point, mesh: snap.mesh, kind: snap.kind, markers: markerMeshes,+    edgeA: snap.edgeA, edgeB: snap.edgeB,+    face: snap.face, faceTri: snap.faceTri,+  });+  if (measurePicks.length === 2) drawMeasureLine();+  clearMeasurePreview();+  renderMeasureHud();+}++function nearestVertex(mesh, point, snapRadius) {+  if (!mesh.getVerticesData) return null;+  const positions = mesh.getVerticesData(BABYLON.VertexBuffer.PositionKind);+  if (!positions) return null;+  const matrix = mesh.computeWorldMatrix(true);+  let best = null;+  let bestD = snapRadius * snapRadius;+  const tmp = new BABYLON.Vector3();+  for (let i = 0; i < positions.length; i += 3) {+    tmp.set(positions[i], positions[i + 1], positions[i + 2]);+    const w = BABYLON.Vector3.TransformCoordinates(tmp, matrix);+    const dx = w.x - point.x, dy = w.y - point.y, dz = w.z - point.z;+    const d2 = dx * dx + dy * dy + dz * dz;+    if (d2 < bestD) { bestD = d2; best = w.clone(); }+  }+  return best;+}++function nearestEdgeMidpoint(mesh, pick, snapRadius) {+  const e = nearestEdgeFull(mesh, pick, snapRadius);+  return e ? e.point : null;+}++// Returns the closest edge to the pick point as { a, b, point } where point+// is the closest point ON that edge to the cursor (not necessarily the+// midpoint). Distance metric: cursor-to-line, not cursor-to-midpoint, so the+// snap is stable as the user slides the cursor along the edge.+function nearestEdgeFull(mesh, pick, snapRadius) {+  if (!mesh.getIndices || !mesh.getVerticesData || pick.faceId == null) return null;+  const positions = mesh.getVerticesData(BABYLON.VertexBuffer.PositionKind);+  const indices = mesh.getIndices();+  if (!positions || !indices) return null;+  const matrix = mesh.computeWorldMatrix(true);+  const f = pick.faceId;+  if (f * 3 + 2 >= indices.length) return null;+  const ia = indices[f * 3], ib = indices[f * 3 + 1], ic = indices[f * 3 + 2];+  const verts = [ia, ib, ic].map(idx => {+    const p = new BABYLON.Vector3(positions[idx * 3], positions[idx * 3 + 1], positions[idx * 3 + 2]);+    return BABYLON.Vector3.TransformCoordinates(p, matrix);+  });+  const c = pick.pickedPoint;+  let best = null;+  let bestD = snapRadius * snapRadius;+  for (let i = 0; i < 3; i++) {+    const a = verts[i], b = verts[(i + 1) % 3];+    const ab = b.subtract(a);+    const lenSq = ab.lengthSquared();+    if (lenSq <= 1e-12) continue;+    let t = BABYLON.Vector3.Dot(c.subtract(a), ab) / lenSq;+    t = Math.max(0, Math.min(1, t));+    const closest = a.add(ab.scale(t));+    const dx = closest.x - c.x, dy = closest.y - c.y, dz = closest.z - c.z;+    const d2 = dx * dx + dy * dy + dz * dz;+    if (d2 < bestD) { bestD = d2; best = { a: a.clone(), b: b.clone(), point: closest.clone() }; }+  }+  return best;+}++// Find the LOGICAL face that contains the picked triangle by flood-filling+// adjacent coplanar triangles. Returns { worldPositions, indices, centroid,+// normal, point, triCount, tri /* legacy: first triangle */ } in world space.+//+// User feedback (May 2026): showing a single triangle for the "Face" filter+// was confusing — users think of a flat surface as ONE face, not "the+// triangle the cursor happened to land on". Flood-fill groups all triangles+// whose normals match within 1° (cos ≈ 0.9998), giving the human-meaningful+// "face". Curved surfaces (gull-wing pin tops, chip leads) break out of the+// fill at the curvature boundary, so each curved patch stays its own face.+function findConnectedFace(mesh, pick, epsilonDeg) {+  if (!mesh.getIndices || !mesh.getVerticesData || pick.faceId == null) return null;+  const positions = mesh.getVerticesData(BABYLON.VertexBuffer.PositionKind);+  const indices = mesh.getIndices();+  if (!positions || !indices) return null;+  const matrix = mesh.computeWorldMatrix(true);+  const numTris = indices.length / 3;+  if (pick.faceId < 0 || pick.faceId >= numTris) return null;++  function vec(i) {+    return new BABYLON.Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]);+  }+  function triNormal(fi) {+    const ia = indices[fi * 3], ib = indices[fi * 3 + 1], ic = indices[fi * 3 + 2];+    const a = vec(ia), b = vec(ib), c = vec(ic);+    const n = BABYLON.Vector3.Cross(b.subtract(a), c.subtract(a));+    if (n.lengthSquared() < 1e-20) return null;+    return n.normalize();+  }+  function edgeKey(a, b) { return Math.min(a, b) + ',' + Math.max(a, b); }++  // Edge → triangle adjacency.+  const edgeMap = new Map();+  for (let f = 0; f < numTris; f++) {+    const ia = indices[f * 3], ib = indices[f * 3 + 1], ic = indices[f * 3 + 2];+    const edges = [[ia, ib], [ib, ic], [ic, ia]];+    for (const e of edges) {+      const k = edgeKey(e[0], e[1]);+      let arr = edgeMap.get(k);+      if (!arr) { arr = []; edgeMap.set(k, arr); }+      arr.push(f);+    }+  }++  const seedNormal = triNormal(pick.faceId);+  if (!seedNormal) return null;+  const cosEps = Math.cos((epsilonDeg || 1) * Math.PI / 180);+  const visited = new Set([pick.faceId]);+  const stack = [pick.faceId];+  while (stack.length) {+    const f = stack.pop();+    const ia = indices[f * 3], ib = indices[f * 3 + 1], ic = indices[f * 3 + 2];+    const edges = [[ia, ib], [ib, ic], [ic, ia]];+    for (const e of edges) {+      const list = edgeMap.get(edgeKey(e[0], e[1]));+      if (!list) continue;+      for (const n of list) {+        if (visited.has(n)) continue;+        const nn = triNormal(n);+        if (!nn) continue;+        if (BABYLON.Vector3.Dot(seedNormal, nn) >= cosEps) {+          visited.add(n);+          stack.push(n);+        }+      }+    }+  }++  // Bake all triangles into one set of world-space positions + indices,+  // and compute the AREA-WEIGHTED centroid (the proper geometric centroid+  // of the polygon, not just the average of triangle vertices — large+  // triangles influence the centroid more than tiny ones).+  const worldPositions = [];+  const triIndices = [];+  let cx = 0, cy = 0, cz = 0, totalArea = 0, vIdx = 0;+  for (const f of visited) {+    const ia = indices[f * 3], ib = indices[f * 3 + 1], ic = indices[f * 3 + 2];+    const wa = BABYLON.Vector3.TransformCoordinates(vec(ia), matrix);+    const wb = BABYLON.Vector3.TransformCoordinates(vec(ib), matrix);+    const wc = BABYLON.Vector3.TransformCoordinates(vec(ic), matrix);+    worldPositions.push(wa.x, wa.y, wa.z, wb.x, wb.y, wb.z, wc.x, wc.y, wc.z);+    triIndices.push(vIdx, vIdx + 1, vIdx + 2, vIdx + 2, vIdx + 1, vIdx);+    vIdx += 3;+    // Triangle area = 0.5 × |edge1 × edge2|+    const cross = BABYLON.Vector3.Cross(wb.subtract(wa), wc.subtract(wa));+    const area = cross.length() * 0.5;+    // Triangle centroid = average of its 3 vertices+    const tcx = (wa.x + wb.x + wc.x) / 3;+    const tcy = (wa.y + wb.y + wc.y) / 3;+    const tcz = (wa.z + wb.z + wc.z) / 3;+    cx += tcx * area; cy += tcy * area; cz += tcz * area;+    totalArea += area;+  }+  const centroid = totalArea > 0+    ? new BABYLON.Vector3(cx / totalArea, cy / totalArea, cz / totalArea)+    : new BABYLON.Vector3(worldPositions[0], worldPositions[1], worldPositions[2]);++  // Legacy `tri` field for backwards compat — first triangle as 3 Vector3s.+  const f0 = pick.faceId;+  const tri = [indices[f0 * 3], indices[f0 * 3 + 1], indices[f0 * 3 + 2]].map(idx =>+    BABYLON.Vector3.TransformCoordinates(vec(idx), matrix)+  );++  return {+    worldPositions, indices: triIndices, centroid,+    normal: seedNormal, point: pick.pickedPoint.clone(),+    triCount: visited.size, tri,+  };+}++// Backwards-compat shim.+function faceTriFromPick(mesh, pick) {+  return findConnectedFace(mesh, pick, 1);+}++function sceneExtentMeters() {+  // Measure / section / pivot markers must scale with the scene, not be+  // hard-coded — a 1m sphere on a 12 mm chip means the camera sits INSIDE+  // the marker. Scale by current scene bounds. (Per 3d-viewer-design §6c.)+  const meshes = scene.meshes.filter(isPickableMesh);+  if (!meshes.length) return 0.05; // 50mm fallback+  const bb = combinedBoundingBox(meshes);+  return bb ? bb.radius * 2 : 0.05;+}++// Build the committed marker meshes for a confirmed pick. Shape matches+// the snap kind so the user sees the same geometry they hovered: edge →+// tube, vertex → sphere, face → translucent triangle, body → bbox volume.+// The dimension line still connects the two pick.point values regardless.+function makeMeasureMarker(snap, label) {+  const ext = sceneExtentMeters();+  const meshes = [];++  function makeMat(name, alpha) {+    const m = new BABYLON.StandardMaterial(name, scene);+    m.disableLighting = true;+    m.emissiveColor = BABYLON.Color3.FromHexString('#00b8b1');+    m.alpha = alpha == null ? 1 : alpha;+    m.backFaceCulling = false;+    return m;+  }+  function reg(m) {+    m.renderingGroupId = 3;+    m.isPickable = false;+    if (measureGlowLayer) measureGlowLayer.addIncludedOnlyMesh(m);+    meshes.push(m);+  }++  if (snap.kind === 'edge' && snap.edgeA && snap.edgeB) {+    const tube = BABYLON.MeshBuilder.CreateTube('measure-edge-' + label, {+      path: [snap.edgeA, snap.edgeB], radius: ext * 0.005,+    }, scene);+    tube.material = makeMat('measure-edge-mat-' + label, 1);+    reg(tube);+  } else if (snap.kind === 'face' && snap.face) {+    // Whole logical face on commit, same as the preview.+    const facePlane = new BABYLON.Mesh('measure-face-' + label, scene);+    const vd = new BABYLON.VertexData();+    vd.positions = snap.face.worldPositions;+    vd.indices = snap.face.indices;+    vd.applyToMesh(facePlane);+    facePlane.material = makeMat('measure-face-mat-' + label, 0.65);+    reg(facePlane);+  } else if (snap.kind === 'body') {+    const bb = combinedBoundingBox([snap.mesh]);+    if (bb) {+      const box = BABYLON.MeshBuilder.CreateBox('measure-body-' + label, {+        width:  Math.max(bb.max.x - bb.min.x, ext * 0.005),+        height: Math.max(bb.max.y - bb.min.y, ext * 0.005),+        depth:  Math.max(bb.max.z - bb.min.z, ext * 0.005),+      }, scene);+      box.position = bb.center.clone();+      box.material = makeMat('measure-body-mat-' + label, 0.22);+      reg(box);+    }+  } else {+    // 'vertex' or fallback 'point' — sphere at the snap point.+    const sphere = BABYLON.MeshBuilder.CreateSphere('measure-vertex-' + label,+      { diameter: ext * 0.04 }, scene);+    sphere.position = snap.point.clone();+    sphere.material = makeMat('measure-vertex-mat-' + label, 1);+    reg(sphere);+  }++  return meshes;+}++// =====================================================================+// Measure preview — emissive geometric helpers that highlight whatever+// the next click would snap to. Wrapped in a GlowLayer so previews bloom+// against the dark scene. NO cursor sphere — the geometric primitive+// itself (edge tube / face triangle / vertex ring / body sphere) IS the+// preview. The HUD's "Snap: <kind>" badge tells the user what they're+// looking at.+// =====================================================================+let measurePreviewMeshes = [];+let measureGlowLayer = null;++function ensureGlowLayer() {+  if (measureGlowLayer) return measureGlowLayer;+  measureGlowLayer = new BABYLON.GlowLayer('measure-glow', scene, {+    mainTextureFixedSize: 1024,+    blurKernelSize: 64,+  });+  measureGlowLayer.intensity = 0.7;+  // Per-mesh emissive override: vertex spheres glow at ~30% so the bloom+  // doesn't wash out the material's alpha=0.5 (otherwise the back face's+  // emissive light fills the front face's see-through). Edges / faces /+  // bodies use full bright emissive — they're thin/flat and the glow+  // doesn't fight the transparency the same way.+  measureGlowLayer.customEmissiveColorSelector = (mesh, _sub, _mat, result) => {+    const isVertex = mesh && mesh.name && mesh.name.startsWith('measure-prev-vertex');+    if (isVertex) {+      result.set(0.0, 0.35, 0.34, 1.0);   // dim teal — gentle bloom+    } else {+      result.set(0.0, 0.72, 0.69, 1.0);   // full brand teal+    }+  };+  return measureGlowLayer;+}++function clearMeasurePreview() {+  if (measureGlowLayer) {+    for (const m of measurePreviewMeshes) measureGlowLayer.removeIncludedOnlyMesh(m);+  }+  for (const m of measurePreviewMeshes) m.dispose();+  measurePreviewMeshes = [];+  setMeasureSnapBadge(null);+}++function setMeasureSnapBadge(kind) {+  const el = document.getElementById('measureSnapKind');+  if (!el) return;+  if (!kind) { el.textContent = '—'; el.removeAttribute('data-kind'); return; }+  // Title-case for display.+  const labels = { edge: 'Edge', face: 'Face', vertex: 'Vertex', body: 'Body', point: 'Point' };+  el.textContent = labels[kind] || kind;+  el.setAttribute('data-kind', kind);+}++// Preview rendering parameters — kept in one place so they stay coherent.+// User feedback 2026-05-02: previews must be ~50% transparent (so the user+// can see the underlying geometry they're picking) and ~30% smaller than+// the committed marker so the two are visually distinct.+const PREVIEW_ALPHA = 0.5;+const PREVIEW_SIZE_SCALE = 0.7;++function previewMaterial(name) {+  const mat = new BABYLON.StandardMaterial(name, scene);+  mat.disableLighting = true;+  mat.emissiveColor = BABYLON.Color3.FromHexString('#00b8b1'); // brand teal+  mat.alpha = PREVIEW_ALPHA;+  mat.backFaceCulling = false;+  return mat;+}++function drawMeasurePreview(snap) {+  clearMeasurePreview();+  if (!snap) return;+  setMeasureSnapBadge(snap.kind);+  const ext = sceneExtentMeters();+  const glow = ensureGlowLayer();+  const S = PREVIEW_SIZE_SCALE;++  function regGlow(m) {+    m.renderingGroupId = 3;+    m.isPickable = false;+    glow.addIncludedOnlyMesh(m);+    measurePreviewMeshes.push(m);+  }++  if (snap.kind === 'edge' && snap.edgeA && snap.edgeB) {+    const tube = BABYLON.MeshBuilder.CreateTube('measure-prev-edge', {+      path: [snap.edgeA, snap.edgeB], radius: ext * 0.004 * S,+    }, scene);+    tube.material = previewMaterial('measure-prev-edge-mat');+    regGlow(tube);+    return;+  }++  if (snap.kind === 'face' && snap.face) {+    // Show the FULL logical face (all coplanar adjacent triangles), not+    // just the picked triangle. Per user feedback May 2026 — humans think+    // of a flat surface as one face, not as the triangulation underneath.+    const facePlane = new BABYLON.Mesh('measure-prev-face', scene);+    const vd = new BABYLON.VertexData();+    vd.positions = snap.face.worldPositions;+    vd.indices = snap.face.indices;+    vd.applyToMesh(facePlane);+    facePlane.material = previewMaterial('measure-prev-face-mat');+    regGlow(facePlane);+    return;+  }++  if (snap.kind === 'vertex') {+    // Translucent sphere AT the vertex (not a ring around it) with a+    // softened glow so the alpha-0.5 transparency actually shows. The+    // GlowLayer's customEmissiveColorSelector dims spheres specifically+    // so the bloom doesn't fill the see-through.+    const sphere = BABYLON.MeshBuilder.CreateSphere('measure-prev-vertex', {+      diameter: ext * 0.025 * S, segments: 24,+    }, scene);+    sphere.position = snap.point.clone();+    const mat = new BABYLON.StandardMaterial('measure-prev-vertex-mat', scene);+    mat.disableLighting = true;+    mat.emissiveColor = BABYLON.Color3.FromHexString('#00b8b1');+    mat.alpha = PREVIEW_ALPHA;+    mat.backFaceCulling = true;       // back face would fill the see-through+    mat.alphaMode = BABYLON.Engine.ALPHA_COMBINE;+    sphere.material = mat;+    regGlow(sphere);+    return;+  }++  if (snap.kind === 'body') {+    const bb = combinedBoundingBox([snap.mesh]);+    if (!bb) return;+    const box = BABYLON.MeshBuilder.CreateBox('measure-prev-body', {+      width:  Math.max(bb.max.x - bb.min.x, ext * 0.005),+      height: Math.max(bb.max.y - bb.min.y, ext * 0.005),+      depth:  Math.max(bb.max.z - bb.min.z, ext * 0.005),+    }, scene);+    box.position = bb.center.clone();+    box.material = previewMaterial('measure-prev-body-mat');+    regGlow(box);+    return;+  }++  // 'point' fallback: no preview, let the user keep moving until they+  // land on a pickable feature.+}++// =====================================================================+// Dimension geometry — compute the SHORTEST path between two picks given+// their snap-kinds. Edge-edge becomes skew-line shortest segment; vertex-+// edge becomes perpendicular projection; everything else falls through to+// the cursor pick points (snap.point) which already lie on the geometry.+// =====================================================================++function closestPointOnSegment(p, a, b) {+  const ab = b.subtract(a);+  const lenSq = ab.lengthSquared();+  if (lenSq < 1e-14) return a.clone();+  let t = BABYLON.Vector3.Dot(p.subtract(a), ab) / lenSq;+  t = Math.max(0, Math.min(1, t));+  return a.add(ab.scale(t));+}++// Standard skew-line closest-segment algorithm. Handles parallel + degenerate+// cases. Returns the two world points that minimize |a - b|.+function shortestSegmentBetweenSegments(p0, p1, q0, q1) {+  const d1 = p1.subtract(p0);+  const d2 = q1.subtract(q0);+  const r  = p0.subtract(q0);+  const a = d1.lengthSquared();+  const e = d2.lengthSquared();+  const f = BABYLON.Vector3.Dot(d2, r);+  let s, t;+  if (a < 1e-14 && e < 1e-14) return { a: p0.clone(), b: q0.clone() };+  if (a < 1e-14) { s = 0; t = Math.max(0, Math.min(1, f / e)); }+  else {+    const c = BABYLON.Vector3.Dot(d1, r);+    if (e < 1e-14) { t = 0; s = Math.max(0, Math.min(1, -c / a)); }+    else {+      const b = BABYLON.Vector3.Dot(d1, d2);+      const denom = a * e - b * b;+      s = denom !== 0 ? Math.max(0, Math.min(1, (b * f - c * e) / denom)) : 0;+      t = (b * s + f) / e;+      if (t < 0)      { t = 0; s = Math.max(0, Math.min(1, -c / a)); }+      else if (t > 1) { t = 1; s = Math.max(0, Math.min(1, (b - c) / a)); }+    }+  }+  return { a: p0.add(d1.scale(s)), b: q0.add(d2.scale(t)) };+}++// Centroid of a picked entity, in scene meters. Per-user UX choice: the+// dimension line connects the **center** of each picked thing — predictable+// and appealing for users who think in "from center of this to center of+// that". Edge-edge → midpoint-to-midpoint, face-face → centroid-to-centroid,+// vertex/point → as-is, body → bbox center.+function pickCentroid(p) {+  if (p.kind === 'edge' && p.edgeA && p.edgeB) {+    return p.edgeA.add(p.edgeB).scale(0.5);+  }+  if (p.kind === 'face' && p.face && p.face.centroid) {+    return p.face.centroid.clone();   // centroid of the whole logical face+  }+  if (p.kind === 'face' && p.faceTri) {+    // Legacy single-tri fallback (shouldn't happen post-flood-fill).+    const [a, b, c] = p.faceTri;+    return a.add(b).add(c).scale(1 / 3);+  }+  return p.point.clone();+}++// The dimension line endpoints. We always go centroid-to-centroid so the+// line is straight, deterministic, and visually predictable. Auxiliary+// helpers (closestPointOnSegment, shortestSegmentBetweenSegments) are kept+// in the codebase for future "perpendicular" / "shortest" annotation modes.+function computeMeasureEndpoints(p1, p2) {+  return { a: pickCentroid(p1), b: pickCentroid(p2) };+}++// Direction vector for a pick (unit). Edges → along the edge. Faces → face+// normal (so face-face angle is the dihedral angle). Vertex/body/point →+// null (no meaningful direction).+function pickDirection(p) {+  if (p.kind === 'edge' && p.edgeA && p.edgeB) {+    const d = p.edgeB.subtract(p.edgeA);+    if (d.lengthSquared() < 1e-14) return null;+    return d.normalize();+  }+  if (p.kind === 'face' && p.face && p.face.normal) {+    return p.face.normal.clone();    // averaged normal of the whole face+  }+  if (p.kind === 'face' && p.faceTri) {+    const [a, b, c] = p.faceTri;+    const n = BABYLON.Vector3.Cross(b.subtract(a), c.subtract(a));+    if (n.lengthSquared() < 1e-14) return null;+    return n.normalize();+  }+  return null;+}++// Angle between two picks in degrees (0..180), or null if either pick has+// no direction (vertex/body/point). Edge-edge → angle between edges.+// Face-face → dihedral. Edge-face → angle between edge and face normal,+// returned as the COMPLEMENT (angle from edge to face plane = 90 - angle).+function computeMeasureAngle(p1, p2) {+  const d1 = pickDirection(p1);+  const d2 = pickDirection(p2);+  if (!d1 || !d2) return null;+  const dot = Math.abs(BABYLON.Vector3.Dot(d1, d2));+  const clamped = Math.max(-1, Math.min(1, dot));+  let deg = Math.acos(clamped) * 180 / Math.PI;+  // Edge-face: report angle FROM edge TO face plane (= 90 - angle to normal).+  const isEF = (p1.kind === 'edge' && p2.kind === 'face') ||+               (p1.kind === 'face' && p2.kind === 'edge');+  if (isEF) deg = 90 - deg;+  return deg;+}++let measureLine = null;+let measureLabel = null;++function makeDimensionLabel(mainText, subText, midpoint) {+  const ext = sceneExtentMeters();+  // 2-row pill: distance on top, kind-context + mils underneath.+  // 2026-05-06 user feedback: main was reading ~32pt on screen — way too+  // large for a UI label. Subtitle was fine. Brought main from 96px →+  // 56px (≈14pt rendered) and tightened texture height + padding so the+  // pill is proportionally smaller without wrapping.+  const texW = 1024, texH = 192;+  const dt = new BABYLON.DynamicTexture('measure-label-tex', { width: texW, height: texH }, scene, true);+  const ctx = dt.getContext();+  ctx.clearRect(0, 0, texW, texH);++  ctx.font = 'bold 56px ui-monospace, "SF Mono", Menlo, monospace';+  const mainW = ctx.measureText(mainText).width;+  ctx.font = '500 44px -apple-system, "Inter", "Segoe UI", system-ui, sans-serif';+  const subW = subText ? ctx.measureText(subText).width : 0;+  const padX = 40, padY = 24;+  const w = Math.min(Math.max(mainW, subW) + padX * 2, texW - 4);+  const h = texH - padY * 0.5;+  const x0 = (texW - w) / 2, y0 = (texH - h) / 2;+  // Pill background — translucent dark so it never blends into a dark chip body+  ctx.fillStyle = 'rgba(18, 22, 28, 0.92)';+  ctx.beginPath();+  const r = 22;+  ctx.moveTo(x0 + r, y0);+  ctx.arcTo(x0 + w, y0, x0 + w, y0 + h, r);+  ctx.arcTo(x0 + w, y0 + h, x0, y0 + h, r);+  ctx.arcTo(x0, y0 + h, x0, y0, r);+  ctx.arcTo(x0, y0, x0 + w, y0, r);+  ctx.closePath();+  ctx.fill();+  ctx.lineWidth = 3;+  ctx.strokeStyle = '#64abff';+  ctx.stroke();+  // Distance — bold white, smaller than v1.0 to read as UI text not headline+  ctx.font = 'bold 56px ui-monospace, "SF Mono", Menlo, monospace';+  ctx.fillStyle = '#ffffff';+  ctx.textAlign = 'center';+  ctx.textBaseline = 'middle';+  ctx.fillText(mainText, texW / 2, subText ? texH / 2 - 22 : texH / 2);+  // Sub text — kind context like "Edge ↔ Edge" in brand blue-light+  if (subText) {+    ctx.font = '500 44px -apple-system, "Inter", "Segoe UI", system-ui, sans-serif';+    ctx.fillStyle = '#64abff';+    ctx.fillText(subText, texW / 2, texH / 2 + 36);+  }+  dt.update();++  const planeWidth = ext * 0.26;+  const planeHeight = planeWidth * (texH / texW);+  const plane = BABYLON.MeshBuilder.CreatePlane('measure-label', {+    width: planeWidth, height: planeHeight, sideOrientation: BABYLON.Mesh.DOUBLESIDE,+  }, scene);+  // Lift the label slightly above the dimension line midpoint so it's not+  // covered by chip body / pin geometry. Offset is a small fraction of+  // scene extent and always toward the camera-up direction.+  const upOffset = ext * 0.04;+  plane.position = midpoint.add(new BABYLON.Vector3(0, 0, upOffset));+  plane.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL;+  const mat = new BABYLON.StandardMaterial('measure-label-mat', scene);+  mat.diffuseTexture = dt;+  mat.diffuseTexture.hasAlpha = true;+  mat.useAlphaFromDiffuseTexture = true;+  mat.emissiveColor = new BABYLON.Color3(1, 1, 1);+  mat.disableLighting = true;+  mat.backFaceCulling = false;+  // Label MUST render on top of all geometry so the user can read the+  // measurement even when the line midpoint is inside / behind the part.+  // depthFunction = ALWAYS makes the depth test always pass, and a higher+  // renderingGroupId puts it after the main scene draw.+  mat.disableDepthWrite = true;+  mat.depthFunction = BABYLON.Engine.ALWAYS;+  plane.material = mat;+  plane.renderingGroupId = 3;+  plane.alwaysSelectAsActiveMesh = true;+  plane.isPickable = false;+  return plane;+}++function drawMeasureLine() {+  if (measureLine) { measureLine.dispose(); measureLine = null; }+  if (measureLabel) { measureLabel.dispose(); measureLabel = null; }+  const ep = computeMeasureEndpoints(measurePicks[0], measurePicks[1]);+  const distance_m = BABYLON.Vector3.Distance(ep.a, ep.b);+  const ext = sceneExtentMeters();+  // Dimension line: brand blue-light (#64ABFF) so it reads as "measurement"+  // rather than "selection" (which is teal). Slightly thinner than+  // committed-edge tubes to keep the visual hierarchy clean.+  measureLine = BABYLON.MeshBuilder.CreateTube('measure-line', {+    path: [ep.a, ep.b], radius: ext * 0.0035, updatable: false,+  }, scene);+  const mat = new BABYLON.StandardMaterial('measure-line-mat', scene);+  mat.disableLighting = true;+  mat.emissiveColor = BABYLON.Color3.FromHexString('#64abff');+  measureLine.material = mat;+  measureLine.renderingGroupId = 3;+  measureLine.isPickable = false;++  // 3D billboard label at midpoint with kind-context + angle subtitle.+  const mid = ep.a.add(ep.b).scale(0.5);+  const distance_mm = distance_m * M_TO_MM;+  const mils = distance_mm / 0.0254;+  const mainText = (Math.round(distance_mm * 1000) / 1000).toFixed(3) + ' mm';+  const milsText = (Math.round(mils * 10) / 10).toFixed(1) + ' mils';+  const k = (s) => s.charAt(0).toUpperCase() + s.slice(1);+  const angle = computeMeasureAngle(measurePicks[0], measurePicks[1]);+  const angleText = angle != null ? (Math.round(angle * 10) / 10).toFixed(1) + '°' : null;+  // Subtitle order: angle (if any) · kind ↔ kind · mils+  const parts = [];+  if (angleText) parts.push(angleText);+  parts.push(k(measurePicks[0].kind) + ' ↔ ' + k(measurePicks[1].kind));+  parts.push(milsText);+  const subText = parts.join('  ·  ');+  measureLabel = makeDimensionLabel(mainText, subText, mid);++  return { endpointA: ep.a, endpointB: ep.b, distance_mm };+}++function renderMeasureHud() {+  const body = document.getElementById('measureBody');+  if (!measurePicks.length) {+    body.innerHTML = '<div class="mh-hint">Click two points on the model.</div>';+    return;+  }+  let html = '';+  for (let i = 0; i < measurePicks.length; i++) {+    const p = measurePicks[i];+    html += '<div class="mh-row"><span class="lbl">Sel ' + (i + 1) + '</span><span class="val">' + p.kind + ' on ' + (p.mesh && p.mesh.name ? p.mesh.name : '?') + '</span></div>';+    html += '<div class="mh-row"><span class="lbl">Position</span><span class="val">' + fmt(p.point.x) + ', ' + fmt(p.point.y) + ', ' + fmt(p.point.z) + '</span></div>';+  }+  if (measurePicks.length === 2) {+    // Use the same dimension endpoints as the 3D line, so HUD numbers+    // match the visible measurement. Edge-edge gives the SHORTEST+    // distance; vertex-edge gives perpendicular; etc.+    const ep = computeMeasureEndpoints(measurePicks[0], measurePicks[1]);+    const dx = Math.abs(ep.b.x - ep.a.x);+    const dy = Math.abs(ep.b.y - ep.a.y);+    const dz = Math.abs(ep.b.z - ep.a.z);+    const d  = Math.sqrt(dx * dx + dy * dy + dz * dz);+    html += '<div class="mh-sep"></div>';+    html += '<div class="mh-row"><span class="lbl">ΔX</span><span class="val">' + dualUnits(dx) + '</span></div>';+    html += '<div class="mh-row"><span class="lbl">ΔY</span><span class="val">' + dualUnits(dy) + '</span></div>';+    html += '<div class="mh-row"><span class="lbl">ΔZ</span><span class="val">' + dualUnits(dz) + '</span></div>';+    html += '<div class="mh-sep"></div>';+    html += '<div class="mh-row"><span class="lbl">Distance</span><span class="val mh-distance">' + dualUnits(d) + '</span></div>';+    const ang = computeMeasureAngle(measurePicks[0], measurePicks[1]);+    if (ang != null) {+      html += '<div class="mh-row"><span class="lbl">Angle</span><span class="val">' + (Math.round(ang * 100) / 100).toFixed(2) + '°</span></div>';+    }+  }+  body.innerHTML = html;+}++// Babylon scene works in meters (glTF / OCCT convention). All UI labels+// are in mm + mils — convert at the display boundary, never propagate+// meters into measure/inspect output.+const M_TO_MM = 1000;+function fmt(meters) { return (Math.round(meters * M_TO_MM * 1000) / 1000).toFixed(3); }+function dualUnits(meters) {+  const mm = meters * M_TO_MM;+  const mils = mm / 0.0254;+  return '<span>' + (Math.round(mm * 1000) / 1000).toFixed(3) + ' mm</span> <span class="mh-secondary">/ ' + (Math.round(mils * 10) / 10).toFixed(1) + ' mils</span>';+}++// =====================================================================+// 11. Inspect tool — hover the model, show an amber card with details.+// =====================================================================++let inspectActive = true;     // hover-inspect is always on by default+let inspectPinned = false;+let lastHoverEntry = null;++// Find the SECOND-LEVEL (depth-1) ancestor of an entry. Used by hover-+// highlight so we light up the meaningful sub-part the cursor is over,+// not the entire chip (which would be too coarse) and not the random+// material/triangle leaf the picker landed on (which would be too fine).+// Per user feedback May 2026: top-level highlight feels like the whole+// thing lights up; second-level matches what they want to inspect (Body+// vs Pin 17 vs Contacts vs MODULE_NAUO2 etc.).+function secondLevelOf(entry) {+  if (!entry) return null;+  if (entry.depth <= 1) return entry;+  let cur = entry;+  while (cur && cur.depth > 1) {+    cur = cur.parentId == null ? null : nodeById.get(cur.parentId);+  }+  return cur || entry;+}++let lastHoverTopLevel = null;++function setHoverHighlight(entry) {+  // Clear previous overlay+  if (lastHoverTopLevel && lastHoverTopLevel !== entry) {+    for (const m of lastHoverTopLevel.allMeshes) {+      if (m && m.renderOverlay) m.renderOverlay = false;+    }+  }+  lastHoverTopLevel = entry;+  if (!entry) return;+  // mesh.renderOverlay + overlayColor/overlayAlpha gives a gentle tint+  // that you can still see geometry through. Brand blue-light at α 0.3+  // — visibly highlights the part without obliterating it like+  // HighlightLayer does.+  for (const m of entry.allMeshes) {+    if (!m) continue;+    m.overlayColor = BABYLON.Color3.FromHexString('#64abff');+    m.overlayAlpha = 0.3;+    m.renderOverlay = true;+  }+}++function findInOutline(entry) {+  if (!entry) return false;+  // Expand every ancestor so the row is visible.+  let cur = entry;+  while (cur) {+    cur.expanded = true;+    cur = cur.parentId == null ? null : nodeById.get(cur.parentId);+  }+  renderTree();+  // Scroll the row into view + flash it.+  const row = document.querySelector('.tree-row[data-id="' + entry.id + '"]');+  if (!row) return false;+  row.scrollIntoView({ block: 'center', behavior: 'smooth' });+  row.classList.remove('flash');+  // Force reflow so the animation restarts even if the same row is re-found.+  void row.offsetWidth;+  row.classList.add('flash');+  setTimeout(() => row.classList.remove('flash'), 1400);+  return true;+}++function setupInspectTool() {+  // POINTERMOVE: gentle hover-highlight of the top-level object the cursor+  // is over. NO popup card (intrusive, gets in the way). User can still+  // open the inspect card explicitly via right-click → Properties.+  scene.onPointerObservable.add((pi) => {+    if (!inspectActive || measureActive) return;+    if (pi.type !== BABYLON.PointerEventTypes.POINTERMOVE) return;+    if (inspectPinned) return;+    const pick = scene.pick(scene.pointerX, scene.pointerY, isPickableMesh);+    if (!pick || !pick.hit) {+      setHoverHighlight(null);+      return;+    }+    const entry = entryForMesh(pick.pickedMesh);+    if (!entry) { setHoverHighlight(null); return; }+    // Highlight the DEEPEST matching entry (per user feedback May 2026):+    // hovering a pin in 3D should light up just that pin, not the whole+    // Pins group. entryForMesh already returns the deepest match.+    if (entry !== lastHoverTopLevel) setHoverHighlight(entry);+  });+  // Left-click: post selection to server (CLI inspect support).+  scene.onPointerObservable.add((pi) => {+    if (!inspectActive || measureActive) return;+    if (pi.type !== BABYLON.PointerEventTypes.POINTERTAP) return;+    if (pi.event.button !== 0) return;+    const pick = scene.pick(scene.pointerX, scene.pointerY, isPickableMesh);+    if (pick && pick.hit) {+      const entry = entryForMesh(pick.pickedMesh);+      if (entry) {+        fetch('api/selection', {+          method: 'POST',+          headers: { 'Content-Type': 'application/json' },+          body: JSON.stringify({ id: entry.id, name: entry.name, depth: entry.depth }),+        }).catch(() => {});+      }+    }+  });++  // Right-click on the 3D canvas: open the same context menu the sidebar+  // rows use (Reveal in Outline / Isolate / Hide Others / Show / Hide /+  // Zoom To / Copy Name / Properties). Mirrors Fusion 360 — right-click+  // on a body in the canvas gives the same actions as right-click on its+  // browser row. All visibility actions are Ctrl+Z-undoable; "Hide" also+  // flashes the row in the sidebar so the user can find their way back.+  const renderContainer = document.getElementById('renderContainer');+  if (renderContainer) {+    renderContainer.addEventListener('contextmenu', (e) => {+      e.preventDefault();   // suppress browser menu+      const pick = scene.pick(scene.pointerX, scene.pointerY, isPickableMesh);+      if (!pick || !pick.hit) {+        showToast('Nothing under the cursor', 'err');+        return;+      }+      const entry = entryForMesh(pick.pickedMesh);+      if (!entry) {+        showToast('Picked mesh has no outline entry', 'err');+        return;+      }+      openContextMenu(e.clientX, e.clientY, entry);+    });+  }+}++function entryForMesh(mesh) {+  // Find the MOST SPECIFIC (deepest) entry whose mesh-set contains this+  // mesh. The previous version returned whichever entry came first in+  // nodeById insertion order — that was always the depth-0 root, so+  // `secondLevelOf` had nothing to walk up to. Now we prefer the deepest+  // match so secondLevelOf can correctly demote to depth-1.+  let best = null;+  for (const e of nodeById.values()) {+    if (!e.allMeshes && !e.directMeshes) continue;+    const has = (e.allMeshes && e.allMeshes.includes(mesh)) ||+                (e.directMeshes && e.directMeshes.includes(mesh));+    if (has && (!best || e.depth > best.depth)) best = e;+  }+  return best;+}++function hideInspectCard() {+  if (inspectPinned) return;+  document.getElementById('inspectCard').classList.remove('open');+}++function showInspectCard(entry, x, y, hoveredMesh) {+  const card = document.getElementById('inspectCard');+  card.classList.add('open');+  document.getElementById('icName').textContent = entry.name;+  document.getElementById('icPath').textContent = pathOf(entry);+  document.getElementById('icBody').innerHTML = inspectBodyHtml(entry, hoveredMesh);+  moveInspectCard(x, y);+}++function pinInspectCard(entry) {+  inspectPinned = true;+  const card = document.getElementById('inspectCard');+  card.classList.add('open', 'pinned');+  document.getElementById('icName').textContent = entry.name;+  document.getElementById('icPath').textContent = pathOf(entry);+  document.getElementById('icBody').innerHTML = inspectBodyHtml(entry, null);+  // Default position: top-right of canvas, just below the section/measure HUD+  // line. Avoid leaving the card wherever the last hover put it (which can+  // be off-screen if pin was triggered from CLI without a mouse position).+  const r = document.getElementById('renderContainer').getBoundingClientRect();+  card.style.left = Math.round(r.right - 280) + 'px';+  card.style.top = Math.round(r.top + 12) + 'px';+}++function unpinInspectCard() {+  inspectPinned = false;+  document.getElementById('inspectCard').classList.remove('pinned', 'open');+}++function moveInspectCard(x, y) {+  const card = document.getElementById('inspectCard');+  const offsetX = 16, offsetY = 16;+  let left = x + offsetX, top = y + offsetY;+  const w = card.offsetWidth, h = card.offsetHeight;+  const W = window.innerWidth, H = window.innerHeight;+  if (left + w > W - 8) left = x - w - offsetX;+  if (top + h > H - 8) top = y - h - offsetY;+  card.style.left = left + 'px';+  card.style.top = top + 'px';+}++function pathOf(entry) {+  const parts = [];+  let cur = entry;+  while (cur) {+    parts.unshift(cur.name);+    cur = cur.parentId == null ? null : nodeById.get(cur.parentId);+  }+  return parts.join(' › ');+}++function inspectBodyHtml(entry, hoveredMesh) {+  const bb = combinedBoundingBox(entry.allMeshes);+  let html = '';+  if (bb) {+    const w = bb.max.x - bb.min.x, d = bb.max.y - bb.min.y, h = bb.max.z - bb.min.z;+    html += '<span class="lbl">Size</span><span class="val">' + dualUnits(w) + ' × ' + fmt(d) + ' × ' + fmt(h) + ' mm</span>';+  }+  let mesh = hoveredMesh;+  if (!mesh && entry.allMeshes.length) mesh = entry.allMeshes[0];+  if (mesh && mesh.material) {+    const matName = mesh.material.name || '?';+    const found = materialsCache.find(m => m.name === matName);+    let swatch = '';+    if (found && found.baseColor && found.baseColor.length >= 3) {+      const [r, g, b] = found.baseColor;+      const css = 'rgb(' + Math.round(r * 255) + ',' + Math.round(g * 255) + ',' + Math.round(b * 255) + ')';+      swatch = '<span class="swatch" style="background:' + css + '"></span>';+    } else if (mesh.material.albedoColor) {+      const c = mesh.material.albedoColor;+      const css = 'rgb(' + Math.round(c.r * 255) + ',' + Math.round(c.g * 255) + ',' + Math.round(c.b * 255) + ')';+      swatch = '<span class="swatch" style="background:' + css + '"></span>';+    } else if (mesh.material.diffuseColor) {+      const c = mesh.material.diffuseColor;+      const css = 'rgb(' + Math.round(c.r * 255) + ',' + Math.round(c.g * 255) + ',' + Math.round(c.b * 255) + ')';+      swatch = '<span class="swatch" style="background:' + css + '"></span>';+    }+    html += '<span class="lbl">Material</span><span class="val">' + swatch + matName + '</span>';+  }+  let totalVerts = 0, totalFaces = 0;+  for (const m of entry.allMeshes) {+    if (m.getTotalVertices) totalVerts += m.getTotalVertices() || 0;+    if (m.getIndices) {+      const idx = m.getIndices();+      if (idx) totalFaces += idx.length / 3;+    }+  }+  html += '<span class="lbl">Geometry</span><span class="val">' + Math.round(totalVerts) + ' verts · ' + Math.round(totalFaces) + ' faces</span>';+  html += '<span class="lbl">Children</span><span class="val">' + entry.children.length + '</span>';+  return html;+}++// Section feature removed (May 2026) — see comment in HTML for context.++// =====================================================================+// 13. Keyboard+// =====================================================================++function popLastMeasurePick() {+  if (!measurePicks.length) return false;+  const last = measurePicks.pop();+  if (last.markers) for (const m of last.markers) m.dispose();+  else if (last.marker) last.marker.dispose();+  if (measureLine) { measureLine.dispose(); measureLine = null; }+  if (measureLabel) { measureLabel.dispose(); measureLabel = null; }+  clearMeasurePreview();+  renderMeasureHud();+  return true;+}++function setupKeyboard() {+  window.addEventListener('keydown', (e) => {+    const inField = e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA');+    if (inField) return;+    if (e.key === 'Escape') {+      // Inspect-pinned: unpin first.+      if (inspectPinned) { unpinInspectCard(); return; }+      // Measure: progressive backout — pop the most recent pick first, then+      // the earlier one, then close the HUD on the third Esc. Matches the+      // adom-tsci behavior the user expects.+      if (measureActive) {+        if (measurePicks.length > 0) { popLastMeasurePick(); return; }+        toggleMeasure();+        return;+      }+      // (Section tool removed.)+    }+    if (e.key === 'm' || e.key === 'M') { toggleMeasure(); }+    // 'S' shortcut retired with the Section tool.+    if (e.key === 'h' || e.key === 'H') { viewer.goHome(); }+    if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); undoVisibility(); }+    if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) { e.preventDefault(); redoVisibility(); }+  });+}++// =====================================================================+// 14. Eval pipeline + window.adomStep API+// =====================================================================++window.adomStep = {+  listComponents() {+    return assemblyTree.map(e => ({+      id: e.id, name: e.name, depth: e.depth, parentId: e.parentId,+      visible: e.visible, childCount: e.children.length, meshCount: e.allMeshes.length,+    }));+  },+  isolate(name, opts) {+    // CLI / AI default re-frames on the isolated subtree — without it, an+    // AI screenshot after isolate often shows the model off-camera. Pass+    // { frame: false } to opt out (sidebar icon does this).+    const e = findEntry(name);+    if (!e) return { error: 'no node named ' + name };+    const o = Object.assign({ frame: true }, opts || {});+    pushVisibilityHistory(() => isolateEntry(e, o));+    return { ok: true, name: e.name };+  },+  show(name) {+    const e = findEntry(name);+    if (!e) return { error: 'no node named ' + name };+    pushVisibilityHistory(() => setSubtreeVisibility(e, true));+    return { ok: true };+  },+  hide(name) {+    const e = findEntry(name);+    if (!e) return { error: 'no node named ' + name };+    pushVisibilityHistory(() => setSubtreeVisibility(e, false));+    return { ok: true };+  },+  showAll() { pushVisibilityHistory(() => showAllEntries()); return { ok: true }; },+  frame(name) {+    if (!name) { viewer.goHome(); return { ok: true }; }+    const e = findEntry(name);+    if (!e) return { error: 'no node named ' + name };+    frameOnEntry(e);+    return { ok: true };+  },+  /**+   * Bounding box of a named entry (or the whole model if `name` is omitted).+   * Returns world-space coords + size in scene units AND in mm. Service+   * step2glb authors GLBs in metres (1 unit = 1 m), so the mm conversion+   * is just ×1000. Call without args to get the full-model bbox; pass+   * a node name like "Body" or "VL53L8 MODULE ASSEMBLY" to scope.+   *+   * Used by chipsmith Tab 2 (Measurements) to compare detected body+   * dims against the manifest's declared body_dims_mm.+   */+  /**+   * Direct Babylon scene access. Returns the active Scene reference+   * so consumers (chipsmith) can author overlay meshes via+   * BABYLON.MeshBuilder. Pair with addOverlay / removeOverlay /+   * setOverlayVisible for tracked groups so cleanup on chip-reload+   * is automatic.+   */+  getScene() {+    return viewer.getScene();+  },+  /**+   * Babylon namespace shortcut for consumers that don't want to walk+   * window.Adom3DViewerBabylon9.BABYLON every time.+   */+  getBabylon() {+    return window.Adom3DViewerBabylon9 && window.Adom3DViewerBabylon9.BABYLON;+  },+  // Overlay-group registry: chipsmith adds tagged mesh groups (footprint+  // pads, faux PCB, heatsink vias, solder-jet domes) and toggles them+  // visible/hidden as a unit. The viewer holds the registry so+  // chip-reload (clearScene) can dispose every group automatically.+  //+  // Per gallia/skills/3d-viewer-design §14 — every overlay group is+  // ALSO a first-class Outline entry: it gets a synthetic assemblyTree+  // row (id range < -10000 so it never collides with GLB-derived+  // negative ids like the material-cluster groups), eye toggle,+  // click-to-isolate, search-filter visibility — same affordances as a+  // regular GLB node.+  _overlayGroups: {},+  _overlayNextId: -10001,+  /**+   * Author an overlay layer with optional per-feature child rows.+   *+   *   addOverlay({+   *     group:   'fp-kicad',+   *     label:   'KiCad footprint (18 pads)',+   *     kind:    'overlay-footprint',+   *     parent:  'overlays-root',                  // optional outline parent+   *     children: [                                 // optional per-feature rows+   *       { meshes: [pad1Mesh], label: 'fp pad 1 (A1)  rect 0.30×0.45  c=(1.95,2.45)', kind: 'overlay-pad' },+   *       { meshes: [pad2Mesh], label: 'fp pad 2  rect 0.30×0.45  c=(1.45,2.45)', kind: 'overlay-pad' },+   *       ...+   *     ],+   *     // Or the legacy single-bag shape:+   *     meshes: [...],                              // bag of meshes attached to the parent row+   *   });+   *+   * When `children` is present, each child becomes its own outline row+   * with its own meshes, eye toggle, click-to-isolate, etc. The parent+   * row's eye toggles ALL children at once (group toggle per+   * gallia/skills/3d-viewer-design §14).+   *+   * Legacy 2-arg form addOverlay(group, meshes) still works.+   */+  addOverlay(groupOrOpts, maybeMeshes) {+    let group, meshes, label, parent, kind, children, tooltip;+    if (typeof groupOrOpts === 'string') {+      group = groupOrOpts;+      meshes = Array.isArray(maybeMeshes) ? maybeMeshes : [maybeMeshes];+      label = group;+      kind = 'overlay';+      children = null;+      tooltip = null;+    } else {+      ({ group, meshes, label, parent, kind, children, tooltip } = groupOrOpts);+      if (meshes && !Array.isArray(meshes)) meshes = [meshes];+      label = label || group;+      kind = kind || 'overlay';+    }+    const rec = this._overlayGroups[group] || { meshes: [], entryId: null, childIds: [] };+    if (!this._overlayGroups[group]) this._overlayGroups[group] = rec;++    // Compute the union mesh list for the parent row's allMeshes+    // (eye toggle on parent walks this).+    const allUnion = [];+    if (Array.isArray(meshes)) allUnion.push(...meshes);+    if (Array.isArray(children)) {+      for (const c of children) {+        if (Array.isArray(c.meshes)) allUnion.push(...c.meshes);+      }+    }+    rec.meshes.push(...allUnion);++    // First call for this group: create the parent outline entry.+    if (rec.entryId === null && groupOrOpts && typeof groupOrOpts !== 'string') {+      const id = this._overlayNextId--;+      let parentId = null;+      let depth = 0;+      if (parent) {+        const pe = findEntry(parent);+        if (pe) { parentId = pe.id; depth = pe.depth + 1; }+      }+      const parentEntry = {+        id, name: label, depth, parentId,+        visible: true, expanded: false,+        children: [],+        meshes: meshes ? meshes.slice() : [],+        allMeshes: allUnion.slice(),+        kind,+        isOverlay: true,+        overlayGroup: group,+        overlayTooltip: tooltip || null,+      };+      assemblyTree.push(parentEntry);+      nodeById.set(id, parentEntry);     // ← REQUIRED for collapse-chain traversal+      rec.entryId = id;++      // Per-child rows.+      if (Array.isArray(children) && children.length) {+        for (const c of children) {+          const cid = this._overlayNextId--;+          const childMeshes = Array.isArray(c.meshes) ? c.meshes : (c.meshes ? [c.meshes] : []);+          const childEntry = {+            id: cid,+            name: c.label || (label + ' child'),+            depth: depth + 1,+            parentId: id,+            visible: true, expanded: false,+            children: [],+            meshes: childMeshes,+            allMeshes: childMeshes.slice(),+            kind: c.kind || (kind + '-child'),+            isOverlay: true,+            overlayGroup: group,+            overlayChildIndex: rec.childIds.length,+          };+          assemblyTree.push(childEntry);+          nodeById.set(cid, childEntry); // ← REQUIRED for collapse-chain traversal+          rec.childIds.push(cid);+          parentEntry.children.push(childEntry);+        }+      }+      if (typeof renderTree === 'function') renderTree();+    }+    return { ok: true, group, count: rec.meshes.length, entryId: rec.entryId, childCount: rec.childIds.length };+  },+  removeOverlay(group) {+    const rec = this._overlayGroups[group];+    if (!rec) return { ok: false, error: 'no group named ' + group };+    for (const m of rec.meshes) {+      try { m.dispose(); } catch (_) {}+    }+    // Remove parent + every child entry from assemblyTree AND nodeById.+    const removeIds = new Set([rec.entryId, ...(rec.childIds || [])].filter(x => x !== null && x !== undefined));+    if (removeIds.size) {+      for (let i = assemblyTree.length - 1; i >= 0; i--) {+        if (removeIds.has(assemblyTree[i].id)) assemblyTree.splice(i, 1);+      }+      for (const id of removeIds) nodeById.delete(id);+      if (typeof renderTree === 'function') renderTree();+    }+    delete this._overlayGroups[group];+    return { ok: true };+  },+  setOverlayVisible(group, visible) {+    const rec = this._overlayGroups[group];+    if (!rec) return { ok: false, error: 'no group named ' + group };+    for (const m of rec.meshes) m.setEnabled(!!visible);+    // Sync parent + child outline entries' visible state.+    if (rec.entryId !== null) {+      const e = assemblyTree.find(x => x.id === rec.entryId);+      if (e) e.visible = !!visible;+    }+    if (Array.isArray(rec.childIds)) {+      for (const cid of rec.childIds) {+        const e = assemblyTree.find(x => x.id === cid);+        if (e) e.visible = !!visible;+      }+    }+    if (typeof renderTree === 'function') renderTree();+    return { ok: true, count: rec.meshes.length };+  },+  bbox(name) {+    let meshes;+    let matchedNames = [];+    if (!name) {+      meshes = viewer.getScene().meshes.filter(m =>+        m.getTotalVertices && m.getTotalVertices() > 0 &&+        !['skyBox','shadowGround','ground','laserEtch','ViewerRoot','axisHelperWorld','axisHelperLocal'].includes(m.name) &&+        !(m.name || '').startsWith('viewCube') &&+        !(m.name || '').startsWith('__'));+    } else {+      // Union across EVERY entry whose name === query (case-insensitive).+      // VL53L8CX-style assemblies have multiple "Body" entries (one per+      // NAUO sub-product); chipsmith Tab 2 wants their union, not just+      // the first match.+      const lc = name.toLowerCase();+      const entries = assemblyTree.filter(e => (e.name || '').toLowerCase() === lc);+      if (entries.length === 0) return { error: 'no node named ' + name };+      meshes = [];+      for (const e of entries) meshes.push(...e.allMeshes);+      matchedNames = entries.map(e => e.id);+    }+    const b = combinedBoundingBox(meshes);+    if (!b) return { error: 'no meshes' };+    const sx = b.max.x - b.min.x, sy = b.max.y - b.min.y, sz = b.max.z - b.min.z;+    const SCENE_TO_MM = 1000;+    return {+      ok: true,+      name: name || '__model__',+      matchCount: matchedNames.length,+      min:  [b.min.x, b.min.y, b.min.z],+      max:  [b.max.x, b.max.y, b.max.z],+      size: [sx, sy, sz],+      mm:   [sx * SCENE_TO_MM, sy * SCENE_TO_MM, sz * SCENE_TO_MM],+      sceneToMm: SCENE_TO_MM,+    };+  },+  measure(nameA, nameB) {+    const a = findEntry(nameA), b = findEntry(nameB);+    if (!a || !b) return { error: 'no node(s) named ' + nameA + ' / ' + nameB };+    const ba = combinedBoundingBox(a.allMeshes), bb = combinedBoundingBox(b.allMeshes);+    if (!ba || !bb) return { error: 'no geometry on one of the nodes' };+    if (!measureActive) toggleMeasure();+    clearMeasure();+    measurePicks.push({+      point: ba.center.clone(), mesh: a.allMeshes[0], kind: 'body',+      markers: makeMeasureMarker({ kind: 'body', point: ba.center.clone(), mesh: a.allMeshes[0] }, 1),+    });+    measurePicks.push({+      point: bb.center.clone(), mesh: b.allMeshes[0], kind: 'body',+      markers: makeMeasureMarker({ kind: 'body', point: bb.center.clone(), mesh: b.allMeshes[0] }, 2),+    });+    drawMeasureLine();+    renderMeasureHud();+    const dx = Math.abs(bb.center.x - ba.center.x) * M_TO_MM;+    const dy = Math.abs(bb.center.y - ba.center.y) * M_TO_MM;+    const dz = Math.abs(bb.center.z - ba.center.z) * M_TO_MM;+    const d = Math.sqrt(dx * dx + dy * dy + dz * dz);+    return { ok: true, from: a.name, to: b.name, distance_mm: d, dx, dy, dz };+  },+  inspect(name) {+    const e = findEntry(name);+    if (!e) return { error: 'no node named ' + name };+    pinInspectCard(e);+    const bb = combinedBoundingBox(e.allMeshes);+    return {+      name: e.name, depth: e.depth, path: pathOf(e),+      visible: e.visible, childCount: e.children.length, meshCount: e.allMeshes.length,+      size_mm: bb ? { x: (bb.max.x - bb.min.x) * M_TO_MM, y: (bb.max.y - bb.min.y) * M_TO_MM, z: (bb.max.z - bb.min.z) * M_TO_MM } : null,+    };+  },+  async screenshot(opts) {+    const eng = viewer.getEngine();+    const cam = viewer.getCamera();+    const w = (opts && opts.width) || 1600;+    const h = (opts && opts.height) || 1000;+    return new Promise((resolve, reject) => {+      try {+        BABYLON.Tools.CreateScreenshotUsingRenderTarget(eng, cam, { width: w, height: h }, (data) => resolve(data), 'image/png', 4, true);+      } catch (e) { reject(e); }+    });+  },+  setView(name, opts) { setView(name, opts || { frameWhole: true }); return { ok: true, view: name }; },+  saveView(name) {+    const cam = scene.activeCamera;+    if (!cam) return { error: 'no camera' };+    const v = { alpha: cam.alpha, beta: cam.beta, radius: cam.radius, target: { x: cam.target.x, y: cam.target.y, z: cam.target.z } };+    const all = JSON.parse(localStorage.getItem('adom-step:views') || '{}');+    all[name] = v;+    localStorage.setItem('adom-step:views', JSON.stringify(all));+    return { ok: true };+  },+  recallView(name) {+    const all = JSON.parse(localStorage.getItem('adom-step:views') || '{}');+    const v = all[name];+    if (!v) return { error: 'no saved view ' + name };+    const cam = scene.activeCamera;+    cam.alpha = v.alpha; cam.beta = v.beta; cam.radius = v.radius;+    cam.target = new BABYLON.Vector3(v.target.x, v.target.y, v.target.z);+    return { ok: true };+  },+  listViews() {+    return JSON.parse(localStorage.getItem('adom-step:views') || '{}');+  },+  // Section JS API removed (May 2026) along with the feature.++  // Pin clustering — detected from material grouping + spatial clustering.+  // Centroids are in scene units (meters); multiply by 1000 for mm.+  pinCount() { return pinClusters.length; },+  pins() {+    return pinClusters.map(c => ({+      id: c.id, side: c.side,+      centroid_mm: { x: c.centroid.x * M_TO_MM, y: c.centroid.y * M_TO_MM, z: c.centroid.z * M_TO_MM },+      meshCount: c.meshes.length,+    }));+  },+  isolatePin(id) {+    const cluster = pinClusters.find(c => c.id === id);+    if (!cluster) return { error: 'no pin ' + id + ' (have ' + pinClusters.length + ')' };+    // Use the unified isolateEntry path so the JS API matches the sidebar+    // diamond exactly (hides everything except the cluster's subtree).+    const entry = assemblyTree.find(e => e.isPinEntry && e.pinId === id);+    if (!entry) return { error: 'no entry for pin ' + id };+    pushVisibilityHistory(() => isolateEntry(entry, { frame: false }));+    return { ok: true, id, side: cluster.side, centroid_mm: { x: cluster.centroid.x * M_TO_MM, y: cluster.centroid.y * M_TO_MM, z: cluster.centroid.z * M_TO_MM } };+  },+  showAllPins() {+    pushVisibilityHistory(() => {+      for (const c of pinClusters) for (const m of c.meshes) m.isVisible = true;+    });+    return { ok: true };+  },+  // strobePins / stopStrobe removed — strobe is out of scope for a+  // STEP viewer (chip-fetcher / chiplinter own per-pin verification).++  // =================================================================+  // DEMO HELPERS — drive visible animations for screen recordings.+  //+  // Pup CDP screencast is paint-throttled by Chrome unless the canvas+  // is actively repainting. Babylon's rAF render loop CAN throttle+  // when the tab loses foreground or is otherwise idle. The demo pump+  // bypasses rAF by calling scene.render() on a setInterval at 30 Hz —+  // forces the canvas to repaint regardless of compositor state, so+  // CDP captures real frames during a recording.+  // =================================================================+  demoPumpStart(opts) {+    if (window.__demoPumpHandle) {+      try { clearInterval(window.__demoPumpHandle); } catch (_) {}+      window.__demoPumpHandle = null;+    }+    const eng = viewer.getEngine();+    const cam = scene.activeCamera;+    try {+      if (cam.inputs && cam.inputs.attached) {+        for (const t of Object.keys(cam.inputs.attached)) cam.inputs.removeByType(t);+      }+    } catch (_) {}+    // Continuous orbit + render at 30Hz. UI events (hover/ctxmenu/isolate)+    // stack on top — they don't replace the orbit. The orbit rate is+    // configurable; default 30°/sec.+    const orbitRatePerTick = ((opts && opts.orbitDegPerSec) != null ? opts.orbitDegPerSec : 30) * (Math.PI / 180) / 30;+    window.__demoPumpHandle = setInterval(() => {+      try {+        if (window.__demoOrbitOn) cam.alpha += orbitRatePerTick;+        eng.beginFrame(); scene.render(); eng.endFrame();+      } catch (_) {}+    }, 33);+    window.__demoOrbitOn = true;+    // Plus a visible CSS animation in the corner — keeps the document+    // compositor busy even if the canvas would otherwise idle.+    let dot = document.getElementById('__demo_pump_dot__');+    if (!dot) {+      dot = document.createElement('div');+      dot.id = '__demo_pump_dot__';+      dot.style.cssText = 'position:fixed;right:8px;bottom:8px;width:6px;height:6px;border-radius:50%;background:#00b8b1;z-index:99998;animation:__demo_pump_pulse__ 0.8s infinite ease-in-out;opacity:0.4;pointer-events:none;';+      const css = document.createElement('style');+      css.textContent = '@keyframes __demo_pump_pulse__ { 0%,100%{transform:scale(1);opacity:0.3} 50%{transform:scale(1.6);opacity:0.7} }';+      document.head.appendChild(css);+      document.body.appendChild(dot);+    }+    return { ok: true };+  },+  demoPumpStop() {+    if (window.__demoPumpHandle) { clearInterval(window.__demoPumpHandle); window.__demoPumpHandle = null; }+    const dot = document.getElementById('__demo_pump_dot__');+    if (dot) dot.remove();+    return { ok: true };+  },++  // Smooth camera orbit — drives alpha via setInterval(33ms) AND forces+  // scene.render() each tick. ArcRotateCamera's input handlers (keyboard,+  // mousewheel, pointers) recompute alpha from stored input state EVERY+  // render frame, overriding direct alpha writes. We must remove the+  // inputs entirely (cam.inputs.removeByType / clear), not merely+  // detachControl, because attachControl/detachControl just toggles+  // event listeners — the input objects still mutate camera state.+  async demoOrbit(opts) {+    const dur = (opts && opts.durationMs) || 5000;+    const rev = (opts && opts.revolutions) || 0.3;+    const cam = scene.activeCamera;+    const eng = viewer.getEngine();+    const canvas = eng.getRenderingCanvas ? eng.getRenderingCanvas() : document.querySelector('canvas');+    const start = cam.alpha;+    const end = start + rev * 2 * Math.PI;+    const t0 = Date.now();+    if (window.__demoOrbitHandle) { clearInterval(window.__demoOrbitHandle); window.__demoOrbitHandle = null; }+    // Save then strip all camera inputs so they can't clobber our writes.+    let savedInputs = null;+    try {+      if (cam.inputs && cam.inputs.attached) {+        savedInputs = Object.keys(cam.inputs.attached).slice();+        cam.detachControl(canvas);+        for (const t of savedInputs) cam.inputs.removeByType(t);+      }+    } catch (_) {}+    return new Promise(resolve => {+      const h = setInterval(() => {+        const t = Math.min(1, (Date.now() - t0) / dur);+        const k = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;+        cam.alpha = start + (end - start) * k;+        try { eng.beginFrame(); scene.render(); eng.endFrame(); } catch (_) {}+        if (t >= 1) {+          clearInterval(h);+          if (window.__demoOrbitHandle === h) window.__demoOrbitHandle = null;+          // Re-add inputs (use Babylon's defaults — addKeyboard / addMouseWheel+          // / addPointers are convenience methods on ArcRotateCameraInputsManager).+          try {+            if (cam.inputs && cam.inputs.addPointers) cam.inputs.addPointers();+            if (cam.inputs && cam.inputs.addMouseWheel) cam.inputs.addMouseWheel();+            if (cam.inputs && cam.inputs.addKeyboard) cam.inputs.addKeyboard();+            if (cam.attachControl) cam.attachControl(canvas, true);+          } catch (_) {}+          resolve({ ok: true, alphaEnd: cam.alpha });+        }+      }, 33);+      window.__demoOrbitHandle = h;+    });+  },++  // Generic camera tween — alpha / beta / radius / target.+  async demoTweenCamera(opts) {+    const dur = (opts && opts.durationMs) || 1500;+    const cam = scene.activeCamera;+    const startAlpha = cam.alpha, endAlpha = (opts && opts.alpha != null) ? opts.alpha : startAlpha;+    const startBeta  = cam.beta,  endBeta  = (opts && opts.beta  != null) ? opts.beta  : startBeta;+    const startRad   = cam.radius,endRad   = (opts && opts.radius!= null) ? opts.radius: startRad;+    const t0 = Date.now();+    return new Promise(resolve => {+      const h = setInterval(() => {+        const t = Math.min(1, (Date.now() - t0) / dur);+        const k = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;+        cam.alpha  = startAlpha + (endAlpha - startAlpha) * k;+        cam.beta   = startBeta  + (endBeta  - startBeta)  * k;+        cam.radius = startRad   + (endRad   - startRad)   * k;+        if (t >= 1) { clearInterval(h); resolve({ ok: true }); }+      }, 33);+    });+  },++  // Visibly walk a path through the outline: expand all ancestors,+  // scroll the row into view, flash it. (Same `findInOutline` used by+  // the canvas right-click → Reveal action — exposed here so a CLI+  // can drive a guided tour through specific entries.)+  demoExpandPath(opts) {+    const e = findEntry(opts && opts.name);+    if (!e) return { error: 'no entry ' + (opts && opts.name) };+    if (typeof findInOutline === 'function') findInOutline(e);+    return { ok: true, name: e.name };+  },++  // Apply the same hover overlay (translucent teal) that mouse-hover+  // produces. Useful for "ahem, look at this row" demo beats.+  demoHover(opts) {+    const e = findEntry(opts && opts.name);+    if (!e) return { error: 'no entry ' + (opts && opts.name) };+    if (typeof setHoverHighlight === 'function') setHoverHighlight(e);+    return { ok: true, name: e.name };+  },+  demoUnhover() { if (typeof setHoverHighlight === 'function') setHoverHighlight(null); return { ok: true }; },++  // Open the canvas-style right-click context menu visibly + optionally+  // click one of its actions. This drives the same code path as a real+  // mouse right-click, including the Hide → row-flash behavior.+  demoCtxMenu(opts) {+    const e = findEntry(opts && opts.name);+    if (!e) return { error: 'no entry ' + (opts && opts.name) };+    const x = (opts && opts.x) || 720;+    const y = (opts && opts.y) || 360;+    if (typeof openContextMenu !== 'function') return { error: 'no openContextMenu' };+    openContextMenu(x, y, e);+    if (opts && opts.action) {+      const m = document.getElementById('ctxMenu');+      const item = m && m.querySelector('[data-act="' + opts.action + '"]');+      if (item) {+        // Defer the click so the menu is visible for a beat.+        setTimeout(() => item.click(), (opts && opts.delayMs) || 600);+      }+    }+    return { ok: true, name: e.name, action: (opts && opts.action) || null };+  },++  // Frame the camera tightly on a detected pin cluster. Useful to+  // showcase pin detection on QFN / LGA / through-hole.+  async demoFramePin(opts) {+    const id = (opts && opts.id) || 1;+    const entry = assemblyTree.find(x => x.isPinEntry && x.pinId === id);+    if (!entry) return { error: 'no pin entry id=' + id };+    frameOnEntry(entry);+    return { ok: true, id, name: entry.name };+  },++  // Source-label readback so a demo driver can verify the right STEP+  // is loaded BEFORE pressing record. Returns the topbar text + mesh+  // count — both authoritative for "is the WAGO loaded yet".+  demoState() {+    const tb = document.getElementById('topSource');+    const eng = viewer && viewer.getEngine && viewer.getEngine();+    // Babylon 9 inspector mounts at #babylon-inspector-container (not the+    // older Babylon 8 #topPart). Check for the new mount point.+    const inspectorMount = document.getElementById('babylon-inspector-container');+    return {+      sourceLabel: tb ? tb.textContent.trim().slice(0, 200) : null,+      meshCount: scene && scene.meshes ? scene.meshes.length : 0,+      pinCount: pinClusters ? pinClusters.length : 0,+      treeRootName: assemblyTree.find(e => e.depth === 0) ? assemblyTree.find(e => e.depth === 0).name : null,+      fps: eng && eng.getFps ? Math.round(eng.getFps()) : null,+      inspectorOpen: !!(scene && scene.debugLayer && scene.debugLayer.isVisible && scene.debugLayer.isVisible()),+      inspectorMounted: !!inspectorMount && inspectorMount.children.length > 0,+    };+  },++  // measurePick — synthesize a real Babylon PickingInfo on a named entry's+  // mesh and route it through the same addMeasurePick handler the mouse+  // click fires. Drives the demo measure-tool clip without faking the HUD.+  //+  // Args: { entryName: 'Pin 1', faceFraction: 0.5 }+  // - entryName: the components-outline entry to pick on (matches findEntry).+  // - faceFraction: 0..1, which triangle in the mesh's face buffer to pick.+  //   0.5 = middle triangle; vary to pick distinct faces on the same mesh.+  //+  // Returns: { ok, kind, point, faceId, mesh } or { error }+  measurePick(opts) {+    if (!measureActive) toggleMeasure();+    const e = findEntry(opts && opts.entryName);+    if (!e) return { error: 'no entry ' + (opts && opts.entryName) };+    const mesh = (e.allMeshes || []).find(m => m && m.getTotalIndices && m.getTotalIndices() > 0);+    if (!mesh) return { error: 'entry has no pickable mesh' };+    const indices = mesh.getIndices();+    const positions = mesh.getVerticesData(BABYLON.VertexBuffer.PositionKind);+    if (!indices || !positions) return { error: 'mesh has no triangle data' };+    const triCount = indices.length / 3;+    const ff = (opts && opts.faceFraction != null) ? opts.faceFraction : 0.5;+    const faceId = Math.max(0, Math.min(triCount - 1, Math.round(ff * (triCount - 1))));+    const i0 = indices[faceId * 3], i1 = indices[faceId * 3 + 1], i2 = indices[faceId * 3 + 2];+    const v0 = new BABYLON.Vector3(positions[i0 * 3], positions[i0 * 3 + 1], positions[i0 * 3 + 2]);+    const v1 = new BABYLON.Vector3(positions[i1 * 3], positions[i1 * 3 + 1], positions[i1 * 3 + 2]);+    const v2 = new BABYLON.Vector3(positions[i2 * 3], positions[i2 * 3 + 1], positions[i2 * 3 + 2]);+    // Centroid of the picked triangle in world space.+    const localCentroid = v0.add(v1).add(v2).scale(1 / 3);+    const worldMatrix = mesh.computeWorldMatrix(true);+    const worldPoint = BABYLON.Vector3.TransformCoordinates(localCentroid, worldMatrix);+    // Build a PickingInfo with the same fields scene.pick() returns.+    const pi = new BABYLON.PickingInfo();+    pi.hit = true;+    pi.pickedPoint = worldPoint;+    pi.pickedMesh = mesh;+    pi.faceId = faceId;+    pi.distance = BABYLON.Vector3.Distance(scene.activeCamera.position, worldPoint);+    // Route through the same addMeasurePick handler the mouse click fires.+    addMeasurePick(pi);+    return {+      ok: true,+      entryName: e.name, meshName: mesh.name,+      faceId,+      point_mm: { x: worldPoint.x * M_TO_MM, y: worldPoint.y * M_TO_MM, z: worldPoint.z * M_TO_MM },+      pickIndex: measurePicks.length,+    };+  },++  // measureClear — drop both picks and reset the HUD without toggling+  // measure mode off. Useful between staged demo beats.+  measureClear() {+    if (typeof clearMeasure === 'function') clearMeasure();+    return { ok: true };+  },+};++function findEntry(name) {+  if (typeof name === 'number') return nodeById.get(name);+  if (!name) return null;+  // Exact match first.+  let e = assemblyTree.find(e => e.name === name);+  if (e) return e;+  // Case-insensitive fallback.+  const lower = name.toLowerCase();+  return assemblyTree.find(e => e.name.toLowerCase() === lower);+}++function setupEvalPolling() {+  async function tick() {+    try {+      const r = await fetch('eval/pending');+      const body = await r.json();+      const job = body.job;+      if (job) {+        let result = null;+        try {+          const fn = new (Object.getPrototypeOf(async function () {}).constructor)(job.code);+          result = await fn();+        } catch (e) {+          result = { error: e.message || String(e) };+        }+        await fetch('eval/' + job.id + '/result', {+          method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(result),+        });+      }+    } catch (e) { /* server may be transiently down */ }+    setTimeout(tick, 200);+  }+  setTimeout(tick, 250);+}++function setupCommandPolling() {+  // Reserved for HTTP commands posted to /api/command. Kept lightweight —+  // most action verbs go through /eval rather than the command bus.+  async function tick() {+    try {+      const r = await fetch('api/poll-command');+      const body = await r.json();+      // No-op handler for now; expanded as needed.+      void body;+    } catch (e) {}+    setTimeout(tick, 1000);+  }+  setTimeout(tick, 1000);+}++// =====================================================================+// Boot+// =====================================================================++boot().catch((e) => {+  console.error('boot failed:', e);+  setSplash('Boot failed — see console: ' + e.message);+});+</script>+</body>+</html>
vendor/adom-step/src/cli/console.rsadded+84
@@ -0,0 +1,84 @@+use crate::cli::{err, hint, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;+use colored::Colorize;+use std::time::Duration;++#[derive(Parser)]+pub struct Args {+    /// Follow mode — keep polling and print new messages.+    #[arg(short, long)]+    pub follow: bool,++    #[arg(long, default_value_t = 0)]+    pub since: u64,++    #[arg(long, default_value_t = 50)]+    pub tail: usize,++    /// Comma-separated levels: log,warn,error.+    #[arg(long)]+    pub level: Option<String>,++    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+    let level_filter: Option<Vec<String>> = args+        .level+        .as_deref()+        .map(|s| s.split(',').map(|x| x.trim().to_lowercase()).collect());+    let mut since = args.since;+    let mut first = true;+    loop {+        let url = format!("http://127.0.0.1:{}/console?since={}", args.port, since);+        let resp = match ureq::get(&url).timeout(Duration::from_secs(2)).call() {+            Ok(r) => r.into_json::<serde_json::Value>()?,+            Err(e) => {+                err(format!("could not reach adom-step: {e}"));+                hint("start it first with `adom-step view <file.step>`");+                return Err(anyhow::anyhow!("unreachable"));+            }+        };+        let next = resp+            .get("next_since")+            .and_then(|v| v.as_u64())+            .unwrap_or(since);+        let empty = vec![];+        let msgs = resp+            .get("messages")+            .and_then(|v| v.as_array())+            .unwrap_or(&empty);+        let slice: Vec<&serde_json::Value> = if first && msgs.len() > args.tail {+            msgs.iter().skip(msgs.len() - args.tail).collect()+        } else {+            msgs.iter().collect()+        };+        for m in slice {+            let lvl = m.get("level").and_then(|v| v.as_str()).unwrap_or("log");+            if let Some(filter) = &level_filter {+                if !filter.contains(&lvl.to_string()) {+                    continue;+                }+            }+            let seq = m.get("seq").and_then(|v| v.as_u64()).unwrap_or(0);+            let ts = m.get("ts_ms").and_then(|v| v.as_u64()).unwrap_or(0);+            let text = m.get("text").and_then(|v| v.as_str()).unwrap_or("");+            let ts_s = format!("{}.{:03}", ts / 1000, ts % 1000);+            let tag = match lvl {+                "error" => "ERR".red().bold().to_string(),+                "warn" => "WRN".yellow().bold().to_string(),+                _ => "LOG".cyan().bold().to_string(),+            };+            println!("[{:>6}] {} {} {}", seq, ts_s.dimmed(), tag, text);+        }+        since = next;+        first = false;+        if !args.follow {+            break;+        }+        std::thread::sleep(Duration::from_millis(400));+    }+    Ok(())+}
vendor/adom-step/src/cli/eval.rsadded+67
@@ -0,0 +1,67 @@+use crate::cli::{err, hint, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;+use std::time::{Duration, Instant};++#[derive(Parser)]+pub struct Args {+    /// JS snippet to run inside the adom-step webview. Wrap `await` freely;+    /// the UI invokes via AsyncFunction so top-level await works.+    pub code: String,++    /// How long to wait for the UI to return a result.+    #[arg(long, default_value_t = 5)]+    pub timeout_secs: u64,++    #[arg(long)]+    pub quiet: bool,++    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+    eval_snippet(&args.code, args.timeout_secs, args.port, args.quiet).map(|v| {+        println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());+    })+}++/// Reusable helper for other CLI subcommands that need to drive the UI via JS.+pub fn eval_snippet(code: &str, timeout_secs: u64, port: u16, quiet: bool) -> Result<serde_json::Value> {+    let base = format!("http://127.0.0.1:{}", port);+    let queue: serde_json::Value = match ureq::post(&format!("{base}/eval"))+        .timeout(Duration::from_secs(2))+        .send_json(serde_json::json!({"code": code}))+    {+        Ok(r) => r.into_json()?,+        Err(e) => {+            err(format!("could not reach adom-step: {e}"));+            hint("start it first with `adom-step view <file.step>`");+            return Err(anyhow::anyhow!("unreachable"));+        }+    };+    let id = queue+        .get("id")+        .and_then(|v| v.as_str())+        .ok_or_else(|| anyhow::anyhow!("server returned no id: {queue}"))?+        .to_string();+    if !quiet {+        eprintln!("queued {id}");+    }+    let deadline = Instant::now() + Duration::from_secs(timeout_secs);+    loop {+        if Instant::now() >= deadline {+            err(format!("timeout waiting for result of {id}"));+            hint("is the webview open and loaded?");+            return Err(anyhow::anyhow!("timeout"));+        }+        let r: serde_json::Value = ureq::get(&format!("{base}/eval/{id}"))+            .timeout(Duration::from_secs(2))+            .call()?+            .into_json()?;+        if r.get("done").and_then(|v| v.as_bool()).unwrap_or(false) {+            return Ok(r.get("result").cloned().unwrap_or(serde_json::Value::Null));+        }+        std::thread::sleep(Duration::from_millis(150));+    }+}
vendor/adom-step/src/cli/frame.rsadded+30
@@ -0,0 +1,30 @@+use crate::cli::{eval, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;++#[derive(Parser)]+pub struct Args {+    /// Optional node name to frame on. Whole model when omitted.+    pub name: Option<String>,+    #[arg(long, default_value_t = 5)]+    pub timeout_secs: u64,+    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+    let arg = match &args.name {+        Some(n) => serde_json::to_string(n).unwrap(),+        None => "null".to_string(),+    };+    let code = format!(+        "return window.adomStep ? window.adomStep.frame({}) : {{ error: 'adomStep not loaded' }};",+        arg+    );+    eval::eval_snippet(&code, args.timeout_secs, args.port, true)?;+    match args.name {+        Some(n) => ok(format!("framed {}", n)),+        None => ok("framed whole model"),+    }+    Ok(())+}
vendor/adom-step/src/cli/health.rsadded+26
@@ -0,0 +1,26 @@+use crate::cli::{err, hint, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;+use std::time::Duration;++#[derive(Parser)]+pub struct Args {+    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+    let url = format!("http://127.0.0.1:{}/health", args.port);+    match ureq::get(&url).timeout(Duration::from_secs(2)).call() {+        Ok(resp) => {+            let body: serde_json::Value = resp.into_json()?;+            ok(format!("healthy on port {} — {}", args.port, body));+            Ok(())+        }+        Err(e) => {+            err(format!("not running on port {}: {e}", args.port));+            hint("start it with `adom-step view <file.step>`");+            Err(anyhow::anyhow!("not running"))+        }+    }+}
vendor/adom-step/src/cli/hide.rsadded+22
@@ -0,0 +1,22 @@+use crate::cli::{eval, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;++#[derive(Parser)]+pub struct Args {+    pub name: String,+    #[arg(long, default_value_t = 5)]+    pub timeout_secs: u64,+    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+    let code = format!(+        "return window.adomStep ? window.adomStep.hide({}) : {{ error: 'adomStep not loaded' }};",+        serde_json::to_string(&args.name).unwrap()+    );+    eval::eval_snippet(&code, args.timeout_secs, args.port, true)?;+    ok(format!("hide {}", args.name));+    Ok(())+}
vendor/adom-step/src/cli/install.rsadded+27
@@ -0,0 +1,27 @@+use crate::cli::{err, ok};+use anyhow::Result;+use clap::Parser;++#[derive(Parser)]+pub struct Args {+    /// Skip copying the binary; only drop SKILL.md and completions.+    #[arg(long)]+    pub skill_only: bool,+}++pub fn run(_args: Args) -> Result<()> {+    let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());+    let skill_dir = format!("{home}/.claude/skills/adom-step");+    if let Err(e) = std::fs::create_dir_all(&skill_dir) {+        err(format!("create {skill_dir}: {e}"));+        return Err(e.into());+    }++    // Bundle: SKILL.md is included via include_str! so users can paste-install.+    let skill_md = include_str!("../../SKILL.md");+    let skill_path = format!("{skill_dir}/SKILL.md");+    std::fs::write(&skill_path, skill_md)?;+    ok(format!("wrote {skill_path}"));++    Ok(())+}
vendor/adom-step/src/cli/isolate.rsadded+25
@@ -0,0 +1,25 @@+use crate::cli::{eval, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;++#[derive(Parser)]+pub struct Args {+    /// Friendly name of the node to isolate.+    pub name: String,++    #[arg(long, default_value_t = 5)]+    pub timeout_secs: u64,++    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+    let code = format!(+        "return window.adomStep ? window.adomStep.isolate({}) : {{ error: 'adomStep not loaded' }};",+        serde_json::to_string(&args.name).unwrap()+    );+    let result = eval::eval_snippet(&code, args.timeout_secs, args.port, true)?;+    ok(format!("isolated {}: {result}", args.name));+    Ok(())+}
vendor/adom-step/src/cli/list_components.rsadded+42
@@ -0,0 +1,42 @@+use crate::cli::{eval, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;++#[derive(Parser)]+pub struct Args {+    /// Print as JSON instead of indented tree.+    #[arg(long)]+    pub json: bool,++    #[arg(long, default_value_t = 5)]+    pub timeout_secs: u64,++    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+    let code = "return window.adomStep ? window.adomStep.listComponents() : { error: 'adomStep not loaded yet' };";+    let result = eval::eval_snippet(code, args.timeout_secs, args.port, true)?;++    if args.json {+        println!("{}", serde_json::to_string_pretty(&result)?);+        return Ok(());+    }++    if let Some(err) = result.get("error").and_then(|v| v.as_str()) {+        eprintln!("ERROR: {err}");+        return Err(anyhow::anyhow!("{err}"));+    }+    let empty = vec![];+    let nodes = result.as_array().unwrap_or(&empty);+    for n in nodes {+        let depth = n.get("depth").and_then(|v| v.as_u64()).unwrap_or(0) as usize;+        let name = n.get("name").and_then(|v| v.as_str()).unwrap_or("?");+        let visible = n.get("visible").and_then(|v| v.as_bool()).unwrap_or(true);+        let mark = if visible { "●" } else { "○" };+        let pad = "  ".repeat(depth);+        println!("{}{} {}", pad, mark, name);+    }+    Ok(())+}
vendor/adom-step/src/cli/measure.rsadded+38
@@ -0,0 +1,38 @@+use crate::cli::{eval, err, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;++#[derive(Parser)]+pub struct Args {+    /// First node name.+    pub from: String,+    /// Second node name.+    pub to: String,+    #[arg(long, default_value_t = 5)]+    pub timeout_secs: u64,+    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+    let code = format!(+        "return window.adomStep ? window.adomStep.measure({}, {}) : {{ error: 'adomStep not loaded' }};",+        serde_json::to_string(&args.from).unwrap(),+        serde_json::to_string(&args.to).unwrap()+    );+    let result = eval::eval_snippet(&code, args.timeout_secs, args.port, true)?;+    if let Some(e) = result.get("error").and_then(|v| v.as_str()) {+        err(format!("measure failed: {e}"));+        return Err(anyhow::anyhow!("{e}"));+    }+    let d = result+        .get("distance_mm")+        .and_then(|v| v.as_f64())+        .unwrap_or(0.0);+    ok(format!(+        "measured {} → {} = {:.3} mm",+        args.from, args.to, d+    ));+    println!("{}", serde_json::to_string_pretty(&result)?);+    Ok(())+}
vendor/adom-step/src/cli/mod.rsadded+45
@@ -0,0 +1,45 @@+pub mod console;+pub mod eval;+pub mod frame;+pub mod health;+pub mod hide;+pub mod install;+pub mod isolate;+pub mod list_components;+pub mod measure;+pub mod open;+pub mod pins;+pub mod screenshot;+pub mod show;+pub mod show_all;+pub mod status;+pub mod stop;+pub mod view;+pub mod view_cube;+pub mod view_library;++use colored::Colorize;++pub fn ok(msg: impl AsRef<str>) {+    println!("{} {}", "OK:".green().bold(), msg.as_ref());+}++pub fn err(msg: impl AsRef<str>) {+    eprintln!("{} {}", "ERROR:".red().bold(), msg.as_ref());+}++pub fn hint(msg: impl AsRef<str>) {+    eprintln!("{} {}", "Hint:".yellow().bold(), msg.as_ref());+}++pub const DEFAULT_PORT: u16 = 8851;++/// Service-step2glb base URL. Override at runtime with `STEP2GLB_SERVICE_API`.+pub const DEFAULT_STEP2GLB_URL: &str = "https://step2glb-gmdoncpxdwx0.adom.cloud";++pub fn step2glb_service_url() -> String {+    std::env::var("STEP2GLB_SERVICE_API")+        .unwrap_or_else(|_| DEFAULT_STEP2GLB_URL.to_string())+        .trim_end_matches('/')+        .to_string()+}
vendor/adom-step/src/cli/open.rsadded+100
@@ -0,0 +1,100 @@+use crate::cli::{err, hint, ok, DEFAULT_PORT};+use anyhow::{anyhow, Result};+use clap::Parser;+use std::process::Command;++const WEBVIEW_PANEL_TYPE: &str = "adom/a1b2c3d4-0031-4000-a000-000000000031";++#[derive(Parser)]+pub struct Args {+    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,++    #[arg(long, default_value = "STEP Viewer")]+    pub tab_name: String,++    #[arg(long, default_value = "mdi:cube-outline")]+    pub display_icon: String,+}++pub fn run(args: Args) -> Result<()> {+    open_webview_tab(args.port, &args.tab_name, &args.display_icon)+}++pub fn open_webview_tab(port: u16, tab_name: &str, display_icon: &str) -> Result<()> {+    let proxy_uri = std::env::var("VSCODE_PROXY_URI").ok();+    let base_url = match &proxy_uri {+        Some(t) => t.replace("{{port}}", &port.to_string()),+        None => format!("http://127.0.0.1:{}/", port),+    };+    let sep = if base_url.contains('?') { '&' } else { '?' };+    let cb = std::time::SystemTime::now()+        .duration_since(std::time::UNIX_EPOCH)+        .map(|d| d.as_millis())+        .unwrap_or(0);+    let webview_url = format!("{}{}_cb={}", base_url, sep, cb);++    let leaf_id = match find_leaf_pane_id() {+        Ok(id) => id,+        Err(e) => {+            err(format!("{e}"));+            hint("Open any web view tab first (or split a new pane), then re-run.");+            return Err(e);+        }+    };+    let initial_state = format!(r#"{{"url":"{}"}}"#, webview_url);++    let status = Command::new("adom-cli")+        .args([+            "hydrogen",+            "workspace",+            "add-tab",+            "--panel-id",+            &leaf_id,+            "--panel-type",+            WEBVIEW_PANEL_TYPE,+            "--display-name",+            tab_name,+            "--display-icon",+            display_icon,+            "--initial-state",+            &initial_state,+        ])+        .status()?;+    if !status.success() {+        return Err(anyhow!(+            "adom-cli workspace add-tab failed (is a tab already named '{}'? pass --tab-name to override)",+            tab_name+        ));+    }+    ok(format!("webview tab '{tab_name}' created"));+    Ok(())+}++/// Find a Web View pane to host the tab. Per memory `feedback_never_cover_vscode`:+/// only place tabs in panes that already host a Web View tab.+fn find_leaf_pane_id() -> Result<String> {+    let output = Command::new("adom-cli")+        .args(["hydrogen", "workspace", "tabs"])+        .output()?;+    let text = String::from_utf8_lossy(&output.stdout);+    let v: serde_json::Value = serde_json::from_str(&text)?;+    let tabs = v+        .get("tabs")+        .and_then(|t| t.as_array())+        .ok_or_else(|| anyhow!("adom-cli workspace tabs returned no tabs array"))?;+    for tab in tabs {+        let ptype = tab+            .get("panelType")+            .and_then(|p| p.as_str())+            .unwrap_or("");+        if ptype == WEBVIEW_PANEL_TYPE {+            if let Some(pid) = tab.get("panelId").and_then(|p| p.as_str()) {+                return Ok(pid.to_string());+            }+        }+    }+    Err(anyhow!(+        "no existing Web View pane found. Open any web view tab first or split a new pane."+    ))+}
vendor/adom-step/src/cli/pins.rsadded+60
@@ -0,0 +1,60 @@+use crate::cli::{eval, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::{Parser, Subcommand};++#[derive(Parser)]+pub struct Args {+    #[command(subcommand)]+    pub action: Action,++    #[arg(long, default_value_t = DEFAULT_PORT, global = true)]+    pub port: u16,+}++#[derive(Subcommand)]+pub enum Action {+    /// Print the detected pin count + per-pin side classification.+    Count,+    /// Print all pin clusters with centroids (mm) — feeds footprint-matching tools.+    List,+    /// Isolate a single pin by 1-based index. Use `pins list` to see available IDs.+    Isolate {+        /// 1-based pin index.+        id: u32,+    },+    /// Restore visibility on every pin.+    ShowAll,+}++pub fn run(args: Args) -> Result<()> {+    match args.action {+        Action::Count => {+            let r = eval::eval_snippet(+                "return window.adomStep.pinCount();",+                5,+                args.port,+                true,+            )?;+            ok(format!("{} pins", r));+        }+        Action::List => {+            let r = eval::eval_snippet(+                "return window.adomStep.pins();",+                5,+                args.port,+                true,+            )?;+            println!("{}", serde_json::to_string_pretty(&r)?);+        }+        Action::Isolate { id } => {+            let code = format!("return window.adomStep.isolatePin({});", id);+            let r = eval::eval_snippet(&code, 5, args.port, true)?;+            ok(format!("isolated pin {id}: {r}"));+        }+        Action::ShowAll => {+            eval::eval_snippet("return window.adomStep.showAllPins();", 5, args.port, true)?;+            ok("showed all pins");+        }+    }+    Ok(())+}
vendor/adom-step/src/cli/screenshot.rsadded+89
@@ -0,0 +1,89 @@+use crate::cli::{eval, err, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;+use std::path::PathBuf;++#[derive(Parser)]+pub struct Args {+    /// Output PNG path. Defaults to /tmp/adom-step-shot.png.+    #[arg(long)]+    pub out: Option<PathBuf>,++    #[arg(long, default_value_t = 1600)]+    pub width: u32,++    #[arg(long, default_value_t = 1000)]+    pub height: u32,++    #[arg(long, default_value_t = 10)]+    pub timeout_secs: u64,++    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+    let out = args+        .out+        .unwrap_or_else(|| PathBuf::from("/tmp/adom-step-shot.png"));++    let code = format!(+        "return window.adomStep ? await window.adomStep.screenshot({{width: {}, height: {}}}) : {{ error: 'adomStep not loaded' }};",+        args.width, args.height+    );+    let result = eval::eval_snippet(&code, args.timeout_secs, args.port, true)?;+    if let Some(e) = result.get("error").and_then(|v| v.as_str()) {+        err(format!("screenshot failed: {e}"));+        return Err(anyhow::anyhow!("{e}"));+    }+    let data_url = result.as_str().unwrap_or("");+    let prefix = "data:image/png;base64,";+    if !data_url.starts_with(prefix) {+        err(format!("unexpected screenshot payload: {data_url}"));+        return Err(anyhow::anyhow!("bad payload"));+    }+    let b64 = &data_url[prefix.len()..];+    let bytes = base64_decode(b64)?;+    std::fs::write(&out, &bytes)?;+    ok(format!(+        "wrote {} ({} KB)",+        out.display(),+        bytes.len() / 1024+    ));+    Ok(())+}++/// Lean base64 decoder so we don't pull in the `base64` crate for one use.+fn base64_decode(s: &str) -> Result<Vec<u8>> {+    let s: String = s.chars().filter(|c| !c.is_whitespace()).collect();+    let map = |c: char| -> Result<u32> {+        Ok(match c {+            'A'..='Z' => c as u32 - 'A' as u32,+            'a'..='z' => c as u32 - 'a' as u32 + 26,+            '0'..='9' => c as u32 - '0' as u32 + 52,+            '+' => 62,+            '/' => 63,+            '=' => 0,+            _ => return Err(anyhow::anyhow!("bad base64 char: {c}")),+        })+    };+    let bytes = s.as_bytes();+    let mut out = Vec::with_capacity(bytes.len() * 3 / 4);+    let mut i = 0;+    while i + 3 < bytes.len() {+        let a = map(bytes[i] as char)?;+        let b = map(bytes[i + 1] as char)?;+        let c = map(bytes[i + 2] as char)?;+        let d = map(bytes[i + 3] as char)?;+        let v = (a << 18) | (b << 12) | (c << 6) | d;+        out.push((v >> 16) as u8);+        if bytes[i + 2] != b'=' {+            out.push(((v >> 8) & 0xFF) as u8);+        }+        if bytes[i + 3] != b'=' {+            out.push((v & 0xFF) as u8);+        }+        i += 4;+    }+    Ok(out)+}
vendor/adom-step/src/cli/show.rsadded+22
@@ -0,0 +1,22 @@+use crate::cli::{eval, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;++#[derive(Parser)]+pub struct Args {+    pub name: String,+    #[arg(long, default_value_t = 5)]+    pub timeout_secs: u64,+    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+    let code = format!(+        "return window.adomStep ? window.adomStep.show({}) : {{ error: 'adomStep not loaded' }};",+        serde_json::to_string(&args.name).unwrap()+    );+    eval::eval_snippet(&code, args.timeout_secs, args.port, true)?;+    ok(format!("show {}", args.name));+    Ok(())+}
vendor/adom-step/src/cli/show_all.rsadded+18
@@ -0,0 +1,18 @@+use crate::cli::{eval, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;++#[derive(Parser)]+pub struct Args {+    #[arg(long, default_value_t = 5)]+    pub timeout_secs: u64,+    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+    let code = "return window.adomStep ? window.adomStep.showAll() : { error: 'adomStep not loaded' };";+    eval::eval_snippet(code, args.timeout_secs, args.port, true)?;+    ok("showed all components");+    Ok(())+}
vendor/adom-step/src/cli/status.rsadded+25
@@ -0,0 +1,25 @@+use crate::cli::{err, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;+use std::time::Duration;++#[derive(Parser)]+pub struct Args {+    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+    let url = format!("http://127.0.0.1:{}/state", args.port);+    match ureq::get(&url).timeout(Duration::from_secs(2)).call() {+        Ok(r) => {+            let body: serde_json::Value = r.into_json()?;+            ok(serde_json::to_string_pretty(&body).unwrap_or_default());+            Ok(())+        }+        Err(e) => {+            err(format!("not running on port {}: {e}", args.port));+            Err(anyhow::anyhow!("not running"))+        }+    }+}
vendor/adom-step/src/cli/stop.rsadded+19
@@ -0,0 +1,19 @@+use crate::cli::{ok, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;+use std::time::Duration;++#[derive(Parser)]+pub struct Args {+    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+    let url = format!("http://127.0.0.1:{}/shutdown", args.port);+    let _ = ureq::post(&url)+        .timeout(Duration::from_secs(2))+        .send_string("");+    ok(format!("sent shutdown to port {}", args.port));+    Ok(())+}
vendor/adom-step/src/cli/view.rsadded+116
@@ -0,0 +1,116 @@+use crate::cli::{err, hint, ok, DEFAULT_PORT};+use crate::server::{self, convert, ServerConfig, ServerState};+use anyhow::{anyhow, Result};+use clap::Parser;+use std::path::PathBuf;++#[derive(Parser)]+pub struct Args {+    /// Path to a .step or .stp file.+    pub file: PathBuf,++    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,++    /// Skip opening the Hydrogen webview tab (for headless test runs).+    #[arg(long)]+    pub no_open: bool,++    #[arg(long, default_value = "STEP Viewer")]+    pub tab_name: String,++    #[arg(long, default_value = "mdi:cube-outline")]+    pub display_icon: String,+}++pub fn run(args: Args) -> Result<()> {+    let file = args.file.canonicalize().map_err(|e| {+        anyhow!(+            "{} does not exist: {e}",+            args.file.display()+        )+    })?;++    let ext = file+        .extension()+        .and_then(|s| s.to_str())+        .map(|s| s.to_ascii_lowercase());+    if !matches!(ext.as_deref(), Some("step") | Some("stp")) {+        err(format!(+            "{} is not a .step / .stp file",+            file.display()+        ));+        hint("adom-step is STEP-only; use the canonical 3D viewer for other formats");+        return Err(anyhow!("not a STEP file"));+    }++    let bytes = std::fs::read(&file).map_err(|e| anyhow!("read {}: {e}", file.display()))?;+    ok(format!(+        "converting {} ({} KB) via service-step2glb...",+        file.display(),+        bytes.len() / 1024+    ));++    let result = match convert::convert_step_bytes(&bytes) {+        Ok(r) => r,+        Err(e) => {+            err(format!("conversion failed: {e}"));+            return Err(e);+        }+    };++    let label = if result.cache_hit {+        "cache HIT"+    } else {+        "cache MISS"+    };+    ok(format!(+        "{label} — GLB at {} ({} KB, {} meshes, {} nodes, {} ms)",+        result.glb_path.display(),+        result.size_bytes / 1024,+        result.meshes,+        result.nodes,+        result.duration_ms+    ));++    let label = file+        .file_name()+        .and_then(|s| s.to_str())+        .unwrap_or("model.step")+        .to_string();++    let config = ServerConfig {+        port: args.port,+        initial_glb_path: Some(result.glb_path.clone()),+        initial_source_label: Some(label),+        initial_source_type: Some("local-file".to_string()),+    };+    let state = ServerState::new(config);++    let shutdown_state = state.clone();+    ctrlc::set_handler(move || {+        eprintln!("\nreceived SIGINT, shutting down adom-step");+        let _ = &shutdown_state;+        std::process::exit(0);+    })?;++    if !args.no_open {+        // Open in a background thread after the server is up so the+        // webview's first load resolves.+        let port = args.port;+        let tab_name = args.tab_name.clone();+        let icon = args.display_icon.clone();+        std::thread::spawn(move || {+            std::thread::sleep(std::time::Duration::from_millis(400));+            if let Err(e) =+                crate::cli::open::open_webview_tab(port, &tab_name, &icon)+            {+                eprintln!("warn: failed to open webview tab: {e}");+            }+        });+    }++    ok(format!("adom-step running on port {}", args.port));+    server::run(state)?;+    Ok(())+}
vendor/adom-step/src/cli/view_cube.rsadded+31
@@ -0,0 +1,31 @@+use crate::cli::{eval, err, ok, DEFAULT_PORT};+use anyhow::Result;+use clap::Parser;++#[derive(Parser)]+pub struct Args {+    /// View name: front | back | left | right | top | bottom | iso | home.+    pub view: String,+    #[arg(long, default_value_t = 5)]+    pub timeout_secs: u64,+    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,+}++pub fn run(args: Args) -> Result<()> {+    let v = args.view.trim().to_lowercase();+    if !matches!(+        v.as_str(),+        "front" | "back" | "left" | "right" | "top" | "bottom" | "iso" | "home"+    ) {+        err(format!("unknown view '{v}'"));+        return Err(anyhow::anyhow!("bad view"));+    }+    let code = format!(+        "return window.adomStep ? window.adomStep.setView({}) : {{ error: 'adomStep not loaded' }};",+        serde_json::to_string(&v).unwrap()+    );+    eval::eval_snippet(&code, args.timeout_secs, args.port, true)?;+    ok(format!("set view: {v}"));+    Ok(())+}
vendor/adom-step/src/cli/view_library.rsadded+90
@@ -0,0 +1,90 @@+use crate::cli::{err, ok, DEFAULT_PORT};+use crate::server::{self, convert, ServerConfig, ServerState};+use anyhow::{anyhow, Result};+use clap::Parser;++#[derive(Parser)]+pub struct Args {+    /// `<Library>/<Name>` path on service-kicad. Example: `Package_QFP/LQFP-64_10x10mm_P0.5mm`.+    pub lib_slash_name: String,++    #[arg(long, default_value_t = DEFAULT_PORT)]+    pub port: u16,++    #[arg(long)]+    pub no_open: bool,++    #[arg(long, default_value = "STEP Viewer")]+    pub tab_name: String,++    #[arg(long, default_value = "mdi:cube-outline")]+    pub display_icon: String,+}++pub fn run(args: Args) -> Result<()> {+    ok(format!(+        "fetching {} via service-step2glb...",+        args.lib_slash_name+    ));+    let result = match convert::convert_from_library(&args.lib_slash_name) {+        Ok(r) => r,+        Err(e) => {+            err(format!("conversion failed: {e}"));+            return Err(e);+        }+    };++    let label = if result.cache_hit {+        "cache HIT"+    } else {+        "cache MISS"+    };+    ok(format!(+        "{label} — GLB at {} ({} KB, {} meshes, {} nodes, {} ms)",+        result.glb_path.display(),+        result.size_bytes / 1024,+        result.meshes,+        result.nodes,+        result.duration_ms+    ));++    let config = ServerConfig {+        port: args.port,+        initial_glb_path: Some(result.glb_path.clone()),+        initial_source_label: Some(args.lib_slash_name.clone()),+        initial_source_type: Some("kicad-library".to_string()),+    };+    let state = ServerState::new(config);++    let shutdown_state = state.clone();+    ctrlc::set_handler(move || {+        eprintln!("\nreceived SIGINT, shutting down adom-step");+        let _ = &shutdown_state;+        std::process::exit(0);+    })?;++    if !args.no_open {+        let port = args.port;+        let tab_name = args.tab_name.clone();+        let icon = args.display_icon.clone();+        std::thread::spawn(move || {+            std::thread::sleep(std::time::Duration::from_millis(400));+            if let Err(e) =+                crate::cli::open::open_webview_tab(port, &tab_name, &icon)+            {+                eprintln!("warn: failed to open webview tab: {e}");+            }+        });+    }++    if !args.lib_slash_name.contains('/') {+        return Err(anyhow!(+            "expected <Library>/<Name>, got: {}",+            args.lib_slash_name+        ));+    }++    ok(format!("adom-step running on port {}", args.port));+    server::run(state)?;+    Ok(())+}
vendor/adom-step/src/main.rsadded+93
@@ -0,0 +1,93 @@+mod cli;+mod server;++use anyhow::Result;+use clap::{Parser, Subcommand};++/// adom-step — Fusion 360-style STEP file viewer.+///+/// Loads a .step / .stp file by piping it through service-step2glb, renders+/// the resulting GLB in the canonical Adom Babylon9 viewer, and adds a+/// Fusion-feel components browser, measure tool, hover/inspect HUD, and+/// section/clip planes on top.+///+/// Every UI verb has a JS API helper, an HTTP endpoint, and a CLI+/// subcommand, so an AI agent can drive the viewer headlessly.+#[derive(Parser)]+#[command(name = "adom-step", version, about, long_about = None)]+struct Cli {+    #[command(subcommand)]+    command: Command,+}++#[derive(Subcommand)]+enum Command {+    /// Open a STEP file in the Hydrogen webview tab. Converts via+    /// service-step2glb (cached on disk by content hash) then loads.+    View(cli::view::Args),+    /// Open a STEP from the service-kicad library by `<Library>/<Name>`.+    /// Example: `adom-step view-library Package_QFP/LQFP-64_10x10mm_P0.5mm`.+    ViewLibrary(cli::view_library::Args),+    /// Stop a running adom-step server on the given port.+    Stop(cli::stop::Args),+    /// Print status of the running adom-step server.+    Status(cli::status::Args),+    /// Re-attach the webview tab without restarting the server.+    Open(cli::open::Args),+    /// Health check against a running server.+    Health(cli::health::Args),+    /// Install binary to ~/.local/bin/, drop SKILL.md, install completions.+    Install(cli::install::Args),+    /// Print the discovered scene-graph component tree (one row per node).+    ListComponents(cli::list_components::Args),+    /// Hide every other top-level subtree and frame the camera on the named node.+    Isolate(cli::isolate::Args),+    /// Show the named node (and its descendants).+    Show(cli::show::Args),+    /// Hide the named node (and its descendants).+    Hide(cli::hide::Args),+    /// Show all nodes (clear visibility overrides).+    ShowAll(cli::show_all::Args),+    /// Frame the camera on a named node, or the whole model when omitted.+    Frame(cli::frame::Args),+    /// Measure the distance between two named nodes.+    /// Snaps each pick to the node's bbox center and commits as a 2-point measurement.+    Measure(cli::measure::Args),+    /// Pin-cluster operations: count | list | isolate <id> | show-all | strobe.+    /// Each "pin" is detected by clustering primitives in the Pins material group+    /// by XY centroid (eps ≈ pin pitch / 2). Useful for footprint matching.+    Pins(cli::pins::Args),+    /// Set the camera to a named view: front | back | left | right | top | bottom | iso | home.+    ViewCube(cli::view_cube::Args),+    /// Save a PNG screenshot of the current canvas.+    Screenshot(cli::screenshot::Args),+    /// Run a JavaScript snippet inside the adom-step webview and print its result.+    Eval(cli::eval::Args),+    /// Print the JavaScript console log forwarded from the adom-step webview.+    Console(cli::console::Args),+}++fn main() -> Result<()> {+    let cli = Cli::parse();+    match cli.command {+        Command::View(a) => cli::view::run(a),+        Command::ViewLibrary(a) => cli::view_library::run(a),+        Command::Stop(a) => cli::stop::run(a),+        Command::Status(a) => cli::status::run(a),+        Command::Open(a) => cli::open::run(a),+        Command::Health(a) => cli::health::run(a),+        Command::Install(a) => cli::install::run(a),+        Command::ListComponents(a) => cli::list_components::run(a),+        Command::Isolate(a) => cli::isolate::run(a),+        Command::Show(a) => cli::show::run(a),+        Command::Hide(a) => cli::hide::run(a),+        Command::ShowAll(a) => cli::show_all::run(a),+        Command::Frame(a) => cli::frame::run(a),+        Command::Measure(a) => cli::measure::run(a),+        Command::Pins(a) => cli::pins::run(a),+        Command::ViewCube(a) => cli::view_cube::run(a),+        Command::Screenshot(a) => cli::screenshot::run(a),+        Command::Eval(a) => cli::eval::run(a),+        Command::Console(a) => cli::console::run(a),+    }+}
vendor/adom-step/src/server/cache.rsadded+40
@@ -0,0 +1,40 @@+//! On-disk content-hashed cache for converted GLBs.+//! Files land at /tmp/adom-step-cache/<sha256>.glb plus a sibling+//! <sha256>.materials.json with the materials payload from the service.++use anyhow::{anyhow, Result};+use sha2::{Digest, Sha256};+use std::path::{Path, PathBuf};++pub fn cache_dir() -> PathBuf {+    let base = std::env::var("ADOM_STEP_CACHE")+        .unwrap_or_else(|_| "/tmp/adom-step-cache".to_string());+    PathBuf::from(base)+}++pub fn ensure_dir() -> Result<PathBuf> {+    let d = cache_dir();+    std::fs::create_dir_all(&d).map_err(|e| anyhow!("create cache dir {}: {e}", d.display()))?;+    Ok(d)+}++pub fn sha256_hex(bytes: &[u8]) -> String {+    let mut h = Sha256::new();+    h.update(bytes);+    hex::encode(h.finalize())+}++pub fn glb_path(hash: &str) -> PathBuf {+    cache_dir().join(format!("{hash}.glb"))+}++pub fn materials_path(hash: &str) -> PathBuf {+    cache_dir().join(format!("{hash}.materials.json"))+}++pub fn read_materials(p: &Path) -> serde_json::Value {+    std::fs::read_to_string(p)+        .ok()+        .and_then(|s| serde_json::from_str(&s).ok())+        .unwrap_or(serde_json::json!([]))+}
vendor/adom-step/src/server/convert.rsadded+176
@@ -0,0 +1,176 @@+//! Thin client to service-step2glb. Converts STEP -> GLB, caches by SHA-256.+//!+//! Mirrors the HTTP shape of step2glb's client.rs (Material structure,+//! X-Materials-Json header, X-Meshes / X-Nodes / X-Duration-Ms headers)+//! but inlines it here so adom-step has zero dependency on the step2glb crate.++use crate::cli::step2glb_service_url;+use crate::server::cache;+use anyhow::{anyhow, Result};+use std::path::PathBuf;+use std::time::Duration;++pub struct ConvertResult {+    pub glb_path: PathBuf,+    pub size_bytes: u64,+    pub meshes: u32,+    pub nodes: u32,+    pub materials: serde_json::Value,+    pub duration_ms: u64,+    pub cache_hit: bool,+}++/// Convert raw STEP bytes via the service. Cache key = SHA-256 of bytes.+/// On hit, returns instantly without hitting the network.+pub fn convert_step_bytes(step: &[u8]) -> Result<ConvertResult> {+    let dir = cache::ensure_dir()?;+    let hash = cache::sha256_hex(step);+    let glb = cache::glb_path(&hash);+    let mat_path = cache::materials_path(&hash);++    // Cache hit+    if glb.exists() {+        let size = std::fs::metadata(&glb).map(|m| m.len()).unwrap_or(0);+        let materials = cache::read_materials(&mat_path);+        return Ok(ConvertResult {+            glb_path: glb,+            size_bytes: size,+            meshes: 0, // not re-introspected on cache hit+            nodes: 0,+            materials,+            duration_ms: 0,+            cache_hit: true,+        });+    }++    // Cache miss — POST to service-step2glb /convert.+    let url = format!("{}/convert", step2glb_service_url());+    let resp = ureq::post(&url)+        .set("Content-Type", "application/step")+        .timeout(Duration::from_secs(180))+        .send_bytes(step)+        .map_err(|e| anyhow!("POST {url}: {e}"))?;++    if resp.status() != 200 {+        let body = resp.into_string().unwrap_or_default();+        return Err(anyhow!("service-step2glb returned HTTP non-200: {body}"));+    }++    let meshes = header_u32(&resp, "X-Meshes");+    let nodes = header_u32(&resp, "X-Nodes");+    let duration_ms = u64::from(header_u32(&resp, "X-Duration-Ms"));+    let mats_json_str = resp.header("X-Materials-Json").unwrap_or("[]").to_string();+    let materials: serde_json::Value =+        serde_json::from_str(&mats_json_str).unwrap_or(serde_json::json!([]));++    // Stream body to disk in cache.+    let mut tmp = dir.join(format!("{hash}.glb.tmp"));+    let mut file = std::fs::File::create(&tmp)+        .map_err(|e| anyhow!("create {}: {e}", tmp.display()))?;+    let mut reader = resp.into_reader();+    let size_bytes = std::io::copy(&mut reader, &mut file)+        .map_err(|e| anyhow!("write GLB: {e}"))?;+    drop(file);+    std::fs::rename(&tmp, &glb).map_err(|e| anyhow!("rename {}: {e}", tmp.display()))?;+    tmp.clear();++    // Write materials sidecar.+    let _ = std::fs::write(&mat_path, mats_json_str);++    Ok(ConvertResult {+        glb_path: glb,+        size_bytes,+        meshes,+        nodes,+        materials,+        duration_ms,+        cache_hit: false,+    })+}++/// Convert from a service-kicad library: GET /convert?library=L&name=N.+pub fn convert_from_library(lib_slash_name: &str) -> Result<ConvertResult> {+    let (lib, name) = lib_slash_name+        .rsplit_once('/')+        .ok_or_else(|| anyhow!("bad path {lib_slash_name:?}; expected <Library>/<Name>"))?;+    let url = format!(+        "{}/convert?library={}&name={}",+        step2glb_service_url(),+        urlencode(lib),+        urlencode(name),+    );+    // Cache key for library entries: hash of the URL path itself.+    let dir = cache::ensure_dir()?;+    let hash = cache::sha256_hex(format!("lib:{lib}/{name}").as_bytes());+    let glb = cache::glb_path(&hash);+    let mat_path = cache::materials_path(&hash);++    if glb.exists() {+        let size = std::fs::metadata(&glb).map(|m| m.len()).unwrap_or(0);+        let materials = cache::read_materials(&mat_path);+        return Ok(ConvertResult {+            glb_path: glb,+            size_bytes: size,+            meshes: 0,+            nodes: 0,+            materials,+            duration_ms: 0,+            cache_hit: true,+        });+    }++    let resp = ureq::get(&url)+        .timeout(Duration::from_secs(180))+        .call()+        .map_err(|e| anyhow!("GET {url}: {e}"))?;++    if resp.status() != 200 {+        let body = resp.into_string().unwrap_or_default();+        return Err(anyhow!(+            "service-step2glb returned HTTP non-200 for library {lib}/{name}: {body}"+        ));+    }++    let meshes = header_u32(&resp, "X-Meshes");+    let nodes = header_u32(&resp, "X-Nodes");+    let duration_ms = u64::from(header_u32(&resp, "X-Duration-Ms"));+    let mats_json_str = resp.header("X-Materials-Json").unwrap_or("[]").to_string();+    let materials: serde_json::Value =+        serde_json::from_str(&mats_json_str).unwrap_or(serde_json::json!([]));++    let tmp = dir.join(format!("{hash}.glb.tmp"));+    let mut file = std::fs::File::create(&tmp)+        .map_err(|e| anyhow!("create {}: {e}", tmp.display()))?;+    let mut reader = resp.into_reader();+    let size_bytes = std::io::copy(&mut reader, &mut file)+        .map_err(|e| anyhow!("write GLB: {e}"))?;+    drop(file);+    std::fs::rename(&tmp, &glb).map_err(|e| anyhow!("rename: {e}"))?;+    let _ = std::fs::write(&mat_path, mats_json_str);++    Ok(ConvertResult {+        glb_path: glb,+        size_bytes,+        meshes,+        nodes,+        materials,+        duration_ms,+        cache_hit: false,+    })+}++fn header_u32(r: &ureq::Response, name: &str) -> u32 {+    r.header(name).and_then(|s| s.parse().ok()).unwrap_or(0)+}++fn urlencode(s: &str) -> String {+    let mut out = String::with_capacity(s.len());+    for b in s.bytes() {+        if matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~') {+            out.push(b as char);+        } else {+            out.push_str(&format!("%{:02X}", b));+        }+    }+    out+}
vendor/adom-step/src/server/mod.rsadded+486
@@ -0,0 +1,486 @@+pub mod cache;+pub mod convert;++use anyhow::Result;+use rust_embed::RustEmbed;+use std::collections::VecDeque;+use std::path::PathBuf;+use std::sync::atomic::{AtomicU64, Ordering};+use std::sync::{Arc, Mutex};+use tiny_http::{Header, Method, Request, Response, StatusCode};++#[derive(RustEmbed)]+#[folder = "vendor/3d-viewer-dist/"]+struct ViewerAssets;++const SHELL_HTML: &[u8] = include_bytes!("../assets/shell.html");+const ADOM_CSS: &[u8] = include_bytes!("../assets/adom.css");+const ICON_SVG: &[u8] = include_bytes!("../assets/icon.svg");++pub struct ServerConfig {+    pub port: u16,+    pub initial_glb_path: Option<PathBuf>,+    pub initial_source_label: Option<String>,+    /// Provenance: "kicad-library" | "local-file" | "upload" | "unknown".+    /// Drives the UI's "From KiCad library / Local file / Upload" badge so+    /// the user always knows where the geometry came from.+    pub initial_source_type: Option<String>,+}++#[derive(Clone, Debug)]+pub struct ConsoleMessage {+    pub seq: u64,+    pub ts_ms: u64,+    pub level: String,+    pub text: String,+}++#[derive(Clone, Debug)]+pub struct EvalJob {+    pub id: String,+    pub code: String,+    pub result: Option<serde_json::Value>,+    pub done: bool,+    pub taken: bool,+}++pub struct ServerState {+    pub config: ServerConfig,+    pub current_glb_path: Mutex<Option<PathBuf>>,+    pub current_source_label: Mutex<Option<String>>,+    pub current_source_type: Mutex<Option<String>>,+    pub current_materials: Mutex<serde_json::Value>,+    /// Last components tree the UI posted to /api/components.+    pub components: Mutex<serde_json::Value>,+    /// Per-node visibility map {nodeName: bool}.+    pub component_visibility: Mutex<serde_json::Value>,+    /// Active section planes — list of {axis, offset, enabled, flipped}.+    pub sections: Mutex<serde_json::Value>,+    /// Last selection posted by the UI (for `adom-step inspect`-like queries).+    pub selection: Mutex<serde_json::Value>,+    /// Pending command bus — UI polls /api/poll-command and applies.+    pub pending_command: Mutex<Option<serde_json::Value>>,+    /// JS eval pipeline.+    pub eval_jobs: Mutex<Vec<EvalJob>>,+    /// Console ring buffer (UI -> server -> CLI tail).+    pub console: Mutex<VecDeque<ConsoleMessage>>,+    pub console_seq: AtomicU64,+}++const CONSOLE_RING_CAP: usize = 2000;++impl ServerState {+    pub fn new(config: ServerConfig) -> Arc<Self> {+        let initial_path = config.initial_glb_path.clone();+        let initial_label = config.initial_source_label.clone();+        let initial_type = config.initial_source_type.clone();+        Arc::new(Self {+            config,+            current_glb_path: Mutex::new(initial_path),+            current_source_label: Mutex::new(initial_label),+            current_source_type: Mutex::new(initial_type),+            current_materials: Mutex::new(serde_json::json!([])),+            components: Mutex::new(serde_json::json!([])),+            component_visibility: Mutex::new(serde_json::json!({})),+            sections: Mutex::new(serde_json::json!([])),+            selection: Mutex::new(serde_json::json!(null)),+            pending_command: Mutex::new(None),+            eval_jobs: Mutex::new(Vec::new()),+            console: Mutex::new(VecDeque::with_capacity(CONSOLE_RING_CAP)),+            console_seq: AtomicU64::new(0),+        })+    }++    pub fn push_console(&self, level: &str, text: &str) {+        let ts_ms = std::time::SystemTime::now()+            .duration_since(std::time::UNIX_EPOCH)+            .map(|d| d.as_millis() as u64)+            .unwrap_or(0);+        let seq = self.console_seq.fetch_add(1, Ordering::SeqCst);+        let mut buf = self.console.lock().unwrap();+        if buf.len() >= CONSOLE_RING_CAP {+            buf.pop_front();+        }+        buf.push_back(ConsoleMessage {+            seq,+            ts_ms,+            level: level.to_string(),+            text: text.to_string(),+        });+    }+}++pub fn run(state: Arc<ServerState>) -> Result<()> {+    let addr = format!("127.0.0.1:{}", state.config.port);+    let server = tiny_http::Server::http(&addr)+        .map_err(|e| anyhow::anyhow!("failed to bind {}: {}", addr, e))?;++    eprintln!("adom-step listening on http://{}", addr);++    for request in server.incoming_requests() {+        let state = state.clone();+        std::thread::spawn(move || {+            if let Err(e) = route(&state, request) {+                eprintln!("request error: {e:#}");+            }+        });+    }+    Ok(())+}++// Cloudflare-compliant anti-cache headers (per gallia/skills/adom guides).+const CACHE_CONTROL_VAL: &str = "no-store, no-cache, must-revalidate, max-age=0";+const PRAGMA_VAL: &str = "no-cache";+const SURROGATE_VAL: &str = "no-store";++fn no_cache<R: std::io::Read>(resp: Response<R>) -> Response<R> {+    resp.with_header(Header::from_bytes(&b"Cache-Control"[..], CACHE_CONTROL_VAL.as_bytes()).unwrap())+        .with_header(Header::from_bytes(&b"Pragma"[..], PRAGMA_VAL.as_bytes()).unwrap())+        .with_header(Header::from_bytes(&b"Surrogate-Control"[..], SURROGATE_VAL.as_bytes()).unwrap())+}++fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {+    let bytes = serde_json::to_vec(body).unwrap_or_default();+    let resp = Response::from_data(bytes)+        .with_status_code(StatusCode(status))+        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap());+    no_cache(resp)+}++fn text_response(status: u16, mime: &str, body: Vec<u8>) -> Response<std::io::Cursor<Vec<u8>>> {+    let resp = Response::from_data(body)+        .with_status_code(StatusCode(status))+        .with_header(Header::from_bytes(&b"Content-Type"[..], mime.as_bytes()).unwrap());+    no_cache(resp)+}++fn read_body(req: &mut Request) -> Vec<u8> {+    let mut buf = Vec::new();+    let _ = req.as_reader().read_to_end(&mut buf);+    buf+}++fn parse_json(req: &mut Request) -> Option<serde_json::Value> {+    let bytes = read_body(req);+    serde_json::from_slice(&bytes).ok()+}++fn next_eval_id(state: &ServerState) -> String {+    let seq = state.console_seq.fetch_add(1, Ordering::SeqCst);+    let ts = std::time::SystemTime::now()+        .duration_since(std::time::UNIX_EPOCH)+        .map(|d| d.as_millis() as u64)+        .unwrap_or(0);+    format!("e_{ts}_{seq}")+}++fn route(state: &Arc<ServerState>, mut request: Request) -> Result<()> {+    let url = request.url().to_string();+    // Strip query string for routing — handlers re-parse from request.url() if needed.+    let path = url.split('?').next().unwrap_or(&url).to_string();+    let method = request.method().clone();++    // ---- Health & state ---------------------------------------------------++    if path == "/health" {+        let body = serde_json::json!({+            "ok": true,+            "service": "adom-step",+            "port": state.config.port,+        });+        return Ok(request.respond(json_response(200, &body))?);+    }++    if path == "/state" {+        let body = serde_json::json!({+            "source": *state.current_source_label.lock().unwrap(),+            "source_type": *state.current_source_type.lock().unwrap(),+            "has_model": state.current_glb_path.lock().unwrap().is_some(),+            "materials": *state.current_materials.lock().unwrap(),+            "components": *state.components.lock().unwrap(),+            "visibility": *state.component_visibility.lock().unwrap(),+            "sections": *state.sections.lock().unwrap(),+        });+        return Ok(request.respond(json_response(200, &body))?);+    }++    if path == "/shutdown" && method == Method::Post {+        let _ = request.respond(json_response(200, &serde_json::json!({"ok": true})));+        std::process::exit(0);+    }++    // ---- Static shell -----------------------------------------------------++    if path == "/" || path == "/index.html" {+        return Ok(request.respond(text_response(200, "text/html; charset=utf-8", SHELL_HTML.to_vec()))?);+    }+    if path == "/adom.css" {+        return Ok(request.respond(text_response(200, "text/css; charset=utf-8", ADOM_CSS.to_vec()))?);+    }+    if path == "/favicon.svg" || path == "/favicon.ico" {+        return Ok(request.respond(text_response(200, "image/svg+xml", ICON_SVG.to_vec()))?);+    }++    // Vendored Babylon9 ESM bundle and chunks: /viewer/<filename>+    if let Some(asset) = path.strip_prefix("/viewer/") {+        if let Some(file) = ViewerAssets::get(asset) {+            let mime = if asset.ends_with(".js") {+                "application/javascript; charset=utf-8"+            } else if asset.ends_with(".css") {+                "text/css; charset=utf-8"+            } else if asset.ends_with(".env") {+                "application/octet-stream"+            } else {+                "application/octet-stream"+            };+            return Ok(request.respond(text_response(200, mime, file.data.into_owned()))?);+        }+        return Ok(request.respond(json_response(404, &serde_json::json!({"error": "not found"})))?);+    }++    // ---- Model serving ----------------------------------------------------++    if path == "/model.glb" {+        let glb_path = state.current_glb_path.lock().unwrap().clone();+        match glb_path {+            Some(p) => match std::fs::read(&p) {+                Ok(bytes) => {+                    let resp = Response::from_data(bytes)+                        .with_status_code(StatusCode(200))+                        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"model/gltf-binary"[..]).unwrap());+                    return Ok(request.respond(no_cache(resp))?);+                }+                Err(e) => {+                    return Ok(request.respond(json_response(+                        500,+                        &serde_json::json!({"error": format!("read {}: {e}", p.display())}),+                    ))?);+                }+            },+            None => {+                return Ok(request.respond(json_response(+                    404,+                    &serde_json::json!({"error": "no model loaded — POST a STEP file to /api/load or drop one onto the canvas"}),+                ))?);+            }+        }+    }++    // ---- STEP upload (drop zone + AI) ------------------------------------++    if path == "/api/load" && method == Method::Post {+        let bytes = read_body(&mut request);+        if bytes.is_empty() {+            return Ok(request.respond(json_response(+                400,+                &serde_json::json!({"error": "empty body"}),+            ))?);+        }+        // Filename hint via X-Filename header (for the source label in HUD).+        let filename = request+            .headers()+            .iter()+            .find(|h| h.field.as_str().as_str().eq_ignore_ascii_case("X-Filename"))+            .map(|h| h.value.as_str().to_string())+            .unwrap_or_else(|| "uploaded.step".to_string());++        match convert::convert_step_bytes(&bytes) {+            Ok(result) => {+                *state.current_glb_path.lock().unwrap() = Some(result.glb_path.clone());+                *state.current_source_label.lock().unwrap() = Some(filename.clone());+                *state.current_source_type.lock().unwrap() = Some("upload".to_string());+                *state.current_materials.lock().unwrap() = result.materials.clone();+                let body = serde_json::json!({+                    "ok": true,+                    "source": filename,+                    "glb_size_bytes": result.size_bytes,+                    "meshes": result.meshes,+                    "nodes": result.nodes,+                    "duration_ms": result.duration_ms,+                    "materials": result.materials,+                    "cache_hit": result.cache_hit,+                });+                return Ok(request.respond(json_response(200, &body))?);+            }+            Err(e) => {+                return Ok(request.respond(json_response(+                    500,+                    &serde_json::json!({"error": e.to_string()}),+                ))?);+            }+        }+    }++    // ---- Components tree (UI <-> server) ---------------------------------++    if path == "/api/components" && method == Method::Post {+        if let Some(v) = parse_json(&mut request) {+            *state.components.lock().unwrap() = v;+        }+        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);+    }+    if path == "/api/components" && method == Method::Get {+        let v = state.components.lock().unwrap().clone();+        return Ok(request.respond(json_response(200, &v))?);+    }++    // ---- Visibility ------------------------------------------------------++    if path == "/api/visibility" && method == Method::Post {+        if let Some(v) = parse_json(&mut request) {+            *state.component_visibility.lock().unwrap() = v;+        }+        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);+    }++    // ---- Sections --------------------------------------------------------++    if path == "/api/sections" && method == Method::Post {+        if let Some(v) = parse_json(&mut request) {+            *state.sections.lock().unwrap() = v;+        }+        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);+    }+    if path == "/api/sections" && method == Method::Get {+        let v = state.sections.lock().unwrap().clone();+        return Ok(request.respond(json_response(200, &v))?);+    }++    // ---- Selection (UI -> server, drives `inspect` CLI) ------------------++    if path == "/api/selection" && method == Method::Post {+        if let Some(v) = parse_json(&mut request) {+            *state.selection.lock().unwrap() = v;+        }+        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);+    }+    if path == "/api/selection" && method == Method::Get {+        let v = state.selection.lock().unwrap().clone();+        return Ok(request.respond(json_response(200, &v))?);+    }++    // ---- Command bus (CLI -> server -> UI poll) --------------------------++    if path == "/api/command" && method == Method::Post {+        if let Some(v) = parse_json(&mut request) {+            *state.pending_command.lock().unwrap() = Some(v);+        }+        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);+    }+    if path == "/api/poll-command" && method == Method::Get {+        let v = state.pending_command.lock().unwrap().take();+        return Ok(request.respond(json_response(200, &serde_json::json!({"command": v})))?);+    }++    // ---- JS eval pipeline ------------------------------------------------++    if path == "/eval" && method == Method::Post {+        let body: serde_json::Value = parse_json(&mut request).unwrap_or(serde_json::json!({}));+        let code = body+            .get("code")+            .and_then(|v| v.as_str())+            .unwrap_or("")+            .to_string();+        if code.is_empty() {+            return Ok(request.respond(json_response(+                400,+                &serde_json::json!({"error": "missing 'code'"}),+            ))?);+        }+        let id = next_eval_id(state);+        state.eval_jobs.lock().unwrap().push(EvalJob {+            id: id.clone(),+            code,+            result: None,+            done: false,+            taken: false,+        });+        return Ok(request.respond(json_response(200, &serde_json::json!({"id": id})))?);+    }++    if path == "/eval/pending" && method == Method::Get {+        let mut jobs = state.eval_jobs.lock().unwrap();+        let next = jobs+            .iter_mut()+            .find(|j| !j.done && !j.taken)+            .map(|j| {+                j.taken = true;+                serde_json::json!({"id": j.id, "code": j.code})+            });+        return Ok(request.respond(json_response(200, &serde_json::json!({"job": next})))?);+    }++    if path.starts_with("/eval/") && path.ends_with("/result") && method == Method::Post {+        let id = path+            .trim_start_matches("/eval/")+            .trim_end_matches("/result")+            .to_string();+        let body = parse_json(&mut request).unwrap_or(serde_json::json!(null));+        let mut jobs = state.eval_jobs.lock().unwrap();+        if let Some(job) = jobs.iter_mut().find(|j| j.id == id) {+            job.result = Some(body);+            job.done = true;+        }+        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);+    }++    if path.starts_with("/eval/")+        && !path.ends_with("/result")+        && path != "/eval/pending"+        && method == Method::Get+    {+        let id = path.trim_start_matches("/eval/").to_string();+        let jobs = state.eval_jobs.lock().unwrap();+        let body = match jobs.iter().find(|j| j.id == id) {+            Some(j) => serde_json::json!({+                "id": j.id,+                "done": j.done,+                "result": j.result.clone().unwrap_or(serde_json::Value::Null),+            }),+            None => serde_json::json!({"error": "no such id"}),+        };+        return Ok(request.respond(json_response(200, &body))?);+    }++    // ---- Console forwarder ----------------------------------------------++    if path == "/console" && method == Method::Post {+        if let Some(v) = parse_json(&mut request) {+            let level = v.get("level").and_then(|x| x.as_str()).unwrap_or("log");+            let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("");+            state.push_console(level, text);+        }+        return Ok(request.respond(json_response(200, &serde_json::json!({"ok": true})))?);+    }++    if path == "/console" && method == Method::Get {+        let qs = url.split('?').nth(1).unwrap_or("");+        let since: u64 = qs+            .split('&')+            .find_map(|p| p.strip_prefix("since=").and_then(|v| v.parse().ok()))+            .unwrap_or(0);+        let buf = state.console.lock().unwrap();+        let messages: Vec<_> = buf+            .iter()+            .filter(|m| m.seq >= since)+            .map(|m| {+                serde_json::json!({+                    "seq": m.seq,+                    "ts_ms": m.ts_ms,+                    "level": m.level,+                    "text": m.text,+                })+            })+            .collect();+        let next_since = buf.iter().last().map(|m| m.seq + 1).unwrap_or(since);+        let body = serde_json::json!({"messages": messages, "next_since": next_since});+        return Ok(request.respond(json_response(200, &body))?);+    }++    // ---- 404 -------------------------------------------------------------++    Ok(request.respond(json_response(+        404,+        &serde_json::json!({"error": format!("no route for {} {}", method, path)}),+    ))?)+}
vendor/adom-step/vendor/3d-viewer-dist/chunk--sWvO0qh.jsadded+18
@@ -0,0 +1,18 @@+import { bW as t } from "./chunk-IERganv1.js";+const e = "volumetricLightingBlendVolumePixelShader", r = `varying vUV: vec2f;var textureSamplerSampler: sampler;var textureSampler: texture_2d<f32>;var depthSampler: texture_2d<f32>;uniform invProjection: mat4x4<f32>;uniform outputTextureSize: vec2f;+#ifdef USE_EXTINCTION+uniform extinction: vec3f;+#endif+#define CUSTOM_FRAGMENT_DEFINITIONS+@fragment+fn main(input: FragmentInputs)->FragmentOutputs {fragmentOutputs.color=textureSample(textureSampler,textureSamplerSampler,input.vUV);+#ifdef USE_EXTINCTION+let depth=textureLoad(depthSampler,vec2u(fragmentInputs.position.xy),0).r;let ndc=vec4f((fragmentInputs.position.xy/uniforms.outputTextureSize)*2.-1.,depth,1.0);var viewPos=uniforms.invProjection*ndc;viewPos=viewPos/viewPos.w;let eyeDist=length(viewPos);fragmentOutputs.color2=vec4f(exp(-uniforms.extinction*eyeDist),1.0);+#endif+}+`;+t.ShadersStoreWGSL[e] || (t.ShadersStoreWGSL[e] = r);+const n = { name: e, shader: r };+export {+  n as volumetricLightingBlendVolumePixelShaderWGSL+};
vendor/adom-step/vendor/3d-viewer-dist/chunk-3sHAV2QP.jsadded+11
@@ -0,0 +1,11 @@+import { bW as t } from "./chunk-IERganv1.js";+const e = "volumetricLightingRenderVolumeVertexShader", o = `#include<sceneUboDeclaration>+#include<meshUboDeclaration>+attribute position : vec3f;varying vWorldPos: vec4f;@vertex+fn main(input : VertexInputs)->FragmentInputs {let worldPos=mesh.world*vec4f(vertexInputs.position,1.0);vertexOutputs.vWorldPos=worldPos;vertexOutputs.position=scene.viewProjection*worldPos;}+`;+t.ShadersStoreWGSL[e] || (t.ShadersStoreWGSL[e] = o);+const n = { name: e, shader: o };+export {+  n as volumetricLightingRenderVolumeVertexShaderWGSL+};
vendor/adom-step/vendor/3d-viewer-dist/chunk-B8Nx8N9J.jsadded+53
@@ -0,0 +1,53 @@+import { bW as t } from "./chunk-IERganv1.js";+const e = "selectionOutlinePixelShader", o = `uniform sampler2D maskSampler;uniform sampler2D depthSampler;varying vec2 vUV;uniform vec2 screenSize;uniform vec3 outlineColor;uniform float outlineThickness;uniform float occlusionStrength;uniform float occlusionThreshold;+#define CUSTOM_FRAGMENT_DEFINITIONS+void main(void) {+#define CUSTOM_FRAGMENT_MAIN_BEGIN+vec2 texelSize=1.0/screenSize;vec2 sampleOffset=texelSize*outlineThickness;+#if defined(OUTLINELAYER_SAMPLING_TRIDIRECTIONAL) || defined(OUTLINELAYER_SAMPLING_OCTADIRECTIONAL)+vec2 maskTopCenter=texture2D(maskSampler,vUV+vec2(0.0,sampleOffset.y)).rg;vec2 maskTopRight=texture2D(maskSampler,vUV+sampleOffset).rg;vec2 maskMiddleCenter=texture2D(maskSampler,vUV).rg;vec2 maskMiddleRight=texture2D(maskSampler,vUV+vec2(sampleOffset.x,0.0)).rg;+#endif+#if defined(OUTLINELAYER_SAMPLING_OCTADIRECTIONAL)+vec2 maskTopLeft=texture2D(maskSampler,vUV+vec2(-sampleOffset.x,sampleOffset.y)).rg;vec2 maskMiddleLeft=texture2D(maskSampler,vUV+vec2(-sampleOffset.x,0.0)).rg;vec2 maskBottomRight=texture2D(maskSampler,vUV+vec2(sampleOffset.x,-sampleOffset.y)).rg;vec2 maskBottomCenter=texture2D(maskSampler,vUV+vec2(0.0,-sampleOffset.y)).rg;vec2 maskBottomLeft=texture2D(maskSampler,vUV+vec2(-sampleOffset.x,-sampleOffset.y)).rg;+#endif+#ifdef OUTLINELAYER_SAMPLING_TRIDIRECTIONAL+vec3 gradient=vec3(+maskMiddleCenter.r-maskMiddleRight.r,+maskMiddleCenter.r-maskTopCenter.r,+maskMiddleCenter.r-maskTopRight.r+);float edgeStrength=length(gradient);+#elif defined(OUTLINELAYER_SAMPLING_OCTADIRECTIONAL)+float gradientX=+(maskTopLeft.r+2.0*maskMiddleLeft.r+maskBottomLeft.r) -+(maskTopRight.r+2.0*maskMiddleRight.r+maskBottomRight.r);float gradientY=+(maskBottomLeft.r+2.0*maskBottomCenter.r+maskBottomRight.r) -+(maskTopLeft.r+2.0*maskTopCenter.r+maskTopRight.r);float edgeStrength=length(vec2(gradientX,gradientY));+#endif+float outlineMask=step(0.5,edgeStrength); +#if defined(OUTLINELAYER_SAMPLING_TRIDIRECTIONAL) || defined(OUTLINELAYER_SAMPLING_OCTADIRECTIONAL)+float depthTopCenter=texture2D(depthSampler,vUV+vec2(0.0,sampleOffset.y)).r;float depthTopRight=texture2D(depthSampler,vUV+sampleOffset).r;float depthMiddleCenter=texture2D(depthSampler,vUV).r;float depthMiddleRight=texture2D(depthSampler,vUV+vec2(sampleOffset.x,0.0)).r;+#endif+#if defined(OUTLINELAYER_SAMPLING_OCTADIRECTIONAL)+float depthTopLeft=texture2D(depthSampler,vUV+vec2(-sampleOffset.x,sampleOffset.y)).r;float depthMiddleLeft=texture2D(depthSampler,vUV+vec2(-sampleOffset.x,0.0)).r;float depthBottomRight=texture2D(depthSampler,vUV+vec2(sampleOffset.x,-sampleOffset.y)).r;float depthBottomCenter=texture2D(depthSampler,vUV+vec2(0.0,-sampleOffset.y)).r;float depthBottomLeft=texture2D(depthSampler,vUV+vec2(-sampleOffset.x,-sampleOffset.y)).r;+#endif+#if defined(OUTLINELAYER_SAMPLING_TRIDIRECTIONAL) || defined(OUTLINELAYER_SAMPLING_OCTADIRECTIONAL)+float occlusionTopCenter=step(occlusionThreshold,abs(maskTopCenter.g-depthTopCenter));float occlusionTopRight=step(occlusionThreshold,abs(maskTopRight.g-depthTopRight));float occlusionMiddleCenter=step(occlusionThreshold,abs(maskMiddleCenter.g-depthMiddleCenter));float occlusionMiddleRight=step(occlusionThreshold,abs(maskMiddleRight.g-depthMiddleRight));+#endif+#if defined(OUTLINELAYER_SAMPLING_OCTADIRECTIONAL)+float occlusionTopLeft=step(occlusionThreshold,abs(maskTopLeft.g-depthTopLeft));float occlusionMiddleLeft=step(occlusionThreshold,abs(maskMiddleLeft.g-depthMiddleLeft));float occlusionBottomRight=step(occlusionThreshold,abs(maskBottomRight.g-depthBottomRight));float occlusionBottomCenter=step(occlusionThreshold,abs(maskBottomCenter.g-depthBottomCenter));float occlusionBottomLeft=step(occlusionThreshold,abs(maskBottomLeft.g-depthBottomLeft));+#endif+float occlusionFactor=occlusionMiddleCenter;+#ifdef OUTLINELAYER_SAMPLING_TRIDIRECTIONAL+occlusionFactor=min(occlusionFactor,occlusionTopCenter);occlusionFactor=min(occlusionFactor,occlusionTopRight);occlusionFactor=min(occlusionFactor,occlusionMiddleRight);+#elif defined(OUTLINELAYER_SAMPLING_OCTADIRECTIONAL)+occlusionFactor=min(occlusionFactor,occlusionTopCenter);occlusionFactor=min(occlusionFactor,occlusionTopRight);occlusionFactor=min(occlusionFactor,occlusionTopLeft);occlusionFactor=min(occlusionFactor,occlusionMiddleRight);occlusionFactor=min(occlusionFactor,occlusionMiddleLeft);occlusionFactor=min(occlusionFactor,occlusionBottomRight);occlusionFactor=min(occlusionFactor,occlusionBottomCenter);occlusionFactor=min(occlusionFactor,occlusionBottomLeft);+#endif+float finalOutlineMask=outlineMask*(1.0-occlusionStrength*occlusionFactor);gl_FragColor=vec4(outlineColor,finalOutlineMask);+#define CUSTOM_FRAGMENT_MAIN_END+}+`;+t.ShadersStore[e] || (t.ShadersStore[e] = o);+const i = { name: e, shader: o };+export {+  i as selectionOutlinePixelShader+};
vendor/adom-step/vendor/3d-viewer-dist/chunk-BLb1yjq-.jsadded+31
@@ -0,0 +1,31 @@+import { bX as a, bV as r, bY as p, bZ as n, R as h } from "./chunk-IERganv1.js";+class o extends a {+  /**+   * Creates a new FlowGraphPointerUpEventBlock.+   * @param config optional configuration+   */+  constructor(e) {+    super(e), this.type = "PointerUp", this.targetMesh = this.registerDataInput("targetMesh", r, e?.targetMesh), this.pointerId = this.registerDataOutput("pointerId", p), this.pickedMesh = this.registerDataOutput("pickedMesh", r), this.pickedPoint = this.registerDataOutput("pickedPoint", r);+  }+  /** @internal */+  _executeEvent(e, s) {+    const i = this.targetMesh.getValue(e), t = s.pickInfo?.pickedMesh;+    return i && !(t === i || t && n(t, i)) ? !0 : (this.pointerId.setValue(s.event.pointerId, e), this.pickedMesh.setValue(t ?? null, e), this.pickedPoint.setValue(s.pickInfo?.pickedPoint ?? null, e), this._execute(e), !this.config?.stopPropagation);+  }+  /** @internal */+  _preparePendingTasks(e) {+  }+  /** @internal */+  _cancelPendingTasks(e) {+  }+  /**+   * @returns the class name of the block.+   */+  getClassName() {+    return "FlowGraphPointerUpEventBlock";+  }+}+h("FlowGraphPointerUpEventBlock", o);+export {+  o as FlowGraphPointerUpEventBlock+};
vendor/adom-step/vendor/3d-viewer-dist/chunk-BwiAhjYz.jsadded+90
@@ -0,0 +1,90 @@+import { bW as n } from "./chunk-IERganv1.js";+const e = "textureMergerPixelShader", r = `#ifdef USE_TEXTURE0+uniform sampler2D inputTexture0;+#endif+#ifdef USE_TEXTURE1+uniform sampler2D inputTexture1;+#endif+#ifdef USE_TEXTURE2+uniform sampler2D inputTexture2;+#endif+#ifdef USE_TEXTURE3+uniform sampler2D inputTexture3;+#endif+#ifdef RED_FROM_TEXTURE+uniform int redTextureIndex;uniform int redSourceChannel;+#else+uniform float redConstantValue;+#endif+#ifdef GREEN_FROM_TEXTURE+uniform int greenTextureIndex;uniform int greenSourceChannel;+#else+uniform float greenConstantValue;+#endif+#ifdef BLUE_FROM_TEXTURE+uniform int blueTextureIndex;uniform int blueSourceChannel;+#else+uniform float blueConstantValue;+#endif+#ifdef ALPHA_FROM_TEXTURE+uniform int alphaTextureIndex;uniform int alphaSourceChannel;+#else+uniform float alphaConstantValue;+#endif+varying vec2 vUV;+#if defined(RED_FROM_TEXTURE) || defined(GREEN_FROM_TEXTURE) || defined(BLUE_FROM_TEXTURE) || defined(ALPHA_FROM_TEXTURE)+vec4 sampleTexture(int textureIndex,vec2 uv) {switch (textureIndex) {+#ifdef USE_TEXTURE0+case 0:+return texture2D(inputTexture0,uv);+#endif+#ifdef USE_TEXTURE1+case 1:+return texture2D(inputTexture1,uv);+#endif+#ifdef USE_TEXTURE2+case 2:+return texture2D(inputTexture2,uv);+#endif+#ifdef USE_TEXTURE3+case 3:+return texture2D(inputTexture3,uv);+#endif+default:+return vec4(0.0,0.0,0.0,1.0); }}+float extractChannel(vec4 color,int channelIndex) {switch (channelIndex) {case 0:+return color.r; +case 1:+return color.g; +case 2:+return color.b; +default:+return color.a; }}+#endif+void main() {vec2 uv=vUV;+#ifdef RED_FROM_TEXTURE+vec4 redSample=sampleTexture(redTextureIndex,uv);float r=extractChannel(redSample,redSourceChannel);+#else+float r=redConstantValue;+#endif+#ifdef GREEN_FROM_TEXTURE+vec4 greenSample=sampleTexture(greenTextureIndex,uv);float g=extractChannel(greenSample,greenSourceChannel);+#else+float g=greenConstantValue;+#endif+#ifdef BLUE_FROM_TEXTURE+vec4 blueSample=sampleTexture(blueTextureIndex,uv);float b=extractChannel(blueSample,blueSourceChannel);+#else+float b=blueConstantValue;+#endif+#ifdef ALPHA_FROM_TEXTURE+vec4 alphaSample=sampleTexture(alphaTextureIndex,uv);float a=extractChannel(alphaSample,alphaSourceChannel);+#else+float a=alphaConstantValue;+#endif+gl_FragColor=vec4(r,g,b,a);}`;+n.ShadersStore[e] || (n.ShadersStore[e] = r);+const a = { name: e, shader: r };+export {+  a as textureMergerPixelShader+};
vendor/adom-step/vendor/3d-viewer-dist/chunk-CA4IW-nR.jsadded+17
@@ -0,0 +1,17 @@+import { bW as o } from "./chunk-IERganv1.js";+const e = "volumetricLightingBlendVolumePixelShader", i = `varying vec2 vUV;uniform sampler2D textureSampler;uniform sampler2D depthSampler;uniform mat4 invProjection;uniform vec2 outputTextureSize;+#ifdef USE_EXTINCTION+uniform vec3 extinction;+#endif+#define CUSTOM_FRAGMENT_DEFINITIONS+void main(void) {gl_FragColor=texture2D(textureSampler,vUV);+#ifdef USE_EXTINCTION+float depth=texelFetch(depthSampler,ivec2(gl_FragCoord.xy),0).r;vec4 ndc=vec4((gl_FragCoord.xy/outputTextureSize)*2.-1.,depth*2.-1.,1.0);vec4 viewPos=invProjection*ndc;viewPos=viewPos/viewPos.w;float eyeDist=length(viewPos);gl_FragColor2=vec4(exp(-extinction*eyeDist),1.0);+#endif+}+`;+o.ShadersStore[e] || (o.ShadersStore[e] = i);+const t = { name: e, shader: i };+export {+  t as volumetricLightingBlendVolumePixelShader+};
vendor/adom-step/vendor/3d-viewer-dist/chunk-DGq4Rr9r.jsadded+52
@@ -0,0 +1,52 @@+import { bW as t } from "./chunk-IERganv1.js";+const e = "gridVertexShader", i = `attribute position: vec3f;attribute normal: vec3f;+#ifdef UV1+attribute uv: vec2f;+#endif+#ifdef UV2+attribute uv2: vec2f;+#endif+#include<instancesDeclaration>+#include<sceneUboDeclaration>+varying vPosition: vec3f;varying vNormal: vec3f;+#include<logDepthDeclaration>+#include<fogVertexDeclaration>+#ifdef OPACITY+varying vOpacityUV: vec2f;uniform opacityMatrix: mat4x4f;uniform vOpacityInfos: vec2f;+#endif+#include<clipPlaneVertexDeclaration>+#define CUSTOM_VERTEX_DEFINITIONS+@vertex+fn main(input : VertexInputs)->FragmentInputs {+#define CUSTOM_VERTEX_MAIN_BEGIN+#include<instancesVertex>+var worldPos: vec4f=finalWorld* vec4f(vertexInputs.position,1.0);+#include<fogVertex>+var cameraSpacePosition: vec4f=scene.view*worldPos;vertexOutputs.position=scene.projection*cameraSpacePosition;+#ifdef OPACITY+#ifndef UV1+var uv: vec2f= vec2f(0.,0.);+#else+var uv: vec2f=vertexInputs.uv;+#endif+#ifndef UV2+var uv2: vec2f= vec2f(0.,0.);+#else+var uv2: vec2f=vertexInputs.uv2;+#endif+if (uniforms.vOpacityInfos.x==0.)+{vertexOutputs.vOpacityUV=(uniforms.opacityMatrix* vec4f(uv,1.0,0.0)).xy;}+else+{vertexOutputs.vOpacityUV=(uniforms.opacityMatrix* vec4f(uv2,1.0,0.0)).xy;}+#endif+#include<clipPlaneVertex>+#include<logDepthVertex>+vertexOutputs.vPosition=vertexInputs.position;vertexOutputs.vNormal=vertexInputs.normal;+#define CUSTOM_VERTEX_MAIN_END+}+`;+t.ShadersStoreWGSL[e] || (t.ShadersStoreWGSL[e] = i);+const r = { name: e, shader: i };+export {+  r as gridVertexShaderWGSL+};
vendor/adom-step/vendor/build-viewer.shadded+64
@@ -0,0 +1,64 @@+#!/bin/bash+# Rebuild the canonical adom-3d-viewer-babylon9 standalone bundle and copy+# it into adom-step/vendor/3d-viewer-dist/. rust-embed bakes that directory+# into the binary at `cargo build` time.+#+# Run from anywhere; resolves paths relative to itself.+#+# The canonical viewer source is a git submodule at+# `vendor/adom-3d-viewer-babylon9/`. This script builds the submodule's+# vite bundle and mirrors the dist output into `vendor/3d-viewer-dist/`.+# Override SRC_DIR with ADOM_3D_VIEWER_SRC=/some/other/path for local dev+# where the contributor wants to point at a sibling checkout.+#+# Auto-inheritance flow (per gallia/skills/3d-viewer-design):+#   git submodule update --remote vendor/adom-3d-viewer-babylon9+#   bash vendor/build-viewer.sh+#   cargo build --release+# That's the full upstream-fix → adom-step inheritance loop.++set -euo pipefail++SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"+# Default to the git submodule; override only for ad-hoc local dev.+SRC_DIR="${ADOM_3D_VIEWER_SRC:-$SCRIPT_DIR/adom-3d-viewer-babylon9}"+DEST_DIR="$SCRIPT_DIR/3d-viewer-dist"++if [[ ! -d "$SRC_DIR" ]]; then+  echo "ERROR: canonical viewer not found at $SRC_DIR" >&2+  echo "If the submodule is missing, run:" >&2+  echo "  git submodule update --init --recursive" >&2+  echo "Or override with ADOM_3D_VIEWER_SRC=/some/path bash vendor/build-viewer.sh" >&2+  exit 1+fi+# Common pitfall: cloning without --recurse-submodules leaves the submodule+# directory present but empty. Refuse to build from that.+if [[ ! -f "$SRC_DIR/package.json" ]]; then+  echo "ERROR: $SRC_DIR exists but has no package.json — submodule not initialized?" >&2+  echo "Run:  git submodule update --init --recursive" >&2+  exit 1+fi++cd "$SRC_DIR"++if [[ ! -d node_modules ]]; then+  echo "==> npm install (first time)..."+  npm install --quiet 2>&1 | tail -5+fi++echo "==> vite build (standalone) in $SRC_DIR..."+npx vite build --config vite.standalone.config.ts 2>&1 | tail -10++if [[ ! -f "$SRC_DIR/dist-standalone/adom-3d-viewer-babylon9.esm.js" ]]; then+  echo "ERROR: build did not produce the ESM entry" >&2+  exit 1+fi++rm -rf "$DEST_DIR"+mkdir -p "$DEST_DIR"+cp -a "$SRC_DIR/dist-standalone/." "$DEST_DIR/"++SIZE=$(du -sh "$DEST_DIR" | awk '{print $1}')+COUNT=$(ls "$DEST_DIR" | wc -l)+echo "✓ Copied $COUNT files to $DEST_DIR ($SIZE)"+echo "  Re-run: cargo build --release  # to bake into binary"