app
Adom Chipsmith
Public Made by Adomby adom
STEP-native chip validator — 5-source cross-validation, OCCT-generated copyright-free 3D models with embedded signal annotations, datasheet-to-STEP pipeline.
← Commit history
Upload chipsmith source (MIT) — Ray can commit on top
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><mpn>.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 >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><mpn>.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><mpn>.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 (< 150 µm)`+ : `rejected — median pad area ${(medArea).toFixed(3)} mm² (< 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><mpn>.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><mpn>.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 <mpn>.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 => ({ '&':'&','<':'<','>':'>' }[c]));+ }+ function escAttr(s) { return esc(s).replace(/"/g, '"'); }+ 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, '&').replace(/</g, '<').replace(/>/g, '>');+ 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),+ }+}