← Commit history

Upload chipsmith source (MIT) — Ray can commit on top

John Lauer ·86c2debda3 ·3mo ago ·parent 8986c62
6 files changed +5176−167
SKILL.md+28−167
@@ -5,186 +5,47 @@ description: >   validator on top of adom-step. Loads a .step + footprint + datasheet PDF +   chipsmith-spec manifest, reads B-rep + assembly hierarchy directly (NOT GLB   triangle meshes), detects every electrical-contact feature (legs / pads / balls-  / tabs / EP / through-hole pins / reinforcement tabs) via Mode 0 (named scene--  graph walk) with Mode 1 (surface-type clustering) fallback, validates-  measurements against declared body_dims_mm with mm-vs-m-vs-inch auto-rescale,-  detects pin-1 chip-side AND footprint-side, layers a 1.6mm faux PCB plate,-  auto-generates 0.3mm heatsink-via grid under any EP, lays out 300µm solder-jet-  domes per the solder-jetting skill, optionally lets the user place a synthetic-  pin-1 indicator, and on sign-off bakes a `<mpn>.chipsmith.step` (canonical) +-  sidecar JSON. Every overlay layer is a first-class Outline citizen with-  eye-toggle / click-to-isolate / right-click menu. Trigger words — chipsmith,-  chip detection, validate chip step, sign off chip, footprint validation,-  pin-1 indicator, place pin1, heatsink vias, faux pcb, solder jet domes,-  manual orient step, mm vs inch step, detect pins step, chip pad detection,-  chip step bake, role tag chip, multi-source footprint cross-validation.+  / tabs / EP / through-hole pins / reinforcement tabs), validates measurements+  against declared body_dims_mm with mm-vs-m-vs-inch auto-rescale, recovers Y-up+  vs Z-up, detects pin-1 chip-side AND footprint-side, layers a 1.6mm faux PCB+  with plated PTH / NPTH / plated slot / unplated slot rendering, auto-generates+  0.3mm heatsink-via grid under any EP, optionally laser-etches the MPN onto the+  chip's top, optionally lets the user place a synthetic pin-1 indicator. On+  sign-off bakes a `<mpn>.chipsmith.step` (canonical) + shadow `.chipsmith.glb` ++  sidecar JSON. Trigger words — chipsmith, chip detection, validate chip step,+  sign off chip, footprint validation, pin-1 indicator, place pin1, heatsink+  vias, faux pcb, laser etch mpn, manual orient step, mm vs inch step,+  detect pins step, chip pad detection, chip step bake, role tag chip. ---  # adom-chipsmith — STEP-native chip validator -Higher-level validator on top of `adom-step`. Loads a-chip's STEP file alongside its footprint + datasheet + manifest, runs detection-on the STEP **B-rep** (not the GLB triangle mesh), validates measurements,-detects pin-1, layers a 1.6mm faux PCB + heatsink vias + solder-jet domes, and-on user sign-off bakes a `<mpn>.chipsmith.step` + sidecar JSON.+The higher-level validator on top of [`adom-step`](../adom-step/SKILL.md). Loads a chip's STEP file alongside its footprint + datasheet + manifest, runs detection on the STEP B-rep (not the GLB triangle mesh), validates measurements, detects pin-1, layers a 1.6mm faux PCB + heatsink vias + solder-jet domes, and on user sign-off bakes a `<mpn>.chipsmith.step` + shadow GLB + sidecar JSON. -Architecture: Tier-3 importer per [`3d-viewer-design`](../3d-viewer-design/SKILL.md)-§2 — chipsmith inherits adom-step + the canonical Babylon viewer via git-submodule chain. Upstream fixes flow → adom-step → chipsmith automatically.+See [PLAN.md](PLAN.md) for the full spec.  ## Quick start  ```bash-# Open a chip from a chip-fetcher library adom-chipsmith view-library /home/adom/project/chip-fetcher/library/VL53L8CX--# Open an arbitrary STEP + footprint + datasheet adom-chipsmith view path/to/chip.step --footprint path/to/chip.kicad_mod --spec path/to/spec.json--# Print the chip's scene-graph-adom-chipsmith list-components--# Headless detection — JSON report-adom-chipsmith detect --json-```--## Verification surface — pup, not Hydrogen webview--Visual verification of chipsmith always happens in a **pup window** (Chrome via `adom-desktop browser_*`), not a Hydrogen webview tab. The chipsmith UI is multi-pane (outline left, canvas center, 7-tab sidebar right, floating sign-off HUD top-right) and gets cramped in a half-Hydrogen pane next to VS Code; pup gives it a dedicated window. (chipfit / aci use Hydrogen — chipsmith does not.)--```bash-# Always check existing pup sessions BEFORE opening — see pup skill,-# "Cross-sessionId duplicates" section. After a conversation compaction-# a prior session's chipsmith window may still be alive under a stale-# sessionId, so spawning a new one creates a duplicate.-adom-desktop browser_status '{}'--# When opening for the first time-adom-desktop browser_open_window '{"sessionId":"chipsmith","profile":"chipsmith","url":"https://chipsmith-<slug>.adom.cloud/"}'-adom-desktop browser_focus_window '{"sessionId":"chipsmith"}'+adom-chipsmith reveal path/to/chip.chipsmith.step    # opens VS Code Explorer at the file ``` -Public proxy registration: chipsmith binds `0.0.0.0:8872` (so cloudflared can reach it through the container bridge), then `adom-cli carbon containers port-add --port 8872 --prefix chipsmith <slug>` exposes it as `chipsmith-<slug>.adom.cloud`. Loopback-only binds will get HTTP 502 from the public URL even though local curl returns 200.--## What the 8 tabs do--| Tab | What it shows | Status pill semantics |-|---|---|---|-| **Source** | Manifest (MPN / mfr / package), declared spec (pin_count / structural_type / body_dims_mm), file paths with reveal-in-VSCode buttons, datasheet via [adom-pdf-viewer](../adom-pdf-viewer/SKILL.md). Inline editor for spec fields — saves into info.json. | ● when all 3 spec fields present, ◐ partial, ○ none |-| **Measurements** | Auto-rescale negotiation across 6 candidates (×1, ÷25.4, ÷1000, ×25.4, ×1000, ÷10) — picks the scale within ±5% of declared body_dims_mm. mm + mils dual readout. | ● if ×1, ◐ if non-1 rescale, ✗ if no scale fits |-| **Detection** | Mode 0 walks the named scene graph; Mode 1 falls back to surface-type clustering on `/api/step-meta` when Mode 0 finds <70%. Click-to-isolate per feature. **Pin-reveal strobe** flashes each detected feature with a numbered HTML badge floating at the screen-projected XY. | Coverage-based: ●/◐/✗ |-| **Footprint** | Per-source breakdown across KiCad / Fusion / Altium / datasheet-derived. Cross-source agreement matrix (pad-by-pad position + size deltas). 3D toggle per source — translucent pads at z=0 in the scene. | ●/◐ based on sources present |-| **Pin-1** | Chip-side via Mode 0 detection; footprint-side via silkscreen marker scan. Place-synthetic-indicator UI (NW/NE/SW/SE buttons) when chip-side missing. | ●/◐/✗ based on agreement |-| **Sign-off** | Gate evaluator. 4 hard + 3 soft gates. CTA POSTs to `/api/signoff` → writes `<mpn>.chipsmith.json` + bakes `<mpn>.chipsmith.step`. | ● when all hard gates pass |-| **Heatsink** | EP detection + auto-generated 0.3mm via grid (1.2mm pitch, 0.4mm margin). 3D toggle renders Cu-tone plated bores + annular pads. | ● if EP found, ─ N/A otherwise |-| **Solder-jet** | 300µm dome layout per [`solder-jetting`](../solder-jetting/SKILL.md) — intra-pad ≥100µm, inter-pad ≥300µm with no-bridging pruning. 3D toggle renders half-spheres on each pad. | ● when domes generated |--## Right-pane HUD shape--The 8-tab pane is a **floating draggable HUD**, not a docked column. Drag the-tab strip to reposition; position persists in `localStorage`. `P` toggles-collapse, `1`-`8` jump to tabs. Default landing at `top:170, right:10` so the-HUD never covers the ViewCube top-right.--## Outline integration (load-bearing rule)--Every 3D overlay chipsmith adds — footprint pads (per source), 1.6mm faux PCB-plate, heatsink vias, solder-jet domes, user-placed pin-1 indicator — appears-as a **first-class entry in the left-side Outline**, with eye-toggle, click--to-isolate, right-click menu, search visibility. Per [`3d-viewer-design`](../3d-viewer-design/SKILL.md) §14.--## CLI surface+## CLI subcommands (initial set)  | Command | Purpose | |---|---|-| `view <step> [--footprint <kicad_mod>] [--spec <json>] [--datasheet <pdf>] [--no-open]` | Open one chip |-| `view-library <chip-fetcher-dir>` | Open a chip from a chip-fetcher library |-| `start --port N` / `stop --port N` / `status` / `health` | Server lifecycle |-| `list-components` | Scene-graph component tree |-| `isolate <name>` / `show <name>` / `hide <name>` / `show-all` / `frame <name>` | Visibility + camera |-| `screenshot --out <png>` | PNG of current canvas |-| `eval <js>` | Run a JS expression inside the webview, print the result |-| `console -f` | Tail JS console output from the webview |-| `reveal <path>` | Reveal a file in VS Code Explorer (delegates to adom-vscode) |-| `detect --json` | Headless detection report on a chip-fetcher dir |--## JS API (window.adomChipsmith)--The full UI is JS-shadowed for AI demo scripts + tests. From the CLI:-`adom-chipsmith eval "window.adomChipsmith.<verb>(...)"`--```-state()                         // full state snapshot-setActiveTab(id)                // 'source' | 'measurements' | ... | 'solderjet'-runDetection()                  // re-fetch + re-run Mode 0 → Mode 1 fallback-runMeasurements()               // re-run rescale negotiation-recomputeStatuses()             // refresh every status pill from current state--placePin1(corner)               // 'NW' | 'NE' | 'SW' | 'SE' — synthetic indicator-removePin1User()--setFootprintOverlay(src, on)    // 'kicad' | 'fusion' | 'altium' | 'datasheet'-setFauxPcbVisible(on)-setHeatsinkVisible(on)-setSolderJetVisible(on)--replayReveal()                  // re-runs the strobe + numbered badges-hoverAt({x,y})                  // returns same payload as the visible HUD-setHoverEnabled(on)--signoff.gates()-signoff.canSignOff()-signoff.signOff()               // POST /api/signoff → writes JSON + bakes STEP-```--## Spec schema (info.json fields chipsmith reads)--```json-{-  "mpn": "VL53L8CX",-  "manufacturer": "STMicroelectronics",-  "package": "OLGA-16 (6.4×3×1.75 mm, optical)",-  "package_family": "LGA-16",-  "pin_count": 16,-  "structural_type": "lead_with_ep",-  "body_dims_mm": [4.4, 5.4, 1.5],-  "expected_pitch_mm": 0.5,-  "datasheet_pdf": "VL53L8CX.pdf"-}-```--`pin_count`, `structural_type`, and `body_dims_mm` are required for sign-off.-The Tab 1 inline editor saves these into info.json so they persist across-reloads. `package` field's "WxHxD mm" is parsed as a fallback for body_dims.--## Sign-off artifacts--| File | Content |-|---|---|-| `<mpn>.chipsmith.json` | Full state — applied scale, detected breakdown, heatsink grid, solder-jet layout, pin-1 user-placed corner, sign-off timestamp |-| `<mpn>.chipsmith.step` | Canonical role-tagged STEP via service-step2glb's `/bake` endpoint. Future Adom tools should prefer this over the original input. |--## Adjacent skills (read before touching)--- `adom-step` — base viewer chipsmith inherits from-- [`3d-viewer-design`](../3d-viewer-design/SKILL.md) — esp. §14 (overlays first-class) and §2 (Tier-3 inheritance)-- [`human-ui-patterns`](../human-ui-patterns/SKILL.md) — esp. `playbooks/icons-and-displays.md` §4b (no emoji icons)-- `adom-pdf-viewer` — datasheet rendering-- [`solder-jetting`](../solder-jetting/SKILL.md) — 300µm dome rules-- [`electrical-engineering`](../electrical-engineering/SKILL.md) — package families, pin-1 conventions, mm + mils-- `adom-vscode` — reveal in Explorer + open-in-editor--## v1.0 ships:-8 tabs all functional, 5 overlay layers (per-source footprints / faux PCB /-heatsink vias / solder-jet / user-placed pin-1) all first-class Outline-citizens, Mode 0 + Mode 1 detection, sign-off bake to .chipsmith.step + JSON-sidecar, full AI-drivable JS surface.--## Open ends:--- Mode 2 face-merge fallback (when Mode 0 + Mode 1 both fail)-- Multi-source footprint overlays simultaneously rendered with per-pair Δ chips-- Datasheet pinout-page deep-link from Detection results-- Y-up auto-retry on detection failure (today: chosen_up_axis from info.json)-- Outline 4-section breaks (Chip / Footprint / PCB / Pre-fab)-- gallia/skills/adom-chipsmith-build/ release playbook+| `view <step> [--footprint <kicad_mod>] [--spec <json>] [--datasheet <pdf>]` | Open one chip in the validator |+| `view-library <chip-fetcher-dir>` | Open a chip from a chip-fetcher library directory |+| `list-components` | Print the discovered scene-graph component tree |+| `isolate <name>` / `show <name>` / `hide <name>` / `show-all` / `frame <name>` | Visibility + camera helpers |+| `screenshot --out <png>` | Save a PNG of the current canvas |+| `eval <js>` / `console -f` | JS bridge for AI sessions |+| `reveal <path>` | Reveal a file in the VS Code Explorer sidebar |+| `start` / `stop` / `status` / `health` / `open` | Server lifecycle |+| `install` | Drop SKILL.md + completions |++## v1 scope++The initial scaffold ships viewer + canonical Adom header + the CLI surface above. Detection (Modes 0/1/2), pin numbering, hover-inspect HUD, sign-off HUD, faux PCB rendering, heatsink vias, laser etch, user-placed pin-1, advanced manual orient/translate, and the `service-step2glb /step-meta` + `/bake` endpoints land in subsequent passes per [PLAN.md](PLAN.md).
VERSIONadded+1
@@ -0,0 +1 @@+0.2.0
src/assets/chipsmith-overlay.jsadded+5038
@@ -0,0 +1,5038 @@+// chipsmith-overlay.js — chipsmith-specific UI layered on top of the+// inherited adom-step base (vendor/adom-step/src/assets/shell.html).+//+// The shell.html itself comes verbatim from adom-step via git submodule+// — this file is everything that makes the running app "chipsmith" vs+// "adom-step": branding swap, the 8-tab right pane, detection pipeline,+// sign-off gates, multi-source footprint cross-validation, faux PCB,+// heatsink vias, solder-jet domes, pin-1 detection, and provenance+// captions. Loaded from /chipsmith-overlay.js, auto-injected before+// </body> of the inherited shell.html by chipsmith's server.+//+// Architecture per gallia/skills/3d-viewer-design §2 — chipsmith is+// Tier-3 (canonical viewer + adom-step base, inherited via submodule).+// Upstream-fix flow: edit adom-step → push → chipsmith+// `git submodule update --remote --recursive && cargo build` → fix+// inherits automatically.+//+// UI design skills (READ BEFORE TOUCHING):+//   gallia/skills/human-ui-patterns/SKILL.md+//   gallia/skills/human-ui-patterns/playbooks/icons-and-displays.md+//     §4b — NO Unicode emoji as UI icons. All chipsmith icons live in+//     the ICONS map below and ship as monochrome currentColor SVGs.+//   gallia/skills/adom-app-header/SKILL.md+//   gallia/skills/3d-viewer-design/SKILL.md+//   gallia/skills/electrical-engineering/SKILL.md+//   gallia/skills/measure-tool-design/SKILL.md+//   gallia/skills/adom-vscode/SKILL.md (reveal buttons → POST /api/reveal)+//   gallia/skills/adom-pdf-viewer/SKILL.md (datasheet viewer port 8985)+// PLAN.md "UI design compliance" section enumerates the same with the+// per-subsystem ladder.++(function chipsmithOverlay() {+  // Chipsmith app icon — custom BGA-from-below silhouette designed+  // specifically for chipsmith. Chip body has a chamfered top-left+  // corner that doubles as the pin-1 indicator (the EE-canonical+  // diagonal cut on QFN/QFP/BGA datasheet drawings), with a 3x3 grid of+  // BGA ball cut-outs inside. Cut-outs rely on fill-rule:evenodd so+  // the balls render as transparent holes punched through the teal body.+  // Built from scratch — not derived from any MDI / Lucide / external+  // icon set so chipsmith has a unique mark.+  const CHIPSMITH_ICON_PATH = [+    // Body: square 5..19 with sharp 1-radius rounded corners on three+    // corners and a 3-unit diagonal CHAMFER at the top-left = pin-1.+    'M8 5 H18 A1 1 0 0 1 19 6 V18 A1 1 0 0 1 18 19 H6 A1 1 0 0 1 5 18 V8 L8 5 Z',+    // 3x3 BGA balls — radius 1 at (8|12|16, 8|12|16). The pin-1 chamfer+    // intentionally clips into the top-left ball area, reinforcing the+    // pin-1 corner reading.+    'M7 8 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',+    'M11 8 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',+    'M15 8 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',+    'M7 12 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',+    'M11 12 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',+    'M15 12 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',+    'M7 16 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',+    'M11 16 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',+    'M15 16 a1 1 0 1 0 2 0 a1 1 0 1 0 -2 0Z',+  ].join(' ');++  // ─── 1. Branding swap ────────────────────────────────────────────+  // Wait for the adom-step shell to mount, then swap "STEP Viewer" /+  // "adom-step" → "Chipsmith" in the canonical 44px header.+  function applyBranding() {+    document.title = 'Chipsmith';+    const appName = document.querySelector('.app-name');+    if (appName) appName.textContent = 'Chipsmith';+    const popoverTitle = document.querySelector('.popover-title');+    if (popoverTitle) popoverTitle.textContent = 'Chipsmith';+    // Swap the inline header logo SVG to the chip icon. The adom-step+    // shell hard-codes a single <path> inside the .logo svg, so we just+    // overwrite its `d` attribute. The viewBox stays at 0 0 24 24.+    // fill-rule:evenodd is required so the BGA ball sub-paths punch+    // transparent holes through the body fill instead of stacking.+    const logoPath = document.querySelector('.brand .logo path');+    if (logoPath && logoPath.getAttribute('d') !== CHIPSMITH_ICON_PATH) {+      logoPath.setAttribute('d', CHIPSMITH_ICON_PATH);+      logoPath.setAttribute('fill-rule', 'evenodd');+    }+    // Also swap the favicon so the browser tab + the Hydrogen tab+    // strip both pick up the chip icon. evenodd fill-rule keeps the BGA+    // balls as transparent holes in the favicon too.+    const fav = document.querySelector('link[rel="icon"]');+    if (fav) {+      const dataUri = 'data:image/svg+xml;utf8,' + encodeURIComponent(+        '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#00b8b1" fill-rule="evenodd">' ++        '<path d="' + CHIPSMITH_ICON_PATH + '"/></svg>');+      fav.setAttribute('href', dataUri);+      fav.setAttribute('type', 'image/svg+xml');+    }+    // Wiki + GitHub links per adom-app-header skill — verified at publish.+    document.querySelectorAll('.popover-link').forEach(a => {+      const href = a.getAttribute('href') || '';+      if (href.includes('/apps/adom-step')) a.setAttribute('href', href.replace('/apps/adom-step', '/apps/adom-chipsmith'));+      if (href.includes('adom-inc/adom-step')) a.setAttribute('href', href.replace('adom-inc/adom-step', 'adom-inc/adom-chipsmith'));+    });+  }++  // Run as soon as DOM is ready; re-run on header re-render if it+  // happens (defensive — re-render normally only happens on hot reload).+  if (document.readyState === 'loading') {+    document.addEventListener('DOMContentLoaded', applyBranding);+  } else {+    applyBranding();+  }+  // One delayed pass — adom-step's header sometimes finalizes after+  // initial DOMContentLoaded as the canonical viewer attaches.+  setTimeout(applyBranding, 200);+  setTimeout(applyBranding, 1000);++  // ─── 2. Right pane — 8 tabs (left-to-right validation flow) ─────+  // Source → Measurements → Detection → Footprint → Pin-1 → Sign-off →+  // Heatsink → Solder-jet. Each tab has a status pill (●/◐/○/✗/─).+  // The pane floats over the canvas like the outline (transparent bg+  // with text-shadow for legibility) and is collapsible — chevron at+  // the right edge of the title row, expand-tab at canvas-right when+  // collapsed, keyboard 'P' toggles. Persistent via localStorage.++  // Pin/feature DETECTION was removed in d8b02af — the heuristic+  // approaches (Mode 0 named-walk, Mode 1 surface-type clustering)+  // didn't generalize across enough chips to be trustworthy. Pin-1+  // indicator placement, footprint pad rendering, and sign-off all+  // continue to work without it. Tab numbering 1..7 (was 1..8).+  const TABS = [+    { id: 'source',       label: 'Source',       hotkey: '1' },+    { id: 'measurements', label: 'Measurements', hotkey: '2' },+    { id: 'footprint',    label: 'Footprint',    hotkey: '3' },+    { id: 'pin1',         label: 'Pin-1',        hotkey: '4' },+    { id: 'signoff',      label: 'Sign-off',     hotkey: '5' },+    { id: 'heatsink',     label: 'Heatsink',     hotkey: '6' },+    { id: 'solderjet',    label: 'Solder-jet',   hotkey: '7' },+  ];++  // Shared state — populated as data lands from /api/* endpoints. Tabs+  // read from this; future detection / measurements / sign-off phases+  // also write into it. Status field per tab drives the pill glyph.+  const state = {+    paths: null,         // /api/paths response+    spec: null,          // /api/spec response (info.json)+    footprintSources: null,  // /api/footprint-sources response+    activeTab: 'source',+    status: {            // gate state per tab — '○' pending, '◐' partial, '●' pass, '✗' fail, '─' n/a+      source: '○', measurements: '○', footprint: '○',+      pin1: '○', signoff: '○', heatsink: '─', solderjet: '─',+    },+    // Names of GLB-product entries the user has flagged (or auto-flagged+    // by name match) as "decorative" — manufacturer-mark logos, lens+    // cosmetic features, vendor-brand reliefs. On bake, these get+    // reparented under a `chipsmith_decorative` branch (visibility OFF+    // by default in downstream Adom tools, toggleable on). NEVER+    // deleted, only moved.+    decorativeProductNames: [],+  };++  // ── Custom monochrome SVG icons ─────────────────────────────────+  // Per gallia/skills/human-ui-patterns/playbooks/icons-and-displays.md+  // §4b: no Unicode emoji as UI icons (they render multi-color and mix+  // poorly with hand-drawn SVG). All icons in chipsmith are monochrome+  // currentColor SVGs at viewBox 0 0 24 24, stroke style ≈1.6, custom+  // drawn for the specific concept they represent. MDI is only the+  // fallback for fully-generic things (folder, eye, cog).+  const ICONS = {+    // Folder (outline) — for "reveal in Explorer" affordances. The lift+    // tab on top distinguishes a folder from a generic rectangle.+    folder:+      '<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 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>' ++      '</svg>',+    // Open book — chipsmith-specific custom icon for "open the+    // datasheet in a viewer". Two facing pages with a binding seam,+    // not a generic book emoji. Drawn with the same stroke weight as+    // the folder so the action-button row reads as a coherent set.+    bookOpen:+      '<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 5.5c2.4-.6 5.1-.9 8-.5 1.4.2 2.5.6 4 1.5 1.5-.9 2.6-1.3 4-1.5 2.9-.4 5.6-.1 8 .5"/>' ++      '<path d="M3 5.5v13c2.4-.6 5.1-.9 8-.5 1.4.2 2.5.6 4 1.5 1.5-.9 2.6-1.3 4-1.5 2.9-.4 5.6-.1 8 .5v-13"/>' ++      '<path d="M12 6.5v13"/>' ++      '</svg>',+  };+  function svgIcon(name) { return `<span class="cs-ic">${ICONS[name] || ''}</span>`; }++  // Inject chipsmith-specific styles. Kept inside the overlay so adom-step+  // doesn't pick them up. Tracks the outline's design language: transparent+  // bg, text-shadow, thin overlay scrollbar, slim collapse chevron.+  function injectStyles() {+    if (document.getElementById('chipsmith-overlay-styles')) return;+    const style = document.createElement('style');+    style.id = 'chipsmith-overlay-styles';+    style.textContent = `+      #cs-pane {+        /* Floating HUD — positioned via top/left at runtime (saved in+           localStorage). Default landing spot is set in init() to a+           location that clears the ViewCube top-right zone. The cube+           occupies viewport (0.85, 0.85, 0.15, 0.15) and at 1500px-tall+           windows that's ~225px of vertical space. top:170 leaves a+           comfortable gap below the cube. */+        position: absolute;+        top: 170px;+        right: 10px;+        width: 360px;+        max-height: calc(100vh - 240px);+        min-height: 280px;+        z-index: 50;+        display: flex;+        flex-direction: column;+        background: rgba(20,24,30,0.55);+        backdrop-filter: blur(6px) saturate(1.1);+        -webkit-backdrop-filter: blur(6px) saturate(1.1);+        border: 1px solid rgba(255,255,255,0.10);+        border-radius: 6px;+        box-shadow: 0 6px 24px rgba(0,0,0,0.35);+        transition: opacity 200ms ease;+        font-family: inherit;+        color: var(--text);+        resize: none;+      }+      #cs-pane.dragging {+        transition: none;+        opacity: 0.92;+      }+      #cs-pane.collapsed {+        display: none;+      }+      .cs-tabs {+        cursor: grab;             /* drag handle: tab strip is the grab target */+      }+      .cs-tabs:active { cursor: grabbing; }+      #cs-pane *,+      #cs-pane {+        text-shadow: 0 1px 2px rgba(0,0,0,0.85), 0 0 4px rgba(0,0,0,0.55);+      }+      .cs-tabs {+        display: flex;+        gap: 2px;+        padding: 6px 8px 0 8px;+        flex-wrap: wrap;+        border-bottom: 1px solid rgba(255,255,255,0.10);+        user-select: none;+      }+      .cs-tab {+        background: rgba(20,24,30,0.45);+        border: 1px solid rgba(255,255,255,0.10);+        border-bottom: none;+        color: var(--text-dim);+        padding: 5px 10px 5px 8px;+        font-size: 11px;+        font-family: inherit;+        cursor: pointer;+        border-radius: 4px 4px 0 0;+        display: inline-flex;+        align-items: center;+        gap: 5px;+        line-height: 1.2;+      }+      .cs-tab:hover {+        background: rgba(0,184,177,0.15);+        color: var(--accent);+      }+      .cs-tab.active {+        background: rgba(0,184,177,0.20);+        color: var(--accent);+        border-color: var(--accent);+      }+      .cs-tab .pill {+        font-size: 10px;+        opacity: 0.85;+      }+      .cs-tab .pill.ok      { color: #1de9b6; }+      .cs-tab .pill.partial { color: #ffb74d; }+      .cs-tab .pill.fail    { color: #ff7043; }+      .cs-tab .pill.pending { color: #b0bec5; opacity: 0.55; }+      .cs-tab .pill.na      { color: #b0bec5; opacity: 0.4; }+      .cs-body {+        flex: 1 1 auto;+        overflow-y: overlay;+        overflow-y: auto;+        padding: 10px 12px 14px 12px;+        scrollbar-width: thin;+        scrollbar-color: rgba(255,255,255,0.18) transparent;+      }+      .cs-body::-webkit-scrollbar       { width: 6px; background: transparent; }+      .cs-body::-webkit-scrollbar-track { background: transparent; }+      .cs-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.10); border-radius: 3px; }+      #cs-pane:hover .cs-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.28); }+      .cs-section { margin-bottom: 14px; }+      .cs-section-title {+        font-size: 11px;+        text-transform: uppercase;+        letter-spacing: 0.06em;+        color: var(--text-dim);+        margin: 0 0 6px 0;+        font-weight: 600;+      }+      .cs-row {+        display: flex;+        align-items: center;+        gap: 8px;+        font-size: 12px;+        padding: 3px 0;+      }+      .cs-row .label { color: var(--text-dim); flex: 0 0 90px; }+      .cs-row .value { color: var(--text); flex: 1 1 auto; word-break: break-word; }+      .cs-row .value.mono { font-family: ui-monospace, monospace; font-size: 11px; }+      .cs-row .value.dim  { color: var(--text-dim); font-style: italic; }+      /* Inline-row reveal icon button — sits next to a file path in the+         Files section. Slim 22x22 square, just the 📂 glyph; the+         tooltip carries the full label per human-ui-patterns §1a. */+      .cs-reveal-icon {+        background: transparent;+        border: 1px solid rgba(255,255,255,0.14);+        color: var(--text-dim);+        cursor: pointer;+        font-size: 12px;+        padding: 0;+        margin-left: 4px;+        line-height: 1;+        font-family: inherit;+        width: 22px;+        height: 22px;+        border-radius: 4px;+        display: inline-flex;+        align-items: center;+        justify-content: center;+        flex: 0 0 auto;+      }+      .cs-reveal-icon:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }+      /* Action button row — primary + secondary CTAs at the bottom of a+         section. Per human-ui-patterns §1: never ALL CAPS, tooltip on+         every button, ≥28px height (real-pointer friendly hit target),+         visible focus state. Primary uses the brand teal; secondary is+         outline-only. */+      .cs-actions {+        display: flex;+        gap: 8px;+        margin-top: 10px;+        flex-wrap: wrap;+      }+      .cs-btn {+        display: inline-flex;+        align-items: center;+        gap: 6px;+        height: 30px;+        padding: 0 12px;+        border-radius: 5px;+        font-family: inherit;+        font-size: 12px;+        line-height: 1;+        cursor: pointer;+        transition: background 120ms ease, border-color 120ms ease, color 120ms ease;+      }+      .cs-btn .lbl  { font-weight: 500; }+      /* Inline SVG icon container — keeps icons monochrome via+         currentColor and sized consistently across buttons + inline+         rows (16px in CTAs, 14px in slim inline buttons). */+      .cs-ic { display: inline-flex; align-items: center; line-height: 1; }+      .cs-ic svg { width: 16px; height: 16px; display: block; }+      .cs-reveal-icon .cs-ic svg { width: 14px; height: 14px; }+      .cs-btn:focus-visible {+        outline: 2px solid var(--accent);+        outline-offset: 2px;+      }+      .cs-btn-primary {+        background: rgba(0,184,177,0.18);+        border: 1px solid var(--accent);+        color: var(--accent);+      }+      .cs-btn-primary:hover {+        background: rgba(0,184,177,0.32);+      }+      .cs-btn-secondary {+        background: rgba(20,24,30,0.45);+        border: 1px solid rgba(255,255,255,0.14);+        color: var(--text);+      }+      .cs-btn-secondary:hover {+        background: var(--accent-soft);+        color: var(--accent);+        border-color: var(--accent);+      }+      /* Shape radio (Pin-1 tab): three options laid out horizontally+         with the active one tinted with the brand teal. Uses native+         <input type=radio> for keyboard + screen-reader, but visually+         styled as a button group. */+      .cs-shape-radio {+        display: flex;+        gap: 6px;+        margin: 6px 0 4px 0;+        flex-wrap: wrap;+      }+      .cs-shape-opt {+        display: inline-flex;+        align-items: center;+        gap: 6px;+        padding: 5px 10px;+        height: 28px;+        border-radius: 5px;+        background: rgba(20,24,30,0.45);+        border: 1px solid rgba(255,255,255,0.14);+        color: var(--text);+        font-size: 12px;+        cursor: pointer;+        user-select: none;+        transition: background 120ms ease, border-color 120ms ease, color 120ms ease;+      }+      .cs-shape-opt:hover { border-color: var(--accent); color: var(--accent); }+      .cs-shape-opt input { margin: 0; cursor: pointer; }+      .cs-shape-opt.active {+        background: rgba(0,184,177,0.18);+        border-color: var(--accent);+        color: var(--accent);+      }+      .cs-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;+        margin-left: auto;+      }+      .cs-collapse:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }+      #cs-expander {+        position: absolute;+        top: 50%;+        right: 0;+        transform: translateY(-50%);+        width: 22px;+        height: 64px;+        background: rgba(20,24,30,0.55);+        border: 1px solid var(--border);+        border-right: none;+        border-radius: 6px 0 0 6px;+        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;+      }+      #cs-expander:hover { background: rgba(0,184,177,0.18); color: var(--accent); }+      body.cs-collapsed #cs-expander { display: flex; }+      .cs-amber  { color: #ffb74d; }+      .cs-empty  { color: var(--text-dim); font-size: 11px; font-style: italic; padding: 6px 0; }+      /* Status block — large readable banner explaining the measurement+         outcome. Color-keyed via .pill-* class on the parent. */+      .cs-meas-status {+        font-size: 12px;+        line-height: 1.45;+        padding: 8px 10px;+        border-radius: 4px;+        border-left: 3px solid;+      }+      .cs-meas-status.pill-ok      { background: rgba(29,233,182,0.10); border-color: #1de9b6; color: #b9f6e2; }+      .cs-meas-status.pill-partial { background: rgba(255,183, 77,0.10); border-color: #ffb74d; color: #ffe0b2; }+      .cs-meas-status.pill-fail    { background: rgba(255,112, 67,0.12); border-color: #ff7043; color: #ffccbc; }+      .cs-meas-status code { font-family: ui-monospace, monospace; font-size: 11px; }+      /* Candidate table — compact list of every rescale candidate, %.+         The winning row gets a brand-teal left border. */+      .cs-tbl {+        width: 100%;+        border-collapse: collapse;+        font-size: 11px;+        margin-top: 4px;+      }+      .cs-tbl th {+        text-align: left;+        font-weight: 500;+        color: var(--text-dim);+        padding: 4px 6px;+        border-bottom: 1px solid rgba(255,255,255,0.10);+      }+      .cs-tbl th.num, .cs-tbl td.num { text-align: right; }+      .cs-tbl td {+        padding: 4px 6px;+        border-bottom: 1px solid rgba(255,255,255,0.06);+        vertical-align: top;+      }+      .cs-tbl td.dim { color: var(--text-dim); }+      .cs-tbl td.mono { font-family: ui-monospace, monospace; }+      .cs-tbl tr.is-winner td:first-child { border-left: 2px solid var(--accent); padding-left: 4px; }+      .cs-tbl tr.is-winner td { color: var(--text); }+      .cs-tbl tr.is-ok td.num { color: #1de9b6; }+      /* Clickable feature row in Tab 3 — hover hints at its+         click-to-isolate behaviour. */+      .cs-row.cs-clickable {+        cursor: pointer;+        border-radius: 3px;+        padding: 3px 6px;+        margin: 0 -6px;+      }+      .cs-row.cs-clickable:hover {+        background: rgba(0,184,177,0.12);+      }+      /* Spec editor inline form. */+      .cs-edit-spec {+        background: transparent;+        border: 1px solid rgba(255,255,255,0.18);+        color: var(--text-dim);+        font-size: 10px;+        padding: 1px 6px;+        margin-left: 6px;+        border-radius: 3px;+        cursor: pointer;+        font-family: inherit;+        text-transform: none;+        letter-spacing: 0;+      }+      .cs-edit-spec:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }+      .cs-spec-input {+        background: rgba(20,24,30,0.55);+        border: 1px solid rgba(255,255,255,0.14);+        color: var(--text);+        padding: 4px 6px;+        border-radius: 3px;+        font-size: 12px;+        font-family: ui-monospace, monospace;+        outline: none;+        width: 100%;+      }+      .cs-spec-input:focus { border-color: var(--accent); }+      .cs-spec-input.cs-body-dim {+        width: 60px;+        margin-right: 4px;+      }+      /* Per-source color swatch — small dot before the source label+         so the user immediately knows which 3D color each source maps+         to. Per the multi-source-cross-validation plan section. */+      .cs-src-swatch {+        display: inline-block;+        width: 10px; height: 10px;+        border-radius: 2px;+        margin-right: 6px;+        flex: 0 0 auto;+        border: 1px solid rgba(255,255,255,0.18);+      }+      /* Floating Sign-off HUD — always visible, top-left default+         (top-right is taken by the right-pane HUD + ViewCube).+         Drag the title row, collapse to compact badge, 'g' toggle. */+      #cs-signoff-hud {+        position: absolute;+        top: 90px;+        left: 300px;             /* clear of the Outline panel (280 wide) */+        width: 280px;+        z-index: 56;+        background: rgba(20,24,30,0.78);+        backdrop-filter: blur(6px) saturate(1.1);+        -webkit-backdrop-filter: blur(6px) saturate(1.1);+        border: 1px solid rgba(255,255,255,0.10);+        border-radius: 6px;+        box-shadow: 0 4px 18px rgba(0,0,0,0.45);+        font-family: inherit;+        color: var(--text);+        font-size: 11px;+        transition: opacity 200ms ease;+        text-shadow: 0 1px 2px rgba(0,0,0,0.55);+      }+      #cs-signoff-hud.dragging { transition: none; opacity: 0.92; }+      #cs-signoff-hud.collapsed .cs-so-body,+      #cs-signoff-hud.collapsed .cs-so-foot { display: none; }+      #cs-signoff-hud.collapsed { width: auto; }+      #cs-signoff-hud .cs-so-head {+        display: flex;+        align-items: center;+        gap: 6px;+        padding: 6px 10px;+        cursor: grab;+        border-bottom: 1px solid rgba(255,255,255,0.08);+        user-select: none;+      }+      #cs-signoff-hud.collapsed .cs-so-head { border-bottom: none; cursor: pointer; }+      #cs-signoff-hud .cs-so-head:active { cursor: grabbing; }+      #cs-signoff-hud .cs-so-title {+        font-weight: 600;+        font-size: 11px;+        text-transform: uppercase;+        letter-spacing: 0.06em;+        color: var(--text-dim);+      }+      #cs-signoff-hud .cs-so-tally {+        font-family: ui-monospace, monospace;+        font-size: 11px;+        margin-left: auto;+        margin-right: 4px;+        padding: 2px 6px;+        border-radius: 3px;+      }+      #cs-signoff-hud .cs-so-tally.pass  { background: rgba(29,233,182,0.18); color: #b9f6e2; }+      #cs-signoff-hud .cs-so-tally.block { background: rgba(255,112, 67,0.18); color: #ffccbc; }+      #cs-signoff-hud .cs-so-collapse {+        background: transparent;+        border: 1px solid transparent;+        color: var(--text-dim);+        cursor: pointer;+        font-size: 11px;+        padding: 0;+        width: 18px; height: 18px;+        border-radius: 3px;+      }+      #cs-signoff-hud .cs-so-collapse:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }+      #cs-signoff-hud .cs-so-body { padding: 4px 8px 6px 8px; max-height: 60vh; overflow-y: auto; }+      #cs-signoff-hud .cs-so-section { margin-top: 6px; }+      #cs-signoff-hud .cs-so-section:first-child { margin-top: 0; }+      #cs-signoff-hud .cs-so-section-title {+        font-size: 9.5px;+        text-transform: uppercase;+        letter-spacing: 0.08em;+        color: var(--text-dim);+        margin: 4px 0 2px 0;+        opacity: 0.75;+      }+      #cs-signoff-hud .cs-so-row {+        display: flex;+        align-items: center;+        gap: 6px;+        padding: 2px 4px;+        border-radius: 3px;+        cursor: pointer;+        line-height: 1.3;+      }+      #cs-signoff-hud .cs-so-row:hover { background: rgba(0,184,177,0.12); }+      #cs-signoff-hud .cs-so-glyph {+        font-family: ui-monospace, monospace;+        width: 12px;+        text-align: center;+        flex: 0 0 auto;+      }+      #cs-signoff-hud .cs-so-glyph.ok      { color: #1de9b6; }+      #cs-signoff-hud .cs-so-glyph.partial { color: #ffb74d; }+      #cs-signoff-hud .cs-so-glyph.fail    { color: #ff7043; }+      #cs-signoff-hud .cs-so-glyph.pending { color: #b0bec5; opacity: 0.65; }+      #cs-signoff-hud .cs-so-glyph.na      { color: #b0bec5; opacity: 0.45; }+      #cs-signoff-hud .cs-so-label.soft   { color: var(--text-dim); font-style: italic; }+      #cs-signoff-hud .cs-so-foot { padding: 6px 8px 8px 8px; border-top: 1px solid rgba(255,255,255,0.08); }+      #cs-signoff-hud .cs-so-foot button { width: 100%; }+      /* Pin-reveal strobe badge animation — pop in then fade. */+      @keyframes cs-strobe-pop {+        0%   { opacity: 0; transform: translate(-50%, -90%) scale(0.7); }+        15%  { opacity: 1; transform: translate(-50%, -110%) scale(1.15); }+        30%  { opacity: 1; transform: translate(-50%, -100%) scale(1.0); }+        100% { opacity: 0; transform: translate(-50%, -90%) scale(0.95); }+      }+    `;+    document.head.appendChild(style);+  }++  // Build right-pane DOM. Tabs strip on top + body container that swaps+  // content per active tab. Inserted into <body> after .canvas-wrap so+  // it's a sibling of the absolute-positioned outline.+  function buildPane() {+    if (document.getElementById('cs-pane')) return;+    const pane = document.createElement('aside');+    pane.id = 'cs-pane';+    pane.innerHTML = `+      <div class="cs-tabs" role="tablist"></div>+      <div class="cs-body" id="cs-body"></div>+    `;+    const tabsEl = pane.querySelector('.cs-tabs');+    TABS.forEach(t => {+      const btn = document.createElement('button');+      btn.className = 'cs-tab' + (t.id === state.activeTab ? ' active' : '');+      btn.dataset.tab = t.id;+      btn.setAttribute('role', 'tab');+      btn.setAttribute('data-tooltip', `${t.label} — ${t.hotkey} or click. Status pill: ● pass · ◐ partial · ○ pending · ✗ fail · ─ n/a.`);+      btn.innerHTML = `<span class="label">${t.label}</span><span class="pill pending">○</span>`;+      btn.onclick = () => setActiveTab(t.id);+      tabsEl.appendChild(btn);+    });+    // Add the collapse chevron on the rightmost tab row — sits inline at+    // the end of the tab strip so it doesn't waste a header row.+    const collapseBtn = document.createElement('button');+    collapseBtn.className = 'cs-collapse';+    collapseBtn.id = 'csCollapse';+    collapseBtn.textContent = '❯';+    collapseBtn.setAttribute('aria-label', 'Collapse pane');+    collapseBtn.setAttribute('data-tooltip', 'Collapse the pane (P) — slides out, leaves the canvas full-width. Click the tab at the right edge to expand.');+    tabsEl.appendChild(collapseBtn);+    document.body.appendChild(pane);++    const expander = document.createElement('button');+    expander.id = 'cs-expander';+    expander.textContent = '❮';+    expander.setAttribute('aria-label', 'Expand pane');+    expander.setAttribute('data-tooltip', 'Expand the chipsmith pane (P).');+    document.body.appendChild(expander);++    const KEY_COLLAPSED = 'chipsmith.pane.collapsed';+    const KEY_POS = 'chipsmith.pane.pos';+    function setCollapsed(c) {+      pane.classList.toggle('collapsed', c);+      document.body.classList.toggle('cs-collapsed', c);+      try { localStorage.setItem(KEY_COLLAPSED, c ? '1' : '0'); } catch (_) {}+    }+    try {+      if (localStorage.getItem(KEY_COLLAPSED) === '1') setCollapsed(true);+    } catch (_) {}+    collapseBtn.onclick = () => setCollapsed(true);+    expander.onclick = () => setCollapsed(false);++    // ── Drag logic ────────────────────────────────────────────+    // Grab anywhere in the tab strip (NOT on a tab/collapse button) to+    // drag. Position persists in localStorage. Constrained to keep at+    // least a corner of the HUD on-screen so the user can never lose it.+    function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }+    function applyPos(pos) {+      pane.style.top = pos.y + 'px';+      pane.style.left = pos.x + 'px';+      pane.style.right = 'auto';+    }+    function loadPos() {+      try {+        const raw = localStorage.getItem(KEY_POS);+        if (raw) return JSON.parse(raw);+      } catch (_) {}+      return null;+    }+    function savePos(pos) {+      try { localStorage.setItem(KEY_POS, JSON.stringify(pos)); } catch (_) {}+    }+    const initialPos = loadPos();+    if (initialPos) applyPos(initialPos);++    let dragOrigin = null;+    tabsEl.addEventListener('mousedown', (e) => {+      // Don't start drag if click is on a tab button or collapse button.+      const onTab = e.target.closest('.cs-tab, .cs-collapse');+      if (onTab) return;+      const r = pane.getBoundingClientRect();+      dragOrigin = {+        startX: e.clientX, startY: e.clientY,+        baseX: r.left, baseY: r.top,+      };+      pane.classList.add('dragging');+      e.preventDefault();+    });+    document.addEventListener('mousemove', (e) => {+      if (!dragOrigin) return;+      const dx = e.clientX - dragOrigin.startX;+      const dy = e.clientY - dragOrigin.startY;+      // Header is 44 px; canvas content starts there. Clamp keeps at+      // least 60×60 of the pane on-screen so it can never be lost.+      const minX = -pane.offsetWidth + 60;+      const maxX = window.innerWidth - 60;+      const minY = 44;+      const maxY = window.innerHeight - 60;+      const x = clamp(dragOrigin.baseX + dx, minX, maxX);+      const y = clamp(dragOrigin.baseY + dy, minY, maxY);+      applyPos({ x, y });+    });+    document.addEventListener('mouseup', () => {+      if (!dragOrigin) return;+      const r = pane.getBoundingClientRect();+      savePos({ x: r.left, y: r.top });+      pane.classList.remove('dragging');+      dragOrigin = null;+    });+    document.addEventListener('keydown', (e) => {+      if (e.ctrlKey || e.metaKey || e.altKey) return;+      const t = e.target;+      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;+      if (e.key === 'p' || e.key === 'P') {+        setCollapsed(!pane.classList.contains('collapsed'));+        return;+      }+      // Number-key tab hotkeys.+      const tab = TABS.find(x => x.hotkey === e.key);+      if (tab) {+        if (pane.classList.contains('collapsed')) setCollapsed(false);+        setActiveTab(tab.id);+      }+    });+  }++  function setActiveTab(id) {+    state.activeTab = id;+    document.querySelectorAll('.cs-tab').forEach(el => {+      el.classList.toggle('active', el.dataset.tab === id);+    });+    renderActiveTab();+  }++  // Recompute every tab's status pill from current state, without+  // rendering any tab. Called on every data mutation (spec save,+  // detection run, footprint load, sign-off action) so cross-tab+  // status cascades correctly even when a downstream tab isn't the+  // active tab.+  function recomputeAllStatuses() {+    // Source.+    const s = state.spec;+    if (s) {+      const pin = s.pin_count, struct = s.structural_type, body = Array.isArray(s.body_dims_mm) ? s.body_dims_mm : null;+      const present = [pin, struct, body].filter(v => v !== undefined && v !== null && v !== '' && v !== '?').length;+      state.status.source = present === 3 ? '●' : (present > 0 ? '◐' : '○');+    }+    // Measurements.+    if (state.measurements) {+      state.status.measurements = state.measurements.status;+    }+    // Footprint.+    const fps = state.footprintSources;+    if (fps) {+      const sourcesPresent = ['kicad','fusion','altium','datasheet','ds2sf'].filter(k => fps[k] && !fps[k]._stub && fps[k].pad_count > 0);+      state.status.footprint = sourcesPresent.length === 0 ? '✗' : (sourcesPresent.length >= 2 ? '◐' : '○');+    }+    // Pin-1: chip-side = user-placed indicator (state.userPin1Corner);+    // fp-side = silkscreen marker on any footprint source.+    {+      const chipPin1 = !!state.userPin1Corner;+      const fpPin1 = ['kicad','fusion','altium','datasheet','ds2sf'].some(k => fps && fps[k] && fps[k].pin1_silk && fps[k].pin1_silk.found);+      state.status.pin1 = (chipPin1 || fpPin1) ? '◐' : '✗';+    }+    // Heatsink + solderjet recompute themselves on render — not+    // status-data-cheap to recompute without running their algorithms.+    refreshStatusPills();+    renderSignoffHud();+  }++  function refreshStatusPills() {+    document.querySelectorAll('.cs-tab').forEach(el => {+      const id = el.dataset.tab;+      const glyph = state.status[id] || '○';+      const pill = el.querySelector('.pill');+      if (!pill) return;+      pill.textContent = glyph;+      pill.className = 'pill ' + ({+        '●': 'ok', '◐': 'partial', '○': 'pending', '✗': 'fail', '─': 'na',+      }[glyph] || 'pending');+    });+  }++  // ── Tab 1: Source ───────────────────────────────────────────────+  // Manifest provenance + datasheet PDF inline + reveal-in-VS-Code+  // buttons next to every file path. Status:+  //   ● when pin_count + structural_type + body_dims_mm are present+  //   ◐ when at least one is present+  //   ○ when none present (default until chip-fetcher / user populates)++  async function renderSourceTab() {+    const body = document.getElementById('cs-body');+    if (!state.spec || !state.paths) {+      body.innerHTML = `<div class="cs-empty">Loading source…</div>`;+      return;+    }+    const s = state.spec, p = state.paths;+    const has = (k) => s[k] !== undefined && s[k] !== null && s[k] !== '' && s[k] !== '?';+    const declaredPinCount = has('pin_count') ? s.pin_count : null;+    const declaredStruct   = has('structural_type') ? s.structural_type : null;+    const declaredBody     = Array.isArray(s.body_dims_mm) ? s.body_dims_mm : null;+    const specReady = (declaredPinCount && declaredStruct && declaredBody) ? '●'+                    : (declaredPinCount || declaredStruct || declaredBody) ? '◐' : '○';+    state.status.source = specReady;+    refreshStatusPills();++    const reveal = (path) => path+      ? `<button class="cs-reveal-icon" data-path="${escAttr(path)}" aria-label="Reveal in Explorer" data-tooltip="Reveal in the VS Code Explorer sidebar (delegates to adom-vscode reveal).">${svgIcon('folder')}</button>`+      : '';+    const fmtBody = declaredBody ? `${declaredBody[0]} × ${declaredBody[1]} × ${declaredBody[2]} mm`+                                 : '<span class="cs-amber">declare body_dims_mm</span>';+    const fmtPin  = declaredPinCount || '<span class="cs-amber">declare pin_count</span>';+    const fmtStr  = declaredStruct   || '<span class="cs-amber">declare structural_type</span>';++    body.innerHTML = `+      <div class="cs-section">+        <h3 class="cs-section-title">Manifest</h3>+        <div class="cs-row"><span class="label">MPN</span><span class="value mono">${esc(s.mpn || '?')}</span></div>+        <div class="cs-row"><span class="label">Manufacturer</span><span class="value">${esc(s.manufacturer || '?')}</span></div>+        <div class="cs-row"><span class="label">Package</span><span class="value">${esc(s.package || '?')}</span></div>+        <div class="cs-row"><span class="label">Family</span><span class="value">${esc(s.family || '?')}</span></div>+        <div class="cs-row"><span class="label">Description</span><span class="value dim">${esc(s.description || '—')}</span></div>+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">Spec <button class="cs-edit-spec" data-tooltip="Edit pin_count / structural_type / body_dims_mm inline. Saves into info.json so the values persist across reloads. The Measurements + Detection tabs cascade automatically.">${state.editingSpec ? '✕ cancel' : 'edit ✎'}</button></h3>+        ${state.editingSpec ? `+          <div class="cs-row"><span class="label">Pin count</span><input type="number" class="cs-spec-input" id="spec-pin_count" min="0" value="${declaredPinCount || ''}" placeholder="e.g. 16"/></div>+          <div class="cs-row"><span class="label">Structure</span>+            <select class="cs-spec-input" id="spec-structural_type">+              <option value="">— pick —</option>+              <option value="smd_pad"      ${declaredStruct === 'smd_pad' ? 'selected' : ''}>smd_pad (QFN/LGA/DFN)</option>+              <option value="smd_leg"      ${declaredStruct === 'smd_leg' ? 'selected' : ''}>smd_leg (SOIC/QFP/SOT)</option>+              <option value="ball"         ${declaredStruct === 'ball'    ? 'selected' : ''}>ball (BGA)</option>+              <option value="thru_hole_pin"${declaredStruct === 'thru_hole_pin' ? 'selected' : ''}>thru_hole_pin (DIP)</option>+              <option value="ep_thermal"   ${declaredStruct === 'ep_thermal'    ? 'selected' : ''}>ep_thermal (EP only)</option>+              <option value="lead_with_ep" ${declaredStruct === 'lead_with_ep'  ? 'selected' : ''}>lead_with_ep (QFN with center EP)</option>+              <option value="hybrid_smd_thru" ${declaredStruct === 'hybrid_smd_thru' ? 'selected' : ''}>hybrid_smd_thru (USB-C)</option>+            </select>+          </div>+          <div class="cs-row"><span class="label">Body (mm)</span>+            <input type="number" step="0.1" class="cs-spec-input cs-body-dim" id="spec-body-x" placeholder="W" value="${declaredBody?.[0] || ''}"/>+            <input type="number" step="0.1" class="cs-spec-input cs-body-dim" id="spec-body-y" placeholder="H" value="${declaredBody?.[1] || ''}"/>+            <input type="number" step="0.1" class="cs-spec-input cs-body-dim" id="spec-body-z" placeholder="D" value="${declaredBody?.[2] || ''}"/>+          </div>+          <div class="cs-actions">+            <button class="cs-btn cs-btn-primary" id="cs-spec-save" data-tooltip="Save → POST /api/spec → write into info.json. Cascades to Measurements + Detection.">${svgIcon('bookOpen')}<span class="lbl">Save</span></button>+          </div>+        ` : `+          <div class="cs-row"><span class="label">Pin count</span><span class="value mono">${fmtPin}</span></div>+          <div class="cs-row"><span class="label">Structure</span><span class="value mono">${fmtStr}</span></div>+          <div class="cs-row"><span class="label">Body (mm)</span><span class="value mono">${fmtBody}</span></div>+        `}+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">Files</h3>+        <div class="cs-row"><span class="label">STEP</span><span class="value mono" title="${escAttr(p.step||'')}">${esc(basename(p.step))}</span>${reveal(p.step)}</div>+        <div class="cs-row"><span class="label">Footprint</span><span class="value mono" title="${escAttr(p.kicad_mod||'')}">${esc(basename(p.kicad_mod))}</span>${reveal(p.kicad_mod)}</div>+        <div class="cs-row"><span class="label">Datasheet</span><span class="value mono" title="${escAttr(p.datasheet||'')}">${esc(basename(p.datasheet))}</span>${reveal(p.datasheet)}</div>+        <div class="cs-row"><span class="label">info.json</span><span class="value mono" title="${escAttr(p.info_json||'')}">${esc(basename(p.info_json))}</span>${reveal(p.info_json)}</div>+        <div class="cs-row"><span class="label">Folder</span><span class="value mono" title="${escAttr(p.dir||'')}">${esc(p.dir||'?')}</span>${reveal(p.dir)}</div>+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">Datasheet</h3>+        ${p.datasheet+          ? `<div class="cs-row"><span class="label">File</span><span class="value mono" title="${escAttr(p.datasheet)}">${esc(basename(p.datasheet))}</span></div>+             <div class="cs-actions">+               <button class="cs-btn cs-btn-primary cs-open-pdf"+                       data-href="${escAttr(pdfViewerUrl(s, p))}"+                       data-tooltip="Open in the Adom PDF viewer (PDF.js + thumbnail strip + page deep-links). Pops a full-screen tab so you can read alongside chipsmith. The viewer also remembers cited pages once detection runs.">+                 ${svgIcon('bookOpen')}<span class="lbl">Open in viewer</span>+               </button>+               <button class="cs-btn cs-btn-secondary cs-reveal"+                       data-path="${escAttr(p.datasheet)}"+                       data-tooltip="Reveal the datasheet PDF in the VS Code Explorer sidebar — useful for confirming where it lives on disk.">+                 ${svgIcon('folder')}<span class="lbl">Reveal</span>+               </button>+             </div>`+          : `<div class="cs-empty">No datasheet attached.</div>`}+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">Cosmetic — laser etch</h3>+        <div class="cs-row dim"><span class="value">Engrave the MPN onto the chip's top face. Cosmetic only — auto-positioned to avoid the pin-1 indicator. Baked into <code>.chipsmith.step</code> at sign-off so the engraving survives every future load.</span></div>+        <div class="cs-row">+          <span class="label">Text</span>+          <input type="text" id="cs-laser-text" value="${escAttr(state.laserEtchText || (state.spec?.mpn || ''))}" style="background:rgba(20,24,30,0.65); border:1px solid rgba(255,255,255,0.14); color:var(--text); padding:4px 8px; border-radius:4px; font-family:inherit; font-size:12px; min-width:140px" placeholder="MPN" data-tooltip="Defaults to the chip's MPN. Override for variant SKUs (e.g. 'LM358 / Ti')."/>+        </div>+        <div class="cs-actions">+          <button class="cs-btn cs-btn-${state.laserEtch ? 'primary' : 'secondary'}" id="cs-laser-toggle" data-tooltip="${state.laserEtch ? 'Hide the laser etch on the chip top.' : 'Render the MPN as a 50µm-deep darker recess on the body top face. Auto-sized + auto-positioned away from the pin-1 indicator.'}">${svgIcon('bookOpen')}<span class="lbl">${state.laserEtch ? 'Hide etch' : 'Show etch'}</span></button>+        </div>+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">Debug — anchor bboxes</h3>+        <div class="cs-row dim"><span class="value">Show four colored slabs marking where chipsmith thinks each canonical surface is: chip-top (cyan), chip-bottom (magenta), footprint-area (yellow @ Y=0), pcb-top (green @ Y=0). Use when chipsmith's placements look wrong — the slabs reveal whether it's a placement bug or a wrong-anchor bug.</span></div>+        <div class="cs-actions">+          <button class="cs-btn cs-btn-${state.debugAnchorsOn ? 'primary' : 'secondary'}" id="cs-debug-anchors-toggle" data-tooltip="${state.debugAnchorsOn ? 'Hide the four debug anchor slabs.' : 'Render four 50µm-thick colored slabs at the chipsmith-internal anchor planes (chip-top, chip-bottom, footprint-area, pcb-top). Each appears in the Outline as a child of Debug — anchor bboxes.'}">${svgIcon('bookOpen')}<span class="lbl">${state.debugAnchorsOn ? 'Hide anchors' : 'Show anchors'}</span></button>+        </div>+      </div>+    `;+    // Wire spec editor controls.+    const editBtn = body.querySelector('.cs-edit-spec');+    if (editBtn) editBtn.onclick = () => {+      state.editingSpec = !state.editingSpec;+      renderSourceTab();+    };+    const saveBtn = document.getElementById('cs-spec-save');+    if (saveBtn) saveBtn.onclick = async () => {+      const merged = Object.assign({}, state.spec || {});+      const pin = parseInt(document.getElementById('spec-pin_count').value, 10);+      if (Number.isFinite(pin) && pin > 0) merged.pin_count = pin;+      const struct = document.getElementById('spec-structural_type').value;+      if (struct) merged.structural_type = struct;+      const bx = parseFloat(document.getElementById('spec-body-x').value);+      const by = parseFloat(document.getElementById('spec-body-y').value);+      const bz = parseFloat(document.getElementById('spec-body-z').value);+      if (Number.isFinite(bx) && Number.isFinite(by) && Number.isFinite(bz)) {+        merged.body_dims_mm = [bx, by, bz];+      }+      saveBtn.disabled = true;+      saveBtn.querySelector('.lbl').textContent = 'Saving…';+      try {+        const r = await fetch('api/spec', {+          method: 'POST',+          headers: { 'Content-Type': 'application/json' },+          body: JSON.stringify(merged),+        });+        const j = await r.json();+        state.spec = merged;+        state.editingSpec = false;+        // Cascade: recompute every status pill, then re-render whichever+        // tab is currently visible.+        recomputeAllStatuses();+        if (state.activeTab === 'source') renderSourceTab();+        if (state.activeTab === 'measurements' && state.measurements) renderMeasurementsTab();+        if (state.activeTab === 'signoff') renderSignoffTab();+      } catch (e) {+        console.error('[chipsmith] spec save failed', e);+        saveBtn.disabled = false;+        saveBtn.querySelector('.lbl').textContent = 'Save (failed — retry)';+      }+    };+    // Laser-etch toggle + text input. Defaults to MPN; user can+    // override per-chip. Re-renders the source tab so the button label+    // flips between "Show etch" / "Hide etch".+    const laserBtn = document.getElementById('cs-laser-toggle');+    const laserInput = document.getElementById('cs-laser-text');+    if (laserInput) {+      laserInput.oninput = () => {+        state.laserEtchText = laserInput.value;+        // Live-update the etch if it's currently visible.+        if (state.laserEtch) renderLaserEtch({ text: laserInput.value });+      };+    }+    if (laserBtn) {+      laserBtn.onclick = () => {+        const text = (laserInput?.value || state.spec?.mpn || '').trim();+        if (state.laserEtch) {+          ensureOverlayDisposed('laser-etch');+          state.laserEtch = null;+        } else {+          renderLaserEtch({ text });+        }+        renderSourceTab();+      };+    }+    const debugBtn = document.getElementById('cs-debug-anchors-toggle');+    if (debugBtn) {+      debugBtn.onclick = () => {+        if (state.debugAnchorsOn) {+          ensureOverlayDisposed('debug-anchors');+          state.debugAnchorsOn = false;+        } else {+          renderDebugAnchors();+        }+        renderSourceTab();+      };+    }+    // Wire reveal buttons → POST /api/reveal { path }. Both the inline+    // file-row 📂 buttons (cs-reveal-icon) and the datasheet "Reveal"+    // CTA (cs-reveal) carry data-path; one handler covers both.+    body.querySelectorAll('.cs-reveal-icon, .cs-reveal').forEach(btn => {+      btn.onclick = async () => {+        const path = btn.dataset.path;+        try {+          await fetch('api/reveal', {+            method: 'POST',+            headers: { 'Content-Type': 'application/json' },+            body: JSON.stringify({ path }),+          });+        } catch (e) { console.error('[chipsmith] reveal failed', e); }+      };+    });+    // Wire "Open full viewer" → opens the same adom-pdf-viewer URL in a+    // new browser tab so the user can read at full screen alongside the+    // chipsmith webview. The inline iframe stays for at-a-glance.+    body.querySelectorAll('.cs-open-pdf').forEach(btn => {+      btn.onclick = () => {+        const href = btn.dataset.href;+        if (href) window.open(href, '_blank', 'noopener');+      };+    });+  }++  // Build the adom-pdf-viewer URL for the loaded chip. The viewer runs+  // on port 8985 (per its SKILL.md) and is reached via the Hydrogen+  // proxy. We pass the datasheet through chipsmith's /api/datasheet+  // endpoint so the viewer fetches a same-origin URL — no CORS surprises.+  // `title` and `reveal` give the viewer's topbar a label + the+  // reveal-in-Explorer button.+  function pdfViewerUrl(spec, paths) {+    // Locate this page's origin + proxy prefix so the constructed URL+    // works under both /proxy/8872/ (Hydrogen) and direct localhost.+    const proxyMatch = location.pathname.match(/^(\/proxy\/\d+\/)/);+    const origin = location.origin;+    // Same-origin URL to chipsmith's /api/datasheet — the pdf-viewer+    // fetches this and renders it in PDF.js.+    const datasheetSrc = (proxyMatch ? proxyMatch[1] : '/') + 'api/datasheet';+    const viewerBase = origin + (proxyMatch ? proxyMatch[1].replace(/\d+/, '8985') : '/proxy/8985/');+    const params = new URLSearchParams();+    params.set('src', datasheetSrc);+    if (spec && spec.mpn) params.set('title', spec.mpn + ' datasheet');+    if (paths && paths.datasheet) params.set('reveal', paths.datasheet);+    return viewerBase + 'v?' + params.toString();+  }++  // ── Tab 2: Measurements ─────────────────────────────────────────+  // Read declared body_dims_mm from the manifest (or extract from the+  // package field as a fallback — many chip-fetcher entries write+  // "OLGA-16 (6.4×3×1.75 mm)" into `package` without populating the+  // structured body_dims_mm yet). Then read the detected body bbox via+  // window.adomStep.bbox('Body') — adom-step returns it in mm — and run+  // a rescale negotiation:+  //+  //   ×1          (declared unit honored)+  //   ÷25.4       (file says mm but is really inches)+  //   ÷1000       (file says mm but is really metres)+  //   ×25.4       (file says inches but is really mm)+  //   ×1000       (file says metres but is really mm)+  //   ÷10         (file says mm but is really cm)+  //+  // Pick the scale whose product (detected × scale) lands within ±5%+  // on every axis (sorted-extents — orientation-agnostic, since chips+  // can ship Y-up vs Z-up; the absolute axis-by-axis match is too+  // brittle here). Status pill: ● if ×1, ◐ if non-1 rescale, ✗ if no+  // scale fits.++  function parseBodyDims(spec) {+    if (!spec) return null;+    if (Array.isArray(spec.body_dims_mm) && spec.body_dims_mm.length === 3) return spec.body_dims_mm.map(Number);+    // Fallback: extract "<W>×<H>×<D> mm" or "<W>x<H>x<D> mm" from the+    // free-text package field. Examples: "OLGA-16 (6.4×3×1.75 mm)"+    // "QFN-32 5x5x0.9 mm".+    const text = String(spec.package || spec.description || '');+    const m = text.match(/(\d+(?:\.\d+)?)\s*[×x]\s*(\d+(?:\.\d+)?)\s*[×x]\s*(\d+(?:\.\d+)?)\s*mm/i);+    if (!m) return null;+    return [parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3])];+  }++  const RESCALE_CANDIDATES = [+    { factor: 1,        label: '×1',     reason: 'Declared unit honored — STEP says mm and is mm.' },+    { factor: 1/25.4,   label: '÷25.4',  reason: 'STEP says mm but the file is really inches (US-mfr CAD export).' },+    { factor: 1/1000,   label: '÷1000',  reason: 'STEP says mm but the file is really metres (Fusion export bug).' },+    { factor: 25.4,     label: '×25.4',  reason: 'STEP says inches but the file is really mm.' },+    { factor: 1000,     label: '×1000',  reason: 'STEP says metres but the file is really mm.' },+    { factor: 1/10,     label: '÷10',    reason: 'STEP says mm but the file is really cm.' },+  ];++  // Score a rescale candidate. Sort BOTH declared + detected extents+  // ascending (so [4.4, 5.4, 1.5] vs detected [1.5, 5.4, 4.4] still+  // matches), then compute max-axis percent deviation. Lower is better;+  // ≤5% counts as a match.+  function scoreScale(declaredMm, detectedMm, factor) {+    const d  = [...declaredMm].sort((a, b) => a - b);+    const dt = detectedMm.map(v => v * factor).sort((a, b) => a - b);+    let maxPct = 0;+    for (let i = 0; i < 3; i++) {+      const pct = Math.abs(dt[i] - d[i]) / d[i];+      if (pct > maxPct) maxPct = pct;+    }+    return { factor, maxPct, scaledMm: detectedMm.map(v => v * factor) };+  }++  function negotiateScale(declaredMm, detectedMm) {+    let best = null;+    for (const c of RESCALE_CANDIDATES) {+      const s = scoreScale(declaredMm, detectedMm, c.factor);+      const cand = { ...c, ...s };+      if (!best || cand.maxPct < best.maxPct) best = cand;+    }+    return best;+  }++  // Get the body bbox via window.adomStep.bbox('Body'). If "Body" isn't+  // in the assembly tree (some STEPs ship a flat hierarchy), fall back+  // to the whole model's bbox.+  function detectBodyBbox() {+    if (!window.adomStep || typeof window.adomStep.bbox !== 'function') return null;+    let r = window.adomStep.bbox('Body');+    if (!r || r.error) r = window.adomStep.bbox();+    if (!r || r.error || !Array.isArray(r.mm)) return null;+    return { mm: r.mm.map(v => Math.abs(v)), source: r.name };+  }++  function renderMeasurementsTab() {+    const body = document.getElementById('cs-body');+    const declared = parseBodyDims(state.spec);+    const detected = detectBodyBbox();++    if (!declared) {+      state.status.measurements = '○';+      refreshStatusPills();+      body.innerHTML = `+        <div class="cs-section">+          <h3 class="cs-section-title">Measurements</h3>+          <div class="cs-row"><span class="value dim">No <code>body_dims_mm</code> declared in info.json — and the <code>package</code> field doesn't carry a parseable <code>W×H×D mm</code> tuple either. Add <code>"body_dims_mm": [w, h, d]</code> to info.json, then come back.</span></div>+        </div>`;+      return;+    }+    if (!detected) {+      state.status.measurements = '○';+      refreshStatusPills();+      body.innerHTML = `+        <div class="cs-section">+          <h3 class="cs-section-title">Measurements</h3>+          <div class="cs-row"><span class="value dim">Waiting for the model to load — body bbox not available yet. <button class="cs-btn cs-btn-secondary" id="cs-meas-retry" style="margin-top:8px"><span class="lbl">Retry</span></button></span></div>+        </div>`;+      const r = document.getElementById('cs-meas-retry');+      if (r) r.onclick = () => renderMeasurementsTab();+      return;+    }+    const result = negotiateScale(declared, detected.mm);+    const passes = result.maxPct <= 0.05;+    const isUnscaled = Math.abs(result.factor - 1) < 1e-9;+    const status = passes ? (isUnscaled ? '●' : '◐') : '✗';+    state.status.measurements = status;+    refreshStatusPills();+    state.measurements = {+      declared_mm: declared,+      detected_mm: detected.mm,+      detected_source: detected.source,+      applied_scale: passes ? result.factor : 1,+      applied_label: passes ? result.label : '×1',+      max_axis_pct: result.maxPct,+      passes, status,+    };++    const fmt = (a) => a.map(v => v.toFixed(3)).join(' × ');+    const fmtMils = (mm) => (mm / 0.0254).toFixed(1);+    const declaredMils = declared.map(fmtMils).join(' × ');+    const scaledMils   = result.scaledMm.map(fmtMils).join(' × ');+    const pctReadout   = (result.maxPct * 100).toFixed(2) + '%';+    const statusText   = passes+      ? (isUnscaled+        ? 'Match within ±5% — declared mm honored, no rescale needed.'+        : `Auto-rescaled by ${result.label} — ${escAttr(result.reason)} Sign-off will record the applied scale in <code>&lt;mpn&gt;.chipsmith.json</code>.`)+      : `No rescale lands within ±5% on every axis (best: ${result.label}, max Δ ${pctReadout}). Either the declared <code>body_dims_mm</code> is wrong or the STEP is geometrically broken. Sign-off blocked.`;+    const statusClass = passes ? (isUnscaled ? 'ok' : 'partial') : 'fail';++    body.innerHTML = `+      <div class="cs-section">+        <h3 class="cs-section-title">Declared vs detected</h3>+        <div class="cs-row"><span class="label">Declared</span><span class="value mono">${esc(fmt(declared))} mm <span class="dim">(${esc(declaredMils)} mils)</span></span></div>+        <div class="cs-row"><span class="label">Detected</span><span class="value mono">${esc(fmt(detected.mm))} mm <span class="dim">(${esc(detected.mm.map(fmtMils).join(' × '))} mils)</span></span></div>+        <div class="cs-row"><span class="label">Source node</span><span class="value mono">${esc(detected.source)}</span></div>+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">Rescale negotiation</h3>+        <div class="cs-row"><span class="label">Best scale</span><span class="value mono">${esc(result.label)}</span></div>+        <div class="cs-row"><span class="label">Scaled</span><span class="value mono">${esc(fmt(result.scaledMm))} mm <span class="dim">(${esc(scaledMils)} mils)</span></span></div>+        <div class="cs-row"><span class="label">Max Δ</span><span class="value mono">${esc(pctReadout)}</span></div>+        <div class="cs-row"><span class="label">Tolerance</span><span class="value mono">±5%</span></div>+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">Status</h3>+        <div class="cs-meas-status pill-${statusClass}">${statusText}</div>+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">Candidates</h3>+        <table class="cs-tbl">+          <thead><tr><th>Scale</th><th class="num">Max Δ</th><th>Reason</th></tr></thead>+          <tbody>+            ${RESCALE_CANDIDATES.map(c => {+              const s = scoreScale(declared, detected.mm, c.factor);+              const isWinner = Math.abs(c.factor - result.factor) < 1e-9;+              const ok = s.maxPct <= 0.05;+              return `<tr class="${isWinner ? 'is-winner' : ''} ${ok ? 'is-ok' : ''}">+                <td class="mono">${esc(c.label)}</td>+                <td class="num mono">${(s.maxPct * 100).toFixed(2)}%</td>+                <td class="dim">${esc(c.reason)}</td>+              </tr>`;+            }).join('')}+          </tbody>+        </table>+      </div>+    `;+  }++  // ── Tab 4: Footprint ────────────────────────────────────────────+  // /api/footprint-sources already returns parsed pads from+  // KiCad / Fusion (.lbr) / Altium / datasheet. v1.0 renders a summary+  // + cross-source agreement matrix; full 3D rendering of the pads at+  // z=0 with bbox-fit raycast is v1.1 (needs Babylon scene mutation+  // hooks beyond the current adom-step API surface).++  function fpExtent(pads) {+    if (!pads || !pads.length) return null;+    let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;+    for (const p of pads) {+      const cx = p.at?.[0] || 0, cy = p.at?.[1] || 0;+      const w = p.size?.[0] || 0, h = p.size?.[1] || 0;+      minX = Math.min(minX, cx - w/2); maxX = Math.max(maxX, cx + w/2);+      minY = Math.min(minY, cy - h/2); maxY = Math.max(maxY, cy + h/2);+    }+    return { width: maxX - minX, height: maxY - minY };+  }++  // ── 3D overlay layers ──────────────────────────────────────────+  // Add footprint pads as thin extruded rectangles at z≈0, faux PCB+  // plate as a 1.6mm FR4 box below the chip, and heatsink vias as+  // cylinders inside the EP. Each overlay is registered with adom-step+  // under a named group so it can be toggled / disposed as a unit.+  //+  // Coordinate system: GLB is in metres (1 unit = 1 mm × 0.001). All+  // footprint pad coords + sizes from /api/footprint-sources are in+  // mm — convert via × 0.001 before authoring meshes.++  const MM = 0.001;++  // ── World-up convention ─────────────────────────────────────────+  // chip-fetcher GLBs come authored Z-up (CAD/EE convention; camera+  // upVector = [0,0,1]). Older Adom 3D viewers were Y-up. Every+  // chipsmith renderer that places overlays at "z above the seat+  // plane" must consult these helpers — picking Y vs Z literally+  // changes which way is "up" in the world.+  //+  // worldUpIdx()    → 1 (Y-up) | 2 (Z-up)+  // planeAxes()     → [0, 2] for Y-up, [0, 1] for Z-up+  //                   The two scene-axis indices that make up the+  //                   horizontal plane (where the PCB lives).+  // fpPlace(x, y, h_mm)+  //                 → returns [worldX, worldY, worldZ] (in scene+  //                   units, metres) for placing a feature at+  //                   footprint-x = x mm, footprint-y = y mm,+  //                   height-above-ground = h mm. Use this for every+  //                   overlay mesh that sits on / above / below the+  //                   PCB plane.+  // upRotation()    → Babylon Euler-angle rotation needed to make a+  //                   default-Z-facing plane (CreatePlane) lie flat+  //                   on the ground. Y-up = π/2 about X; Z-up = 0+  //                   (default plane already faces +Z).+  function worldUpIdx() {+    if (typeof window === 'undefined' || !window.adomStep || !window.adomStep.getScene) return 1;+    try {+      const cam = window.adomStep.getScene().activeCamera;+      if (cam && cam.upVector && Math.abs(cam.upVector.z) > 0.5) return 2;+    } catch (_) {}+    return 1;+  }+  function planeAxes() {+    return worldUpIdx() === 2 ? [0, 1] : [0, 2];+  }+  function fpPlace(fpX_mm, fpY_mm, h_mm) {+    const upI = worldUpIdx();+    const ax = planeAxes();+    const out = [0, 0, 0];+    out[ax[0]] = (fpX_mm || 0) * MM;+    out[ax[1]] = (fpY_mm || 0) * MM;+    out[upI]   = (h_mm   || 0) * MM;+    return out;+  }+  function upRotation() {+    return worldUpIdx() === 1 ? Math.PI / 2 : 0;+  }+  // For CreateBox — pick the (width, height, depth) tuple so that the+  // box's THIN axis aligns with world-up. plane_w / plane_h map to the+  // two horizontal axes; thickness maps to world-up.+  function flatBoxDims(plane_w_mm, plane_h_mm, thickness_mm) {+    const upI = worldUpIdx();+    if (upI === 1) {+      // Y-up: thickness in Y, big dims in X (width) + Z (depth)+      return { width: plane_w_mm * MM, height: thickness_mm * MM, depth: plane_h_mm * MM };+    }+    // Z-up: thickness in Z, big dims in X (width) + Y (height)+    return { width: plane_w_mm * MM, height: plane_h_mm * MM, depth: thickness_mm * MM };+  }+  // Read body bbox extent along the world-up axis. Use this anywhere+  // the legacy code grabbed bb.max[1] / bb.min[1] expecting "top" /+  // "bottom" — those work in Y-up but read the chip's LENGTH axis in+  // Z-up. Returns { min, max, center } in scene units (metres).+  function bodyHeightExtent() {+    const bb = window.adomStep && window.adomStep.bbox && window.adomStep.bbox('Body');+    if (!bb || bb.error) return null;+    const upI = worldUpIdx();+    return {+      min: bb.min[upI],+      max: bb.max[upI],+      center: (bb.min[upI] + bb.max[upI]) / 2,+      // Plane-axis extents (everything not the up axis), useful for+      // sizing decals that should fit the chip's top face.+      planeW: bb.max[planeAxes()[0]] - bb.min[planeAxes()[0]],+      planeH: bb.max[planeAxes()[1]] - bb.min[planeAxes()[1]],+      planeCx: (bb.min[planeAxes()[0]] + bb.max[planeAxes()[0]]) / 2,+      planeCy: (bb.min[planeAxes()[1]] + bb.max[planeAxes()[1]]) / 2,+    };+  }+  // Fill in the spec fields chipsmith needs to run gates from a+  // single chip-fetcher-style `package` string. chip-fetcher writes+  // strings like "SOIC-8 (D, 4.9×3.9 mm, 1.27 mm pitch)" and+  // "VQFN-32 (RRG, 5×5×0.9 mm)" but leaves the structured fields+  // (pin_count / package_kind / structural_type / body_dims_mm /+  // expected_pitch_mm / has_ep) null. We parse the string here so the+  // user doesn't have to hand-populate every chip's info.json before+  // chipsmith works. Existing non-null fields are NEVER overwritten.+  //+  // Coverage: every package family in+  // gallia/skills/electrical-engineering/SKILL.md §"Package families".+  // When a string fails to parse, returns the input unchanged.+  function enrichSpec(spec) {+    if (!spec || typeof spec !== 'object') return spec;+    const out = Object.assign({}, spec);+    const pkgStr = (spec.package || spec.package_family || '').toString();+    // Family + pin count: leading word then optional dash + integer.+    // "VQFN-32 (RRG, 5×5×0.9 mm)"           → kind "VQFN", pins 32, family "VQFN-32"+    // "SOIC-8 (D, 4.9×3.9 mm, 1.27 mm pitch)" → kind "SOIC", pins 8, family "SOIC-8"+    // "SOT-23 (3-lead)"                     → kind "SOT", pins 3, family "SOT-23-3"+    // "LGA-16 (4.4x5.4mm Pitch0.5mm)"       → kind "LGA", pins 16, family "LGA-16"+    let m = pkgStr.match(/^([A-Z][A-Z0-9-]*?)-(\d+)\b/i);+    if (m) {+      if (out.package_kind == null)   out.package_kind = m[1].toUpperCase().replace(/^V?Q?FN$/, 'QFN').replace(/^[VST]?SO[PI]C?$/, 'SOIC');+      if (out.pin_count == null)      out.pin_count = parseInt(m[2], 10);+      if (out.package_family == null) out.package_family = `${m[1].toUpperCase()}-${m[2]}`;+    } else if ((m = pkgStr.match(/^(SOT-23(?:-\d+)?|TO-220|TO-247|DPAK|D2PAK|USB-?C|RJ-?45|DIP-?\d*|SIP-?\d*)/i))) {+      // Connector-ish + special non-dash families.+      if (out.package_kind == null)   out.package_kind = m[1].toUpperCase().replace(/-.*$/, '');+      if (out.package_family == null) out.package_family = m[1].toUpperCase();+    }++    // body_dims_mm: parse "5×5×0.9 mm" / "4.9x3.9 mm" / "5×5×0.9mm" /+    // "4.4×5.4×1.5 mm". Fall back to two-axis if no third found.+    if (!Array.isArray(out.body_dims_mm)) {+      m = pkgStr.match(/(\d+(?:\.\d+)?)\s*[×xX]\s*(\d+(?:\.\d+)?)(?:\s*[×xX]\s*(\d+(?:\.\d+)?))?\s*mm/);+      if (m) {+        const w = parseFloat(m[1]), h = parseFloat(m[2]), z = m[3] ? parseFloat(m[3]) : null;+        // Per-package-kind default thickness when not given. Datasheet+        // typical, NOT max. Conservative — gates use ±5%.+        const kind = out.package_kind || '';+        const defaultZ = ({+          SOIC: 1.65, SSOP: 1.20, TSSOP: 1.10, MSOP: 1.10, VSSOP: 1.10,+          QFN:  0.85, DFN: 0.85, VQFN: 0.85, LGA: 0.93, BGA: 1.10,+          SOT:  1.10, TQFP: 1.40, LQFP: 1.40, QFP: 1.40, DIP: 4.00,+        }[kind] || 1.00);+        out.body_dims_mm = z != null ? [w, h, z] : [w, h, defaultZ];+      }+    }++    // expected_pitch_mm: parse "0.5 mm pitch" / "Pitch 0.5mm" / "1.27 mm pitch".+    if (out.expected_pitch_mm == null) {+      m = pkgStr.match(/(?:Pitch\s*|pitch[ :]*|P)(\d+(?:\.\d+)?)\s*mm|(\d+(?:\.\d+)?)\s*mm\s*pitch/i);+      if (m) out.expected_pitch_mm = parseFloat(m[1] || m[2]);+      else if (out.package_kind) {+        // Fallback by package kind (canonical pitch per family).+        out.expected_pitch_mm = ({+          SOIC: 1.27, SSOP: 0.65, TSSOP: 0.65, MSOP: 0.65, VSSOP: 0.50,+          QFN:  0.50, DFN: 0.50, VQFN: 0.50, LGA: 0.50, BGA: 0.50,+          DIP:  2.54, SOT: 0.95, TQFP: 0.50, LQFP: 0.50, QFP: 0.65,+        }[out.package_kind] || null);+      }+    }++    // structural_type: derived from package_kind.+    if (out.structural_type == null && out.package_kind) {+      out.structural_type = ({+        SOIC: 'smd_leg', SSOP: 'smd_leg', TSSOP: 'smd_leg', MSOP: 'smd_leg', VSSOP: 'smd_leg',+        TQFP: 'smd_leg', LQFP: 'smd_leg', QFP: 'smd_leg', SOT: 'smd_leg',+        QFN: 'smd_pad', DFN: 'smd_pad', VQFN: 'smd_pad', LGA: 'smd_pad',+        BGA: 'ball',+        DIP: 'thru_hole_pin', PDIP: 'thru_hole_pin',+      }[out.package_kind] || null);+    }++    // has_ep: QFN/DFN/VQFN almost always. LGA sometimes. Others rarely.+    if (out.has_ep == null && out.package_kind) {+      const epKinds = new Set(['QFN', 'DFN', 'VQFN', 'TQFN']);+      // Mark LGA "maybe EP" — datasheet-specific. Conservative default true+      // for LGA so the heatsink-via gate runs.+      if (epKinds.has(out.package_kind)) out.has_ep = true;+    }++    return out;+  }++  // Apply the standard "lay down on the ground plane" rotation to a+  // mesh whose long axis is Babylon's default +Y (cylinder, capsule).+  // Y-up: leave Y-axis alone. Z-up: rotate so Y maps to Z.+  function alignVerticalToWorldUp(mesh) {+    if (worldUpIdx() === 2) {+      mesh.rotation.x = Math.PI / 2;+    }+  }+  // Place a mesh at footprint (fpX, fpY) mm with its center at world-up+  // height h_mm. Assigns to mesh.position directly.+  function placeMeshAt(mesh, fpX_mm, fpY_mm, h_mm) {+    const p = fpPlace(fpX_mm, fpY_mm, h_mm);+    mesh.position.x = p[0];+    mesh.position.y = p[1];+    mesh.position.z = p[2];+  }++  // Source-tinted overlay colors per the plan §"Multi-source footprint+  // cross-validation". Datasheet = canonical truth (orange); KiCad =+  // green; Fusion = blue; Altium = purple.+  const SRC_COLOR = {+    datasheet: { r: 1.00, g: 0.55, b: 0.10 },+    kicad:     { r: 0.30, g: 0.85, b: 0.45 },+    fusion:    { r: 0.30, g: 0.55, b: 0.95 },+    altium:    { r: 0.65, g: 0.40, b: 0.85 },+    // ds2sf v0.6.0 — 5th independent pad-position source. Warm amber+    // distinct from datasheet-orange and kicad-green.+    ds2sf:     { r: 0.95, g: 0.75, b: 0.20 },+    // Stock-library ghost — service-kicad-backed authoritative footprint.+    // Translucent off-white so it reads as a "ghost" against any of the+    // four user sources without competing for the eye.+    stock:     { r: 0.85, g: 0.85, b: 0.92 },+  };++  function ensureOverlayDisposed(group) {+    if (window.adomStep && window.adomStep.removeOverlay) {+      try { window.adomStep.removeOverlay(group); } catch (_) {}+    }+  }++  const SRC_LABEL = { kicad: 'KiCad', fusion: 'Fusion (.lbr)', altium: 'Altium (.PcbLib)', datasheet: 'Datasheet-derived', ds2sf: 'ds2sf pads' };++  function renderFootprintOverlay(srcKey, padsMm) {+    if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon)) return null;+    const scene = window.adomStep.getScene();+    const B = window.adomStep.getBabylon();+    if (!scene || !B) return null;+    const group = 'fp-' + srcKey;+    ensureOverlayDisposed(group);+    const color = SRC_COLOR[srcKey] || { r: 0.5, g: 0.5, b: 0.5 };+    const mat = new B.StandardMaterial(group + '-mat', scene);+    mat.diffuseColor = new B.Color3(color.r, color.g, color.b);+    mat.alpha = 0.55;+    mat.specularColor = new B.Color3(0, 0, 0);+    mat.backFaceCulling = false;+    mat.zOffset = -1;+    // Per-source Z stagger — when multiple sources render their pads at+    // the same height they Z-fight. Stagger 5 µm between sources so the+    // user can visibly stack them in the outline without flicker.+    // datasheet sits lowest (it's the canonical truth, behind everything),+    // then kicad / fusion / altium ascending.+    const Z_STAGGER = { datasheet: 0.020, kicad: 0.025, fusion: 0.030, altium: 0.035, ds2sf: 0.040 };+    const padHeight_mm = Z_STAGGER[srcKey] != null ? Z_STAGGER[srcKey] : 0.025;++    // Build per-pad children — each pad gets its own outline row with+    // inline metadata: number, shape, size, layers, rotation.+    // Pad rendering covers KiCad's full shape vocabulary:+    //   • rect / rounded — box (rounded uses box too; corner radius+    //     would need CSG which isn't worth it for an overlay).+    //   • circle — cylinder along world-up axis.+    //   • oval — capsule along world-up; long axis = footprint X+    //     (rotation applied after).+    //   • custom polygon — fallback to rect bbox.+    // Pad rotation (third value of `at`, in degrees) rotates the mesh+    // around the world-up axis. Critical for SOIC/BGA layouts where+    // pads are rotated 90° vs the part's symbol orientation.+    const children = [];+    for (const p of padsMm) {+      if (!p.size || p.size[0] <= 0 || p.size[1] <= 0) continue;+      const fpX = p.at?.[0] || 0;+      const fpY = p.at?.[1] || 0;+      const rotDeg = p.at?.[2] || 0;+      const w = p.size[0], h = p.size[1];+      const shape = (p.shape || 'rect').toLowerCase();+      let m;+      const cr = p.corner_radius || 0;+      if (shape === 'circle') {+        m = B.MeshBuilder.CreateCylinder(`${group}-pad-${p.number}`, {+          diameter: Math.max(w, h) * MM, height: 0.04 * MM, tessellation: 24,+        }, scene);+        alignVerticalToWorldUp(m);+      } else if (shape === 'oval') {+        const longer = Math.max(w, h) * MM;+        const shorter = Math.min(w, h) * MM;+        m = B.MeshBuilder.CreateCapsule(`${group}-pad-${p.number}`, {+          radius: shorter / 2, height: longer, tessellation: 18,+        }, scene);+        alignVerticalToWorldUp(m);+        if (w >= h) {+          if (worldUpIdx() === 1) m.rotation.z = Math.PI / 2;+          else                    m.rotation.y = Math.PI / 2;+        }+        m.scaling.y = (0.04 * MM) / longer;+      } else if ((shape === 'roundrect' || cr > 0.01) && typeof window.earcut === 'function') {+        // Rounded-rect pad via earcut. Quarter-circle arcs at each+        // corner, 6 points per arc. Two face layers (top + bottom at+        // ±0.02mm) so the mesh has Z-extent and scene.pick works.+        const hw = w / 2, hh = h / 2;+        const r = Math.min(cr, hw, hh);+        const N = 6;+        const pts2d = [];+        for (let i = 0; i <= N; i++) { const a = -Math.PI/2 + (Math.PI/2)*i/N; pts2d.push([hw - r + r*Math.cos(a), -hh + r + r*Math.sin(a)]); }+        for (let i = 0; i <= N; i++) { const a = 0 + (Math.PI/2)*i/N; pts2d.push([hw - r + r*Math.cos(a), hh - r + r*Math.sin(a)]); }+        for (let i = 0; i <= N; i++) { const a = Math.PI/2 + (Math.PI/2)*i/N; pts2d.push([-hw + r + r*Math.cos(a), hh - r + r*Math.sin(a)]); }+        for (let i = 0; i <= N; i++) { const a = Math.PI + (Math.PI/2)*i/N; pts2d.push([-hw + r + r*Math.cos(a), -hh + r + r*Math.sin(a)]); }+        const flat = new Float64Array(pts2d.length * 2);+        for (let i = 0; i < pts2d.length; i++) { flat[i*2] = pts2d[i][0]; flat[i*2+1] = pts2d[i][1]; }+        const tris = window.earcut(flat);+        if (tris && tris.length >= 3) {+          const upI = worldUpIdx();+          const nPts = pts2d.length;+          const thick = 0.04 * MM;+          const positions = new Float32Array(nPts * 2 * 3);+          const normals = new Float32Array(nPts * 2 * 3);+          for (let k = 0; k < nPts; k++) {+            const px = pts2d[k][0] * MM, py = pts2d[k][1] * MM;+            if (upI === 2) {+              positions[k*3] = px; positions[k*3+1] = py; positions[k*3+2] = thick/2;+              positions[(nPts+k)*3] = px; positions[(nPts+k)*3+1] = py; positions[(nPts+k)*3+2] = -thick/2;+            } else {+              positions[k*3] = px; positions[k*3+1] = thick/2; positions[k*3+2] = -py;+              positions[(nPts+k)*3] = px; positions[(nPts+k)*3+1] = -thick/2; positions[(nPts+k)*3+2] = -py;+            }+            normals[k*3+upI] = 1;+            normals[(nPts+k)*3+upI] = -1;+          }+          const indices = new Uint32Array(tris.length * 2);+          for (let k = 0; k < tris.length; k += 3) {+            indices[k] = tris[k]; indices[k+1] = tris[k+1]; indices[k+2] = tris[k+2];+            indices[tris.length+k] = tris[k]+nPts; indices[tris.length+k+1] = tris[k+2]+nPts; indices[tris.length+k+2] = tris[k+1]+nPts;+          }+          m = new B.Mesh(`${group}-pad-${p.number}`, scene);+          const vd = new B.VertexData();+          vd.positions = positions; vd.indices = indices; vd.normals = normals;+          vd.applyToMesh(m, false);+        } else {+          m = B.MeshBuilder.CreateBox(`${group}-pad-${p.number}`, flatBoxDims(w, h, 0.04), scene);+        }+      } else {+        m = B.MeshBuilder.CreateBox(`${group}-pad-${p.number}`, flatBoxDims(w, h, 0.04), scene);+      }+      const pos = fpPlace(fpX, fpY, padHeight_mm);+      m.position.x = pos[0]; m.position.y = pos[1]; m.position.z = pos[2];+      // Apply pad rotation around the world-up axis.+      if (rotDeg) {+        const rotRad = -rotDeg * Math.PI / 180;   // KiCad rotates CW; Babylon Y-rotation is CCW+        if (worldUpIdx() === 1) m.rotation.y = (m.rotation.y || 0) + rotRad;+        else                    m.rotation.z = (m.rotation.z || 0) + rotRad;+      }+      m.material = mat;+      const layerStr = (p.layers || []).map(l => l.replace(/^F\./,'F.').replace(/^B\./,'B.').replace(/^\*\./, '*.')).join('+');+      const rotStr = rotDeg ? `  rot ${rotDeg.toFixed(0)}°` : '';+      const nameStr = p.name ? ` (${p.name})` : '';+      const shapeStr = cr > 0.01 ? `roundrect r=${cr.toFixed(2)}` : shape;+      const label = `pad ${p.number}${nameStr}  ${shapeStr} ${w.toFixed(2)}×${h.toFixed(2)} mm  c=(${fpX.toFixed(2)},${fpY.toFixed(2)})${rotStr}  ${layerStr}`;+      children.push({ meshes: [m], label, kind: 'overlay-fp-pad' });+    }+    const SRC_PROVENANCE = {+      kicad:     'Parsed from the .kicad_mod file in the chip-fetcher library.\nThird-party footprint — a KiCad library maintainer traced the\nmanufacturer datasheet into this format.',+      fusion:    'Parsed from the Eagle .lbr file (Fusion 360 format) in the\nchip-fetcher library. Third-party footprint — authored by\nSnapEDA, SnapMagic, or Ultra Librarian.',+      altium:    'Parsed from the .PcbLib file in the chip-fetcher library.\nThird-party footprint — authored by the Altium library\necosystem (SnapEDA, SnapMagic, Ultra Librarian, or CSE).',+      datasheet: 'Derived from the manufacturer datasheet PDF. First-party\nsource — the pad positions trace directly to the mechanical\ndrawing page, not to any EDA library.',+      ds2sf:     'Extracted by ds2sf directly from the manufacturer datasheet\nmechanical drawing. This is the only source that traces pad\npositions back to the manufacturer — every other source is a\nthird-party interpretation. Disagreements here mean the EDA\nlibraries may have gotten it wrong.',+    };+    const provStr = SRC_PROVENANCE[srcKey] || '';+    const displayLabel = `${SRC_LABEL[srcKey] || srcKey} footprint  (${children.length} pads)`;+    window.adomStep.addOverlay({+      group,+      label: displayLabel,+      tooltip: provStr ? `${displayLabel}\n\n${provStr}` : displayLabel,+      kind: 'overlay-footprint',+      children,+    });+    return { group, count: children.length };+  }++  // Render a 1.6 mm FR4 plate sized to the footprint extent + 5 mm+  // margin, CSG-drilled with every hole declared in the footprint+  // pads. Four hole types (per the plan):+  //   • plated round (PTH) — pad.type == 'thru_hole', drill.shape circle+  //   • unplated round (NPTH) — pad.type == 'np_thru_hole', drill.shape circle+  //   • plated slot — pad.type == 'thru_hole', drill.shape oval+  //   • unplated slot — pad.type == 'np_thru_hole', drill.shape oval+  // Plated entries get a Cu-tone (0xC79A4F) inner-bore shell + a top+  // and bottom annular ring so the plating reads visibly on zoom. CSG+  // is via Babylon's B.CSG; we union all holes once then subtract in+  // a single pass so a 12-pin DIP doesn't take 12 round-trips.+  function renderFauxPcbPlate(extentMm, pads) {+    if (!extentMm) return null;+    if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon)) return null;+    const scene = window.adomStep.getScene();+    const B = window.adomStep.getBabylon();+    if (!scene || !B) return null;+    const group = 'faux-pcb';+    ensureOverlayDisposed(group);+    const padMargin = 5;+    const plateW_mm = extentMm.width  + padMargin * 2;+    const plateH_mm = extentMm.height + padMargin * 2;+    const w = plateW_mm * MM;+    const h = plateH_mm * MM;+    const t_mm = 1.6;+    const plate = B.MeshBuilder.CreateBox('faux-pcb-plate', flatBoxDims(plateW_mm, plateH_mm, t_mm), scene);+    // Plate top face flush with seat plane (height = 0). Center of+    // the plate is therefore 0.8 mm BELOW the seat plane.+    placeMeshAt(plate, 0, 0, -t_mm / 2);+    const matFR4 = new B.StandardMaterial('faux-pcb-mat', scene);+    matFR4.diffuseColor = new B.Color3(0.08, 0.35, 0.18);+    matFR4.alpha = 1.0;+    matFR4.specularColor = new B.Color3(0.05, 0.10, 0.07);+    matFR4.backFaceCulling = false;+    plate.material = matFR4;++    // Bucket pads by hole kind. SMD (no drill) is filtered out.+    // ALSO filter out thru-hole pads that fall inside the EP region+    // when chipsmith has its own heatsink-via grid: those holes belong+    // to the heatsink overlay (rendered as hollow Cu barrels), not to+    // the faux PCB. Otherwise the user sees the same holes drilled+    // twice — once by the faux PCB CSG, once by the heatsink overlay.+    // (Manufacturer .kicad_mod files often include the recommended+    // heatsink-via grid as thru_hole pads inside the EP. chipsmith+    // owns those when its grid is on.)+    const ep = state.heatsinkGrid?.ep;+    const epHalfX = ep ? ep.size_mm[0] / 2 : 0;+    const epHalfY = ep ? ep.size_mm[1] / 2 : 0;+    const epOx   = ep ? ep.origin_mm.x : 0;+    const epOy   = ep ? ep.origin_mm.y : 0;+    function isInsideEp(x_mm, y_mm) {+      if (!ep) return false;+      return Math.abs(x_mm - epOx) <= epHalfX && Math.abs(y_mm - epOy) <= epHalfY;+    }+    let suppressedByEp = 0;+    const drilled = [];+    if (Array.isArray(pads)) {+      for (const p of pads) {+        const d = p && p.drill;+        if (!d || typeof d !== 'object') continue;+        const isPlated = (p.type === 'thru_hole');+        const isNP     = (p.type === 'np_thru_hole');+        if (!isPlated && !isNP) continue;+        const isSlot = d.shape === 'oval';+        const at = Array.isArray(p.at) ? p.at : [0, 0, 0];+        let dw, dh;+        if (isSlot && Array.isArray(d.size)) { dw = d.size[0]; dh = d.size[1]; }+        else { dw = d.diameter || 0; dh = d.diameter || 0; }+        if (dw <= 0 || dh <= 0) continue;+        // EP suppression — owned by the heatsink overlay.+        if (isInsideEp(at[0] || 0, at[1] || 0)) { suppressedByEp++; continue; }+        drilled.push({+          number: p.number || '?',+          x_mm: at[0] || 0,+          y_mm: at[1] || 0,+          dw_mm: dw,+          dh_mm: dh,+          isPlated, isSlot,+        });+      }+    }+    if (suppressedByEp > 0) {+      console.log(`[chipsmith] faux PCB: suppressed ${suppressedByEp} thru-hole pad(s) inside the EP (owned by heatsink overlay)`);+    }+    state.fauxPcbSuppressedEpHoles = suppressedByEp;++    // CSG-subtract every hole at once. Babylon CSG is slow to construct+    // per-mesh, so we union in a tight loop. Hole height is plate+    // thickness + 0.2 mm overrun on each side to guarantee a clean cut+    // through both faces (CSG is sensitive to coplanar shells).+    let plateMesh = plate;+    if (drilled.length) {+      const overrun_mm = t_mm + 0.4;+      let cutCSG = null;+      const cutMeshes = [];+      for (const hole of drilled) {+        let cutter;+        if (hole.isSlot) {+          // Capsule (slot cutter) — Babylon's default capsule axis is +Y;+          // we lay it along world-up so it cuts through the plate's+          // thickness, then orient it within the plane so its length+          // runs along whichever footprint axis is longer.+          const longer  = Math.max(hole.dw_mm, hole.dh_mm) * MM;+          const shorter = Math.min(hole.dw_mm, hole.dh_mm) * MM;+          cutter = B.MeshBuilder.CreateCapsule('cutter-' + hole.number, {+            radius: shorter / 2, height: longer, tessellation: 18,+          }, scene);+          // 1) Lay along world-up. 2) Rotate within the plane so the+          // capsule's length runs along the longer footprint axis.+          alignVerticalToWorldUp(cutter);+          if (hole.dw_mm >= hole.dh_mm) {+            // Long axis along plane[0] (footprint X)+            if (worldUpIdx() === 1) cutter.rotation.z = Math.PI / 2;+            else                    cutter.rotation.y = Math.PI / 2;+          }+          cutter.scaling.y = (overrun_mm * MM) / longer;+          placeMeshAt(cutter, hole.x_mm, hole.y_mm, -t_mm / 2);+        } else {+          // Cylinder cutter — default Babylon axis is +Y, align to+          // world-up so it cuts through plate thickness.+          cutter = B.MeshBuilder.CreateCylinder('cutter-' + hole.number, {+            diameter: hole.dw_mm * MM, height: overrun_mm * MM, tessellation: 24,+          }, scene);+          alignVerticalToWorldUp(cutter);+          placeMeshAt(cutter, hole.x_mm, hole.y_mm, -t_mm / 2);+        }+        cutter.isVisible = false;+        cutMeshes.push(cutter);+        const csg = B.CSG.FromMesh(cutter);+        cutCSG = cutCSG ? cutCSG.union(csg) : csg;+      }+      try {+        const plateCSG = B.CSG.FromMesh(plate);+        const drilledCSG = plateCSG.subtract(cutCSG);+        const drilledMesh = drilledCSG.toMesh('faux-pcb-plate-drilled', matFR4, scene);+        // CSG.toMesh re-anchors at world origin; plate top face stays+        // flush with seat plane (height=0) because the source plate+        // was already in that pose.+        plate.dispose();+        plateMesh = drilledMesh;+      } catch (e) {+        console.warn('[chipsmith] CSG drill failed; rendering undrilled plate', e);+      }+      for (const c of cutMeshes) c.dispose();+    }++    // Cu plating + annular rings for plated holes/slots only.+    const matCu = new B.StandardMaterial('faux-pcb-cu', scene);+    matCu.diffuseColor = new B.Color3(0.78, 0.55, 0.25);+    matCu.specularColor = new B.Color3(0.30, 0.20, 0.10);+    const plateMeshes = [plateMesh];+    const annularT = 0.05 * MM;+    const ringExtra = 0.20 * MM; // ring extends 0.20 mm past hole edge+    const platedPTH = drilled.filter(h =>  h.isPlated && !h.isSlot);+    const platedSLT = drilled.filter(h =>  h.isPlated &&  h.isSlot);+    const npPTH     = drilled.filter(h => !h.isPlated && !h.isSlot);+    const npSLT     = drilled.filter(h => !h.isPlated &&  h.isSlot);+    function addPlatingForRound(hole, target) {+      const innerR_mm = hole.dw_mm / 2;+      const outerR_mm = innerR_mm + (ringExtra / MM);+      // Inner-bore plating (cylinder along world-up axis, plate-thick+      // minus a hair so it's visible inside the drilled hole).+      const bore = B.MeshBuilder.CreateCylinder('plating-bore-' + hole.number, {+        diameter: innerR_mm * 2 * MM + 0.005 * MM,+        height: (t_mm - 0.001) * MM,+        tessellation: 24,+      }, scene);+      alignVerticalToWorldUp(bore);+      placeMeshAt(bore, hole.x_mm, hole.y_mm, -t_mm / 2);+      bore.material = matCu;+      target.push(bore);+      // Top + bottom annular pad rings.+      for (const h_mm of [0.001, -(t_mm + 0.001)]) {+        const ring = B.MeshBuilder.CreateCylinder('plating-ring-' + hole.number + '-' + h_mm.toFixed(4), {+          diameterTop: outerR_mm * 2 * MM, diameterBottom: outerR_mm * 2 * MM,+          height: annularT, tessellation: 24,+        }, scene);+        alignVerticalToWorldUp(ring);+        placeMeshAt(ring, hole.x_mm, hole.y_mm, h_mm);+        ring.material = matCu;+        target.push(ring);+      }+    }+    function addPlatingForSlot(hole, target) {+      const longer  = Math.max(hole.dw_mm, hole.dh_mm) * MM;+      const shorter = Math.min(hole.dw_mm, hole.dh_mm) * MM;+      const outerLong  = longer  + ringExtra * 2;+      const outerShort = shorter + ringExtra * 2;+      for (const h_mm of [0.001, -(t_mm + 0.001)]) {+        const ring = B.MeshBuilder.CreateCapsule('slot-ring-' + hole.number + '-' + h_mm.toFixed(4), {+          radius: outerShort / 2, height: outerLong, tessellation: 18,+        }, scene);+        // 1) Lay along world-up; 2) rotate within plane so length runs+        // along the longer footprint axis. 3) Squash along its long+        // axis to annular-thickness.+        alignVerticalToWorldUp(ring);+        if (hole.dw_mm >= hole.dh_mm) {+          if (worldUpIdx() === 1) ring.rotation.z = Math.PI / 2;+          else                    ring.rotation.y = Math.PI / 2;+        }+        ring.scaling.y = annularT / outerLong;+        placeMeshAt(ring, hole.x_mm, hole.y_mm, h_mm);+        ring.material = matCu;+        target.push(ring);+      }+    }+    const platingMeshes = [];+    for (const h of platedPTH) addPlatingForRound(h, platingMeshes);+    for (const h of platedSLT) addPlatingForSlot(h, platingMeshes);++    // Build per-bucket child rows. Each leaf row carries a list of+    // pad numbers in its label so the user can scan which holes ended+    // up where without expanding nine times.+    function summary(holes, kindLabel, allMeshes) {+      if (!holes.length) {+        return { meshes: [], label: `${kindLabel}  0`, kind: 'overlay-pcb-' + kindLabel.toLowerCase().replace(/[^a-z]/g, '-') };+      }+      const nums = holes.map(h => h.number).join(', ');+      const sizeStr = holes[0].isSlot+        ? holes.map(h => `${h.dw_mm.toFixed(2)}×${h.dh_mm.toFixed(2)}`).join(', ')+        : holes.map(h => `Ø${h.dw_mm.toFixed(2)}`).join(', ');+      return {+        meshes: allMeshes,+        label: `${kindLabel}  ${holes.length}  pads ${nums}  ${sizeStr} mm`,+        kind: 'overlay-pcb-' + kindLabel.toLowerCase().replace(/[^a-z]/g, '-'),+      };+    }++    const children = [+      { meshes: [plateMesh, ...platingMeshes], label: `FR4 plate  ${plateW_mm.toFixed(1)}×${plateH_mm.toFixed(1)}×1.6 mm  translucent green${platingMeshes.length ? `  + ${platingMeshes.length} Cu plating shells` : ''}`, kind: 'overlay-pcb-plate' },+      summary(platedPTH, 'Plated holes (PTH)', platingMeshes),+      summary(npPTH,     'Unplated holes (NPTH)', []),+      summary(platedSLT, 'Plated slots',     platingMeshes),+      summary(npSLT,     'Unplated slots',   []),+    ];+    if (suppressedByEp > 0) {+      children.push({+        meshes: [],+        label: `↳ ${suppressedByEp} EP-region thru-hole(s) suppressed — owned by Heatsink overlay`,+        kind: 'overlay-pcb-ep-deferred',+      });+    }+    const epNote = suppressedByEp > 0 ? `  ·  ${suppressedByEp} deferred to Heatsink` : '';+    window.adomStep.addOverlay({+      group,+      label: `Faux PCB  (1.6 mm FR4, ${plateW_mm.toFixed(1)}×${plateH_mm.toFixed(1)} mm  ·  ${drilled.length} drill${drilled.length === 1 ? '' : 's'}${epNote})`,+      kind: 'overlay-pcb',+      children,+    });+    state.fauxPcbDims = { w_mm: plateW_mm, h_mm: plateH_mm, drilled };+    return { group, w_mm: plateW_mm, h_mm: plateH_mm, drilled };+  }++  // ── ds2sf SVG vector parser ─────────────────────────────────────+  // ds2sf gives PURE VECTOR DATA (mm-accurate <path> elements with+  // viewBox in mm). Rasterizing it to a texture and slapping it on+  // a plane was the original approach — looked nasty the moment you+  // zoomed in (texture filtering blur + mipmap loss). This pass+  // parses each <path>'s d-attribute into 2D points and builds real+  // Babylon polygon meshes via earcut, so the geometry stays sharp+  // at any zoom — same way the chip body and pad overlays do.+  function parseSvgPathD(d) {+    if (!d) return [];+    const tokens = d.replace(/\s+/g, ' ').match(/[MLZmlz]|-?\d*\.?\d+(?:[eE][+-]?\d+)?/g) || [];+    const points = [];+    let cmd = null;+    let i = 0;+    while (i < tokens.length) {+      const t = tokens[i];+      if (/^[MLZmlz]$/.test(t)) {+        cmd = t.toUpperCase();+        i++;+        if (cmd === 'Z') continue;+        continue;+      }+      const x = parseFloat(tokens[i]);+      const y = parseFloat(tokens[i + 1]);+      if (isFinite(x) && isFinite(y)) points.push([x, y]);+      i += 2;+      if (cmd === 'M') cmd = 'L';+    }+    return points;+  }+  // Resolve an SVG element's fill color. Checks (in order):+  // 1. Inline style="fill:#xxx"+  // 2. fill="..." attribute+  // 3. CSS class rules from the <style> block (ds2sf-synthesized SVGs+  //    use classes like .pad, .body, .pin1 with fills defined in <style>)+  // 4. Walk up the parent chain (inherited fills from <g> groups —+  //    pcbnew stdlib SVGs use this)+  function svgResolveFill(el, cssMap) {+    const styleAttr = (e, key) => {+      const s = e.getAttribute('style') || '';+      const m = s.match(new RegExp(key + '\\s*:\\s*([^;]+)', 'i'));+      return m ? m[1].trim() : null;+    };+    let cur = el;+    while (cur && cur.nodeType === 1) {+      const f = styleAttr(cur, 'fill') || cur.getAttribute('fill');+      if (f && f.toLowerCase() !== 'none') return f;+      if (cssMap) {+        const cls = (cur.getAttribute('class') || '').split(/\s+/);+        for (const c of cls) {+          if (c && cssMap[c]) return cssMap[c];+        }+      }+      cur = cur.parentElement;+    }+    return null;+  }+  function svgResolveFillOpacity(el, cssMap) {+    const styleAttr = (e, key) => {+      const s = e.getAttribute('style') || '';+      const m = s.match(new RegExp(key + '\\s*:\\s*([^;]+)', 'i'));+      return m ? m[1].trim() : null;+    };+    let cur = el;+    while (cur && cur.nodeType === 1) {+      const f = styleAttr(cur, 'fill-opacity');+      if (f) return parseFloat(f) || 1;+      cur = cur.parentElement;+    }+    return 1;+  }+  // Parse a <style> block into a className → fill map so class-based+  // SVGs (ds2sf-synthesized) resolve fills without getComputedStyle.+  function svgParseCssMap(doc) {+    const map = {};+    const styleEls = doc.querySelectorAll('style');+    for (const sEl of styleEls) {+      const txt = sEl.textContent || '';+      const re = /\.([a-zA-Z0-9_-]+)\s*\{[^}]*fill\s*:\s*([^;}\s]+)/g;+      let m;+      while ((m = re.exec(txt))) map[m[1]] = m[2].trim();+    }+    return map;+  }+  // Convert an SVG <rect> to 4 corner points (with optional rx rounding+  // approximated as straight corners — good enough for the overlay).+  function svgRectToPoints(el) {+    const x = parseFloat(el.getAttribute('x')) || 0;+    const y = parseFloat(el.getAttribute('y')) || 0;+    const w = parseFloat(el.getAttribute('width')) || 0;+    const h = parseFloat(el.getAttribute('height')) || 0;+    if (w <= 0 || h <= 0) return [];+    return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]];+  }+  // Convert an SVG <circle> to N-point approximation.+  function svgCircleToPoints(el, n) {+    const cx = parseFloat(el.getAttribute('cx')) || 0;+    const cy = parseFloat(el.getAttribute('cy')) || 0;+    const r = parseFloat(el.getAttribute('r')) || 0;+    if (r <= 0) return [];+    const pts = [];+    for (let i = 0; i < (n || 24); i++) {+      const a = (2 * Math.PI * i) / (n || 24);+      pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);+    }+    return pts;+  }++  // ds2sf v0.5.1 authoritative footprint — parsed as REAL VECTOR+  // POLYGONS via earcut (NOT a texture-mapped raster — that approach+  // looked nasty at any zoom level). First-class Outline citizen+  // alongside the kicad / fusion / altium overlays; one sub-row per+  // SVG path so the user can isolate / inspect any individual pad+  // outline, silk stroke, fab label, or body silhouette.+  //+  // SVG → world coord transform:+  //   • viewBox is in millimetres (pcbnew SVG convention).+  //   • Translate so chip-center → world origin (0,0).+  //   • SVG-Y matches the kicad/fusion/altium pad coord convention+  //     (Y increases "down the page" — pcbnew authored both), so+  //     pads from ds2sf and from .kicad_mod end up at the same+  //     world XY. NO Y-flip.+  //+  // Layer Z-stacking from pcbnew's standard fill colors:+  //   #C83434 → F.Cu / pad copper (z = 0.006 mm above seat)+  //   #B4A09A → solder mask        (z = 0.002 mm)+  //   #D864FF → silkscreen         (z = 0.013 mm)+  //   #F2EDA1 → F.Fab labels       (z = 0.016 mm)+  //   any other colour             (z = 0.008 mm)+  async function renderDs2sfOverlay(opts = {}) {+    if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon)) return null;+    const ds2sf = state.ds2sfResult;+    if (!ds2sf || !ds2sf.available) return { error: 'no ds2sf result for this chip' };+    if (typeof window.earcut !== 'function') return { error: 'earcut not loaded — vector polygon build needs window.earcut' };+    const source = ds2sf.outputs?.footprint_svg_source || 'unknown';+    const scene = window.adomStep.getScene();+    const B = window.adomStep.getBabylon();+    const group = 'ds2sf';+    ensureOverlayDisposed(group);++    // Pull the SVG text and parse the DOM client-side.+    let svgText;+    try {+      svgText = await fetch('api/ds2sf-svg?cb=' + Date.now()).then(r => r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status)));+    } catch (e) {+      return { error: 'fetch ds2sf SVG: ' + (e?.message || e) };+    }+    const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');+    const svg = doc.documentElement;+    const vb = (svg.getAttribute('viewBox') || '0 0 10 10').trim().split(/\s+/).map(parseFloat);+    const vbX = vb[0] || 0, vbY = vb[1] || 0, vbW = vb[2] || 10, vbH = vb[3] || 10;++    // Detect SVG format. pcbnew stdlib SVGs use mm in the viewBox+    // (width="22.6568mm"). ds2sf-synthesized SVGs use px (width="277",+    // no unit suffix) and carry an extent_mm in the extraction result.+    // We need a scale factor from SVG units → mm.+    const widthAttr = svg.getAttribute('width') || '';+    const isPx = !widthAttr.includes('mm');+    let svgToMm = 1;+    if (isPx) {+      const ext = ds2sf.outputs?.footprint_extent_mm;+      if (ext && Array.isArray(ext) && ext.length === 2 && ext[0] > 0) {+        svgToMm = ext[0] / vbW;+      } else {+        // ds2sf-synthesized SVGs embed a <rect class="body"> whose+        // px dimensions correspond to the chip's body_dims_mm.+        // Use that to derive px → mm.+        const bodyRect = doc.querySelector('rect.body');+        const bodyDims = state.spec?.body_dims_mm;+        if (bodyRect && bodyDims && Array.isArray(bodyDims) && bodyDims[0] > 0) {+          const bodyPxW = parseFloat(bodyRect.getAttribute('width')) || 1;+          svgToMm = bodyDims[0] / bodyPxW;+        } else if (bodyDims && Array.isArray(bodyDims) && bodyDims[0] > 0) {+          svgToMm = bodyDims[0] / vbW * 3;+        } else {+          svgToMm = 0.01;+        }+      }+    }+    const cx = vbX + vbW / 2;+    const cy = vbY + vbH / 2;++    // Parse CSS <style> for class-based fills (ds2sf-synthesized format)+    const cssMap = svgParseCssMap(doc);++    function zForFill(fill) {+      const f = (fill || '').toLowerCase();+      if (f === '#c83434') return 0.006;   // F.Cu (copper) — pcbnew+      if (f === '#b4a09a') return 0.002;   // solder mask — pcbnew+      if (f === '#d864ff') return 0.013;   // silkscreen — pcbnew+      if (f === '#f2eda1') return 0.016;   // F.Fab labels — pcbnew+      if (f === '#d68830') return 0.006;   // pad — ds2sf synthesized+      if (f === '#2c5f2d') return 0.003;   // body — ds2sf synthesized+      if (f === '#ff5555') return 0.010;   // pin-1 — ds2sf synthesized+      if (f === '#0d1117') return -0.001;  // background — skip rendering+      return 0.008;+    }+    function rgbForFill(fill) {+      const f = (fill || '').toLowerCase();+      if (/^#[0-9a-f]{6}$/.test(f)) {+        return {+          r: parseInt(f.slice(1, 3), 16) / 255,+          g: parseInt(f.slice(3, 5), 16) / 255,+          b: parseInt(f.slice(5, 7), 16) / 255,+        };+      }+      return { r: 0.5, g: 0.5, b: 0.5 };+    }+    const matCache = {};+    function getMat(fill, opacity) {+      const key = fill + '@' + opacity;+      if (matCache[key]) return matCache[key];+      const c = rgbForFill(fill);+      const m = new B.StandardMaterial(group + '-mat-' + Object.keys(matCache).length, scene);+      m.diffuseColor = new B.Color3(c.r, c.g, c.b);+      m.emissiveColor = new B.Color3(c.r * 0.4, c.g * 0.4, c.b * 0.4);+      m.specularColor = new B.Color3(0, 0, 0);+      m.alpha = (opacity != null ? opacity : 1) * 0.9;+      m.backFaceCulling = false;+      m.zOffset = -1;+      matCache[key] = m;+      return m;+    }++    // Build one VertexData mesh per filled SVG shape (path / rect /+    // circle) using earcut for the triangulation. Handles BOTH SVG+    // formats: pcbnew stdlib (<path> with inline styles) and ds2sf+    // synthesized (<rect>/<circle> with CSS-class fills).+    const meshes = [];+    const childGroups = [];+    let kept = 0, skipped = 0;+    const counterByLayer = {};++    // Collect shapes from all renderable element types.+    const shapeEls = doc.querySelectorAll('path, rect, circle');+    for (let i = 0; i < shapeEls.length; i++) {+      const el = shapeEls[i];+      const tag = el.tagName.toLowerCase();+      const fill = svgResolveFill(el, cssMap);+      if (!fill || fill.toLowerCase() === 'none') { skipped++; continue; }+      if (zForFill(fill) < 0) { skipped++; continue; }+      const opacity = svgResolveFillOpacity(el, cssMap);+      let pts;+      if (tag === 'path') {+        pts = parseSvgPathD(el.getAttribute('d'));+      } else if (tag === 'rect') {+        pts = svgRectToPoints(el);+      } else if (tag === 'circle') {+        pts = svgCircleToPoints(el, 24);+      } else { skipped++; continue; }+      if (!pts || pts.length < 3) { skipped++; continue; }+      const z = zForFill(fill);+      const layerKey = fill.toLowerCase();+      counterByLayer[layerKey] = (counterByLayer[layerKey] || 0) + 1;++      // Triangulate via earcut. earcut takes a flat [x0,y0,x1,y1,...]+      // array and returns a flat triangle-index list.+      const flat = new Array(pts.length * 2);+      for (let k = 0; k < pts.length; k++) {+        flat[k * 2]     = pts[k][0];+        flat[k * 2 + 1] = pts[k][1];+      }+      const tris = window.earcut(flat);+      if (!tris || tris.length < 3) { skipped++; continue; }++      // Build positions in world coords. svgToMm converts SVG units+      // (px for synthesized, mm for pcbnew) into millimetres. Then+      // MM (0.001) converts mm → scene metres.+      const upI = worldUpIdx();+      const positions = new Float32Array(pts.length * 3);+      const zMeters = z * MM;+      if (upI === 2) {+        for (let k = 0; k < pts.length; k++) {+          positions[k * 3]     = (pts[k][0] - cx) * svgToMm * MM;+          positions[k * 3 + 1] = (pts[k][1] - cy) * svgToMm * MM;+          positions[k * 3 + 2] = zMeters;+        }+      } else {+        for (let k = 0; k < pts.length; k++) {+          positions[k * 3]     =  (pts[k][0] - cx) * svgToMm * MM;+          positions[k * 3 + 1] = zMeters;+          positions[k * 3 + 2] = -(pts[k][1] - cy) * svgToMm * MM;+        }+      }+      // Up-facing normals along world-up.+      const normals = new Float32Array(pts.length * 3);+      for (let k = 0; k < pts.length; k++) normals[k * 3 + upI] = 1;++      // Earcut returns CCW or CW depending on input winding. We want+      // the up-facing side. Test the first triangle's signed area in+      // input (svg) space — pcbnew authored Y-down so a CCW input is+      // "wound the wrong way" for our +Z-up normals; flip the index+      // order in that case so the front face points up.+      const a = pts[tris[0]], b = pts[tris[1]], c = pts[tris[2]];+      const cross = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);+      const indices = new Uint32Array(tris.length);+      if (cross < 0) {+        // Already wound the way we want — keep as-is.+        for (let k = 0; k < tris.length; k++) indices[k] = tris[k];+      } else {+        // Flip every triangle.+        for (let k = 0; k < tris.length; k += 3) {+          indices[k]     = tris[k];+          indices[k + 1] = tris[k + 2];+          indices[k + 2] = tris[k + 1];+        }+      }++      const mesh = new B.Mesh(`${group}-poly-${i}`, scene);+      const vd = new B.VertexData();+      vd.positions = positions;+      vd.indices = indices;+      vd.normals = normals;+      vd.applyToMesh(mesh, false);+      mesh.material = getMat(fill, opacity);+      // Render on top of source overlays at same z but we let the+      // material's zOffset push us forward in the depth test.+      mesh.alphaIndex = 5;+      mesh.isPickable = false;+      meshes.push(mesh);++      // Outline child entry — one sub-row per polygon, labelled by+      // layer. Use the per-layer counter so the row reads+      // "F.Cu polygon 3 of N", "silk polygon 2 of N", etc.+      const layerName = ({ '#c83434': 'F.Cu', '#b4a09a': 'mask', '#d864ff': 'silk', '#f2eda1': 'fab' })[layerKey] || 'other';+      childGroups.push({+        mesh, layer: layerName, idx: counterByLayer[layerKey],+        verts: pts.length,+      });+      kept++;+    }++    if (!meshes.length) {+      return { error: 'no renderable paths in ds2sf SVG' };+    }++    const sourceTag = source === 'service-kicad-stdlib' ? '✓ stdlib (pixel-trustworthy)' : '◐ synthesized (layout-only)';+    const vbMmW = vbW * svgToMm, vbMmH = vbH * svgToMm;+    const layerMap = { '#c83434':'F.Cu','#b4a09a':'mask','#d864ff':'silk','#f2eda1':'fab',+                       '#d68830':'pad','#2c5f2d':'body','#ff5555':'pin1' };+    const layerSummary = Object.entries(counterByLayer)+      .map(([f, n]) => `${layerMap[f] || 'other'}:${n}`)+      .join(' ');+    window.adomStep.addOverlay({+      group,+      meshes,+      label: `ds2sf svg  ${vbMmW.toFixed(2)}×${vbMmH.toFixed(2)} mm  ${kept} polys (${layerSummary})  ${sourceTag}`,+      tooltip: `ds2sf svg  ${vbMmW.toFixed(2)}×${vbMmH.toFixed(2)} mm  ${kept} polys\n\n` ++        (source === 'ds2sf-datasheet-derived'+          ? 'Rendered by ds2sf from the manufacturer datasheet mechanical\ndrawing. Every shape traces back to a specific figure on a\nspecific page of the PDF. This is NOT a KiCad or third-party\nlibrary render — it is drawn from the manufacturer\'s own data.'+          : source === 'service-kicad-stdlib'+          ? 'Rendered by KiCad pcbnew from the standard library footprint.\nThis is a third-party render (a KiCad maintainer traced the\ndatasheet). Kept for visual comparison against the ds2sf pads.'+          : 'SVG source: ' + source),+      kind: 'overlay-ds2sf',+    });+    state.ds2sfOverlayOn = true;+    return { ok: true, source, widthMm: vbW, heightMm: vbH, polygonCount: kept, skippedCount: skipped, byLayer: counterByLayer };+  }++  // Combined heatsink renderer — one overlay group "heatsink" with+  // children: the BOTTOM-side EP copper pour (B.Cu, on the underside of+  // the PCB), and the via grid that connects EP-on-top to pour-on-bottom.+  //+  // The pour goes on B.Cu, NOT F.Cu. The whole point of the heatsink+  // architecture is to dump heat from the EP (on top, where the chip+  // sits) THROUGH the plated vias TO a copper region on the bottom that+  // radiates / conducts away (often a free zone fill that connects to+  // a larger ground plane or external heatsink). Putting the pour on+  // F.Cu would just duplicate the EP — useless.+  //+  // Outline structure: one Heatsink parent row, two children:+  //   • EP copper pour (B.Cu, bottom)+  //   • Heatsink vias (N × Ø d mm)   — one sub-row per via+  // The user can eye-toggle either child independently. Default on+  // for both; sidecar always carries them regardless of visibility.+  function renderHeatsink(epOriginMm, epSize, grid, opts = {}) {+    if (!grid || !grid.vias || !grid.vias.length) return null;+    if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon)) return null;+    const scene = window.adomStep.getScene();+    const B = window.adomStep.getBabylon();+    if (!scene || !B) return null;+    const group = 'heatsink';+    ensureOverlayDisposed(group);++    const matCu = new B.StandardMaterial(group + '-cu', scene);+    matCu.diffuseColor  = new B.Color3(0.78, 0.55, 0.25);+    matCu.specularColor = new B.Color3(0.30, 0.20, 0.10);+    matCu.emissiveColor = new B.Color3(0.10, 0.07, 0.03);++    const ox_mm = epOriginMm?.x || 0;+    const oy_mm = epOriginMm?.y || 0;+    const PCB_THICKNESS_MM = 1.6;+    const drillD_mm = grid.drill;+    const outerD_mm = grid.outerCu;+    const annularT  = 0.05 * MM;+    const ringMm = Math.max(0, (grid.outerCu - grid.drill) / 2);++    const children = [];++    // ── Child 1: EP copper pour on B.Cu (bottom of PCB) ──+    // The pour is CSG-drilled with a hole at each via position. Real+    // PCB convention: the bottom Cu pour around plated through-holes+    // has a clearance hole at each drill (the donut around each via+    // is part of the pour itself; what's "missing" from the rectangle+    // is the via's drilled hole). Without these holes the pour is a+    // solid sheet that the via barrels appear to pass THROUGH at the+    // mesh-intersection level — visually wrong and electrically wrong+    // (downstream tools would extrude a solid pour with no clearance).+    const expansion_mm = opts.expansion_mm ?? 0;+    const pourW_mm = epSize.width + expansion_mm * 2;+    const pourH_mm = epSize.height + expansion_mm * 2;+    const pourThick_mm = 0.04;+    const pourSolid = B.MeshBuilder.CreateBox(group + '-pour-solid', flatBoxDims(pourW_mm, pourH_mm, pourThick_mm), scene);+    placeMeshAt(pourSolid, ox_mm, oy_mm, -(PCB_THICKNESS_MM + 0.02));+    pourSolid.material = matCu;+    // CSG-drill holes at each via position. Use the drill diameter+    // (no expansion) so the via barrel passes cleanly through.+    let pour = pourSolid;+    try {+      let holesCSG = null;+      const cutters = [];+      for (const v of grid.vias) {+        const fpX_mm = v.x_mm + ox_mm;+        const fpY_mm = v.y_mm + oy_mm;+        const cutter = B.MeshBuilder.CreateCylinder(group + '-pour-cut-' + v.index, {+          diameter: drillD_mm * MM,+          height: (pourThick_mm + 0.4) * MM,        // overrun for clean cut+          tessellation: 18,+        }, scene);+        alignVerticalToWorldUp(cutter);+        placeMeshAt(cutter, fpX_mm, fpY_mm, -(PCB_THICKNESS_MM + 0.02));+        cutter.isVisible = false;+        cutters.push(cutter);+        const csg = B.CSG.FromMesh(cutter);+        holesCSG = holesCSG ? holesCSG.union(csg) : csg;+      }+      if (holesCSG) {+        const pourCSG = B.CSG.FromMesh(pourSolid);+        const drilledCSG = pourCSG.subtract(holesCSG);+        const drilled = drilledCSG.toMesh(group + '-pour', matCu, scene);+        pourSolid.dispose();+        pour = drilled;+      }+      for (const c of cutters) c.dispose();+    } catch (e) {+      console.warn('[chipsmith] EP pour CSG drill failed; rendering solid pour', e);+      pourSolid.name = group + '-pour';   // still keep the solid mesh+    }+    children.push({+      meshes: [pour],+      label: `EP copper pour (B.Cu, bottom, ${grid.vias.length} via clearances)  ${pourW_mm.toFixed(2)} × ${pourH_mm.toFixed(2)} mm  ${expansion_mm > 0 ? `(+${expansion_mm.toFixed(2)} mm expansion)` : '(matches EP)'}`,+      kind: 'overlay-heatsink-pour',+    });+    state.epCopperPour = {+      enabled: true,+      layer: 'B.Cu',+      origin_mm: { x: ox_mm, y: oy_mm },+      size_mm: { width: pourW_mm, height: pourH_mm },+      expansion_mm,+      world_height_mm: -PCB_THICKNESS_MM,+    };++    // ── Children 2..N+1: per-via meshes ──+    // Each via is a real plated through-hole:+    //   1. Hollow Cu barrel — a tube (cylinder with NO_CAP) at the+    //      drill radius, DOUBLE-SIDE so both inner + outer faces of+    //      the Cu wall render. The interior is empty — you can see+    //      light through the hole if you look down it from the top+    //      face. (Real PTHs are plated with ~17.5 µm Cu on the wall;+    //      we render the wall as a 2D shell for performance, not a+    //      true thickness — visually indistinguishable at chip scale.)+    //   2. Top + bottom annular rings — thin Cu discs around the+    //      outside of the hole, on F.Cu and B.Cu. These close the+    //      "donut" view from above + below.+    for (const v of grid.vias) {+      const fpX_mm = v.x_mm + ox_mm;+      const fpY_mm = v.y_mm + oy_mm;+      const viaMeshes = [];+      // Hollow Cu barrel. Babylon's CreateTube wants a path; a single+      // straight segment along Y (Babylon's default cylinder axis).+      // We then alignVerticalToWorldUp() to lay it along world-up if+      // needed (Z-up), and placeMeshAt() to position. cap = NO_CAP+      // means open ends so the hole is visibly through-and-through.+      const halfH = (PCB_THICKNESS_MM / 2) * MM;+      const tubePath = [+        new B.Vector3(0, -halfH, 0),+        new B.Vector3(0,  halfH, 0),+      ];+      const barrel = B.MeshBuilder.CreateTube(`${group}-barrel-${v.index}`, {+        path: tubePath,+        radius: (drillD_mm / 2) * MM,+        tessellation: 18,+        cap: B.Mesh.NO_CAP,+        sideOrientation: B.Mesh.DOUBLESIDE,+      }, scene);+      alignVerticalToWorldUp(barrel);+      // Center the tube at the PCB's mid-thickness so it spans the+      // full plate (z = -PCB_THICKNESS to 0). Putting it at z=0+      // would float half the tube above the seat plane.+      placeMeshAt(barrel, fpX_mm, fpY_mm, -PCB_THICKNESS_MM / 2);+      barrel.material = matCu;+      viaMeshes.push(barrel);+      // Top + bottom annular rings — real DONUTS (with the hole in+      // the middle), not solid discs. Lathe a rectangular cross-+      // section (inner = drillR, outer = outerR, height = annularT)+      // around the lathe axis to produce a washer-shaped ring. The+      // inner edge lines up with the barrel's outer wall so the+      // hole reads cleanly down through both the top ring + the+      // barrel + the bottom ring.+      const innerR = (drillD_mm / 2) * MM;+      const outerR = (outerD_mm / 2) * MM;+      const ringProfile = [+        new B.Vector3(innerR, 0, 0),+        new B.Vector3(outerR, 0, 0),+        new B.Vector3(outerR, annularT, 0),+        new B.Vector3(innerR, annularT, 0),+        new B.Vector3(innerR, 0, 0),       // close the loop+      ];+      for (const h_mm of [0.001, -(PCB_THICKNESS_MM + annularT / MM + 0.001)]) {+        const ring = B.MeshBuilder.CreateLathe(`${group}-ring-${v.index}-${h_mm.toFixed(4)}`, {+          shape: ringProfile,+          radius: 1,        // ignored when shape carries absolute coords+          tessellation: 18,+          sideOrientation: B.Mesh.DOUBLESIDE,+        }, scene);+        alignVerticalToWorldUp(ring);+        placeMeshAt(ring, fpX_mm, fpY_mm, h_mm);+        ring.material = matCu;+        viaMeshes.push(ring);+      }+      const label = `via ${v.index}  (${v.x_mm.toFixed(2)}, ${v.y_mm.toFixed(2)}) mm  plated PTH (hollow)  Ø${grid.drill} drill / Ø${grid.outerCu} outer Cu / ${ringMm.toFixed(3)} mm ring`;+      viaMeshes.parentGroup = 'heatsink-vias';+      children.push({ meshes: viaMeshes, label, kind: 'overlay-via' });+    }++    window.adomStep.addOverlay({+      group,+      label: `Heatsink  (${grid.vias.length} vias  +  EP pour ${pourW_mm.toFixed(1)}×${pourH_mm.toFixed(1)} mm on B.Cu)`,+      kind: 'overlay-heatsink',+      children,+    });+    return { group, vias: grid.vias.length, pour: state.epCopperPour };+  }++  // Legacy aliases — preserved so existing callers (CLI verbs, the+  // signoff payload tooling) don't break. Both delegate to the+  // combined renderHeatsink().+  function renderHeatsinkVias(epOriginMm, grid) {+    if (!state.heatsinkGrid) return null;+    return renderHeatsink(epOriginMm, { width: state.heatsinkGrid.ep.size_mm[0], height: state.heatsinkGrid.ep.size_mm[1] }, grid);+  }+  function renderEpCopperPour(epOriginMm, epSize, opts) {+    if (!state.heatsinkGrid) return null;+    return renderHeatsink(epOriginMm, epSize, state.heatsinkGrid.grid, opts);+  }++  function pairAgreement(a, b, tolMm = 0.05) {+    if (!a || !b || !a.pads || !b.pads || a._stub || b._stub) return null;+    const byNum = (pads) => Object.fromEntries(pads.map(p => [p.number, p]));+    const ai = byNum(a.pads), bi = byNum(b.pads);+    const keys = new Set([...Object.keys(ai), ...Object.keys(bi)]);+    let matched = 0, posOff = 0, sizeOff = 0, missing = 0, maxDelta = 0;+    for (const k of keys) {+      const pa = ai[k], pb = bi[k];+      if (!pa || !pb) { missing++; continue; }+      const dx = (pa.at[0] - pb.at[0]), dy = (pa.at[1] - pb.at[1]);+      const dist = Math.sqrt(dx*dx + dy*dy);+      const ds = Math.max(Math.abs(pa.size[0] - pb.size[0]), Math.abs(pa.size[1] - pb.size[1]));+      if (dist > tolMm) posOff++;+      if (ds   > tolMm) sizeOff++;+      maxDelta = Math.max(maxDelta, dist);+      matched++;+    }+    const total = keys.size;+    return { matched, missing, posOff, sizeOff, maxDelta, total, ok: posOff === 0 && sizeOff === 0 && missing === 0 };+  }++  function renderFootprintTab() {+    const body = document.getElementById('cs-body');+    const fps = state.footprintSources;+    if (!fps) {+      body.innerHTML = `<div class="cs-empty">Loading footprint sources…</div>`;+      fetch('api/footprint-sources').then(r => r.ok ? r.json() : null).then(d => {+        state.footprintSources = d;+        if (state.activeTab === 'footprint') renderFootprintTab();+      });+      return;+    }+    const SRCS = ['kicad', 'fusion', 'altium', 'datasheet', 'ds2sf'];+    const sourcesPresent = SRCS.filter(s => fps[s] && !fps[s]._stub && fps[s].pad_count > 0);+    const overview = SRCS.map(s => {+      const f = fps[s];+      if (!f) return null;+      const stub = !!f._stub;+      const pc = f.pad_count || 0;+      const ext = stub ? null : fpExtent(f.pads);+      const status = stub ? '○' : (pc > 0 ? '●' : '✗');+      const cls = stub ? 'pending' : (pc > 0 ? 'ok' : 'fail');+      return `<div class="cs-row">+        <span class="label">${esc(SRC_LABEL[s])}</span>+        <span class="value mono">${stub ? '— stub' : (pc + ' pads' + (ext ? ` · ${ext.width.toFixed(2)}×${ext.height.toFixed(2)} mm` : ''))}</span>+        <span class="pill ${cls}" style="margin-left:auto">${status}</span>+      </div>`;+    }).filter(Boolean).join('');++    // Agreement matrix — pairwise across whichever sources are present.+    let agreementHTML = '';+    if (sourcesPresent.length >= 2) {+      const rows = [];+      for (let i = 0; i < sourcesPresent.length; i++) {+        for (let j = i + 1; j < sourcesPresent.length; j++) {+          const a = sourcesPresent[i], b = sourcesPresent[j];+          const agg = pairAgreement(fps[a], fps[b]);+          if (!agg) continue;+          const cls = agg.ok ? 'is-ok' : (agg.matched > 0 ? '' : '');+          rows.push(`<tr class="${cls}">+            <td>${esc(SRC_LABEL[a])} ↔ ${esc(SRC_LABEL[b])}</td>+            <td class="num mono">${agg.matched}</td>+            <td class="num mono">${agg.missing}</td>+            <td class="num mono">${agg.posOff}</td>+            <td class="num mono">${agg.maxDelta.toFixed(3)}</td>+          </tr>`);+        }+      }+      agreementHTML = `+        <div class="cs-section">+          <h3 class="cs-section-title">Cross-source agreement</h3>+          <table class="cs-tbl">+            <thead><tr><th>Pair</th><th class="num">Matched</th><th class="num">Missing</th><th class="num">Off</th><th class="num">Max Δ mm</th></tr></thead>+            <tbody>${rows.join('')}</tbody>+          </table>+          <div class="cs-row dim" style="margin-top:8px"><span class="value">Tolerance: position ±0.05 mm. Per-source counts include only pads with matching pad numbers.</span></div>+        </div>`;+    }++    // Bbox-fit: compare chip body extent (from Tab 2) vs the larger of+    // the two pad-extent bboxes. The chip's body should fit INSIDE the+    // footprint pad envelope (or be very close to it).+    let bboxFitHTML = '';+    if (state.measurements && sourcesPresent.length) {+      const chipMm = state.measurements.detected_mm.slice().sort((a, b) => b - a); // desc+      const fpMaxExt = sourcesPresent+        .map(s => fpExtent(fps[s].pads))+        .filter(Boolean)+        .reduce((acc, e) => acc && acc.width > e.width ? acc : e, null);+      if (fpMaxExt) {+        const dx = chipMm[0] - fpMaxExt.width;+        const dy = chipMm[1] - fpMaxExt.height;+        const fits = (dx <= 0.2) && (dy <= 0.2);+        bboxFitHTML = `+          <div class="cs-section">+            <h3 class="cs-section-title">Bbox fit (chip ⊂ footprint)</h3>+            <div class="cs-row"><span class="label">Chip XY</span><span class="value mono">${chipMm[0].toFixed(2)} × ${chipMm[1].toFixed(2)} mm</span></div>+            <div class="cs-row"><span class="label">Footprint XY</span><span class="value mono">${fpMaxExt.width.toFixed(2)} × ${fpMaxExt.height.toFixed(2)} mm</span></div>+            <div class="cs-row"><span class="label">Δ</span><span class="value mono">${dx.toFixed(3)} × ${dy.toFixed(3)} mm</span></div>+            <div class="cs-meas-status pill-${fits ? 'ok' : 'fail'}">${fits ? 'Chip body fits within the footprint envelope.' : 'Chip body exceeds the footprint envelope by &gt;0.2mm — likely Y-up vs Z-up mismatch or wrong footprint.'}</div>+          </div>`;+      }+    }++    state.status.footprint = (sourcesPresent.length > 0)+      ? (sourcesPresent.length >= 2 ? '◐' : '○')+      : '✗';+    refreshStatusPills();++    // Overlay toggles per source. Tracks state.fpOverlaysOn so toggles+    // survive tab switches. On the FIRST render of Tab 4 with at least+    // one non-stub source we auto-show every present source — the user+    // came to this tab to compare libraries, so the comparison should+    // be visible by default. Subsequent renders honor the user's+    // toggles (state.fpOverlaysAutoShown is the latch).+    state.fpOverlaysOn = state.fpOverlaysOn || {};+    if (!state.fpOverlaysAutoShown && sourcesPresent.length > 0) {+      for (const s of sourcesPresent) {+        if (!state.fpOverlaysOn[s]) {+          renderFootprintOverlay(s, fps[s].pads);+          state.fpOverlaysOn[s] = true;+        }+      }+      state.fpOverlaysAutoShown = true;+    }+    // ds2sf authoritative SVG plane — auto-shown once on first Tab 4+    // visit when a result is available. Same auto-show convention as+    // the per-source overlays. User can hide via the toggle below.+    if (!state.ds2sfAutoShown && state.ds2sfResult?.available) {+      renderDs2sfOverlay();+      state.ds2sfAutoShown = true;+    }+    const allSourcesOn = sourcesPresent.length > 0 && sourcesPresent.every(s => state.fpOverlaysOn[s]);+    const someSourcesOn = sourcesPresent.some(s => state.fpOverlaysOn[s]);+    const overlayRows = sourcesPresent.map(s => {+      const on = !!state.fpOverlaysOn[s];+      const c = SRC_COLOR[s] || { r: 0.5, g: 0.5, b: 0.5 };+      const swatch = `<span class="cs-src-swatch" style="background:rgba(${Math.round(c.r*255)},${Math.round(c.g*255)},${Math.round(c.b*255)},0.55)"></span>`;+      return `<div class="cs-row">+        ${swatch}<span class="label">${esc(SRC_LABEL[s])}</span>+        <button class="cs-btn cs-btn-${on ? 'primary' : 'secondary'}" data-fpsrc="${esc(s)}" data-tooltip="Toggle the ${esc(s)} footprint as a translucent overlay at z=0 in the 3D scene. Each source has its own color (datasheet orange, kicad green, fusion blue, altium purple) — stack multiple to see disagreement visually.">${svgIcon('bookOpen')}<span class="lbl">${on ? 'Hide' : 'Show'} in 3D</span></button>+      </div>`;+    }).join('');+    // Stock-library overlay — service-kicad-backed ghost. Visible only+    // when the spec carries footprint_name (chip-fetcher's responsibility+    // to populate at fetch time). Internally fetched from KiCad's+    // library, but never labeled "KiCad" in the UI per the plan's+    // EE-tool-neutrality rule.+    const stockFpName = state.spec?.footprint_name || state.spec?.stock_footprint_id || null;+    const stockState = state.stockOverlay || {}; // { loading, loaded, parsed, fetched_url, error }+    const stockOn = !!state.fpOverlaysOn?.stock;++    // ds2sf v0.5.1 authoritative SVG. Two flavours:+    //   • service-kicad-stdlib  — pixel-trustworthy KiCad render (green/✓)+    //   • ds2sf-synthesized     — best-effort layout reference  (amber/◐)+    // The state.ds2sfResult is hydrated lazily on first Tab 4 render.+    const ds2sf = state.ds2sfResult || null;+    const ds2sfSrc = ds2sf?.outputs?.footprint_svg_source;+    const ds2sfAvailable = !!ds2sf?.available && !!ds2sfSrc;+    const ds2sfStdlib = ds2sfSrc === 'service-kicad-stdlib';+    const ds2sfHTML = ds2sfAvailable ? `+      <div class="cs-section">+        <h3 class="cs-section-title">ds2sf svg <span class="dim">(pcbnew render)</span></h3>+        <div class="cs-row">+          <span class="label">Source</span>+          <span class="value mono">${esc(ds2sfSrc)}</span>+          <span class="pill ${ds2sfStdlib ? 'ok' : 'partial'}" style="margin-left:auto" data-tooltip="${ds2sfStdlib+            ? 'service-kicad-stdlib — canonical KiCad render. Pixel-trustworthy: pad positions, pad sizes, body outline, pin-1 marker are all authoritative ground truth. Diff your 3D rendering against this.'+            : 'ds2sf-synthesized — best-effort 2D layout from extracted body + lead dimensions. Layout-only reference: pad shapes + absolute positions are best-effort, NOT pixel-accurate. Use as visual sanity-check, not as a measurement.'}">${ds2sfStdlib ? '●' : '◐'}</span>+        </div>+        <div class="cs-row dim" style="font-size:11px"><span class="value">${ds2sfStdlib+          ? 'Pixel-trustworthy ground truth — pad positions, body outline, and pin-1 marker (red dot) match the canonical KiCad library exactly. Use for diffing your 3D rendering.'+          : 'Layout-only reference — body + leads from datasheet extraction. Stamped as such in the SVG itself. Treat as visual sanity-check, not as a pad-position oracle.'}</span></div>+        <div class="cs-svg-frame" style="margin-top:8px;background:rgba(20,24,30,0.45);border:1px solid rgba(255,255,255,0.14);border-radius:5px;padding:10px;text-align:center">+          <img src="api/ds2sf-svg" alt="${esc(ds2sfSrc)} authoritative footprint" style="max-width:100%;max-height:280px;display:inline-block"/>+        </div>+      </div>` : '';+    const stockHTML = stockFpName ? `+      <div class="cs-section">+        <h3 class="cs-section-title">Stock library footprint</h3>+        <div class="cs-row">+          <span class="label">Library identity</span>+          <span class="value mono">${esc(stockFpName)}</span>+        </div>+        ${stockState.loaded ? `+          <div class="cs-row">+            <span class="label">Pads</span>+            <span class="value mono">${stockState.parsed?.pad_count || 0}</span>+            <span class="pill ok" style="margin-left:auto">●</span>+          </div>+          ${stockState.parsed?.pin1_silk?.found ? `<div class="cs-row"><span class="label">Pin-1 silk</span><span class="value mono">marker present</span></div>` : ''}+          <div class="cs-row">+            <span class="cs-src-swatch" style="background:rgba(217,217,235,0.55)"></span>+            <span class="label">Ghost overlay</span>+            <button class="cs-btn cs-btn-${stockOn ? 'primary' : 'secondary'}" id="cs-stock-toggle" data-tooltip="Render the authoritative library footprint as a translucent off-white ghost stacked alongside the four user sources. Per-pad delta against each present source surfaces below.">${svgIcon('bookOpen')}<span class="lbl">${stockOn ? 'Hide' : 'Show'} stock ghost</span></button>+          </div>+        ` : (stockState.loading+          ? `<div class="cs-row dim"><span class="value">Fetching stock library footprint…</span></div>`+          : (stockState.error+            ? `<div class="cs-row"><span class="label">Status</span><span class="value mono">unavailable</span><span class="pill fail" style="margin-left:auto">✗</span></div>+               <div class="cs-row dim" style="font-size:11px"><span class="value">${esc(stockState.error)}</span></div>+               <div class="cs-actions"><button class="cs-btn cs-btn-secondary" id="cs-stock-retry" data-tooltip="Re-fetch /api/kicad-stock?footprint_name=${esc(stockFpName)}">${svgIcon('bookOpen')}<span class="lbl">Retry</span></button></div>`+            : `<div class="cs-actions"><button class="cs-btn cs-btn-primary" id="cs-stock-load" data-tooltip="Pull the authoritative library footprint from service-kicad and parse it into the same pad/silk schema as the four user sources, so it joins cross-validation.">${svgIcon('bookOpen')}<span class="lbl">Fetch stock library</span></button></div>`))}+      </div>` : '';++    const ds2sfOn = !!state.ds2sfOverlayOn;+    const ds2sfRowHTML = ds2sfAvailable ? `+        <div class="cs-row">+          <span class="cs-src-swatch" style="background:${ds2sfStdlib ? 'rgba(120,200,140,0.55)' : 'rgba(220,170,90,0.55)'}"></span>+          <span class="label">ds2sf svg</span>+          <button class="cs-btn cs-btn-${ds2sfOn ? 'primary' : 'secondary'}" id="cs-ds2sf-toggle" data-tooltip="${ds2sfStdlib+            ? 'KiCad pcbnew SVG render — rounded copper pads, silk outline, pin-1 dot, solder mask. The prettiest ground truth.'+            : 'ds2sf-synthesized SVG — simpler rects, layout-only reference.'}">${svgIcon('bookOpen')}<span class="lbl">${ds2sfOn ? 'Hide' : 'Show'} ds2sf svg</span></button>+        </div>` : '';+    const overlayHTML = sourcesPresent.length ? `+      <div class="cs-section">+        <h3 class="cs-section-title">3D overlays</h3>+        <div class="cs-row">+          <span class="label">All sources</span>+          <button class="cs-btn cs-btn-${allSourcesOn ? 'primary' : 'secondary'}" id="cs-fp-all-toggle" data-tooltip="${allSourcesOn ? 'Hide every source overlay' : 'Show every available source as a translucent overlay simultaneously, stacked. Each source uses its own tint so disagreements pop visually — datasheet orange + kicad green + fusion blue + altium purple.'}">${svgIcon('bookOpen')}<span class="lbl">${allSourcesOn ? 'Hide all' : 'Show all (' + sourcesPresent.length + ')'}</span></button>+        </div>+        ${overlayRows}+        ${ds2sfRowHTML}+        <div class="cs-row">+          <span class="label">Faux PCB plate</span>+          <button class="cs-btn cs-btn-${state.fauxPcbOn ? 'primary' : 'secondary'}" id="cs-fpplate-toggle" data-tooltip="1.6mm FR4 plate sized to the footprint extent + 5mm margin. Visualizes how the chip will sit on a real board.">${svgIcon('bookOpen')}<span class="lbl">${state.fauxPcbOn ? 'Hide' : 'Show'} 1.6mm faux PCB</span></button>+        </div>+      </div>` : '';++    body.innerHTML = `+      <div class="cs-section">+        <h3 class="cs-section-title">Sources</h3>+        ${overview || '<div class="cs-empty">No footprint sources available.</div>'}+      </div>+      ${bboxFitHTML}+      ${ds2sfHTML}+      ${stockHTML}+      ${overlayHTML}+      ${agreementHTML}+    `;+    body.querySelectorAll('button[data-fpsrc]').forEach(btn => {+      btn.onclick = () => {+        const s = btn.dataset.fpsrc;+        const on = !state.fpOverlaysOn[s];+        if (on) {+          renderFootprintOverlay(s, fps[s].pads);+        } else {+          ensureOverlayDisposed('fp-' + s);+        }+        state.fpOverlaysOn[s] = on;+        renderFootprintTab();+      };+    });+    const allBtn = document.getElementById('cs-fp-all-toggle');+    if (allBtn) {+      allBtn.onclick = () => {+        const turnOn = !allSourcesOn;+        for (const s of sourcesPresent) {+          if (turnOn && !state.fpOverlaysOn[s]) {+            renderFootprintOverlay(s, fps[s].pads);+          } else if (!turnOn && state.fpOverlaysOn[s]) {+            ensureOverlayDisposed('fp-' + s);+          }+          state.fpOverlaysOn[s] = turnOn;+        }+        renderFootprintTab();+      };+    }+    const plateBtn = document.getElementById('cs-fpplate-toggle');+    if (plateBtn) {+      plateBtn.onclick = () => {+        const on = !state.fauxPcbOn;+        if (on) {+          // Use the largest extent across present sources.+          // Pick the first source with the widest extent; pass its pads+          // through so CSG-drilling has access to drill geometry.+          let bestSrc = null, bestExt = null;+          for (const s of sourcesPresent) {+            const e = fpExtent(fps[s].pads);+            if (e && (!bestExt || e.width > bestExt.width)) { bestExt = e; bestSrc = s; }+          }+          renderFauxPcbPlate(bestExt, bestSrc ? fps[bestSrc].pads : null);+        } else {+          ensureOverlayDisposed('faux-pcb');+        }+        state.fauxPcbOn = on;+        renderFootprintTab();+      };+    }+    const ds2sfBtn = document.getElementById('cs-ds2sf-toggle');+    if (ds2sfBtn) {+      ds2sfBtn.onclick = () => {+        const on = !state.ds2sfOverlayOn;+        if (on) {+          renderDs2sfOverlay();+        } else {+          ensureOverlayDisposed('ds2sf');+          state.ds2sfOverlayOn = false;+        }+        renderFootprintTab();+      };+    }+    const stockLoadBtn = document.getElementById('cs-stock-load');+    if (stockLoadBtn) stockLoadBtn.onclick = () => loadStockLibraryOverlay();+    const stockRetryBtn = document.getElementById('cs-stock-retry');+    if (stockRetryBtn) stockRetryBtn.onclick = () => loadStockLibraryOverlay({ force: true });+    const stockToggleBtn = document.getElementById('cs-stock-toggle');+    if (stockToggleBtn) {+      stockToggleBtn.onclick = () => {+        state.fpOverlaysOn = state.fpOverlaysOn || {};+        const on = !state.fpOverlaysOn.stock;+        if (on) {+          const parsed = state.stockOverlay?.parsed;+          if (parsed && Array.isArray(parsed.pads)) renderFootprintOverlay('stock', parsed.pads);+        } else {+          ensureOverlayDisposed('fp-stock');+        }+        state.fpOverlaysOn.stock = on;+        renderFootprintTab();+      };+    }+  }++  // Pull the authoritative library footprint from service-kicad via+  // /api/kicad-stock, parse server-side (Rust .kicad_mod parser), and+  // store on state so renderFootprintTab can offer the toggle. Uses+  // spec.footprint_name (chip-fetcher persists this at fetch time).+  async function loadStockLibraryOverlay(opts) {+    const fpName = state.spec?.footprint_name || state.spec?.stock_footprint_id;+    if (!fpName) return { error: 'no footprint_name in spec' };+    if (!opts?.force && state.stockOverlay?.loaded) return { ok: true, cached: true };+    state.stockOverlay = { loading: true };+    if (state.activeTab === 'footprint') renderFootprintTab();+    try {+      const url = 'api/kicad-stock?footprint_name=' + encodeURIComponent(fpName)+        + (state.spec?.kicad_3d_name ? '&kicad_3d_name=' + encodeURIComponent(state.spec.kicad_3d_name) : '');+      const r = await fetch(url);+      const j = await r.json();+      if (j.parsed) {+        state.stockOverlay = {+          loaded: true,+          parsed: j.parsed,+          fetched_url: j.kicad_mod_url || null,+          matched: j.matched_footprint_name || fpName,+          kicad_3d_url: j.kicad_3d_url || null,+        };+      } else {+        state.stockOverlay = {+          loaded: false,+          error: j.error_kicad_mod?.error || 'no parsed pads in response (service-kicad probably 404)',+        };+      }+    } catch (e) {+      state.stockOverlay = { loaded: false, error: String(e) };+    }+    if (state.activeTab === 'footprint') renderFootprintTab();+    return state.stockOverlay;+  }++  // ── Tab 5: Pin-1 ────────────────────────────────────────────────+  // Chip-side: user-placed indicator (state.userPin1Corner) — chipsmith+  // does NOT auto-detect manufacturer-shipped pin-1 dots; the heuristic+  // approaches were too unreliable. The user clicks NW/NE/SW/SE below+  // and chipsmith places a synthetic dimple. Footprint-side: every+  // footprint source's pin1_silk field (KiCad/Fusion/Altium parsers+  // report the silkscreen marker if found).++  function renderPin1Tab() {+    const body = document.getElementById('cs-body');+    const fps = state.footprintSources || {};+    const chipPin1 = state.userPin1Corner ? { corner: state.userPin1Corner } : null;+    const SRCS = ['kicad', 'fusion', 'altium', 'datasheet', 'ds2sf'];+    const fpPin1Found = SRCS.some(s => fps[s] && fps[s].pin1_silk && fps[s].pin1_silk.found);++    let status = '○';+    let summary = '';+    if (chipPin1 && fpPin1Found) {+      status = '◐';+      summary = `Chip-side indicator (user-placed at ${esc(chipPin1.corner)}) + footprint silkscreen marker both present. For visual corner-agreement, hover the chip top to spot the dimple and compare to the silk dot on the footprint overlay.`;+    } else if (chipPin1 && !fpPin1Found) {+      status = '◐';+      summary = `Chip-side indicator placed at ${esc(chipPin1.corner)}, but no silkscreen marker on any of the ${SRCS.length} footprint sources. Footprint may be missing its pin-1 dot — fix in upstream library.`;+    } else if (!chipPin1 && fpPin1Found) {+      status = '◐';+      summary = `Footprint silkscreen marker present. Place a chip-side indicator using one of the corner buttons below.`;+    } else {+      status = '✗';+      summary = `No pin-1 indicator on either side. Place a chip-side indicator using one of the corner buttons below — sign-off needs at least one.`;+    }+    state.status.pin1 = status;+    refreshStatusPills();++    const fpRows = SRCS.map(s => {+      const f = fps[s];+      if (!f) return null;+      if (f._stub) return `<div class="cs-row"><span class="label">${esc(s)}</span><span class="value dim">stub — not parsed</span></div>`;+      const found = !!(f.pin1_silk && f.pin1_silk.found);+      return `<div class="cs-row">+        <span class="label">${esc(s)}</span>+        <span class="value mono">${found ? 'silkscreen marker present' : 'no marker'}</span>+        <span class="pill ${found ? 'ok' : 'pending'}" style="margin-left:auto">${found ? '●' : '○'}</span>+      </div>`;+    }).filter(Boolean).join('');++    // Place-pin-1 affordance — only shown when chip-side is missing.+    // Three placement modes (per the plan's Pin-1 spec):+    //   1. "Use footprint marker" — project pin1_silk from any footprint+    //      source onto the body top + snap to nearest corner. Default+    //      when a marker exists; chip + silk agree by construction.+    //   2. "Pick a corner" — explicit NW/NE/SW/SE buttons.+    //   3. "Click to place" — JS API only for now: window.adomChipsmith+    //      .placePin1({ mode: 'click', xyMm: [x,y] }). UI canvas-pick+    //      lands in v1.1.+    // Three shapes (radio): dimple (default, recessed cylinder), raised+    // dot (protruding), corner chamfer (angled cut). All tagged+    // 'pin1_indicator_user_placed' + registered as first-class Outline+    // overlays + baked into .chipsmith.step at sign-off.+    const curShape = state.userPin1Shape || 'dimple';+    const SHAPES = [+      { id: 'dimple',    label: 'Dimple',         tip: 'Recessed cylinder, Ø 0.40 × 0.05 mm deep, slightly darker tint than the body. The default — matches QFN/QFP/LGA datasheet conventions.' },+      { id: 'raisedDot', label: 'Raised dot',     tip: 'Protruding cylinder, Ø 0.40 × 0.05 mm tall, body color. Common on SOIC and some moulded plastic packages where the dot is silk-printed not embossed.' },+      { id: 'chamfer',   label: 'Corner chamfer', tip: 'Angled cut on the body\'s top corner edge. Common on TQFP and some BGA where the corner itself is shaved.' },+    ];+    const shapeRadio = SHAPES.map(s => `+      <label class="cs-shape-opt${curShape === s.id ? ' active' : ''}" data-shape="${s.id}" data-tooltip="${escAttr(s.tip)}">+        <input type="radio" name="cs-pin1-shape" value="${s.id}"${curShape === s.id ? ' checked' : ''}>+        <span class="lbl">${esc(s.label)}</span>+      </label>`).join('');++    const fpMarkerBtn = fpPin1Found+      ? `<button class="cs-btn cs-btn-primary cs-pin1-fpmarker" data-tooltip="Project the silkscreen pin-1 marker from the first available footprint source onto the body top, snap to the nearest corner. Chip-side and silkscreen agree by construction — the safest default when a footprint marker exists.">${svgIcon('bookOpen')}<span class="lbl">Use footprint marker</span></button>`+      : `<button class="cs-btn cs-btn-secondary" disabled data-tooltip="No silkscreen pin-1 marker on any footprint source. Use a corner button below or place via the JS API: window.adomChipsmith.placePin1({mode:'click', xyMm:[x,y]}).">${svgIcon('bookOpen')}<span class="lbl">Use footprint marker — unavailable</span></button>`;++    const placeUI = chipPin1 ? '' : `+      <div class="cs-section">+        <h3 class="cs-section-title">Place a synthetic indicator</h3>+        <div class="cs-row dim"><span class="value">Manufacturer's STEP has no pin-1 dot. Place one here — it'll be tagged <code>pin1_indicator_user_placed</code> and baked into <code>.chipsmith.step</code> at sign-off (v1.1) so it survives every future load.</span></div>+        <div class="cs-row" style="margin-top:6px"><span class="label">Shape</span></div>+        <div class="cs-shape-radio">${shapeRadio}</div>+        <div class="cs-row" style="margin-top:8px"><span class="label">Mode</span></div>+        <div class="cs-actions" style="flex-wrap:wrap;gap:6px">+          ${fpMarkerBtn}+        </div>+        <div class="cs-row" style="margin-top:6px"><span class="label">Or pick a corner</span></div>+        <div class="cs-actions">+          <button class="cs-btn cs-btn-secondary cs-place-pin1" data-corner="NW" data-tooltip="Place at the chip's top-left (NW) corner — the canonical pin-1 location for QFN/QFP/LGA datasheet drawings.">${svgIcon('bookOpen')}<span class="lbl">NW</span></button>+          <button class="cs-btn cs-btn-secondary cs-place-pin1" data-corner="NE" data-tooltip="Place at the top-right (NE) corner.">${svgIcon('bookOpen')}<span class="lbl">NE</span></button>+          <button class="cs-btn cs-btn-secondary cs-place-pin1" data-corner="SW" data-tooltip="Place at the bottom-left (SW) corner.">${svgIcon('bookOpen')}<span class="lbl">SW</span></button>+          <button class="cs-btn cs-btn-secondary cs-place-pin1" data-corner="SE" data-tooltip="Place at the bottom-right (SE) corner.">${svgIcon('bookOpen')}<span class="lbl">SE</span></button>+        </div>+        <div class="cs-row" style="margin-top:6px"><span class="label">Or click on the body</span></div>+        <div class="cs-actions">+          <button class="cs-btn cs-btn-${state.pin1ClickArmed ? 'primary' : 'secondary'}" id="cs-pin1-click-arm" data-tooltip="Arm canvas-pick mode — your next click on the chip body's top face places the indicator at that point. Hold Alt to use the exact click point; otherwise chipsmith snaps to the nearest body corner. Press Esc to cancel.">${svgIcon('bookOpen')}<span class="lbl">${state.pin1ClickArmed ? 'Cancel click-to-place' : 'Click-to-place mode'}</span></button>+        </div>+        <div class="cs-row dim" style="margin-top:6px"><span class="value" style="font-size:11px">JS API: <code>window.adomChipsmith.placePin1({mode:'click', xyMm:[x,y], shape:'${curShape}'})</code></span></div>+      </div>`;++    body.innerHTML = `+      <div class="cs-section">+        <h3 class="cs-section-title">Chip-side indicator</h3>+        <div class="cs-row">+          <span class="label">Detected</span>+          <span class="value mono">${chipPin1 ? esc(chipPin1.name) : '<span class="cs-amber">none — manufacturer STEP has no pin-1 product</span>'}</span>+          <span class="pill ${chipPin1 ? 'ok' : 'pending'}" style="margin-left:auto">${chipPin1 ? '●' : '○'}</span>+        </div>+      </div>+      ${placeUI}+      <div class="cs-section">+        <h3 class="cs-section-title">Footprint silkscreen marker</h3>+        ${fpRows}+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">Agreement</h3>+        <div class="cs-meas-status pill-${status === '✗' ? 'fail' : (status === '●' ? 'ok' : 'partial')}">${summary}</div>+      </div>+    `;+    body.querySelectorAll('.cs-place-pin1').forEach(btn => {+      btn.onclick = () => placePin1({ corner: btn.dataset.corner, shape: state.userPin1Shape || 'dimple' });+    });+    body.querySelectorAll('.cs-shape-opt input').forEach(inp => {+      inp.onchange = () => {+        state.userPin1Shape = inp.value;+        renderPin1Tab();+      };+    });+    const fpBtn = body.querySelector('.cs-pin1-fpmarker');+    if (fpBtn) {+      fpBtn.onclick = () => {+        const r = placePin1({ mode: 'fpMarker', shape: state.userPin1Shape || 'dimple' });+        if (r && r.error) { console.warn('[chipsmith] placePin1 fpMarker:', r.error); alert(r.error); }+      };+    }+    const armBtn = document.getElementById('cs-pin1-click-arm');+    if (armBtn) {+      armBtn.onclick = () => {+        if (state.pin1ClickArmed) { disarmPin1Click(); }+        else                       { armPin1Click(); }+        renderPin1Tab();+      };+    }+  }++  // Click-to-place pin-1 — single-shot canvas-pick handler. When armed,+  // the next pointer-up on a body mesh resolves to (x,y) in mm relative+  // to the body bbox center, snaps to the nearest corner unless Alt is+  // held, then calls placePin1({mode:'click'}). Esc cancels. The crosshair+  // cursor + dim banner-overlay tells the user something is armed; auto-+  // disarm after one successful pick or on Esc.+  let _pin1ClickHandlers = null;+  function armPin1Click() {+    if (state.pin1ClickArmed) return;+    if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon)) return;+    const scene = window.adomStep.getScene();+    const canvas = scene.getEngine().getRenderingCanvas();+    if (!canvas) return;+    const bb = window.adomStep.bbox('Body');+    if (!bb || bb.error) {+      alert('Click-to-place needs a body bbox. Tab 2 must finish loading first.');+      return;+    }+    state.pin1ClickArmed = true;+    canvas.style.cursor = 'crosshair';+    showClickArmBanner();++    function onPointerUp(ev) {+      const rect = canvas.getBoundingClientRect();+      const x = ev.clientX - rect.left;+      const y = ev.clientY - rect.top;+      const pick = scene.pick(x, y);+      if (!pick || !pick.hit || !pick.pickedPoint) {+        return; // miss — keep armed for another click+      }+      // Convert hit point to footprint-mm coordinates (the chipsmith+      // canonical XY plane perpendicular to world-up). placePin1's+      // 'click' mode interprets xyMm as positions in this same plane.+      const ax = planeAxes();+      const hit = [pick.pickedPoint.x, pick.pickedPoint.y, pick.pickedPoint.z];+      const planeCx = (bb.min[ax[0]] + bb.max[ax[0]]) / 2;+      const planeCy = (bb.min[ax[1]] + bb.max[ax[1]]) / 2;+      const dxMm = (hit[ax[0]] - planeCx) / MM;+      const dyMm = (hit[ax[1]] - planeCy) / MM;+      const altHeld = !!ev.altKey;+      const opts = altHeld+        ? { mode: 'click', xyMm: [dxMm, dyMm], shape: state.userPin1Shape || 'dimple' }+        : { mode: 'corner', shape: state.userPin1Shape || 'dimple',+            corner: (dxMm < 0 ? (dyMm < 0 ? 'SW' : 'NW') : (dyMm < 0 ? 'SE' : 'NE')) };+      const r = placePin1(opts);+      if (r && r.error) { console.warn('[chipsmith] placePin1 click:', r.error); alert(r.error); }+      disarmPin1Click();+      if (state.activeTab === 'pin1') renderPin1Tab();+    }+    function onKey(ev) {+      if (ev.key === 'Escape') disarmPin1Click();+    }+    canvas.addEventListener('pointerup', onPointerUp);+    document.addEventListener('keydown', onKey);+    _pin1ClickHandlers = { canvas, onPointerUp, onKey };+  }+  function disarmPin1Click() {+    if (!state.pin1ClickArmed) return;+    state.pin1ClickArmed = false;+    if (_pin1ClickHandlers) {+      const { canvas, onPointerUp, onKey } = _pin1ClickHandlers;+      canvas.removeEventListener('pointerup', onPointerUp);+      document.removeEventListener('keydown', onKey);+      canvas.style.cursor = '';+      _pin1ClickHandlers = null;+    }+    hideClickArmBanner();+    if (state.activeTab === 'pin1') renderPin1Tab();+  }+  function showClickArmBanner() {+    let b = document.getElementById('cs-pin1-arm-banner');+    if (!b) {+      b = document.createElement('div');+      b.id = 'cs-pin1-arm-banner';+      b.style.cssText = 'position:fixed;top:48px;left:50%;transform:translateX(-50%);background:rgba(0,184,177,0.92);color:#0a0c10;font-size:12px;padding:6px 12px;border-radius:5px;z-index:99998;pointer-events:none;font-family:inherit;font-weight:500;box-shadow:0 4px 16px rgba(0,0,0,0.4)';+      document.body.appendChild(b);+    }+    b.textContent = 'Click on the chip body to place pin-1 (Alt = exact point, otherwise snap to nearest corner; Esc cancels)';+  }+  function hideClickArmBanner() {+    const b = document.getElementById('cs-pin1-arm-banner');+    if (b) b.remove();+  }++  // Place a synthetic pin-1 indicator on the chip's top face.+  //+  //   placePin1(cornerOrOpts, optsOrUndef)+  //+  // Calling shapes:+  //   placePin1('NW')                                      // legacy — corner + dimple+  //   placePin1({ corner: 'NW', shape: 'dimple' })         // explicit+  //   placePin1({ mode: 'fpMarker', shape: 'raisedDot' })  // snap to footprint silk+  //   placePin1({ mode: 'click', xyMm: [1.5, -0.8], shape: 'chamfer' })+  //+  // Modes:+  //   corner    — pick from NW/NE/SW/SE (default if `corner` is given)+  //   fpMarker  — project pin1_silk from a footprint source onto body top,+  //               snap to nearest corner; use this when you want chip + silk+  //               to agree by construction+  //   click     — caller passes xyMm (relative to chip body center)+  //+  // Shapes:+  //   dimple    — Ø 0.40 × 0.05 mm recessed cylinder (default)+  //   raisedDot — Ø 0.40 × 0.05 mm protruding cylinder, body color+  //   chamfer   — angled cut on the corner edge (cone segment)+  function placePin1(cornerOrOpts, optsOrUndef) {+    if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon && window.adomStep.bbox)) return { error: 'adom-step API not ready' };+    let mode, corner, xyMm, shape;+    if (typeof cornerOrOpts === 'string') {+      // Legacy form: placePin1('NW')+      mode = 'corner'; corner = cornerOrOpts;+      shape = (optsOrUndef && optsOrUndef.shape) || 'dimple';+    } else {+      const o = cornerOrOpts || {};+      mode = o.mode || (o.corner ? 'corner' : 'corner');+      corner = o.corner;+      xyMm = o.xyMm;+      shape = o.shape || 'dimple';+    }+    const scene = window.adomStep.getScene();+    const B = window.adomStep.getBabylon();+    const bb = window.adomStep.bbox('Body');+    if (!bb || bb.error) return { error: 'no body bbox' };+    ensureOverlayDisposed('user-pin1');+    // World-up-aware corner geometry. The body's "top" is along the+    // world-up axis; the corners live in the plane perpendicular to it.+    const upI = worldUpIdx();+    const ax  = planeAxes();              // [planeA, planeB]+    const inset_mm = 0.4;+    const minA = bb.min[ax[0]], maxA = bb.max[ax[0]];   // body extent along plane-A+    const minB = bb.min[ax[1]], maxB = bb.max[ax[1]];   // body extent along plane-B+    const topUp_mm    = bb.max[upI] / MM;+    const bottomUp_mm = bb.min[upI] / MM;+    const bodyCenterA = (minA + maxA) / 2;+    const bodyCenterB = (minB + maxB) / 2;++    // Pin-1 corner naming uses NW/NE/SW/SE — North/South map to the+    // higher/lower plane-B value, West/East map to the lower/higher+    // plane-A value. Same convention as legacy code; just plumbed+    // through the actual plane axes.+    function nearestCornerFromPlanePos_mm(a_mm, b_mm) {+      const left = (a_mm * MM) < bodyCenterA;+      const back = (b_mm * MM) < bodyCenterB;+      if ( left && !back) return 'NW';+      if (!left && !back) return 'NE';+      if ( left &&  back) return 'SW';+      return 'SE';+    }+    let centerA_mm, centerB_mm, sourceLabel = '';+    if (mode === 'fpMarker') {+      const fps = state.footprintSources || {};+      let pad = null, src = null;+      for (const s of ['kicad','fusion','altium','datasheet','ds2sf']) {+        const f = fps[s];+        if (f && f.pin1_silk && f.pin1_silk.found && Array.isArray(f.pin1_silk.center)) {+          pad = f.pin1_silk; src = s; break;+        }+      }+      if (!pad) return { error: 'no pin1_silk marker on any footprint source — try corner or click mode' };+      // Footprint marker is in footprint XY mm; project to body plane.+      corner = nearestCornerFromPlanePos_mm(pad.center[0], pad.center[1]);+      sourceLabel = `from ${src} silk marker`;+    }+    if (mode === 'click' && Array.isArray(xyMm)) {+      centerA_mm = xyMm[0];+      centerB_mm = xyMm[1];+      sourceLabel = `at click (${xyMm[0].toFixed(2)}, ${xyMm[1].toFixed(2)})`;+    } else {+      // corner mode (or fpMarker after corner-snap).+      const minA_mm = minA / MM, maxA_mm = maxA / MM;+      const minB_mm = minB / MM, maxB_mm = maxB / MM;+      switch (corner) {+        case 'NW': centerA_mm = minA_mm + inset_mm; centerB_mm = maxB_mm - inset_mm; break;+        case 'NE': centerA_mm = maxA_mm - inset_mm; centerB_mm = maxB_mm - inset_mm; break;+        case 'SW': centerA_mm = minA_mm + inset_mm; centerB_mm = minB_mm + inset_mm; break;+        case 'SE':+        default:   centerA_mm = maxA_mm - inset_mm; centerB_mm = minB_mm + inset_mm; break;+      }+      sourceLabel = sourceLabel || `corner ${corner}`;+    }++    // Build the indicator mesh per shape, with its long axis aligned+    // to world-up. placeMeshAt uses footprint-coords semantics (X,Y in+    // the plane, height along world-up) — same convention as every+    // other chipsmith overlay so domes / etch / pin-1 all agree.+    const meshes = [];+    if (shape === 'raisedDot') {+      const dot = B.MeshBuilder.CreateCylinder('user-pin1-raisedDot', {+        diameter: 0.40 * MM, height: 0.05 * MM, tessellation: 24,+      }, scene);+      alignVerticalToWorldUp(dot);+      // Cylinder centered 0.025 mm above body top so it protrudes.+      placeMeshAt(dot, centerA_mm, centerB_mm, topUp_mm + 0.025);+      const m = new B.StandardMaterial('user-pin1-mat', scene);+      m.diffuseColor = new B.Color3(0.20, 0.20, 0.22);+      m.specularColor = new B.Color3(0.05, 0.05, 0.05);+      dot.material = m;+      meshes.push(dot);+    } else if (shape === 'chamfer') {+      // Angled cut on the corner edge. Box has width along plane-A,+      // depth along plane-B (per flatBoxDims), thickness along world-up.+      // Then we rotate ABOUT the world-up axis so the long edge points+      // inward to the body center.+      const cham = B.MeshBuilder.CreateBox('user-pin1-chamfer', flatBoxDims(0.85, 0.10, 0.10), scene);+      placeMeshAt(cham, centerA_mm, centerB_mm, topUp_mm - 0.05);+      const dirA = ((centerA_mm * MM) > bodyCenterA ? 1 : -1);+      const dirB = ((centerB_mm * MM) > bodyCenterB ? 1 : -1);+      const yaw = Math.atan2(-dirA, -dirB) + Math.PI / 4;+      // Rotate around world-up axis so the chamfer's long edge points+      // toward the body center diagonally.+      if (upI === 1) cham.rotation.y = yaw;+      else           cham.rotation.z = yaw;+      const m = new B.StandardMaterial('user-pin1-mat', scene);+      m.diffuseColor = new B.Color3(0.08, 0.08, 0.09);+      m.specularColor = new B.Color3(0.05, 0.05, 0.05);+      cham.material = m;+      meshes.push(cham);+    } else {+      // dimple (default): recessed cylinder.+      const dim = B.MeshBuilder.CreateCylinder('user-pin1-dimple', {+        diameter: 0.40 * MM, height: 0.05 * MM, tessellation: 24,+      }, scene);+      alignVerticalToWorldUp(dim);+      // Cylinder centered 0.025 mm BELOW body top so it reads as recessed.+      placeMeshAt(dim, centerA_mm, centerB_mm, topUp_mm - 0.025);+      const m = new B.StandardMaterial('user-pin1-mat', scene);+      m.diffuseColor = new B.Color3(0.10, 0.10, 0.12);+      m.specularColor = new B.Color3(0.05, 0.05, 0.05);+      dim.material = m;+      meshes.push(dim);+    }+    const finalCorner = corner || nearestCornerFromPlanePos_mm(centerA_mm, centerB_mm);+    window.adomStep.addOverlay({+      group: 'user-pin1', meshes,+      label: `Pin-1 indicator  ${shape}  ${sourceLabel}`,+      kind: 'overlay-pin1-user',+    });+    state.userPin1Corner = finalCorner;+    state.userPin1Shape = shape;+    state.userPin1Mode = mode;+    recomputeAllStatuses();+    if (state.activeTab === 'pin1') renderPin1Tab();+    return { ok: true, corner: finalCorner, shape, mode };+  }++  // ── Laser-etch (cosmetic MPN engraving on the chip's top face) ──+  // Per the plan: "Optional laser-etch of the MPN onto the chip's top+  // surface — toggleable per-chip cosmetic feature that engraves the+  // part number into the body's top face (50µm-deep recess, slightly+  // darker than body color, auto-sized + auto-positioned to avoid the+  // pin-1 indicator). EEs love seeing the part number on the chip+  // when they're laying out a board downstream."+  //+  // Implementation: a thin Babylon plane sits 1µm above the body's top+  // face (visually "engraved" via a darker emissive vs the body), with+  // a DynamicTexture that paints the MPN text. Auto-sized to fit+  // within max_size_mm bounds (default 0.6 of body XZ). Auto-positioned+  // away from the pin-1 corner: if the user-placed pin-1 indicator is+  // at NW, the etch shifts SE-ward by 0.4 mm so the two don't visually+  // collide. Tagged 'overlay-laser-etch' for outline + hover-HUD.+  function renderLaserEtch(opts) {+    if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon && window.adomStep.bbox)) return null;+    const scene = window.adomStep.getScene();+    const B = window.adomStep.getBabylon();+    const bb = window.adomStep.bbox('Body');+    if (!bb || bb.error) return { error: 'no body bbox — cannot place laser etch' };+    ensureOverlayDisposed('laser-etch');+    const text = (opts && opts.text) || state.spec?.mpn || '???';+    const depthMm = (opts && opts.depth_mm) || 0.05;+    // World-up-aware sizing: pick the body's two PLANE-axis extents+    // for the etch's width/height (not its world-up height).+    const upI = worldUpIdx();+    const ax  = planeAxes();+    const bodyW_mm = (bb.max[ax[0]] - bb.min[ax[0]]) / MM;   // plane-A+    const bodyD_mm = (bb.max[ax[1]] - bb.min[ax[1]]) / MM;   // plane-B+    const maxW_mm = (opts && opts.max_size_mm && opts.max_size_mm[0]) || Math.min(bodyW_mm * 0.7, 3.0);+    const maxH_mm = (opts && opts.max_size_mm && opts.max_size_mm[1]) || Math.min(bodyD_mm * 0.18, 0.6);+    // Base position = body top-face center along the plane axes;+    // height = body bbox max along world-up.+    const planeCx_mm = ((bb.min[ax[0]] + bb.max[ax[0]]) / 2) / MM;+    const planeCy_mm = ((bb.min[ax[1]] + bb.max[ax[1]]) / 2) / MM;+    const topUp_mm   = bb.max[upI] / MM;+    let dxMm = 0, dyMm = 0;+    // Auto-shift away from the pin-1 indicator if one is placed.+    if (state.userPin1Corner) {+      const c = state.userPin1Corner;+      const off = 0.4;+      if (c === 'NW') { dxMm =  off; dyMm = -off; }+      if (c === 'NE') { dxMm = -off; dyMm = -off; }+      if (c === 'SW') { dxMm =  off; dyMm =  off; }+      if (c === 'SE') { dxMm = -off; dyMm =  off; }+    }++    // DynamicTexture label. Real chip silkscreen is light text on the+    // body's matte top — we paint a SOLID body-colored background with+    // bright off-white text on top, then render with full emissive so+    // the text reads regardless of scene lighting. No alpha-blending+    // (transparency was causing the text to wash out into the body+    // through mipmap sampling). The plane sits a hair above the body+    // top so it doesn't z-fight.+    const txW = 1024, txH = 192;+    const dt = new B.DynamicTexture('laser-etch-tex', { width: txW, height: txH }, scene, true, B.Texture.TRILINEAR_SAMPLINGMODE);+    const ctx = dt.getContext();+    // Background = matte-black body color (matches typical SOIC/QFN body).+    ctx.fillStyle = 'rgb(20, 20, 22)';+    ctx.fillRect(0, 0, txW, txH);+    let fontPx = Math.floor(txH * 0.72);+    ctx.fillStyle = 'rgb(232, 232, 220)';            // off-white silkscreen ink+    ctx.textAlign = 'center';+    ctx.textBaseline = 'middle';+    while (fontPx > 12) {+      ctx.font = `bold ${fontPx}px sans-serif`;+      const w = ctx.measureText(text).width;+      if (w <= txW - 24) break;+      fontPx -= 4;+    }+    ctx.font = `bold ${fontPx}px sans-serif`;+    ctx.fillText(text, txW / 2, txH / 2);+    dt.update();++    const mat = new B.StandardMaterial('laser-etch-mat', scene);+    mat.diffuseTexture = dt;+    mat.emissiveTexture = dt;+    mat.emissiveColor = new B.Color3(1, 1, 1);+    mat.diffuseColor  = new B.Color3(1, 1, 1);+    mat.specularColor = new B.Color3(0, 0, 0);+    // Render both faces so the etch reads regardless of which side+    // the camera approaches from. (The plane's "up" face after rotation+    // depends on Babylon's handedness; doublesided sidesteps it.)+    mat.backFaceCulling = false;++    // Plane sized in scene units. Babylon's CreatePlane defaults to+    // facing +Z (normal along +Z, lies in XY plane). For Z-up worlds+    // the default orientation already faces world-up — perfect.+    // For Y-up worlds we rotate +π/2 about X so the painted face+    // points to +Y. dt.vScale = -1 flips the V axis so text reads+    // right-side-up when viewed from above.+    const planeW = maxW_mm * MM;+    const planeH = maxH_mm * MM;+    const plane = B.MeshBuilder.CreatePlane('laser-etch-plane', {+      width: planeW, height: planeH,+    }, scene);+    if (upI === 1) {+      plane.rotation.x = Math.PI / 2;     // Y-up: rotate to face +Y+      dt.vScale = -1; dt.vOffset = 1;     // flip V so text reads right-side up+    }+    // Position 10 µm above the body's top face along world-up. Plane+    // axes get the offset+center; world-up gets topUp + 0.010.+    placeMeshAt(plane, planeCx_mm + dxMm, planeCy_mm + dyMm, topUp_mm + 0.010);+    plane.material = mat;++    window.adomStep.addOverlay({+      group: 'laser-etch',+      meshes: [plane],+      label: `Laser etch  "${text}"  ${maxW_mm.toFixed(2)}×${maxH_mm.toFixed(2)} mm  depth ${(depthMm*1000).toFixed(0)} µm`,+      kind: 'overlay-laser-etch',+    });+    state.laserEtch = { text, depth_mm: depthMm, max_size_mm: [maxW_mm, maxH_mm], position: { dx_mm: dxMm, dy_mm: dyMm } };+    return { ok: true, ...state.laserEtch };+  }++  // ── Debug: anchor bboxes ────────────────────────────────────────+  // Four colored thin slabs that visualize EXACTLY where chipsmith+  // thinks each canonical surface is in the scene. The user toggles+  // these on when reporting "your placement is wrong" so we can both+  // see whether the discrepancy is a placement bug (chipsmith put the+  // dome at the right Y but wrong XZ) or a model bug (chipsmith thinks+  // the body's top is at Y=1.65 mm but actually it's at Y=2.10 mm).+  //+  // Anchors:+  //   chip-top         body bbox max-Y plane                     (cyan)+  //   chip-bottom      body bbox min-Y plane                     (magenta)+  //   footprint-area   bbox of all pad centers from best source  (yellow)+  //                    sitting at the same Y-plane chipsmith uses+  //                    when rendering footprint overlays (z=0 in+  //                    KiCad coords → Y=0 in scene)+  //   pcb-top          1.6mm faux PCB top face plane             (green)+  //                    derived from the PCB plate's expected+  //                    footprint extent + 5mm margin, even when+  //                    the plate isn't currently rendered+  //+  // Each anchor is a thin (50µm) horizontal slab so it reads as a+  // "this is the plane I'm anchoring on" surface, not a shape that+  // could be confused with real geometry.+  function renderDebugAnchors() {+    if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon && window.adomStep.bbox)) {+      return { error: 'adom-step API not ready' };+    }+    const scene = window.adomStep.getScene();+    const B = window.adomStep.getBabylon();+    const group = 'debug-anchors';+    ensureOverlayDisposed(group);+    const SLAB = 0.05 * MM;+    const out = [];+    const children = [];++    // World-up detection — the chip-fetcher GLBs are authored with+    // Z as the height axis (CAD/EE convention; camera.upVector =+    // [0,0,1]). The chipsmith renderers were originally authored+    // Y-up; the debug anchors here use the SAME convention as the+    // active camera so they actually anchor where the chip lives,+    // and they expose to the user when other chipsmith renderers+    // disagree with that convention.+    const cam = scene.activeCamera;+    const up = cam && cam.upVector+      ? Math.abs(cam.upVector.z) > 0.5 ? 'Z' : (Math.abs(cam.upVector.y) > 0.5 ? 'Y' : 'X')+      : 'Y';+    const upIdx = up === 'Z' ? 2 : (up === 'Y' ? 1 : 0);+    const planeAxes = up === 'Z' ? [0, 1] : (up === 'Y' ? [0, 2] : [1, 2]); // axes orthogonal to up++    // makeSlab(name, w_m, h_m, center_xyz, color, labelExtra, slabUpAxis)+    //   slabUpAxis: 'X' | 'Y' | 'Z' — which axis the slab is THIN along.+    //   Defaults to the world-up axis. Override for slabs that anchor+    //   chipsmith's CURRENT placement (legacy Y-up footprint pads) so+    //   the user sees if those placements disagree with the world-up.+    function makeSlab(name, w_m, h_m, center_xyz, color, labelExtra, slabUpAxis) {+      const slabUp  = slabUpAxis || up;+      const slabIdx = slabUp === 'Z' ? 2 : (slabUp === 'Y' ? 1 : 0);+      const slabPlane = slabUp === 'Z' ? [0, 1] : (slabUp === 'Y' ? [0, 2] : [1, 2]);+      const dims = { width: 0.001, height: 0.001, depth: 0.001 };+      const dimNames = ['width', 'height', 'depth'];+      dims[dimNames[slabPlane[0]]] = w_m;+      dims[dimNames[slabPlane[1]]] = h_m;+      dims[dimNames[slabIdx]]      = SLAB;+      const m = B.MeshBuilder.CreateBox(name, dims, scene);+      m.position.x = center_xyz[0];+      m.position.y = center_xyz[1];+      m.position.z = center_xyz[2];+      const mat = new B.StandardMaterial(name + '-mat', scene);+      mat.diffuseColor  = new B.Color3(color.r, color.g, color.b);+      mat.emissiveColor = new B.Color3(color.r * 0.7, color.g * 0.7, color.b * 0.7);+      mat.specularColor = new B.Color3(0, 0, 0);+      mat.alpha = 0.55;+      m.material = mat;+      out.push(m);+      const labelW_mm = (w_m / MM).toFixed(2);+      const labelH_mm = (h_m / MM).toFixed(2);+      const upMm = (center_xyz[slabIdx] / MM).toFixed(3);+      const axisNote = slabUp === up ? '' : ` (THIN-${slabUp} ⚠ world-up=${up})`;+      children.push({+        meshes: [m],+        label: `${name.replace(/^debug-/, '')}  ${labelW_mm}×${labelH_mm} mm @ ${slabUp}=${upMm} mm${axisNote}  ${labelExtra || ''}`.trim(),+        kind: 'overlay-debug-anchor',+      });+    }++    // ── chip-top + chip-bottom ──────────────────────────+    // Use the body's bbox along the world-UP axis, with the slab+    // spanning the body's full footprint in the two plane axes.+    const bb = window.adomStep.bbox('Body');+    if (bb && !bb.error) {+      const planeW = bb.max[planeAxes[0]] - bb.min[planeAxes[0]];+      const planeH = bb.max[planeAxes[1]] - bb.min[planeAxes[1]];+      const ctrA   = (bb.min[planeAxes[0]] + bb.max[planeAxes[0]]) / 2;+      const ctrB   = (bb.min[planeAxes[1]] + bb.max[planeAxes[1]]) / 2;+      function pos(upVal) {+        const p = [0, 0, 0];+        p[planeAxes[0]] = ctrA;+        p[planeAxes[1]] = ctrB;+        p[upIdx]        = upVal;+        return p;+      }+      makeSlab('debug-chip-top',    planeW, planeH, pos(bb.max[upIdx]),+        { r: 0.30, g: 0.85, b: 0.95 }, `— body bbox max-${up}`);+      makeSlab('debug-chip-bottom', planeW, planeH, pos(bb.min[upIdx]),+        { r: 0.95, g: 0.30, b: 0.75 }, `— body bbox min-${up}`);+    } else {+      children.push({ meshes: [], label: 'chip-top — unavailable (no Body bbox)', kind: 'overlay-debug-anchor' });+      children.push({ meshes: [], label: 'chip-bottom — unavailable (no Body bbox)', kind: 'overlay-debug-anchor' });+    }++    // ── footprint-area ──────────────────────────────────+    // Compute extent of pad centers from the most-pad-trustworthy+    // source. The pads themselves are placed by renderFootprintOverlay+    // in world XZ (legacy Y-up convention) — we mirror THAT placement+    // so the anchor coincides with where chipsmith currently puts+    // its overlays, even if that placement disagrees with world-up.+    // (When the user sees the yellow slab in a different plane than+    // the green pcb-top slab, that's the bug.)+    const fps = state.footprintSources || {};+    const candidates = ['kicad', 'fusion', 'altium', 'datasheet']+      .map(s => ({ src: s, fp: fps[s] }))+      .filter(x => x.fp && !x.fp._stub && Array.isArray(x.fp.pads) && x.fp.pads.length > 0);+    if (candidates.length) {+      const best = candidates.sort((a, b) => b.fp.pad_count - a.fp.pad_count)[0];+      const ext = fpExtent(best.fp.pads);+      if (ext) {+        const w = ext.width  * MM;+        const h = ext.height * MM;+        const xs = best.fp.pads.map(p => p.at?.[0] || 0);+        const ys = best.fp.pads.map(p => p.at?.[1] || 0);+        const cxMm = (Math.min(...xs) + Math.max(...xs)) / 2;+        const cyMm = (Math.min(...ys) + Math.max(...ys)) / 2;+        // chipsmith places footprint pads at fpPlace(fpX, fpY, 0.02mm),+        // so the anchor goes there too. The slab is thin along the+        // world-up axis (matching the pad's flat orientation) — when+        // the chip body and footprint are correctly aligned, this+        // anchor sits flat on the seat plane right under chip-bottom.+        const center = fpPlace(cxMm, cyMm, 0);+        makeSlab('debug-footprint-area',+          w, h, center,+          { r: 0.95, g: 0.85, b: 0.20 },+          `— ${best.fp.pad_count} pads from ${best.src}`);+      }+    } else {+      children.push({ meshes: [], label: 'footprint-area — unavailable (no parsed pads)', kind: 'overlay-debug-anchor' });+    }++    // ── pcb-top ─────────────────────────────────────────+    // Where chipsmith currently puts the faux PCB plate's +Y face:+    // world Y=0, sized footprint extent + 5mm margin in world XZ.+    // (Same legacy Y-up convention as the footprint pads.)+    if (candidates.length) {+      const best = candidates.sort((a, b) => b.fp.pad_count - a.fp.pad_count)[0];+      const ext = fpExtent(best.fp.pads);+      if (ext) {+        const padMargin = 5;+        const w = (ext.width  + padMargin * 2) * MM;+        const h = (ext.height + padMargin * 2) * MM;+        // PCB plate top face is at world-up height = 0 (renderFauxPcbPlate+        // places the plate so its +up face is flush with the seat plane).+        makeSlab('debug-pcb-top', w, h, fpPlace(0, 0, 0),+          { r: 0.30, g: 0.85, b: 0.45 },+          '— FR4 plate +up face @ height=0');+      }+    } else {+      children.push({ meshes: [], label: 'pcb-top — unavailable (no faux PCB extent)', kind: 'overlay-debug-anchor' });+    }++    window.adomStep.addOverlay({+      group,+      label: `Debug — anchor bboxes  (${children.length})  · world-up=${up}`,+      kind: 'overlay-debug-anchors',+      children,+    });+    state.debugAnchorsOn = true;+    return { ok: true, count: out.length, world_up_axis: up };+  }++  // ── Tab 7: Heatsink vias ────────────────────────────────────────+  // Auto-generate a square via grid under any detected exposed pad+  // (EP / ep_thermal). Drill 0.3mm, outer Cu 0.6mm, pitch 1.2mm,+  // 0.4mm edge margin per the plan. Output: positions in the EP's+  // local frame. v1.0 surfaces the data; v1.1 renders the 3D vias as+  // an overlay layer the user can toggle.++  function generateViaGrid(epSize, opts = {}) {+    const drill   = opts.drill_mm   || 0.30;+    const outerCu = opts.outer_cu_mm || 0.60;+    const pitch   = opts.pitch_mm   || 1.20;+    const margin  = opts.margin_mm  || 0.40;+    if (!epSize || epSize.width <= 2 * margin) return [];+    const usableX = epSize.width  - 2 * margin;+    const usableY = epSize.height - 2 * margin;+    const nx = Math.max(1, Math.floor(usableX / pitch) + 1);+    const ny = Math.max(1, Math.floor(usableY / pitch) + 1);+    const startX = -((nx - 1) * pitch) / 2;+    const startY = -((ny - 1) * pitch) / 2;+    const vias = [];+    for (let iy = 0; iy < ny; iy++) {+      for (let ix = 0; ix < nx; ix++) {+        vias.push({+          index: iy * nx + ix + 1,+          x_mm: startX + ix * pitch,+          y_mm: startY + iy * pitch,+          drill_mm: drill,+          outer_cu_mm: outerCu,+        });+      }+    }+    return { drill, outerCu, pitch, margin, nx, ny, count: nx * ny, vias };+  }++  function renderHeatsinkTab() {+    const body = document.getElementById('cs-body');+    const fps = state.footprintSources || {};+    if (!fps || Object.keys(fps).length === 0) {+      body.innerHTML = `<div class="cs-empty">Loading footprint sources…</div>`;+      fetch('api/footprint-sources').then(r => r.ok ? r.json() : null).then(d => {+        state.footprintSources = d;+        if (state.activeTab === 'heatsink') renderHeatsinkTab();+      });+      return;+    }+    // Find the EP from any footprint source (its size is what we need).+    const SRCS = ['kicad', 'fusion', 'altium', 'datasheet', 'ds2sf'];+    let epPad = null, epSrc = null;+    for (const s of SRCS) {+      const f = fps[s];+      if (!f || f._stub || !f.pads) continue;+      const ep = f.pads.find(p => p.size && p.size[0] >= 1.5 && p.size[1] >= 1.5 && p.size[0] === p.size[1]);+      // Heuristic: a square SMD pad ≥1.5×1.5 mm is almost always the EP/thermal.+      // Or look for the largest pad if the heuristic fails.+      const largest = f.pads.reduce((acc, p) => {+        if (!p.size) return acc;+        const a = p.size[0] * p.size[1];+        return (!acc || a > acc.area) ? { pad: p, area: a } : acc;+      }, null);+      const candidate = ep || (largest && largest.area > 2.0 ? largest.pad : null);+      if (candidate) { epPad = candidate; epSrc = s; break; }+    }+    if (!epPad) {+      state.status.heatsink = '─';+      refreshStatusPills();+      body.innerHTML = `+        <div class="cs-section">+          <h3 class="cs-section-title">Heatsink vias</h3>+          <div class="cs-meas-status pill-partial">No exposed pad / thermal pad detected on any footprint source. Heatsink-via grid is N/A for this chip.</div>+        </div>`;+      return;+    }+    const epSize = { width: epPad.size[0], height: epPad.size[1] };+    const grid = generateViaGrid(epSize);+    const epOriginMm = { x: epPad.at?.[0] || 0, y: epPad.at?.[1] || 0 };+    state.heatsinkGrid = { ep: { source: epSrc, pad_number: epPad.number, size_mm: epPad.size, origin_mm: epOriginMm }, grid };+    state.status.heatsink = grid.count > 0 ? '●' : '─';+    refreshStatusPills();+    // Derived spec figures EEs care about. ringMm = (outer Cu - drill) / 2.+    // InstaPCB minimum: 0.10 mm. The InstaPCB skill (gallia/skills/instapcb)+    // doesn't yet publish a min annular ring; 0.10 mm is the working value+    // confirmed by the user, derived from UV-fiber-laser positional+    // accuracy (~±25–50 µm) + drill-kerf tolerance (~±25 µm). For+    // reference: IPC-2221 Class 2 (commercial) is 0.05 mm, Class 3+    // (mil/medical) is 0.13 mm.+    const ringMm = Math.max(0, (grid.outerCu - grid.drill) / 2);+    const ringClass = ringMm >= 0.13 ? 'InstaPCB ✓ + IPC-2221 Class 3'+                    : ringMm >= 0.10 ? 'InstaPCB-OK (≥0.10 mm)'+                    : ringMm >= 0.05 ? '⚠ below InstaPCB min (0.10 mm) — IPC-2221 Class 2 only'+                    : '✗ below InstaPCB min (0.10 mm) and below IPC-2221 Class 2';+    // EP coverage: cumulative drill-hole area over the EP area.+    const drillArea = grid.count * Math.PI * Math.pow(grid.drill / 2, 2);+    const epArea    = epSize.width * epSize.height;+    const epCoveragePct = epArea > 0 ? (drillArea / epArea) * 100 : 0;+    const sample = grid.vias.slice(0, 16).map((v, i) => `+      <div class="cs-row">+        <span class="label">via ${v.index}</span>+        <span class="value mono">(${v.x_mm.toFixed(3)}, ${v.y_mm.toFixed(3)}) mm</span>+        <span class="value dim">drill Ø${v.drill_mm} / outer Ø${v.outer_cu_mm} / ring ${ringMm.toFixed(3)} mm</span>+      </div>`).join('');+    body.innerHTML = `+      <div class="cs-section">+        <h3 class="cs-section-title">Exposed pad</h3>+        <div class="cs-row"><span class="label">Source</span><span class="value mono">${esc(epSrc)}</span></div>+        <div class="cs-row"><span class="label">Pad number</span><span class="value mono">${esc(epPad.number)}</span></div>+        <div class="cs-row"><span class="label">Size</span><span class="value mono">${esc(epSize.width)} × ${esc(epSize.height)} mm</span></div>+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">Via grid</h3>+        <div class="cs-row" data-tooltip="Finished hole diameter — what the drill bit produces. InstaPCB minimum is 0.20 mm / 8 mil; 0.30 mm gives comfortable margin for laser-ablated barrels at 0.5 oz Cu plating."><span class="label">Drill (finished hole)</span><span class="value mono">Ø ${grid.drill.toFixed(2)} mm</span></div>+        <div class="cs-row" data-tooltip="Outer pad diameter (the copper land around the hole). Defines the via's outer Cu disc on top + bottom layers."><span class="label">Outer Cu pad</span><span class="value mono">Ø ${grid.outerCu.toFixed(2)} mm</span></div>+        <div class="cs-row" data-tooltip="Annular ring width = (outer pad Ø − drill Ø) / 2. The copper that surrounds the hole on each face. InstaPCB minimum: 0.10 mm (derived from UV-fiber-laser positional accuracy + kerf tolerance). IPC-2221 Class 2 ≥ 0.05 mm; Class 3 ≥ 0.13 mm."><span class="label">Annular ring</span><span class="value mono">${ringMm.toFixed(3)} mm  <span class="dim">(${ringClass})</span></span></div>+        <div class="cs-row" data-tooltip="Cu wall thickness inside the barrel. InstaPCB plates 0.5 oz Cu (≈ 17.5 µm / 0.0175 mm) per the published capabilities table. Affects the via's current-carrying capacity + thermal conductance through the barrel."><span class="label">Plating thickness</span><span class="value mono">17.5 µm <span class="dim">(0.5 oz Cu, InstaPCB)</span></span></div>+        <div class="cs-row"><span class="label">Pitch (center-to-center)</span><span class="value mono">${grid.pitch.toFixed(2)} mm</span></div>+        <div class="cs-row"><span class="label">Edge margin (from EP edge)</span><span class="value mono">${grid.margin.toFixed(2)} mm</span></div>+        <div class="cs-row"><span class="label">Grid</span><span class="value mono">${grid.nx} × ${grid.ny} = ${grid.count} vias</span></div>+        <div class="cs-row" data-tooltip="Fraction of the EP's copper area that is via opening (drill cross-section). High coverage = better thermal path but more solder loss into the holes during reflow. ~3–8% is typical."><span class="label">EP coverage</span><span class="value mono">${epCoveragePct.toFixed(1)}% <span class="dim">(via Cu / EP area)</span></span></div>+        <div class="cs-meas-status pill-ok">${grid.count} plated through-hole vias auto-generated under the EP. Sign-off bakes the grid into <code>&lt;mpn&gt;.chipsmith.json</code> so downstream board-design tools can ingest as a single "place this heatsink-via grid" action.</div>+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">EP copper pour (B.Cu, bottom side)</h3>+        <div class="cs-row dim"><span class="value">Copper region on the <strong>bottom</strong> of the PCB that the heatsink vias dump into. The chip's EP solders to F.Cu on top; vias drill through the FR4; pour on B.Cu radiates the heat away. <strong>On by default</strong> — drawing this pour by hand is busywork; chipsmith bakes its outline into <code>&lt;mpn&gt;.chipsmith.json</code> so downstream board-design tools can drop it as a single zone fill.</span></div>+        <div class="cs-row"><span class="label">Pour size</span><span class="value mono">${epSize.width.toFixed(2)} × ${epSize.height.toFixed(2)} mm <span class="dim">(matches EP)</span></span></div>+        <div class="cs-row"><span class="label">Layer</span><span class="value mono">B.Cu (bottom)</span></div>+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">3D overlay</h3>+        <div class="cs-row dim"><span class="value">Both the via grid and the EP pour are children of the <code>Heatsink</code> row in the Outline (left). Eye-toggle each independently from the Outline. The button below toggles the whole heatsink (both children) at once.</span></div>+        <div class="cs-row">+          <span class="label">Visibility</span>+          <button class="cs-btn cs-btn-${state.heatsinkOn ? 'primary' : 'secondary'}" id="cs-heatsink-toggle" data-tooltip="Toggle the entire heatsink overlay (vias + EP pour) on/off. For per-piece control, use the eye icons in the Outline panel on the heatsink's child rows.">${svgIcon('bookOpen')}<span class="lbl">${state.heatsinkOn ? 'Hide' : 'Show'} heatsink (${grid.count} vias + EP pour)</span></button>+        </div>+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">Positions (first ${Math.min(16, grid.count)} of ${grid.count})</h3>+        ${sample}+      </div>+    `;+    const hsBtn = document.getElementById('cs-heatsink-toggle');+    if (hsBtn) {+      hsBtn.onclick = () => {+        const on = !state.heatsinkOn;+        if (on) renderHeatsink(epOriginMm, epSize, grid);+        else { ensureOverlayDisposed('heatsink'); state.epCopperPour = null; }+        state.heatsinkOn = on;+        state.viasOn = on;       // legacy state mirrors+        state.epPourOn = on;+        renderHeatsinkTab();+      };+    }+    // Default-ON behaviour — first time the heatsink tab renders for+    // this chip, draw the entire heatsink (vias + B.Cu pour) automatically.+    // The user can flip it off, but the sidecar always carries the spec+    // regardless of visibility — downstream tools inherit it.+    if (state.heatsinkOn === undefined && epOriginMm && epSize) {+      state.heatsinkOn = true;+      state.viasOn = true;+      state.epPourOn = true;+      renderHeatsink(epOriginMm, epSize, grid);+    }+  }++  // ── Tab 8: Solder-jet ───────────────────────────────────────────+  // Per gallia/skills/solder-jetting/SKILL.md: 300µm dome per dispense,+  // intra-pad spacing ≥100µm edge-to-edge, inter-pad spacing ≥300µm+  // edge-to-edge. Always at least 1 dot per pad — pads narrower than+  // the droplet get a single centred dot.++  function generateSolderJets(pads, opts = {}) {+    const D = (opts.droplet_um || 300) / 1000;       // mm+    const INTRA = (opts.intra_um || 100) / 1000;     // mm dot-edge to dot-edge inside a pad+    const INTER = (opts.inter_um || 300) / 1000;     // mm dot-edge to dot-edge across pads+    const all = [];+    for (const pad of pads) {+      if (!pad.size || pad.size[0] <= 0 || pad.size[1] <= 0) continue;+      const padW = pad.size[0], padH = pad.size[1];+      const cx = pad.at?.[0] || 0, cy = pad.at?.[1] || 0;+      // Pad too narrow for the dot? center one dot.+      if (Math.min(padW, padH) < D) {+        all.push({ x: cx, y: cy, padId: pad.number, droplet: D });+        continue;+      }+      const nx = Math.max(1, Math.floor((padW - D) / (D + INTRA)) + 1);+      const ny = Math.max(1, Math.floor((padH - D) / (D + INTRA)) + 1);+      const stepX = nx > 1 ? (padW - D) / (nx - 1) : 0;+      const stepY = ny > 1 ? (padH - D) / (ny - 1) : 0;+      const startX = cx - (padW - D) / 2;+      const startY = cy - (padH - D) / 2;+      for (let iy = 0; iy < ny; iy++) {+        for (let ix = 0; ix < nx; ix++) {+          all.push({+            x: startX + ix * stepX,+            y: startY + iy * stepY,+            padId: pad.number,+            droplet: D,+          });+        }+      }+    }+    // Prune cross-pad violations: any pair with different pad IDs+    // closer than D + INTER (centre to centre) drops the one whose+    // pad has more remaining dots.+    const minPair = D + INTER;+    const counts = {};+    for (const c of all) counts[c.padId] = (counts[c.padId] || 0) + 1;+    const drop = new Set();+    for (let i = 0; i < all.length; i++) {+      if (drop.has(i)) continue;+      for (let j = i + 1; j < all.length; j++) {+        if (drop.has(j)) continue;+        const a = all[i], b = all[j];+        if (a.padId === b.padId) continue;+        const dx = a.x - b.x, dy = a.y - b.y;+        if (Math.hypot(dx, dy) < minPair) {+          // Drop from whichever has more remaining dots.+          const losing = counts[a.padId] >= counts[b.padId] ? i : j;+          drop.add(losing);+          counts[all[losing].padId]--;+        }+      }+    }+    const kept = all.filter((_, i) => !drop.has(i));+    return {+      droplet_mm: D,+      intra_mm: INTRA,+      inter_mm: INTER,+      total: kept.length,+      pruned: drop.size,+      domes: kept,+      per_pad: counts,+    };+  }++  function renderSolderJetOverlay(domes) {+    if (!domes || !domes.length) return null;+    if (!(window.adomStep && window.adomStep.getScene && window.adomStep.getBabylon)) return null;+    const scene = window.adomStep.getScene();+    const B = window.adomStep.getBabylon();+    if (!scene || !B) return null;+    const group = 'solder-jet';+    ensureOverlayDisposed(group);+    const mat = new B.StandardMaterial(group + '-mat', scene);+    mat.diffuseColor = new B.Color3(0.75, 0.78, 0.85);+    mat.specularColor = new B.Color3(0.30, 0.32, 0.36);+    mat.emissiveColor = new B.Color3(0.08, 0.08, 0.10);+    // Group domes by their parent pad, one outline child per pad.+    // Babylon's CreateSphere with slice:0.5 keeps the +Y hemisphere+    // (flat side at y=0, curved side bulging up to y=+R). For Y-up+    // worlds that's already correct. For Z-up worlds we rotate by+    // -π/2 about X so the curved side bulges toward +Z (up).+    const byPad = new Map();+    let total = 0;+    for (const d of domes) {+      const dome = B.MeshBuilder.CreateSphere(`${group}-${total}`, {+        diameter: d.droplet * MM, segments: 8, slice: 0.5,+      }, scene);+      if (worldUpIdx() === 2) dome.rotation.x = -Math.PI / 2;+      // Sit just above the pad's top face. Pad top is at height = 0.04+      // (renderFootprintOverlay puts pads at h = 0.02 ± 0.02 thickness),+      // dome flat face anchored at h=0.05 leaves a tiny visible gap so+      // the dome doesn't z-fight the pad.+      placeMeshAt(dome, d.x, d.y, 0.05);+      dome.material = mat;+      total++;+      const key = String(d.padId);+      if (!byPad.has(key)) byPad.set(key, { padId: key, meshes: [], drop: d.droplet });+      byPad.get(key).meshes.push(dome);+    }+    // Approx volume per dome: hemisphere V = (2/3)πr³, in nL (mm³).+    function volNl(r) { return (2/3) * Math.PI * r * r * r; }+    const children = Array.from(byPad.values()).map(p => {+      const r = (p.drop / 2);+      const v = p.meshes.length * volNl(r);+      return {+        meshes: p.meshes,+        label: `pad ${p.padId}  ${p.meshes.length} dome${p.meshes.length === 1 ? '' : 's'}  Ø ${(p.drop*1000).toFixed(0)} µm  vol Σ ${v.toFixed(2)} nL`,+        kind: 'overlay-solderjet-pad',+      };+    });+    window.adomStep.addOverlay({+      group,+      label: `Solder-jet  (${total} × Ø${(domes[0].droplet * 1000).toFixed(0)} µm  intra ≥0.10  inter ≥0.30)`,+      kind: 'overlay-solderjet',+      children,+    });+    return { group, count: total };+  }++  function renderSolderjetTab() {+    const body = document.getElementById('cs-body');+    const fps = state.footprintSources || {};+    // Source-picker policy: filter out sources whose pads are clearly+    // garbage-scaled (the kicad library entry for VL53L8CX, for example,+    // ships pads at 0.1×0.2 mm — smaller than the 300 µm solder droplet,+    // which would force one centered dome per pad regardless of the+    // real package geometry). A pad whose smaller side is < 0.15 mm+    // (smaller than half the droplet) is treated as untrustworthy; a+    // source where the MEDIAN pad area is < 0.04 mm² is rejected+    // entirely. Among trustworthy sources, prefer the one with the+    // most pads. Fall back to raw-most-pads if every source fails the+    // sanity gate so we still render something.+    const candidates = ['kicad', 'fusion', 'altium', 'datasheet']+      .map(s => ({ src: s, fp: fps[s] }))+      .filter(x => x.fp && !x.fp._stub && Array.isArray(x.fp.pads) && x.fp.pads.length > 0);+    function padArea(p) {+      const w = p?.size?.[0] || 0, h = p?.size?.[1] || 0;+      return w * h;+    }+    function padMinSide(p) {+      const w = p?.size?.[0] || 0, h = p?.size?.[1] || 0;+      return Math.min(w, h);+    }+    function median(arr) {+      if (!arr.length) return 0;+      const s = arr.slice().sort((a, b) => a - b);+      const m = Math.floor(s.length / 2);+      return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;+    }+    const trustworthy = candidates.filter(c => {+      const minSides = c.fp.pads.map(padMinSide).filter(v => v > 0);+      const areas    = c.fp.pads.map(padArea).filter(v => v > 0);+      if (minSides.length === 0) return false;+      const minMin = Math.min(...minSides);+      const medArea = median(areas);+      // 0.15 mm = half the 300 µm droplet; below that a single dome+      // can't sit on the pad without overhang. 0.04 mm² ≈ 200×200 µm,+      // smaller than just about any real SMD pad.+      return minMin >= 0.15 && medArea >= 0.04;+    });+    const pool = trustworthy.length ? trustworthy : candidates;+    pool.sort((a, b) => b.fp.pad_count - a.fp.pad_count);+    const best = pool[0];+    const sourceDistrust = trustworthy.length === 0 && candidates.length > 0;+    if (!best) {+      state.status.solderjet = '─';+      refreshStatusPills();+      body.innerHTML = `<div class="cs-empty">No footprint pads — load a footprint source first.</div>`;+      return;+    }+    const sj = generateSolderJets(best.fp.pads);+    state.solderjet = { source: best.src, ...sj };+    state.status.solderjet = sj.total > 0 ? '●' : '─';+    refreshStatusPills();++    // Per-pad row breakdown.+    const padRows = best.fp.pads.slice(0, 12).map(p => {+      const c = sj.per_pad[p.number] || 0;+      return `<div class="cs-row">+        <span class="label">${esc(p.number)}</span>+        <span class="value mono">${esc(p.size?.[0] || '?')} × ${esc(p.size?.[1] || '?')} mm</span>+        <span class="value dim">${c} dot${c === 1 ? '' : 's'}</span>+      </div>`;+    }).join('');++    // Build a per-source diagnostic so the user can see exactly why we+    // picked the source we did (and which sources were rejected).+    const sourceDiagRows = candidates.map(c => {+      const minSides = c.fp.pads.map(padMinSide).filter(v => v > 0);+      const areas    = c.fp.pads.map(padArea).filter(v => v > 0);+      const minMin = minSides.length ? Math.min(...minSides) : 0;+      const medArea = median(areas);+      const trust = (minMin >= 0.15 && medArea >= 0.04);+      const isPicked = c.src === best.src;+      const reason = trust+        ? (isPicked ? 'picked' : 'eligible')+        : (minMin < 0.15+          ? `rejected — smallest pad side ${(minMin * 1000).toFixed(0)} µm (&lt; 150 µm)`+          : `rejected — median pad area ${(medArea).toFixed(3)} mm² (&lt; 0.04 mm²)`);+      const cls = isPicked ? 'ok' : (trust ? 'pending' : 'fail');+      const glyph = isPicked ? '●' : (trust ? '○' : '✗');+      return `<div class="cs-row">+        <span class="label">${esc(c.src)}</span>+        <span class="value mono">${c.fp.pads.length} pads · min side ${(minMin*1000).toFixed(0)} µm · med area ${medArea.toFixed(3)} mm²</span>+        <span class="value dim" style="margin-left:auto">${reason}</span>+        <span class="pill ${cls}" style="margin-left:6px">${glyph}</span>+      </div>`;+    }).join('');+    const distrustNote = sourceDistrust+      ? `<div class="cs-meas-status pill-fail">Every available source has untrustworthy pad sizes (smaller than the 300 µm droplet) — falling back to raw pad count. The dome layout below may not reflect the real datasheet pad geometry; fix the upstream chip-fetcher data or load a better source.</div>`+      : '';++    body.innerHTML = `+      <div class="cs-section">+        <h3 class="cs-section-title">Source pick</h3>+        ${sourceDiagRows}+        ${distrustNote}+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">Spec (per gallia/skills/solder-jetting)</h3>+        <div class="cs-row"><span class="label">Droplet Ø</span><span class="value mono">${(sj.droplet_mm * 1000).toFixed(0)} µm</span></div>+        <div class="cs-row"><span class="label">Intra-pad gap</span><span class="value mono">≥ ${(sj.intra_mm * 1000).toFixed(0)} µm</span></div>+        <div class="cs-row"><span class="label">Inter-pad gap</span><span class="value mono">≥ ${(sj.inter_mm * 1000).toFixed(0)} µm</span></div>+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">Result</h3>+        <div class="cs-row"><span class="label">Total domes</span><span class="value mono">${sj.total}</span></div>+        <div class="cs-row"><span class="label">Pruned</span><span class="value mono">${sj.pruned} <span class="dim">(no-bridging override)</span></span></div>+        <div class="cs-meas-status pill-${sj.total > 0 ? 'ok' : 'fail'}">${sj.total} half-sphere domes auto-laid out across ${best.fp.pads.length} pads. Each dome ≥ 1 per pad. Sign-off bakes the dome layout into <code>&lt;mpn&gt;.chipsmith.json</code> for the InstaPCB solder-jet head.</div>+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">3D overlay</h3>+        <div class="cs-row">+          <span class="label">Visibility</span>+          <button class="cs-btn cs-btn-${state.solderjetOn ? 'primary' : 'secondary'}" id="cs-sj-toggle" data-tooltip="Toggle the ${sj.total} solder-jet domes as half-spheres at z>0 above each footprint pad. Pre-reflow paste preview.">${svgIcon('bookOpen')}<span class="lbl">${state.solderjetOn ? 'Hide' : 'Show'} ${sj.total} domes in 3D</span></button>+        </div>+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">Per-pad (first 12)</h3>+        ${padRows}+      </div>+    `;+    const sjBtn = document.getElementById('cs-sj-toggle');+    if (sjBtn) {+      sjBtn.onclick = () => {+        const on = !state.solderjetOn;+        if (on) renderSolderJetOverlay(sj.domes);+        else ensureOverlayDisposed('solder-jet');+        state.solderjetOn = on;+        renderSolderjetTab();+      };+    }+  }++  // ── Tab 6: Sign-off ─────────────────────────────────────────────+  // Gate evaluator. Reads every state.status pill + the underlying+  // state objects, builds the canonical sign-off gate list per the+  // plan, evaluates pass/blocked, and offers a "Sign off" CTA when+  // every required gate passes. v1.0 writes <mpn>.chipsmith.json+  // sidecar via POST /api/signoff (the Rust endpoint already exists).+  // Baking <mpn>.chipsmith.step is v1.1 (needs upstream service-step2glb+  // /bake endpoint).++  function gateState() {+    const status = state.status;+    return [+      { id: 'source',         label: 'Manifest + spec complete',           pill: status.source,       hard: true,  tab: 'source' },+      { id: 'measurements',   label: 'Measurements within ±5%',            pill: status.measurements, hard: true,  tab: 'measurements' },+      { id: 'footprint',      label: 'Footprint source(s) loaded',         pill: status.footprint,    hard: true,  tab: 'footprint' },+      { id: 'pin1',           label: 'Pin-1 indicator',                    pill: status.pin1,         hard: false, tab: 'pin1' },+      { id: 'heatsink',       label: 'Heatsink vias generated (if EP)',    pill: status.heatsink,     hard: false, tab: 'heatsink' },+      { id: 'solderjet',      label: 'Solder-jet layout',                  pill: status.solderjet,    hard: false, tab: 'solderjet' },+    ];+  }++  function canSignOff() {+    const gates = gateState();+    const blockers = gates.filter(g => g.hard && (g.pill === '○' || g.pill === '✗'));+    return { ok: blockers.length === 0, blockers };+  }++  function renderSignoffTab() {+    const body = document.getElementById('cs-body');+    const gates = gateState();+    const { ok, blockers } = canSignOff();+    state.status.signoff = ok ? '●' : (blockers.length === gates.length ? '○' : '◐');+    refreshStatusPills();+    const rows = gates.map(g => {+      const cls = ({ '●': 'ok', '◐': 'partial', '○': 'pending', '✗': 'fail', '─': 'na' }[g.pill] || 'pending');+      const label = g.hard ? esc(g.label) : `${esc(g.label)} <span class="dim">(soft)</span>`;+      return `<div class="cs-row cs-clickable" data-tab="${g.tab}" data-tooltip="Click to jump to the ${esc(g.tab)} tab and address this gate.">+        <span class="label">${label}</span>+        <span class="pill ${cls}" style="margin-left:auto">${g.pill}</span>+      </div>`;+    }).join('');++    // Already signed off? Show the emitted artifact paths with reveal+    // buttons so the user can jump straight to them in the VS Code+    // Explorer. The `state.signoffResult` is set in the click handler;+    // on tab re-render after page reload, fetch GET /api/signoff to+    // re-hydrate from server state.+    let artifactsHTML = '';+    function buildArtifactsHTML(r) {+      if (!r) return '';+      const reveal = (path) => path+        ? `<button class="cs-reveal-icon" data-path="${escAttr(path)}" aria-label="Reveal in Explorer" data-tooltip="Reveal this file in the VS Code Explorer sidebar (delegates to adom-vscode reveal).">${svgIcon('folder')}</button>`+        : '';+      const dirOf = (p) => p ? p.replace(/\/[^/]+$/, '') : null;+      const enclosing = r.sidecar_path ? dirOf(r.sidecar_path) : null;+      const rows = [];+      if (r.sidecar_path) rows.push(`<div class="cs-row"><span class="label">.chipsmith.json</span><span class="value mono" title="${escAttr(r.sidecar_path)}">${esc(basename(r.sidecar_path))}</span>${reveal(r.sidecar_path)}</div>`);+      if (r.step_path)    rows.push(`<div class="cs-row"><span class="label">.chipsmith.step</span><span class="value mono" title="${escAttr(r.step_path)}">${esc(basename(r.step_path))}</span>${reveal(r.step_path)}</div>`);+      if (enclosing)      rows.push(`<div class="cs-row"><span class="label">Enclosing folder</span><span class="value mono" title="${escAttr(enclosing)}">${esc(enclosing)}</span>${reveal(enclosing)}</div>`);+      const bakeNote = r.bake_status+        ? `<div class="cs-row dim" style="margin-top:6px"><span class="value" style="font-size:11px">${esc(r.bake_status)}</span></div>`+        : '';+      return `+        <div class="cs-section">+          <h3 class="cs-section-title">Sign-off artifacts</h3>+          ${rows.join('')}+          ${bakeNote}+        </div>`;+    }+    if (state.signoffResult && state.signoffResult.ok) {+      artifactsHTML = buildArtifactsHTML(state.signoffResult);+    }+    body.innerHTML = `+      <div class="cs-section">+        <h3 class="cs-section-title">Gates</h3>+        ${rows}+      </div>+      <div class="cs-section">+        <h3 class="cs-section-title">Sign-off</h3>+        <div class="cs-meas-status pill-${ok ? 'ok' : 'partial'}">+          ${ok+            ? 'All hard gates pass. Click <strong>Sign off</strong> to write <code>&lt;mpn&gt;.chipsmith.json</code> with the applied scale, detected breakdown, and pin-1 agreement. Baking <code>.chipsmith.step</code> is v1.1.'+            : `${blockers.length} hard gate(s) blocking: <strong>${blockers.map(b => esc(b.label)).join(', ')}</strong>. Click each red row above to jump to its tab.`}+        </div>+        <div class="cs-actions">+          <button class="cs-btn cs-btn-primary" id="cs-signoff-btn" ${ok ? '' : 'disabled style="opacity:0.5;cursor:not-allowed"'} data-tooltip="Write the &lt;mpn&gt;.chipsmith.json sidecar with the full chipsmith state — applied scale, detected breakdown, pin-1 agreement, sign-off timestamp. Disabled until every hard gate passes.">${svgIcon('bookOpen')}<span class="lbl">Sign off this chip</span></button>+        </div>+      </div>+      ${artifactsHTML}+    `;+    // Hydrate sign-off artifacts from server (in case the page was+    // reloaded after a previous sign-off).+    if (!state.signoffResult) {+      fetch('api/signoff').then(r => r.ok ? r.json() : null).then(j => {+        if (j && j.signed) {+          state.signoffResult = { ok: true, sidecar_path: j.sidecar_path, step_path: j.step_path, bake_status: j.bake_status };+          if (state.activeTab === 'signoff') renderSignoffTab();+        }+      }).catch(()=>{});+    }+    // Wire reveal icons inside the artifacts section.+    body.querySelectorAll('.cs-reveal-icon').forEach(btn => {+      btn.onclick = async (e) => {+        e.stopPropagation();+        const path = btn.dataset.path;+        try {+          await fetch('api/reveal', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({path}) });+          btn.classList.add('cs-reveal-ok');+          setTimeout(() => btn.classList.remove('cs-reveal-ok'), 500);+        } catch (err) { console.error('[chipsmith] reveal failed', err); }+      };+    });+    body.querySelectorAll('.cs-clickable[data-tab]').forEach(row => {+      row.onclick = () => setActiveTab(row.dataset.tab);+    });+    const btn = document.getElementById('cs-signoff-btn');+    if (btn && ok) {+      btn.onclick = async () => {+        btn.disabled = true;+        btn.querySelector('.lbl').textContent = 'Signing off…';+        try {+          const payload = {+            mpn: state.spec?.mpn,+            measurements: state.measurements,+            heatsink: state.heatsinkGrid, ep_copper_pour: state.epCopperPour || null,+            user_placed_pin1: state.userPin1Corner ? { corner: state.userPin1Corner } : null,+            decorative_product_names: state.decorativeProductNames || [],+            signed_at: new Date().toISOString(),+          };+          const r = await fetch('api/signoff', {+            method: 'POST',+            headers: { 'Content-Type': 'application/json' },+            body: JSON.stringify(payload),+          });+          const j = await r.json();+          btn.querySelector('.lbl').textContent = j.ok ? 'Signed off ✓' : 'Failed — see console';+          if (!j.ok) console.error('[chipsmith] signoff failed', j);+          else {+            state.signoffResult = j;+            renderSignoffTab(); // re-render to surface the artifacts panel + reveal buttons+          }+        } catch (e) {+          console.error('[chipsmith] signoff error', e);+          btn.querySelector('.lbl').textContent = 'Failed — see console';+        }+      };+    }+  }++  // ── Other tabs — placeholder content for now (Phase 9+) ─────────+  function renderPlaceholder(label, what) {+    const body = document.getElementById('cs-body');+    body.innerHTML = `+      <div class="cs-empty">+        <strong>${esc(label)}</strong> — ${esc(what)}+      </div>+    `;+  }++  function renderActiveTab() {+    const id = state.activeTab;+    if (id === 'source') return renderSourceTab();+    if (id === 'measurements') return renderMeasurementsTab();+    if (id === 'footprint')    return renderFootprintTab();+    if (id === 'pin1')         return renderPin1Tab();+    if (id === 'signoff')      return renderSignoffTab();+    if (id === 'heatsink')     return renderHeatsinkTab();+    if (id === 'solderjet')    return renderSolderjetTab();+  }++  // ── tiny helpers ──────────────────────────────────────────────+  function esc(s) {+    if (s === undefined || s === null) return '';+    return String(s).replace(/[&<>]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;' }[c]));+  }+  function escAttr(s) { return esc(s).replace(/"/g, '&quot;'); }+  function basename(p) { return p ? String(p).split('/').pop() : '—'; }++  // ── Wire it up: build pane, fetch backend state, render Tab 1 ─+  async function init() {+    injectStyles();+    buildPane();+    refreshStatusPills();+    try {+      const [paths, spec, fps, ds2sf, ds2sfPads, chip3d] = await Promise.all([+        fetch('api/paths').then(r => r.ok ? r.json() : null).catch(() => null),+        fetch('api/spec').then(r => r.ok ? r.json() : null).catch(() => null),+        fetch('api/footprint-sources').then(r => r.ok ? r.json() : null).catch(() => null),+        fetch('api/ds2sf').then(r => r.ok ? r.json() : null).catch(() => null),+        fetch('api/ds2sf-pads').then(r => r.ok ? r.json() : null).catch(() => null),+        fetch('api/ds2sf-chip3d').then(r => r.ok ? r.json() : null).catch(() => null),+      ]);+      state.paths = paths;+      state.spec = enrichSpec(spec);+      state.footprintSources = fps;+      state.ds2sfResult = ds2sf;+      state.chip3d = chip3d;+      // ds2sf v0.6.0 per-pad data → inject as a 5th footprint source+      // alongside kicad / fusion / altium / datasheet. Same schema.+      if (ds2sfPads && Array.isArray(ds2sfPads.pads) && ds2sfPads.pads.length > 0 && fps) {+        fps.ds2sf = {+          pads: ds2sfPads.pads,+          pad_count: ds2sfPads.pad_count || ds2sfPads.pads.length,+          source: ds2sfPads.source || 'ds2sf',+        };+      }+      enrichSubjectPopover();+      // Show loading placeholders in the outline while OCCT generates.+      const generating = [];+      if (chip3d && chip3d.chip_3d) generating.push('chip');+      if (ds2sfPads && ds2sfPads.pads && ds2sfPads.pads.length > 0) generating.push('footprint');+      if (generating.length && window.adomStep?.addOverlay) {+        for (const g of generating) {+          const group = g === 'chip' ? 'adom-chip' : 'adom-footprint';+          const label = g === 'chip'+            ? 'Adom 3D chip  ⏳ generating from datasheet via OCCT...'+            : 'Adom footprint  ⏳ generating extruded copper pads...';+          window.adomStep.addOverlay({ group, meshes: [], label, kind: 'overlay-generating' });+        }+      }+      for (const g of generating) loadGeneratedGlb(g);+    } catch (e) {+      console.error('[chipsmith] initial fetch failed', e);+    }+    renderActiveTab();+  }++  async function loadGeneratedGlb(kind) {+    const tag = kind === 'chip' ? 'chip' : 'footprint';+    const url = `api/generate-${tag === 'chip' ? 'chip' : 'footprint'}-glb?cb=${Date.now()}`;+    try {+      const resp = await fetch(url);+      if (!resp.ok) { console.log(`[chipsmith] generate-${tag}-glb: ${resp.status}`); return; }+      const blob = await resp.blob();+      const blobUrl = URL.createObjectURL(blob);+      const B = window.adomStep?.getBabylon();+      const scene = window.adomStep?.getScene();+      if (!B || !scene) return;+      const result = await B.SceneLoader.ImportMeshAsync('', '', blobUrl, scene, null, '.glb');+      const allMeshes = result.meshes.filter(m => m.getTotalVertices && m.getTotalVertices() > 0);+      if (!allMeshes.length) return;+      const group = tag === 'chip' ? 'adom-chip' : 'adom-footprint';+      ensureOverlayDisposed(group);++      // Read annotations from the server cache (the chipsmith_metadata+      // STEP product doesn't survive STEP→GLB conversion).+      let annotations = null;+      try {+        const annotResp = await fetch(`api/generated-${tag === 'chip' ? 'chip' : 'footprint'}-annotations`);+        if (annotResp.ok) {+          const config = await annotResp.json();+          // Build annotation structure from the generation config.+          const doc = config.document_metadata || {};+          const products = {};+          if (tag === 'chip') {+            products.chipsmith_body = { body_width_mm: config.body_width_mm, body_depth_mm: config.body_depth_mm, body_height_mm: config.body_height_mm, standoff_mm: config.standoff_height_mm };+            for (const la of (config.lead_annotations || [])) {+              products[`chipsmith_lead_${la.number}`] = { ...la, lead_shape: config.lead_shape, source: 'OCCT' };+            }+          } else {+            for (const p of (config.pads || [])) {+              products[`chipsmith_fp_pad_${p.number}`] = { pin_number: p.number, signal: p.name, function: p.function, shape: p.shape, corner_radius_mm: p.corner_radius, size_mm: p.size, center_mm: p.at, type: p.type, layers: p.layers, source: 'OCCT' };+            }+          }+          annotations = { document: doc, products };+        }+      } catch (_) {}+      const prodMeta = annotations?.products || {};++      // Group meshes by their parent node. Assembly hierarchy produces+      // =>[0:1:1:N] usage-occurrence names — extract the index N and+      // map to the annotation by position (index 1 = first product).+      const groups = new Map();+      for (const m of allMeshes) {+        const parentName = m.parent?.name || m.name;+        if (parentName.startsWith('chipsmith_metadata')) continue;+        if (parentName.includes('metadata')) continue;+        if (!groups.has(parentName)) groups.set(parentName, []);+        groups.get(parentName).push(m);+      }++      // Build an ordered annotation list so we can map by index.+      // The STEP assembly orders children: for chip = body, leads 1-N, pin1, ep, metadata.+      // For footprint = pads 1-N, metadata.+      const annotList = [];+      if (tag === 'chip') {+        annotList.push({ key: 'chipsmith_body', ...prodMeta.chipsmith_body });+        for (let i = 1; i <= 100; i++) {+          const k = `chipsmith_lead_${i}`;+          if (prodMeta[k]) annotList.push({ key: k, ...prodMeta[k] });+          else break;+        }+        if (prodMeta.chipsmith_pin1) annotList.push({ key: 'chipsmith_pin1', ...prodMeta.chipsmith_pin1 });+        if (prodMeta.chipsmith_ep) annotList.push({ key: 'chipsmith_ep', ...prodMeta.chipsmith_ep });+      } else {+        for (let i = 1; i <= 100; i++) {+          const k = `chipsmith_fp_pad_${i}`;+          if (prodMeta[k]) annotList.push({ key: k, ...prodMeta[k] });+          else break;+        }+      }++      // Build children from groups, matching by index.+      const children = [];+      let groupIdx = 0;+      for (const [prodName, meshes] of groups) {+        if (groupIdx >= annotList.length) break;+        const annot = annotList[groupIdx] || {};+        groupIdx++;+        let childLabel;+        const key = annot.key || prodName;+        if (key === 'chipsmith_body') {+          childLabel = `body  ${annot.body_width_mm||'?'}×${annot.body_depth_mm||'?'}×${annot.body_height_mm||'?'} mm`;+        } else if (key === 'chipsmith_pin1') {+          childLabel = 'pin-1 indicator';+        } else if (key === 'chipsmith_ep') {+          childLabel = `exposed pad  ${annot.ep_width_mm||'?'}×${annot.ep_depth_mm||'?'} mm`;+        } else if (annot.signal || annot.name) {+          childLabel = `${annot.pin_number || annot.number || '?'} (${annot.signal || annot.name})`;+        } else if (annot.pin_number || annot.number) {+          childLabel = `pad ${annot.pin_number}`;+        } else {+          childLabel = key.replace('chipsmith_', '').replace(/_/g, ' ');+        }+        children.push({ meshes, label: childLabel, kind: 'overlay-generated-child' });+      }++      const parentLabel = tag === 'chip'+        ? `Adom 3D chip  (${children.length} parts, generated from datasheet)`+        : `Adom footprint  (${children.length} pads, extruded copper at z=0)`;+      const tooltip = tag === 'chip'+        ? `Adom 3D chip (generated)\n\nParametric chip body + leads built by Adom from the\nmanufacturer datasheet mechanical drawing via ds2sf +\nservice-step2glb /create-chip (OCCT). Copyright-free.\n\n${annotations?.document?.license || ''}`+        : `Adom footprint (generated)\n\nExtruded copper pads built by Adom from ds2sf pad\npositions via /create-footprint (OCCT). Pad top at\nz=0, copper extends 35 µm downward.\n\n${annotations?.document?.license || ''}`;+      window.adomStep.addOverlay({ group, meshes: [], label: parentLabel, tooltip, kind: 'overlay-generated', children });+      state[tag === 'chip' ? 'generatedChipLoaded' : 'generatedFpLoaded'] = true;+      state[tag === 'chip' ? 'chipAnnotations' : 'fpAnnotations'] = annotations;+      console.log(`[chipsmith] ${tag} GLB loaded: ${allMeshes.length} meshes, ${children.length} products`);+    } catch (e) {+      console.log(`[chipsmith] generate-${tag}-glb failed: ${e.message || e}`);+    }+  }++  // ── AI-drivability surface ────────────────────────────────────+  // Every UI verb on the right-pane gets a JS hook so demo scripts and+  // tests can drive it without click-by-click DOM walking. CLI parity+  // is via `adom-chipsmith eval "<expr>"`.+  window.adomChipsmith = window.adomChipsmith || {};+  Object.assign(window.adomChipsmith, {+    overlayLoaded: true,+    version: 'overlay-v0.3',++    // Core state access + control.+    state:           () => state,+    setActiveTab,+    refreshStatus:   refreshStatusPills,+    reload:          init,++    // Per-tab actions.+    runMeasurements: () => { renderMeasurementsTab(); recomputeAllStatuses(); return state.measurements; },+    recomputeStatuses: recomputeAllStatuses,+    placePin1,                                       // (corner | {mode, shape, …})+    // Click-to-place arming — UI counterpart calls these from the+    // Pin-1 tab toggle. Programmatic callers can also arm + simulate+    // a pick via window.adomChipsmith.placePin1({mode:'click', xyMm}).+    armPin1Click,+    disarmPin1Click,+    // Laser-etch — cosmetic MPN engraving on the chip's top face.+    // Toggle via setLaserEtchVisible(true|false). renderLaserEtch+    // takes opts: {text, depth_mm, max_size_mm:[w,h]}.+    renderLaserEtch,+    setLaserEtchVisible: (on, opts) => {+      if (on) return renderLaserEtch(opts || {});+      ensureOverlayDisposed('laser-etch');+      state.laserEtch = null;+      return { ok: true, hidden: true };+    },+    // Debug — show four colored slabs marking the chipsmith-internal+    // anchor planes (chip-top, chip-bottom, footprint-area, pcb-top).+    // Toggle on when reporting "your placement is wrong" — the slabs+    // show whether chipsmith's understanding of "the body's top face"+    // matches reality.+    renderDebugAnchors,+    setDebugAnchorsVisible: (on) => {+      if (on) return renderDebugAnchors();+      ensureOverlayDisposed('debug-anchors');+      state.debugAnchorsOn = false;+      return { ok: true, hidden: true };+    },+    removePin1User:  () => {+      ensureOverlayDisposed('user-pin1');+      state.userPin1Corner = null;+      recomputeAllStatuses();+      if (state.activeTab === 'pin1') renderPin1Tab();+      return { ok: true };+    },+    // Alias matching the CLI verb `set-overlay pin1 off`.+    removePin1Indicator: () => {+      ensureOverlayDisposed('user-pin1');+      state.userPin1Corner = null;+      recomputeAllStatuses();+      if (state.activeTab === 'pin1') renderPin1Tab();+      return { ok: true };+    },++    // 3D overlay toggles (programmatic counterparts to the buttons).+    setFootprintOverlay: (src, on) => {+      const fps = state.footprintSources;+      if (!fps || !fps[src] || fps[src]._stub) return { error: 'no source ' + src };+      state.fpOverlaysOn = state.fpOverlaysOn || {};+      if (on) renderFootprintOverlay(src, fps[src].pads);+      else ensureOverlayDisposed('fp-' + src);+      state.fpOverlaysOn[src] = !!on;+      if (state.activeTab === 'footprint') renderFootprintTab();+      return { ok: true, src, on: !!on };+    },+    setAllFootprintOverlays: (on) => {+      const fps = state.footprintSources;+      if (!fps) return { error: 'no footprint sources loaded' };+      const present = ['kicad','fusion','altium','datasheet','ds2sf'].filter(s => fps[s] && !fps[s]._stub && fps[s].pad_count > 0);+      state.fpOverlaysOn = state.fpOverlaysOn || {};+      for (const s of present) {+        if (on && !state.fpOverlaysOn[s]) renderFootprintOverlay(s, fps[s].pads);+        else if (!on && state.fpOverlaysOn[s]) ensureOverlayDisposed('fp-' + s);+        state.fpOverlaysOn[s] = !!on;+      }+      if (state.activeTab === 'footprint') renderFootprintTab();+      return { ok: true, count: present.length, on: !!on };+    },+    // Stock-library overlay — fetch + show/hide + status. Backed by+    // service-kicad via /api/kicad-stock; the spec must carry+    // footprint_name (and ideally kicad_3d_name) for the lookup to+    // succeed. Returns { loaded, parsed, fetched_url } on success.+    loadStockLibraryOverlay: (force) => loadStockLibraryOverlay({ force: !!force }),+    setStockOverlayVisible: (on) => {+      const parsed = state.stockOverlay?.parsed;+      if (!parsed) return { error: 'stock library not loaded — call loadStockLibraryOverlay() first' };+      state.fpOverlaysOn = state.fpOverlaysOn || {};+      if (on) renderFootprintOverlay('stock', parsed.pads);+      else ensureOverlayDisposed('fp-stock');+      state.fpOverlaysOn.stock = !!on;+      if (state.activeTab === 'footprint') renderFootprintTab();+      return { ok: true, on: !!on };+    },+    setFauxPcbVisible: (on) => {+      const fps = state.footprintSources;+      if (!fps) return { error: 'no footprint sources loaded' };+      const present = ['kicad','fusion','altium','datasheet','ds2sf'].filter(s => fps[s] && !fps[s]._stub && fps[s].pads?.length);+      if (on) {+        let bestSrc = null, bestExt = null;+        for (const s of present) {+          const e = fpExtent(fps[s].pads);+          if (e && (!bestExt || e.width > bestExt.width)) { bestExt = e; bestSrc = s; }+        }+        renderFauxPcbPlate(bestExt, bestSrc ? fps[bestSrc].pads : null);+      } else {+        ensureOverlayDisposed('faux-pcb');+      }+      state.fauxPcbOn = !!on;+      if (state.activeTab === 'footprint') renderFootprintTab();+      return { ok: true, on: !!on };+    },+    setDs2sfVisible: async (on) => {+      if (on) {+        if (!state.ds2sfResult?.available) return { error: 'no ds2sf result for this chip' };+        const r = await renderDs2sfOverlay();+        if (state.activeTab === 'footprint') renderFootprintTab();+        return { ok: !!state.ds2sfOverlayOn, on: !!state.ds2sfOverlayOn, ...r };+      } else {+        ensureOverlayDisposed('ds2sf');+        state.ds2sfOverlayOn = false;+        if (state.activeTab === 'footprint') renderFootprintTab();+        return { ok: true, on: false };+      }+    },+    setHeatsinkVisible: (on, opts) => {+      if (!state.heatsinkGrid) return { error: 'no heatsink grid — run Tab 7 first' };+      const ep = state.heatsinkGrid.ep;+      if (on) renderHeatsink(ep.origin_mm, { width: ep.size_mm[0], height: ep.size_mm[1] }, state.heatsinkGrid.grid, opts || {});+      else { ensureOverlayDisposed('heatsink'); state.epCopperPour = null; }+      state.heatsinkOn = !!on;+      state.viasOn = !!on;+      state.epPourOn = !!on;+      if (state.activeTab === 'heatsink') renderHeatsinkTab();+      return { ok: true, on: !!on, pour: state.epCopperPour };+    },+    setSolderJetVisible: (on) => {+      if (!state.solderjet || !state.solderjet.domes) return { error: 'no solder-jet layout — run Tab 8 first' };+      if (on) renderSolderJetOverlay(state.solderjet.domes);+      else ensureOverlayDisposed('solder-jet');+      state.solderjetOn = !!on;+      if (state.activeTab === 'solderjet') renderSolderjetTab();+      return { ok: true, on: !!on };+    },++    // Sign-off API.+    signoff: {+      gates:        () => gateState(),+      canSignOff:   () => canSignOff(),+      signOff:      async () => {+        const { ok, blockers } = canSignOff();+        if (!ok) return { error: 'blocked', blockers };+        const payload = {+          mpn: state.spec?.mpn,+          measurements: state.measurements,+          heatsink: state.heatsinkGrid, ep_copper_pour: state.epCopperPour || null,+          solderjet: state.solderjet ? {+            droplet_mm: state.solderjet.droplet_mm,+            total: state.solderjet.total,+            pruned: state.solderjet.pruned,+          } : null,+          user_placed_pin1: state.userPin1Corner ? { corner: state.userPin1Corner } : null,+          decorative_product_names: state.decorativeProductNames || [],+          signed_at: new Date().toISOString(),+        };+        const r = await fetch('api/signoff', {+          method: 'POST',+          headers: { 'Content-Type': 'application/json' },+          body: JSON.stringify(payload),+        });+        const j = await r.json();+        // Cache the result so renderSignoffTab can render the artifacts+        // panel (sidecar + step + enclosing folder rows + reveal icons)+        // even when sign-off was triggered programmatically (CLI / AI+        // hook), not via the button.+        if (j && j.ok) {+          state.signoffResult = j;+          if (state.activeTab === 'signoff') renderSignoffTab();+        }+        return j;+      },+    },+  });++  // ── Hover-inspect HUD ───────────────────────────────────────────+  // Floats next to the cursor whenever it's over the 3D canvas.+  // Names what the user is looking at in plain language BEFORE they+  // have to click anything. Per the PLAN "Hover-inspect HUD" section.+  // Pickable filtered to chipsmith-tagged meshes + body / pads / etc;+  // throttled to one update per render frame; suppressed during camera+  // drag. Toggle: 'i' key or right-edge inspect button.++  function setupHoverHud() {+    if (!(window.adomStep && window.adomStep.getScene)) {+      setTimeout(setupHoverHud, 500);+      return;+    }+    const scene = window.adomStep.getScene();+    if (!scene) {+      setTimeout(setupHoverHud, 500);+      return;+    }+    const card = document.createElement('div');+    card.id = 'cs-hover-hud';+    card.style.cssText = `position:absolute; pointer-events:none; z-index:55;+      display:none; background:rgba(20,24,30,0.94);+      border:1px solid rgba(255,255,255,0.14); border-left:3px solid #00b8b1;+      border-radius:6px; padding:10px 12px; min-width:200px; max-width:320px;+      font-family:'Satoshi',sans-serif; font-size:11.5px; color:#e6edf3;+      box-shadow:0 6px 18px rgba(0,0,0,0.55);+      backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);`;+    const style = document.createElement('style');+    style.textContent = `+      #cs-hover-hud .title { font-family:'Familjen Grotesk',sans-serif; font-weight:600; margin-bottom:5px; }+      #cs-hover-hud .row { display:flex; justify-content:space-between; gap:12px; padding:2px 0; line-height:1.45; }+      #cs-hover-hud .row span { color:rgba(200,210,225,0.5); flex-shrink:0; min-width:56px; font-size:10.5px; }+      #cs-hover-hud .row b { color:#e6edf3; font-weight:500; text-align:right; word-break:break-word; }+      #cs-hover-hud .row b.mono { font-family:'JetBrains Mono',monospace; font-size:10.5px; }+    `;+    document.head.appendChild(style);+    document.body.appendChild(card);+    let suspended = false;       // suspended during camera drag+    let hoverEnabled = true;     // toggleable via 'i' / Esc+    let lastFrame = 0;++    // Categorize a picked mesh into a kind label + tint. Mirrors the+    // PLAN's category table.+    const KIND_TINTS = {+      body:        { label: 'chip body',                   tint: '#888' },+      pin1:        { label: 'pin-1 indicator',             tint: '#ffb74d' },+      pad:         { label: 'SMD pad',                     tint: '#00b8b1' },+      lead:        { label: 'SMD leg',                     tint: '#9d6cff' },+      ball:        { label: 'BGA ball',                    tint: '#cfd8dc' },+      ep:          { label: 'exposed pad / thermal',       tint: '#ff7043' },+      tab:         { label: 'reinforcement tab',           tint: '#90a4ae' },+      marker:      { label: 'silkscreen marker',           tint: '#fff' },+      'overlay-footprint': { label: 'footprint pad (overlay)', tint: '#1de9b6' },+      'overlay-pcb':       { label: 'FR4 board (faux)',         tint: '#66bb6a' },+      'overlay-vias':      { label: 'heatsink via',             tint: '#c79a4f' },+      'overlay-solderjet': { label: 'solder-jet dome',          tint: '#cfd8dc' },+      'overlay-pin1-user': { label: 'pin-1 indicator (user)',   tint: '#ffb74d' },+    };++    function classifyMesh(mesh) {+      if (!mesh || !mesh.name) return null;+      const n = mesh.name;+      // Overlay meshes — name pattern starts with "fp-<src>-pad-..."+      // or "faux-pcb-..." or "heatsink-vias-..." etc.+      if (n.startsWith('fp-')) {+        const m = n.match(/^fp-(\w+)-pad-(.+?)$/);+        return m+          ? { kind: 'overlay-footprint', label: KIND_TINTS['overlay-footprint'].label, tint: KIND_TINTS['overlay-footprint'].tint, detail: { source: m[1], pad: m[2] } }+          : null;+      }+      if (n === 'faux-pcb-plate') return { kind: 'overlay-pcb', ...KIND_TINTS['overlay-pcb'], detail: state.fauxPcbDims || {} };+      if (n.startsWith('heatsink-vias-bore')) {+        const m = n.match(/-(\d+)$/);+        return { kind: 'overlay-vias', ...KIND_TINTS['overlay-vias'], detail: { via: m && m[1] } };+      }+      if (n.startsWith('solder-jet-')) return { kind: 'overlay-solderjet', ...KIND_TINTS['overlay-solderjet'], detail: {} };+      if (n.startsWith('user-pin1-')) return { kind: 'overlay-pin1-user', ...KIND_TINTS['overlay-pin1-user'], detail: {} };++      // OCCT-generated products from /create-chip and /create-footprint.+      // Mesh names are "chipsmith_lead_1_primitive0" etc — walk to the+      // parent TransformNode to get the product name, then look up the+      // rich annotation metadata from the embedded chipsmith_metadata.+      let prodName = null;+      let cur = mesh.parent || mesh;+      while (cur) {+        const pn = cur.name || '';+        if (pn.startsWith('chipsmith_')) { prodName = pn; break; }+        cur = cur.parent;+      }+      if (!prodName && (n.startsWith('chipsmith_') || (mesh.parent && mesh.parent.name && mesh.parent.name.startsWith('chipsmith_')))) {+        prodName = mesh.parent?.name || n;+      }+      if (prodName && !prodName.startsWith('chipsmith_metadata')) {+        const chipMeta = state.chipAnnotations?.products?.[prodName];+        const fpMeta = state.fpAnnotations?.products?.[prodName];+        const meta = chipMeta || fpMeta || {};+        if (prodName === 'chipsmith_body') {+          return { kind: 'body', label: 'chip body (generated)', tint: '#888', detail: { ...meta, source: 'OCCT' } };+        }+        if (prodName === 'chipsmith_pin1') {+          return { kind: 'pin1', label: 'pin-1 indicator (generated)', tint: '#ffb74d', detail: meta };+        }+        if (prodName === 'chipsmith_ep') {+          return { kind: 'ep', label: 'exposed pad (generated)', tint: '#ff7043', detail: meta };+        }+        if (prodName.startsWith('chipsmith_lead_')) {+          const sig = meta.signal || '';+          const fn = meta.function || '';+          return { kind: 'lead', label: sig ? `lead ${meta.pin_number} (${sig})` : prodName, tint: '#d4a84b', detail: { ...meta, source: 'OCCT' } };+        }+        if (prodName.startsWith('chipsmith_fp_pad_')) {+          const sig = meta.signal || '';+          return { kind: 'overlay-footprint', label: sig ? `pad ${meta.pin_number} (${sig})` : prodName, tint: '#c79a4f', detail: { ...meta, source: 'OCCT' } };+        }+      }++      // Non-overlay, non-generated meshes — walk the parent chain.+      cur = mesh.parent;+      let parentName = null;+      while (cur) {+        if (cur.name && !cur.name.startsWith('__') && cur.name !== 'rootnode') {+          parentName = cur.name;+          break;+        }+        cur = cur.parent;+      }+      if (parentName) {+        const lower = parentName.toLowerCase();+        if (lower === 'body')     return { kind: 'body',  ...KIND_TINTS.body,  detail: { name: parentName } };+        if (lower.includes('contact') || lower.includes('lead') || lower.includes('pin'))+                                  return { kind: 'lead',  ...KIND_TINTS.lead,  detail: { name: parentName } };+        if (lower.includes('marker'))+                                  return { kind: 'marker',...KIND_TINTS.marker,detail: { name: parentName } };+        // Generic fallback — surface the PRODUCT name + raw mesh name so+        // the user has SOMETHING readable rather than a silent miss.+        return { kind: 'body', label: 'chip part — ' + parentName, tint: '#90a4ae', detail: { name: parentName } };+      }+      return null;+    }++    function fmtBbox(mesh) {+      try {+        mesh.computeWorldMatrix(true);+        const bi = mesh.getBoundingInfo();+        const lo = bi.boundingBox.minimumWorld, hi = bi.boundingBox.maximumWorld;+        const sx = (hi.x - lo.x) * 1000, sy = (hi.y - lo.y) * 1000, sz = (hi.z - lo.z) * 1000;+        return `${sx.toFixed(2)} × ${sz.toFixed(2)} × ${sy.toFixed(2)} mm`;+      } catch (_) { return '—'; }+    }++    function lookupPadData(source, padNum) {+      const fps = state.footprintSources;+      if (!fps || !fps[source]) return null;+      const pads = fps[source].pads;+      if (!Array.isArray(pads)) return null;+      return pads.find(p => String(p.number) === String(padNum));+    }++    function renderAnnotatedCard(detailLines, meta) {+      if (meta.source === 'OCCT') detailLines.push(`<div class="row"><span>source</span><b>STEP file (OCCT-generated)</b></div>`);+      if (meta.pin_number) detailLines.push(`<div class="row"><span>pad</span><b>${esc(meta.pin_number)}</b></div>`);+      if (meta.signal)     detailLines.push(`<div class="row"><span>signal</span><b>${esc(meta.signal)}</b></div>`);+      if (meta.function)   detailLines.push(`<div class="row"><span>function</span><b style="font-weight:400;font-size:10.5px">${esc(meta.function)}</b></div>`);+      if (meta.lead_shape) detailLines.push(`<div class="row"><span>lead shape</span><b>${esc(meta.lead_shape)}</b></div>`);+      if (meta.side)       detailLines.push(`<div class="row"><span>side</span><b>${esc(meta.side)}</b></div>`);+      if (meta.shape)      detailLines.push(`<div class="row"><span>shape</span><b>${esc(meta.shape)}${meta.corner_radius_mm ? ' r=' + meta.corner_radius_mm + ' mm' : ''}</b></div>`);+      const sz = meta.size_mm;+      if (Array.isArray(sz)) detailLines.push(`<div class="row"><span>pad size</span><b class="mono">${sz[0].toFixed(2)} × ${sz[1].toFixed(2)} mm</b></div>`);+      const ct = meta.center_mm;+      if (Array.isArray(ct)) detailLines.push(`<div class="row"><span>center</span><b class="mono">(${ct[0].toFixed(3)}, ${ct[1].toFixed(3)}) mm</b></div>`);+      const ly = meta.layers;+      if (Array.isArray(ly)) detailLines.push(`<div class="row"><span>layers</span><b>${esc(ly.join(' + '))}</b></div>`);+      if (meta.body_width_mm) detailLines.push(`<div class="row"><span>body</span><b class="mono">${meta.body_width_mm} × ${meta.body_depth_mm} × ${meta.body_height_mm} mm</b></div>`);+      if (meta.standoff_mm) detailLines.push(`<div class="row"><span>standoff</span><b class="mono">${meta.standoff_mm} mm</b></div>`);+      if (meta.ep_width_mm) detailLines.push(`<div class="row"><span>EP size</span><b class="mono">${meta.ep_width_mm} × ${meta.ep_depth_mm} mm</b></div>`);+    }++    function renderCard(c) {+      card.style.borderLeftColor = c.cls.tint;+      const detailLines = [];+      const d = c.cls.detail || {};++      // OCCT-generated products — read annotation metadata from the STEP+      if (d.source === 'OCCT' || d.pin_number || d.signal || d.body_width_mm) {+        renderAnnotatedCard(detailLines, d);+      }+      // Babylon overlay pads (ds2sf pads / kicad / fusion / altium)+      else if (c.cls.kind === 'overlay-footprint' && d.pad) {+        const pd = lookupPadData(d.source, d.pad);+        const srcLabel = (SRC_LABEL[d.source] || d.source);+        detailLines.push(`<div class="row"><span>source</span><b>${esc(srcLabel)}</b></div>`);+        detailLines.push(`<div class="row"><span>pad</span><b>${esc(d.pad)}</b></div>`);+        if (pd) {+          if (pd.name)     detailLines.push(`<div class="row"><span>signal</span><b>${esc(pd.name)}</b></div>`);+          if (pd.function) detailLines.push(`<div class="row"><span>function</span><b style="font-weight:400;font-size:10.5px">${esc(pd.function)}</b></div>`);+          detailLines.push(`<div class="row"><span>shape</span><b>${esc(pd.shape || 'rect')}${pd.corner_radius ? ' r=' + pd.corner_radius.toFixed(2) + ' mm' : ''}</b></div>`);+          if (pd.size) detailLines.push(`<div class="row"><span>pad size</span><b class="mono">${pd.size[0].toFixed(2)} × ${pd.size[1].toFixed(2)} mm</b></div>`);+          if (pd.at)   detailLines.push(`<div class="row"><span>center</span><b class="mono">(${pd.at[0].toFixed(3)}, ${pd.at[1].toFixed(3)}) mm</b></div>`);+          if (pd.layers) detailLines.push(`<div class="row"><span>layers</span><b>${esc(pd.layers.join(' + '))}</b></div>`);+          if (pd.drill)  detailLines.push(`<div class="row"><span>drill</span><b class="mono">${pd.drill.diameter ? pd.drill.diameter.toFixed(2) + ' mm ' + pd.drill.shape : 'none'}</b></div>`);+        }+      }+      // Generic chip parts+      else if (c.cls.kind === 'pad' || c.cls.kind === 'ball' || c.cls.kind === 'lead') {+        detailLines.push(`<div class="row"><span>name</span><b>${esc(d.name || c.mesh.name)}</b></div>`);+        detailLines.push(`<div class="row"><span>size</span><b>${fmtBbox(c.mesh)}</b></div>`);+      } else if (c.cls.kind === 'overlay-vias') {+        detailLines.push(`<div class="row"><span>via</span><b>${esc(d.via || '?')}</b></div>`);+        detailLines.push(`<div class="row"><span>size</span><b>${fmtBbox(c.mesh)}</b></div>`);+      } else {+        detailLines.push(`<div class="row"><span>size</span><b>${fmtBbox(c.mesh)}</b></div>`);+      }+      card.innerHTML = `+        <div class="title" style="color:${c.cls.tint};">${esc(c.cls.label)}</div>+        ${detailLines.join('')}`;+    }++    function pointerMove(e) {+      if (!hoverEnabled || suspended) { card.style.display = 'none'; return; }+      const now = performance.now();+      if (now - lastFrame < 16) return;+      lastFrame = now;+      // Babylon's scene.pointerX/Y only update on real input events; for+      // synthetic events (AI hook hoverAt + tests) we derive canvas-local+      // coords from clientX/Y instead.+      const canvasEl = scene.getEngine().getRenderingCanvas();+      const r = canvasEl ? canvasEl.getBoundingClientRect() : { left: 0, top: 0 };+      const pickX = (e && typeof e.clientX === 'number') ? (e.clientX - r.left) : scene.pointerX;+      const pickY = (e && typeof e.clientY === 'number') ? (e.clientY - r.top)  : scene.pointerY;+      const pickResult = scene.pick(pickX, pickY, m => {+        if (!m || !m.isEnabled() || !m.isVisible) return false;+        if (!m.getTotalVertices || m.getTotalVertices() <= 0) return false;+        if (['skyBox','shadowGround','ground','ViewerRoot'].includes(m.name)) return false;+        if ((m.name || '').startsWith('viewCube')) return false;+        // Skip ghosted meshes (isolation sets alpha ~0.1) so the focused+        // overlay pads underneath are reachable by the pick ray.+        const mat = m.material;+        if (mat && typeof mat.alpha === 'number' && mat.alpha < 0.15) return false;+        return true;+      });+      if (!pickResult || !pickResult.hit || !pickResult.pickedMesh) {+        card.style.display = 'none';+        return;+      }+      const cls = classifyMesh(pickResult.pickedMesh);+      if (!cls) { card.style.display = 'none'; return; }+      renderCard({ mesh: pickResult.pickedMesh, cls });+      // Position 12px down-right of cursor; flip if near edges.+      const x = e.clientX + 14, y = e.clientY + 14;+      const cw = card.offsetWidth, ch = card.offsetHeight;+      const ww = window.innerWidth, wh = window.innerHeight;+      card.style.left = (x + cw > ww ? e.clientX - cw - 14 : x) + 'px';+      card.style.top  = (y + ch > wh ? e.clientY - ch - 14 : y) + 'px';+      card.style.display = 'block';+    }++    // Listen on the canvas (only fires when cursor is over the 3D view)+    const canvas = scene.getEngine().getRenderingCanvas();+    if (canvas) {+      canvas.addEventListener('pointermove', pointerMove);+      canvas.addEventListener('pointerleave', () => { card.style.display = 'none'; });+      canvas.addEventListener('pointerdown', () => { suspended = true; card.style.display = 'none'; });+      window.addEventListener('pointerup', () => { suspended = false; });+    }+    document.addEventListener('keydown', (e) => {+      if (e.ctrlKey || e.metaKey || e.altKey) return;+      const t = e.target;+      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;+      if (e.key === 'i' || e.key === 'I') {+        hoverEnabled = !hoverEnabled;+        if (!hoverEnabled) card.style.display = 'none';+      } else if (e.key === 'Escape') {+        hoverEnabled = false;+        card.style.display = 'none';+      }+    });++    // AI hooks per PLAN: hoverAt({x, y}) returns same payload as the+    // visible card; lastHover() returns the most recent.+    let lastHover = null;+    const origRender = renderCard;+    renderCard = (c) => { lastHover = { name: c.mesh.name, kind: c.cls.kind, label: c.cls.label }; origRender(c); };+    window.adomChipsmith.hoverAt = ({ x, y }) => {+      const fakeEvent = { clientX: x, clientY: y };+      pointerMove(fakeEvent);+      return lastHover;+    };+    window.adomChipsmith.lastHover = () => lastHover;+    window.adomChipsmith.setHoverEnabled = (on) => {+      hoverEnabled = !!on;+      if (!hoverEnabled) card.style.display = 'none';+    };+  }++  // ── Floating Sign-off HUD (always visible from every tab) ──────+  // Per PLAN.md: "A floating verification card pinned to the top-right+  // of the canvas, visible from every tab." Top-right is taken by the+  // ViewCube + the right-pane HUD; we land at top-LEFT instead (offset+  // below the Outline header so it doesn't cover the search box).+  // Drag-positionable, collapsible to a compact badge, keyboard 'g'+  // toggles. State persists in localStorage.++  function setupSignoffHud() {+    if (document.getElementById('cs-signoff-hud')) return;+    const hud = document.createElement('div');+    hud.id = 'cs-signoff-hud';+    hud.innerHTML = `+      <div class="cs-so-head">+        <span class="cs-so-title">Sign-off</span>+        <span class="cs-so-tally"></span>+        <button class="cs-so-collapse" aria-label="Collapse" data-tooltip="Collapse to a compact badge that stays pinned. Click the badge to re-expand.">❮</button>+      </div>+      <div class="cs-so-body"></div>+      <div class="cs-so-foot">+        <button class="cs-btn cs-btn-primary cs-so-signoff-btn" disabled data-tooltip="Sign off — POST /api/signoff → writes <mpn>.chipsmith.json + bakes <mpn>.chipsmith.step. Disabled until every hard gate passes.">${svgIcon('bookOpen')}<span class="lbl">Sign off</span></button>+      </div>+    `;+    document.body.appendChild(hud);+    const HUD_KEY_POS = 'chipsmith.signoff-hud.pos';+    const HUD_KEY_COLLAPSED = 'chipsmith.signoff-hud.collapsed';+    function applyPos(pos) {+      hud.style.top = pos.y + 'px';+      hud.style.left = pos.x + 'px';+      hud.style.right = 'auto';+      hud.style.bottom = 'auto';+    }+    try {+      const raw = localStorage.getItem(HUD_KEY_POS);+      if (raw) applyPos(JSON.parse(raw));+    } catch (_) {}+    function setCollapsed(c) {+      hud.classList.toggle('collapsed', c);+      try { localStorage.setItem(HUD_KEY_COLLAPSED, c ? '1' : '0'); } catch (_) {}+    }+    try {+      if (localStorage.getItem(HUD_KEY_COLLAPSED) === '1') setCollapsed(true);+    } catch (_) {}+    hud.querySelector('.cs-so-collapse').onclick = () => setCollapsed(true);+    hud.querySelector('.cs-so-head').onclick = (e) => {+      // Click anywhere on the head area while collapsed → expand. Drag+      // start handled separately via mousedown below.+      if (hud.classList.contains('collapsed') && !e.target.closest('.cs-so-collapse')) setCollapsed(false);+    };++    // Drag.+    let dragOrigin = null;+    hud.querySelector('.cs-so-head').addEventListener('mousedown', (e) => {+      if (e.target.closest('.cs-so-collapse')) return;+      const r = hud.getBoundingClientRect();+      dragOrigin = { startX: e.clientX, startY: e.clientY, baseX: r.left, baseY: r.top };+      hud.classList.add('dragging');+      e.preventDefault();+    });+    document.addEventListener('mousemove', (e) => {+      if (!dragOrigin) return;+      const dx = e.clientX - dragOrigin.startX, dy = e.clientY - dragOrigin.startY;+      const x = Math.max(-hud.offsetWidth + 60, Math.min(window.innerWidth - 60, dragOrigin.baseX + dx));+      const y = Math.max(44, Math.min(window.innerHeight - 60, dragOrigin.baseY + dy));+      applyPos({ x, y });+    });+    document.addEventListener('mouseup', () => {+      if (!dragOrigin) return;+      const r = hud.getBoundingClientRect();+      try { localStorage.setItem(HUD_KEY_POS, JSON.stringify({ x: r.left, y: r.top })); } catch (_) {}+      hud.classList.remove('dragging');+      dragOrigin = null;+    });++    // Keyboard 'g' toggles (gates) — picked because P/O/B/I/M are taken.+    document.addEventListener('keydown', (e) => {+      if (e.ctrlKey || e.metaKey || e.altKey) return;+      const t = e.target;+      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;+      if (e.key === 'g' || e.key === 'G') {+        setCollapsed(!hud.classList.contains('collapsed'));+      }+    });+    return hud;+  }++  // Re-render the floating Sign-off HUD body. Called on every status+  // change. Groups gates per the PLAN spec (Inputs / Integrity /+  // Footprint / Pin-1 / Manufacturing).+  function renderSignoffHud() {+    const hud = document.getElementById('cs-signoff-hud');+    if (!hud) return;+    const body = hud.querySelector('.cs-so-body');+    const tallyEl = hud.querySelector('.cs-so-tally');+    const btn = hud.querySelector('.cs-so-signoff-btn');+    if (!body) return;++    // Build the structured gate list. Sections per PLAN.md.+    const s = state.spec || {};+    const fps = state.footprintSources || {};+    const m = state.measurements;+    const sj = state.solderjet;+    const hs = state.heatsinkGrid;+    const has = (k) => s[k] !== undefined && s[k] !== null && s[k] !== '' && s[k] !== '?';+    const fpsPresent = ['kicad','fusion','altium','datasheet','ds2sf'].filter(k => fps[k] && !fps[k]._stub && fps[k].pad_count > 0);+    const fpAnyPin1 = ['kicad','fusion','altium','datasheet','ds2sf'].some(k => fps[k] && fps[k].pin1_silk && fps[k].pin1_silk.found);+    const userPin1 = !!state.userPin1Corner;++    function row(id, label, pill, hard, tab, reason) {+      return { id, label, pill, hard, tab, reason };+    }++    const sections = [+      { title: 'Inputs', rows: [+        row('input_step',     'STEP loaded',           state.paths?.step ? '●' : '○',           true,  'source', state.paths?.step || '—'),+        row('input_fp',       'Footprint loaded',      fpsPresent.length > 0 ? '●' : '○',       true,  'source', fpsPresent.join(', ') || 'none'),+        row('input_datasheet','Datasheet loaded',      state.paths?.datasheet ? '●' : '─',      false, 'source', state.paths?.datasheet || '—'),+        row('input_spec',     'Spec complete',+          (has('pin_count') && has('structural_type') && Array.isArray(s.body_dims_mm)) ? '●'+          : ([has('pin_count'), has('structural_type'), Array.isArray(s.body_dims_mm)].filter(Boolean).length ? '◐' : '○'),+          true, 'source',+          `pin_count=${s.pin_count || '—'}, structural_type=${s.structural_type || '—'}, body_dims_mm=${Array.isArray(s.body_dims_mm) ? '['+s.body_dims_mm.join(',')+']' : '—'}`),+      ]},+      { title: 'Integrity', rows: [+        row('measurements', 'Measurements within ±5%',+          m ? m.status : '○',+          true, 'measurements',+          m ? `applied ${m.applied_label}, max Δ ${(m.max_axis_pct*100).toFixed(2)}%` : 'not run'),+      ]},+      { title: 'Footprint', rows: [+        row('fp_sources_loaded', 'Sources loaded',+          fpsPresent.length === 0 ? '✗' : (fpsPresent.length >= 2 ? '●' : '◐'),+          true, 'footprint',+          fpsPresent.join(', ') || 'none'),+        row('fp_bbox_fit',       'Bbox fit (chip ⊂ fp)',+          (m && fpsPresent.length) ? '◐' : '─',+          false, 'footprint',+          'visual check on Tab 3'),+      ]},+      { title: 'Pin-1', rows: [+        row('pin1_chip',  'Chip-side indicator',  userPin1   ? '●' : '○', false, 'pin1', userPin1 ? `placed ${state.userPin1Corner}` : 'not placed'),+        row('pin1_fp',    'Footprint silk marker', fpAnyPin1 ? '●' : '○', false, 'pin1', fpAnyPin1 ? 'present in at least one source' : 'no marker on any source'),+        row('pin1_agree', 'Cross-side agreement',  (userPin1 && fpAnyPin1) ? '◐' : '─', false, 'pin1', 'visual check'),+      ]},+      { title: 'Manufacturing', rows: [+        row('mfg_heatsink',  'Heatsink vias generated (if EP)', hs ? '●' : '─', false, 'heatsink',  hs ? `${hs.grid.count} vias under ${hs.ep.pad_number}` : 'no EP detected'),+        row('mfg_solderjet', 'Solder-jet layout',                sj ? '●' : '─', false, 'solderjet', sj ? `${sj.total} domes (Ø ${(sj.droplet_mm*1000).toFixed(0)} µm)` : 'not generated'),+        row('mfg_etch',      'Laser-etch',                       '─',                       false, 'source',    'optional, opt-in'),+      ]},+    ];++    // Tally = count of ● + ─ over total (excluding ─ from "fail" math+    // but including in "satisfied" because N/A is satisfied-by-design).+    const all = sections.flatMap(sec => sec.rows);+    const total = all.length;+    const passed = all.filter(r => r.pill === '●' || r.pill === '─').length;+    const blockers = all.filter(r => r.hard && (r.pill === '○' || r.pill === '✗'));+    const canSign = blockers.length === 0;++    tallyEl.textContent = `${passed} / ${total} ✓`;+    tallyEl.className = 'cs-so-tally ' + (canSign ? 'pass' : 'block');++    btn.disabled = !canSign;+    btn.classList.toggle('cs-btn-primary', canSign);+    btn.classList.toggle('cs-btn-secondary', !canSign);+    btn.title = canSign ? '' : `${blockers.length} hard gate${blockers.length === 1 ? '' : 's'} blocking: ${blockers.map(b => b.label).join(', ')}`;+    btn.onclick = canSign ? doSignoff : null;++    // Body markup.+    body.innerHTML = sections.map(sec => `+      <div class="cs-so-section">+        <div class="cs-so-section-title">${esc(sec.title)}</div>+        ${sec.rows.map(r => {+          const cls = ({'●':'ok','◐':'partial','○':'pending','✗':'fail','─':'na'}[r.pill] || 'pending');+          return `<div class="cs-so-row" data-tab="${esc(r.tab)}" title="${escAttr(r.reason)}" data-tooltip="${escAttr(r.reason)} — click to jump to ${esc(r.tab)} tab">+            <span class="cs-so-glyph ${cls}">${r.pill}</span>+            <span class="cs-so-label${r.hard ? '' : ' soft'}">${esc(r.label)}</span>+          </div>`;+        }).join('')}+      </div>+    `).join('');+    body.querySelectorAll('.cs-so-row').forEach(rEl => {+      rEl.onclick = () => setActiveTab(rEl.dataset.tab);+    });+  }++  async function doSignoff() {+    if (!window.adomChipsmith?.signoff?.signOff) return;+    const btn = document.querySelector('#cs-signoff-hud .cs-so-signoff-btn');+    if (btn) { btn.disabled = true; btn.querySelector('.lbl').textContent = 'Signing off…'; }+    try {+      const r = await window.adomChipsmith.signoff.signOff();+      if (btn) btn.querySelector('.lbl').textContent = r && r.ok ? 'Signed ✓' : 'Failed';+      if (state.activeTab === 'signoff') renderSignoffTab();+    } catch (e) {+      console.error('[chipsmith] sign-off failed', e);+      if (btn) btn.querySelector('.lbl').textContent = 'Failed — see console';+    }+  }++  // Decorative geometry — flagging and bake-payload contribution.+  // Per the plan, on /bake the flagged products get reparented under+  // a `chipsmith_decorative` branch so downstream tools can default+  // them off. chipsmith's responsibility here is to (a) auto-flag+  // products whose names match marketing-mark patterns, and (b)+  // surface a context-menu verb so the user can flag/unflag any+  // product. The baked-side reparent is upstream of us in+  // service-step2glb /bake.+  const DECORATIVE_NAME_PATTERNS = [+    /\b(logo|brand|trademark|copyright)\b/i,+    /^[A-Z]{2,3}$/,                         // bare 2-3 letter manufacturer mark, e.g., "ST" / "TI" / "NXP"+    /\b(silkscreen|laser|engrav)/i,+  ];+  function isAutoDecorativeName(name) {+    if (!name || typeof name !== 'string') return false;+    return DECORATIVE_NAME_PATTERNS.some(re => re.test(name));+  }+  function setDecorative(name, on) {+    if (!name) return;+    const set = new Set(state.decorativeProductNames || []);+    if (on) set.add(name); else set.delete(name);+    state.decorativeProductNames = Array.from(set);+    refreshSignoffHud();+  }+  function isDecorative(name) {+    return (state.decorativeProductNames || []).includes(name);+  }+  function autoFlagDecorativeFromTree() {+    if (!(window.adomStep && window.adomStep.listComponents)) return;+    let changed = false;+    const set = new Set(state.decorativeProductNames || []);+    for (const c of window.adomStep.listComponents()) {+      if (isAutoDecorativeName(c.name) && !set.has(c.name)) { set.add(c.name); changed = true; }+    }+    if (changed) state.decorativeProductNames = Array.from(set);+  }+  function registerCtxExtras() {+    window.adomStepCtxMenuExtras = (window.adomStepCtxMenuExtras || []).filter(e => !String(e.id).startsWith('chipsmith-'));+    window.adomStepCtxMenuExtras.push({+      id: 'chipsmith-decorative',+      visibleFor: (entry) => entry && !entry.isOverlay && !entry.isMaterialGroup && !!entry.name,+      label: (entry) => isDecorative(entry.name)+        ? 'Unflag decorative — keep on canonical bake'+        : 'Move to decorative branch on bake',+      tooltip: (entry) => isDecorative(entry.name)+        ? `Currently flagged decorative — will be reparented under chipsmith_decorative on sign-off (default OFF in downstream tools). Click to unflag.`+        : `Mark "${entry.name}" as decorative geometry. On sign-off the bake reparents it under chipsmith_decorative — visible by default in chipsmith but OFF in downstream Adom tools (chipfit, InstaPCB preview, etc.). Use for vendor logos, lens cosmetics, embossed marks. Geometry is moved, NEVER deleted.`,+      onClick: (entry) => setDecorative(entry.name, !isDecorative(entry.name)),+    });+  }+  function refreshSignoffHud() {+    if (typeof recomputeAllStatuses === 'function') recomputeAllStatuses();+  }++  // Run after the adom-step shell + outline mount. Slight delay so the+  // viewer has finished its initial frame and the canvas/right-edge is+  // measurable.+  function enrichSubjectPopover() {+    try {+      const sp = state.spec;+      if (!sp) return;+      const subj = document.getElementById('subject-info');+      if (!subj) return;+      if (subj.querySelector('.cs-chip-meta')) return;+      const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');+      const addRow = (k, v) => {+        if (!v) return;+        const txt = esc(v.length > 120 ? v.slice(0, 117) + '...' : v);+        const row = document.createElement('div');+        row.className = 'popover-row cs-chip-meta';+        row.innerHTML = '<span class="k">' + esc(k) + '</span><span class="v">' + txt + '</span>';+        subj.appendChild(row);+      };+      addRow('manufacturer', sp.manufacturer);+      addRow('package', sp.package || sp.package_family);+      addRow('family', sp.family);+      addRow('description', sp.description);+    } catch (_) {}+  }++  function bootChipsmith() {+    init();+    setupHoverHud();+    setupSignoffHud();+    registerCtxExtras();+    autoFlagDecorativeFromTree();+    // Chipsmith always needs the bottom fill light — you're inspecting+    // BGA balls, QFN pads, through-hole leg bottoms, footprint overlays.+    // The base viewer defaults to OFF; chipsmith overrides to ON.+    try {+      const btn = document.getElementById('tbBottomLight');+      const viewer = window.adomStep;+      if (viewer && viewer.setBottomLight && !viewer.getBottomLightState()) {+        viewer.setBottomLight(true);+        if (btn) btn.classList.add('active');+        try { localStorage.setItem('adom-step.bottomLight', '1'); } catch (_) {}+      }+    } catch (_) {}+  }+  if (document.readyState === 'loading') {+    document.addEventListener('DOMContentLoaded', () => setTimeout(bootChipsmith, 250));+  } else {+    setTimeout(bootChipsmith, 250);+  }++  console.log('[chipsmith-overlay] loaded — branding + Phase 1 right-pane + Tab 1 Source live');+})();
src/assets/earcut.min.jsadded+1
@@ -0,0 +1 @@+!function(e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).earcut=e()}(function(){return function r(x,i,u){function f(n,e){if(!i[n]){if(!x[n]){var t="function"==typeof require&&require;if(!e&&t)return t(n,!0);if(o)return o(n,!0);throw(e=new Error("Cannot find module '"+n+"'")).code="MODULE_NOT_FOUND",e}t=i[n]={exports:{}},x[n][0].call(t.exports,function(e){return f(x[n][1][e]||e)},t,t.exports,r,x,i,u)}return i[n].exports}for(var o="function"==typeof require&&require,e=0;e<u.length;e++)f(u[e]);return f}({1:[function(e,n,t){"use strict";function r(e,n,t){t=t||2;var r,x,i,u,f,o=n&&n.length,v=o?n[0]*t:e.length,p=s(e,0,v,t,!0),y=[];if(p&&p.next!==p.prev){if(o&&(p=function(e,n,t,r){var x,i,u,f,o=[];for(x=0,i=n.length;x<i;x++)f=n[x]*r,u=x<i-1?n[x+1]*r:e.length,(f=s(e,f,u,r,!1))===f.next&&(f.steiner=!0),o.push(function(e){var n=e,t=e;for(;(n.x<t.x||n.x===t.x&&n.y<t.y)&&(t=n),n=n.next,n!==e;);return t}(f));for(o.sort(c),x=0;x<o.length;x++)t=function(e,n){var t=function(e,n){var t,r=n,x=e.x,i=e.y,u=-1/0;do{if(i<=r.y&&i>=r.next.y&&r.next.y!==r.y){var f=r.x+(i-r.y)*(r.next.x-r.x)/(r.next.y-r.y);if(f<=x&&u<f&&(u=f,t=r.x<r.next.x?r:r.next,f===x))return t}}while(r=r.next,r!==n);if(!t)return null;var o,v=t,p=t.x,y=t.y,a=1/0;r=t;for(;x>=r.x&&r.x>=p&&x!==r.x&&D(i<y?x:u,i,p,y,i<y?u:x,i,r.x,r.y)&&(o=Math.abs(i-r.y)/(x-r.x),_(r,e)&&(o<a||o===a&&(r.x>t.x||r.x===t.x&&function(e,n){return E(e.prev,e,n.prev)<0&&E(n.next,e,e.next)<0}(t,r)))&&(t=r,a=o)),r=r.next,r!==v;);return t}(e,n);if(!t)return n;n=j(t,e);return q(n,n.next),q(t,t.next)}(o[x],t);return t}(e,n,p,t)),e.length>80*t){for(var a=r=e[0],l=x=e[1],h=t;h<v;h+=t)(i=e[h])<a&&(a=i),(u=e[h+1])<l&&(l=u),r<i&&(r=i),x<u&&(x=u);f=0!==(f=Math.max(r-a,x-l))?32767/f:0}O(p,y,t,a,l,f,0)}return y}function s(e,n,t,r,x){var i,u;if(x===0<d(e,n,t,r))for(i=n;i<t;i+=r)u=f(i,e[i],e[i+1],u);else for(i=t-r;n<=i;i-=r)u=f(i,e[i],e[i+1],u);return u&&N(u,u.next)&&(C(u),u=u.next),u}function q(e,n){if(!e)return e;n=n||e;var t,r=e;do{if(t=!1,r.steiner||!N(r,r.next)&&0!==E(r.prev,r,r.next))r=r.next;else{if(C(r),(r=n=r.prev)===r.next)break;t=!0}}while(t||r!==n);return n}function O(e,n,t,r,x,i,u){if(e){if(!u&&i){for(var f=e,o=r,v=x,p=i,y=f;0===y.z&&(y.z=k(y.x,y.y,o,v,p)),y.prevZ=y.prev,y.nextZ=y.next,(y=y.next)!==f;);y.prevZ.nextZ=null,y.prevZ=null;var a,l,h,s,c,d,Z,g,w=y,M=1;do{for(l=w,c=w=null,d=0;l;){for(d++,h=l,a=Z=0;a<M&&(Z++,h=h.nextZ);a++);for(g=M;0<Z||0<g&&h;)0!==Z&&(0===g||!h||l.z<=h.z)?(l=(s=l).nextZ,Z--):(h=(s=h).nextZ,g--),c?c.nextZ=s:w=s,s.prevZ=c,c=s;l=h}}while(c.nextZ=null,M*=2,1<d)}for(var b,m,z=e;e.prev!==e.next;)if(b=e.prev,m=e.next,i?function(e,n,t,r){var x=e.prev,i=e,u=e.next;if(0<=E(x,i,u))return;var f=x.x,o=i.x,v=u.x,p=x.y,y=i.y,a=u.y,l=f<o?f<v?f:v:o<v?o:v,h=p<y?p<a?p:a:y<a?y:a,s=o<f?v<f?f:v:v<o?o:v,c=y<p?a<p?p:a:a<y?y:a,d=k(l,h,n,t,r),Z=k(s,c,n,t,r),g=e.prevZ,w=e.nextZ;for(;g&&g.z>=d&&w&&w.z<=Z;){if(g.x>=l&&g.x<=s&&g.y>=h&&g.y<=c&&g!==x&&g!==u&&D(f,p,o,y,v,a,g.x,g.y)&&0<=E(g.prev,g,g.next))return;if(g=g.prevZ,w.x>=l&&w.x<=s&&w.y>=h&&w.y<=c&&w!==x&&w!==u&&D(f,p,o,y,v,a,w.x,w.y)&&0<=E(w.prev,w,w.next))return;w=w.nextZ}for(;g&&g.z>=d;){if(g.x>=l&&g.x<=s&&g.y>=h&&g.y<=c&&g!==x&&g!==u&&D(f,p,o,y,v,a,g.x,g.y)&&0<=E(g.prev,g,g.next))return;g=g.prevZ}for(;w&&w.z<=Z;){if(w.x>=l&&w.x<=s&&w.y>=h&&w.y<=c&&w!==x&&w!==u&&D(f,p,o,y,v,a,w.x,w.y)&&0<=E(w.prev,w,w.next))return;w=w.nextZ}return 1}(e,r,x,i):function(e){var n=e.prev,t=e,e=e.next;if(0<=E(n,t,e))return;var r=n.x,x=t.x,i=e.x,u=n.y,f=t.y,o=e.y,v=r<x?r<i?r:i:x<i?x:i,p=u<f?u<o?u:o:f<o?f:o,y=x<r?i<r?r:i:i<x?x:i,a=f<u?o<u?u:o:o<f?f:o,l=e.next;for(;l!==n;){if(l.x>=v&&l.x<=y&&l.y>=p&&l.y<=a&&D(r,u,x,f,i,o,l.x,l.y)&&0<=E(l.prev,l,l.next))return;l=l.next}return 1}(e))n.push(b.i/t|0),n.push(e.i/t|0),n.push(m.i/t|0),C(e),e=m.next,z=m.next;else if((e=m)===z){u?1===u?O(e=function(e,n,t){var r=e;do{var x=r.prev,i=r.next.next}while(!N(x,i)&&U(x,r,r.next,i)&&_(x,i)&&_(i,x)&&(n.push(x.i/t|0),n.push(r.i/t|0),n.push(i.i/t|0),C(r),C(r.next),r=e=i),r=r.next,r!==e);return q(r)}(q(e),n,t),n,t,r,x,i,2):2===u&&function(e,n,t,r,x,i){var u=e;do{for(var f,o=u.next.next;o!==u.prev;){if(u.i!==o.i&&function(e,n){return e.next.i!==n.i&&e.prev.i!==n.i&&!function(e,n){var t=e;do{if(t.i!==e.i&&t.next.i!==e.i&&t.i!==n.i&&t.next.i!==n.i&&U(t,t.next,e,n))return 1}while(t=t.next,t!==e);return}(e,n)&&(_(e,n)&&_(n,e)&&function(e,n){var t=e,r=!1,x=(e.x+n.x)/2,i=(e.y+n.y)/2;for(;t.y>i!=t.next.y>i&&t.next.y!==t.y&&x<(t.next.x-t.x)*(i-t.y)/(t.next.y-t.y)+t.x&&(r=!r),t=t.next,t!==e;);return r}(e,n)&&(E(e.prev,e,n.prev)||E(e,n.prev,n))||N(e,n)&&0<E(e.prev,e,e.next)&&0<E(n.prev,n,n.next))}(u,o))return f=j(u,o),u=q(u,u.next),f=q(f,f.next),O(u,n,t,r,x,i,0),O(f,n,t,r,x,i,0);o=o.next}}while(u=u.next,u!==e)}(e,n,t,r,x,i):O(q(e),n,t,r,x,i,1);break}}}function c(e,n){return e.x-n.x}function k(e,n,t,r,x){return(e=1431655765&((e=858993459&((e=252645135&((e=16711935&((e=(e-t)*x|0)|e<<8))|e<<4))|e<<2))|e<<1))|(n=1431655765&((n=858993459&((n=252645135&((n=16711935&((n=(n-r)*x|0)|n<<8))|n<<4))|n<<2))|n<<1))<<1}function D(e,n,t,r,x,i,u,f){return(e-u)*(i-f)<=(x-u)*(n-f)&&(t-u)*(n-f)<=(e-u)*(r-f)&&(x-u)*(r-f)<=(t-u)*(i-f)}function E(e,n,t){return(n.y-e.y)*(t.x-n.x)-(n.x-e.x)*(t.y-n.y)}function N(e,n){return e.x===n.x&&e.y===n.y}function U(e,n,t,r){var x=v(E(e,n,t)),i=v(E(e,n,r)),u=v(E(t,r,e)),f=v(E(t,r,n));return x!==i&&u!==f||(0===x&&o(e,t,n)||(0===i&&o(e,r,n)||(0===u&&o(t,e,r)||!(0!==f||!o(t,n,r)))))}function o(e,n,t){return n.x<=Math.max(e.x,t.x)&&n.x>=Math.min(e.x,t.x)&&n.y<=Math.max(e.y,t.y)&&n.y>=Math.min(e.y,t.y)}function v(e){return 0<e?1:e<0?-1:0}function _(e,n){return E(e.prev,e,e.next)<0?0<=E(e,n,e.next)&&0<=E(e,e.prev,n):E(e,n,e.prev)<0||E(e,e.next,n)<0}function j(e,n){var t=new u(e.i,e.x,e.y),r=new u(n.i,n.x,n.y),x=e.next,i=n.prev;return(e.next=n).prev=e,(t.next=x).prev=t,(r.next=t).prev=r,(i.next=r).prev=i,r}function f(e,n,t,r){e=new u(e,n,t);return r?(e.next=r.next,(e.prev=r).next.prev=e,r.next=e):(e.prev=e).next=e,e}function C(e){e.next.prev=e.prev,e.prev.next=e.next,e.prevZ&&(e.prevZ.nextZ=e.nextZ),e.nextZ&&(e.nextZ.prevZ=e.prevZ)}function u(e,n,t){this.i=e,this.x=n,this.y=t,this.prev=null,this.next=null,this.z=0,this.prevZ=null,this.nextZ=null,this.steiner=!1}function d(e,n,t,r){for(var x=0,i=n,u=t-r;i<t;i+=r)x+=(e[u]-e[i])*(e[i+1]+e[u+1]),u=i;return x}n.exports=r,(n.exports.default=r).deviation=function(e,n,t,r){var x=n&&n.length,i=x?n[0]*t:e.length,u=Math.abs(d(e,0,i,t));if(x)for(var f=0,o=n.length;f<o;f++){var v=n[f]*t,p=f<o-1?n[f+1]*t:e.length;u-=Math.abs(d(e,v,p,t))}for(var y=0,f=0;f<r.length;f+=3){var a=r[f]*t,l=r[f+1]*t,h=r[f+2]*t;y+=Math.abs((e[a]-e[h])*(e[1+l]-e[1+a])-(e[a]-e[l])*(e[1+h]-e[1+a]))}return 0===u&&0===y?0:Math.abs((y-u)/u)},r.flatten=function(e){for(var n=e[0][0].length,t={vertices:[],holes:[],dimensions:n},r=0,x=0;x<e.length;x++){for(var i=0;i<e[x].length;i++)for(var u=0;u<n;u++)t.vertices.push(e[x][i][u]);0<x&&(r+=e[x-1].length,t.holes.push(r))}return t}},{}]},{},[1])(1)});
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>
src/main.rsadded+107
@@ -0,0 +1,107 @@+mod cli;+mod server;++use anyhow::Result;+use clap::{Parser, Subcommand};++/// adom-chipsmith — STEP-native chip detection + footprint validation + sign-off.+///+/// Loads a .step file (alongside its .kicad_mod footprint, datasheet PDF, and+/// chipsmith-spec.json manifest), reads the STEP's B-rep + assembly hierarchy+/// directly via service-step2glb's `/step-meta` endpoint (NOT the GLB triangle+/// mesh), runs detection in three modes (named PRODUCT walk → surface-type+/// cluster → face-merge fallback), validates measurements, auto-recovers from+/// Y-up vs Z-up, detects pin-1 on both chip and footprint, layers a 1.6mm faux+/// PCB + heatsink vias + solder-jet domes, and on user sign-off bakes a+/// `<mpn>.chipsmith.step` (canonical) + shadow `.chipsmith.glb` + sidecar JSON.+///+/// Every UI verb has a JS API helper, an HTTP endpoint, and a CLI subcommand+/// so an AI agent can drive the validator headlessly.+///+/// See PLAN.md for the full spec.+#[derive(Parser)]+#[command(name = "adom-chipsmith", version, about, long_about = None)]+struct Cli {+    #[command(subcommand)]+    command: Command,+}++#[derive(Subcommand)]+enum Command {+    /// Open a chip in the Hydrogen webview tab. Loads the STEP via+    /// service-step2glb (cached on disk by content hash) and pulls the+    /// step-meta sidecar so detection can run on B-rep data.+    View(cli::view::Args),+    /// Open a chip from a chip-fetcher library directory. Pulls the .step,+    /// .kicad_mod footprint, datasheet PDF, and info.json manifest from+    /// `<chip-fetcher>/library/<MPN>/`.+    ViewLibrary(cli::view_library::Args),+    /// Stop a running adom-chipsmith server on the given port.+    Stop(cli::stop::Args),+    /// Print status of the running adom-chipsmith 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),+    /// Save a PNG screenshot of the current canvas.+    Screenshot(cli::screenshot::Args),+    /// Run a JavaScript snippet inside the webview and print its result.+    Eval(cli::eval::Args),+    /// Print the JavaScript console log forwarded from the webview.+    Console(cli::console::Args),+    /// Reveal a file in the VS Code Explorer sidebar (delegates to adom-vscode).+    Reveal(cli::reveal::Args),+    /// Run detection headlessly on a chip-fetcher dir and print a JSON report.+    Detect(cli::detect::Args),+    /// Place a synthetic pin-1 indicator (corner / fp-marker / xy click).+    PlacePin1(cli::place_pin1::Args),+    /// Toggle a chipsmith overlay layer (footprint / stock / faux-pcb / heatsink / solderjet).+    SetOverlay(cli::set_overlay::Args),+    /// Sign off the loaded chip — writes <mpn>.chipsmith.json + bakes <mpn>.chipsmith.step.+    SignOff(cli::sign_off::Args),+    /// Kill → optional rebuild → restart server → reload pup browser. One command.+    Restart(cli::restart::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::Screenshot(a) => cli::screenshot::run(a),+        Command::Eval(a) => cli::eval::run(a),+        Command::Console(a) => cli::console::run(a),+        Command::Reveal(a) => cli::reveal::run(a),+        Command::Detect(a) => cli::detect::run(a),+        Command::PlacePin1(a) => cli::place_pin1::run(a),+        Command::SetOverlay(a) => cli::set_overlay::run(a),+        Command::SignOff(a) => cli::sign_off::run(a),+        Command::Restart(a) => cli::restart::run(a),+    }+}