<!DOCTYPE html>
<html lang="en">
<head><script>
(() => {
  // adom-tsci static export — see src/cli/export_wiki.rs.
  const orig = window.fetch.bind(window);
  // GLB metadata: shell.html does fetch('glb/meta') -> .json() before
  // calling viewer.loadModel. Without a backing server we fake a meta
  // payload with a constant mtime; the XHR for the model itself
  // resolves to the bundled ./3d.glb (rewritten at export time).
  const META_PATTERN = /^\/?glb\/meta$/;
  // Live-only endpoints that drive features only meaningful on the
  // running adom-tsci server. 204 No Content is the friendliest
  // no-op for callers that chain .json() / .text().
  const NOOP = [
    /^\/?api\/(toggle|toggle-component|camera-command|components|tts|walkthrough-status|reveal-folder|reload|eval)/,
    /^\/?(state|rerun|console)$/,
    /^\/?eval\//,
    /^\/?runframe\//,
    /^\/?chip-footprint\//,
  ];
  window.fetch = function(input, init) {
    const url = typeof input === 'string' ? input : (input && input.url) || '';
    const path = url.replace(/^https?:\/\/[^/]+/, '');
    if (META_PATTERN.test(path)) {
      const body = JSON.stringify({mtime: 1, size: 0});
      return Promise.resolve(new Response(body, {
        status: 200,
        headers: {'content-type': 'application/json'},
      }));
    }
    for (const re of NOOP) {
      if (re.test(path)) {
        return Promise.resolve(new Response(null, {status: 204}));
      }
    }
    return orig(input, init);
  };
  window.__ADOM_TSCI_STATIC = true;

  // Autoplay the Walkthrough Demo unless ?autoplay=off is in the URL.
  // The walkthrough is the most-cited demo of adom-tsci's 3D viewer
  // (component flyovers, x-ray net-isolate, narrated steps), so it
  // should fire on its own when the bundle is embedded on a wiki
  // page — that's the canonical "tour the board" experience for an
  // external viewer who isn't going to know to click anything.
  const params = new URLSearchParams(window.location.search);
  const autoplayOff = params.get('autoplay') === 'off';
  if (!autoplayOff) {
    // Wait for viewer + walkthrough.json to be loaded. Poll for
    // typeof walkthroughStart === 'function' AND the WALKTHROUGH
    // global being populated (shell.html sets it after fetching
    // walkthrough.json). 30 × 200ms = 6 s budget; well under the
    // wall time of viewer init on a slow network.
    let tries = 0;
    const tick = () => {
      tries += 1;
      const ready = typeof walkthroughStart === 'function'
                  && typeof WALKTHROUGH !== 'undefined'
                  && Array.isArray(WALKTHROUGH)
                  && WALKTHROUGH.length > 0;
      if (ready) {
        try { walkthroughStart(); } catch (e) { console.warn('[autoplay] walkthroughStart threw', e); }
        return;
      }
      if (tries < 30) setTimeout(tick, 200);
    };
    // Defer past initViewer's own work — initViewer fires loadCurrentGlb
    // on completion, and that's also when we want the walkthrough to
    // start. Polling from t=2s is the friendly entry point.
    setTimeout(tick, 2000);
  }
})();
</script>

<meta charset="UTF-8">
<title>adom-tsci</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="adom.css">
<style>
html, body { margin: 0; padding: 0; }
body {
  display: flex; flex-direction: column; height: 100vh; overflow: hidden;
  /* Compensate for the position:fixed header below so the 3D / PCB /
     Schematic panels start below it instead of underneath. */
  padding-top: 54px;
  box-sizing: border-box;
}

/* Header ======================================================= */
.header {
  /* Fixed rather than in-flow: some embedding contexts (Hydrogen Web
     View iframes with the "Some sites block embedding" hint bar) clip
     the first ~20–30px of the iframe's top. Pinning the header to the
     viewport dodges that entirely — and it stays visible on scroll. */
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 999;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 6px 14px;
  min-height: 48px;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
  font-size: 13px;
  line-height: 1.5;
}
.tabs-primary { display: flex; gap: 0; }
.tab-primary {
  padding: 10px 18px;
  cursor: pointer;
  color: var(--text-dim);
  font-size: 13px;
  font-weight: 600;
  line-height: 1.5;
  border-bottom: 2px solid transparent;
  user-select: none;
  display: inline-flex;
  align-items: center;
}
.tab-primary:hover { color: var(--text); }
.tab-primary.active {
  color: var(--accent);
  border-bottom-color: var(--accent);
}
.spacer { flex: 1; }
.btn-rerun {
  background: transparent; color: var(--text-dim); border: 1px solid var(--border);
  border-radius: 6px; padding: 6px 12px; font: inherit; font-size: 12px;
  cursor: pointer; display: flex; align-items: center; gap: 6px;
}
.btn-rerun:hover { color: var(--text); border-color: var(--accent); }
.btn-rerun:disabled { opacity: 0.5; cursor: wait; }
.tabs-secondary {
  display: flex; gap: 0; border-left: 1px solid var(--border);
  padding-left: 8px; margin-left: 8px;
}
.tab-secondary {
  padding: 6px 10px; cursor: pointer; color: var(--text-dim); font-size: 11px;
  letter-spacing: 0.02em; border-radius: 4px; user-select: none;
}
.tab-secondary:hover { color: var(--text); background: var(--surface2); }
.tab-secondary.active { color: var(--accent); background: var(--surface2); }

/* Panels ======================================================= */
.panel {
  flex: 1 1 auto; display: none; position: relative; overflow: hidden; min-height: 0;
}
.panel.active { display: flex; flex-direction: column; }
.panel iframe {
  border: 0; flex: 1 1 auto; width: 100%; display: block;
}

/* 3D panel: full-bleed canvas wrap, everything else floats over it */
#panel-3d { position: relative; }
#viewer-wrap {
  position: absolute; inset: 0; background: #1a1a1a;
}
#viewer-wrap canvas { display: block; width: 100%; height: 100%; outline: none; }
#viewer-loading {
  position: absolute; inset: 0; display: flex;
  align-items: center; justify-content: center;
  color: var(--text-dim); pointer-events: none; font-size: 13px;
}
#viewer-loading.hidden { display: none; }

/* Floating toolbar =============================================
   Absolute-positioned over the canvas; drag the grip to move. Opens
   centred-top by default. Single row of compact 32×28px buttons. */
#toolbar {
  position: absolute;
  top: 30px; left: 50%;
  transform: translateX(-50%);
  z-index: 25;
  display: flex; align-items: center; gap: 1px;
  background: rgba(22, 27, 34, 0.92);
  backdrop-filter: blur(12px);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 4px;
  box-shadow: 0 6px 20px rgba(0,0,0,0.4);
  user-select: none;
}
#toolbar .tb-grip {
  width: 16px; height: 22px;
  display: flex; align-items: center; justify-content: center;
  color: var(--text-dim); cursor: move; font-size: 14px;
  padding: 0 4px;
}
#toolbar .tb-grip:hover { color: var(--text); }
#toolbar button {
  display: flex; align-items: center; justify-content: center;
  width: 32px; height: 28px;
  background: transparent; border: 1px solid transparent; border-radius: 4px;
  color: var(--text-dim); cursor: pointer; font-size: 13px; font-weight: 600;
  transition: background 0.12s, color 0.12s, border-color 0.12s;
  position: relative;
}
#toolbar button:hover { background: var(--surface2); color: var(--text); border-color: var(--border); }
#toolbar button.kb-flash { background: rgba(127, 212, 199, 0.3); border-color: #7fd4c7; transition: background 0.1s ease, border-color 0.1s ease; }
#toolbar button.active { color: var(--accent); border-color: var(--accent); background: rgba(0, 184, 176, 0.12); }
#toolbar .tb-sep { width: 1px; height: 20px; background: var(--border); margin: 0 3px; }
#toolbar button[data-tip]:hover::after {
  content: attr(data-tip);
  position: absolute; top: calc(100% + 6px); left: 50%;
  transform: translateX(-50%);
  background: rgba(13, 17, 23, 0.95);
  color: var(--text);
  padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border);
  white-space: nowrap; font-weight: normal; font-size: 11px;
  pointer-events: none; z-index: 100;
}

/* Floating component panel overlay */
#comp-overlay {
  position: absolute; top: 52px; right: 12px;
  width: 220px; max-height: 400px;
  background: rgba(22, 27, 34, 0.94);
  backdrop-filter: blur(12px);
  border: 1px solid var(--border); border-radius: 8px;
  display: flex; flex-direction: column;
  z-index: 24;
  box-shadow: 0 6px 20px rgba(0,0,0,0.4);
  font-size: 12px;
}
#comp-overlay.collapsed { max-height: 28px; overflow: hidden; }
#trace-overlay {
  position: absolute; top: 52px; right: 240px;
  width: 240px; max-height: 460px;
  background: rgba(22, 27, 34, 0.94);
  backdrop-filter: blur(12px);
  border: 1px solid var(--border); border-radius: 8px;
  display: flex; flex-direction: column;
  z-index: 24;
  box-shadow: 0 6px 20px rgba(0,0,0,0.4);
  font-size: 12px;
}
#trace-overlay.collapsed { max-height: 28px; overflow: hidden; }
#trace-overlay .co-header {
  display: flex; align-items: center; justify-content: space-between;
  padding: 6px 10px; color: var(--text-dim); font-size: 11px; font-weight: 600;
  letter-spacing: 0.02em;
  border-bottom: 1px solid var(--border);
  cursor: move; user-select: none;
}
#trace-overlay .co-actions { display: flex; gap: 10px; }
#trace-overlay a {
  color: var(--text-dim); cursor: pointer; text-decoration: none;
  font-size: 11px;
}
#trace-overlay a:hover { color: var(--accent); }
#trace-overlay a#trace-glow {
  /* The ✦ glyph is decorative — make sure it lines up vertically with
     the other text-only links in the action row, and reads as ON in
     the default state via the same accent color used elsewhere. */
  font-size: 13px; line-height: 1;
}
#trace-overlay a#trace-glow.active { color: var(--accent); text-shadow: 0 0 4px rgba(127,212,199,0.55); }
#trace-overlay a#trace-glow:not(.active) { color: var(--text-dim); text-shadow: none; }
#trace-overlay .co-list {
  overflow-y: auto; padding: 6px 0;
  flex: 1 1 auto;
}
#trace-overlay .co-group {
  display: grid;
  grid-template-columns: 16px 1fr auto auto;
  align-items: center; gap: 6px;
  padding: 4px 10px; font-weight: 600; color: var(--text);
  cursor: pointer; user-select: none;
}
#trace-overlay .co-group:hover { background: rgba(127,212,199,0.06); }
#trace-overlay .co-group .co-caret { color: var(--text-dim); font-size: 10px; }
#trace-overlay .co-group.collapsed .co-caret { transform: rotate(-90deg); }
#trace-overlay .co-group-count { color: var(--text-dim); font-size: 10px; padding-right: 4px; }
#trace-overlay .co-group-eye { color: var(--accent); font-size: 12px; cursor: pointer; }
#trace-overlay .co-row {
  padding: 3px 10px 3px 32px;
  cursor: pointer;
  display: flex; align-items: center; justify-content: space-between;
  color: var(--text-dim); font-size: 11px;
}
#trace-overlay .co-row:hover { background: rgba(127,212,199,0.08); color: var(--text); }
#trace-overlay .co-row.selected { background: rgba(127,212,199,0.18); color: #7fd4c7; }
#trace-overlay .co-row .net-meta { color: var(--text-dim); font-size: 10px; }
#comp-overlay .co-header {
  display: flex; align-items: center; justify-content: space-between;
  padding: 6px 10px; color: var(--text-dim); font-size: 11px; font-weight: 600;
  letter-spacing: 0.02em;
  border-bottom: 1px solid var(--border);
  cursor: move; user-select: none;
}
#comp-overlay .co-actions { display: flex; gap: 10px; }
#comp-overlay a {
  color: var(--text-dim); cursor: pointer; text-decoration: none;
  font-size: 10px; user-select: none;
}
#comp-overlay a:hover { color: var(--accent); }
#comp-overlay .co-list {
  flex: 1 1 auto; overflow-y: auto; padding: 4px 0;
}
.co-group {
  display: flex; align-items: center; gap: 6px;
  padding: 4px 8px 4px 6px;
  color: var(--text-dim); font-size: 11px; font-weight: 600;
  letter-spacing: 0.02em;
  background: rgba(255,255,255,0.02);
  border-top: 1px solid var(--border);
  user-select: none; cursor: pointer;
}
.co-group:first-child { border-top: 0; }
.co-group .co-caret {
  display: inline-block; width: 12px; text-align: center;
  transition: transform 0.12s;
}
.co-group.collapsed .co-caret { transform: rotate(-90deg); }
.co-group .co-group-name { flex: 1; color: var(--text); }
.co-group .co-group-count { color: var(--text-dim); font-weight: 400; font-size: 9px; }
.co-group .co-group-eye {
  width: 18px; height: 18px; display: flex; align-items: center; justify-content: center;
  border-radius: 3px; color: var(--accent); font-size: 12px;
}
.co-group .co-group-eye:hover { background: var(--surface2); color: var(--text); }
.co-group.all-hidden .co-group-eye { color: var(--text-dim); }
.co-group.mixed .co-group-eye { color: #d4a34f; }  /* amber = partial */
.co-row {
  padding: 3px 10px 3px 24px; display: flex; align-items: center; gap: 6px;
  color: var(--text); cursor: pointer; user-select: none;
}
.co-row:hover { background: var(--surface2); }
.co-row.hidden-component { color: var(--text-dim); opacity: 0.5; }
.co-row .co-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
.co-row.hidden-component .co-dot { background: var(--text-dim); }
#comp-overlay .co-silkmode {
  display: flex; align-items: center; gap: 4px;
  padding: 6px 10px;
  background: rgba(255,255,255,0.02);
  border-bottom: 1px solid var(--border);
  font-size: 10.5px;
}
#comp-overlay .co-silkmode-label {
  color: var(--text-dim); font-weight: 600;
  letter-spacing: 0.04em; margin-right: 4px;
}
#comp-overlay .co-silk-btn {
  background: transparent; color: var(--text-dim);
  border: 1px solid var(--border); border-radius: 3px;
  padding: 2px 8px; font: inherit; font-size: 10.5px; cursor: pointer;
  transition: background 0.12s, color 0.12s, border-color 0.12s;
}
#comp-overlay .co-silk-btn:hover { background: rgba(127,212,199,0.08); color: var(--text); border-color: #7fd4c7; }
#comp-overlay .co-silk-btn.active { background: rgba(127,212,199,0.15); color: #7fd4c7; border-color: #7fd4c7; }
#comp-overlay .co-silklayer-btn, #comp-overlay .co-mask-btn {
  background: transparent; color: var(--text-dim);
  border: 1px solid var(--border); border-radius: 3px;
  padding: 2px 8px; font: inherit; font-size: 10.5px; cursor: pointer;
  transition: background 0.12s, color 0.12s, border-color 0.12s;
}
#comp-overlay .co-silklayer-btn:hover, #comp-overlay .co-mask-btn:hover { background: rgba(127,212,199,0.08); color: var(--text); border-color: #7fd4c7; }
#comp-overlay .co-silklayer-btn.active, #comp-overlay .co-mask-btn.active { background: rgba(127,212,199,0.15); color: #7fd4c7; border-color: #7fd4c7; }
.co-row .co-name { flex: 1; font-family: var(--mono), monospace; font-size: 11px; display: flex; flex-direction: column; gap: 1px; overflow: hidden; }
.co-row .co-name-secondary {
  font-family: 'Satoshi', sans-serif; font-size: 9.5px; font-weight: 400;
  color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.co-row .co-kind { display: none; }   /* kind now shown only on the group header */


/* Measure HUD — Fusion 360 layout (vertical label | control grid).
   Header, row per param, collapsible "Selection N" sections, Close. */
/* Walkthrough narration bar \u2014 docked at the BOTTOM of the 3D panel
   during the guided tour. Full width, low height, never covers the
   board view the user's trying to look at. Non-draggable (docking
   makes that moot). A thin progress strip at the very top tracks the
   step timer. */
#walkthrough-bar {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  z-index: 31;
  background: linear-gradient(to top, rgba(10,12,16,0.97) 0%, rgba(18,22,28,0.94) 100%);
  backdrop-filter: blur(16px);
  border-top: 1px solid #3a4554;
  box-shadow: 0 -8px 24px rgba(0,0,0,0.45);
  font-family: 'Satoshi', 'Segoe UI', sans-serif;
  font-size: 13px;
  color: #e4e7eb;
  user-select: none;
  display: flex; flex-direction: column;
}
#walkthrough-bar::before {
  content: ""; display: block;
  height: 2px; background: #7fd4c7;
  width: var(--wt-progress-pct, 0%);
  transition: width 0.2s linear;
}
#walkthrough-bar.paused::before { background: #d4a34f; }
#walkthrough-bar .wt-header {
  display: flex; align-items: center; gap: 10px;
  padding: 6px 16px;
  background: rgba(255,255,255,0.03);
  border-bottom: 1px solid #2a323d;
  font-size: 11px;
  color: #8b949e;
  letter-spacing: 0.04em;
  text-transform: none;
}
#walkthrough-bar .wt-header .wt-icon { font-size: 13px; }
#walkthrough-bar .wt-header .wt-title-bar { font-weight: 600; color: #c7ccd3; }
#walkthrough-bar .wt-header .wt-progress {
  color: #8b949e; font-family: var(--mono, monospace); font-size: 11px;
  padding: 1px 6px; background: rgba(255,255,255,0.05);
  border-radius: 2px;
}
#walkthrough-bar .wt-header .wt-spacer { flex: 1; }
#walkthrough-bar .wt-header .wt-pause,
#walkthrough-bar .wt-header .wt-close {
  width: 22px; height: 22px;
  display: inline-flex; align-items: center; justify-content: center;
  color: #8b949e; border-radius: 3px; cursor: pointer;
  transition: background 0.12s, color 0.12s;
}
#walkthrough-bar .wt-header .wt-pause:hover { background: rgba(127,212,199,0.15); color: #7fd4c7; }
#walkthrough-bar .wt-header .wt-close:hover { background: rgba(255,107,107,0.15); color: #ff8080; }
#walkthrough-bar.paused .wt-pause::before { content: "\25B6"; }
#walkthrough-bar:not(.paused) .wt-pause::before { content: "\23F8"; }
#walkthrough-bar .wt-pause { font-size: 0; }
#walkthrough-bar .wt-pause::before { font-size: 13px; }
#walkthrough-bar .wt-body {
  display: flex; align-items: flex-start; gap: 24px;
  padding: 12px 16px 10px 16px;
}
#walkthrough-bar .wt-step-title {
  font-weight: 700; font-size: 15px; color: #e4e7eb;
  letter-spacing: 0.01em;
  min-width: 220px; max-width: 280px;
  flex: 0 0 auto;
}
#walkthrough-bar .wt-step-text {
  color: #c7ccd3; font-weight: 400; line-height: 1.5;
  white-space: pre-wrap;
  flex: 1 1 auto;
}
#walkthrough-bar .wt-footer {
  display: flex; align-items: center; gap: 8px;
  padding: 6px 16px 8px 16px;
  border-top: 1px solid #2a323d;
  background: rgba(0,0,0,0.15);
}
#walkthrough-bar .wt-footer .wt-spacer { flex: 1; }
#walkthrough-bar .wt-nav {
  background: transparent; border: 1px solid #3a4554;
  color: #c7ccd3; padding: 4px 12px;
  border-radius: 3px; font: inherit; font-size: 12px;
  cursor: pointer;
  transition: background 0.12s, border-color 0.12s, color 0.12s;
}
#walkthrough-bar .wt-nav:hover { border-color: #7fd4c7; color: #e4e7eb; background: rgba(127,212,199,0.08); }
#walkthrough-bar .wt-nav:disabled { opacity: 0.35; cursor: not-allowed; }
#walkthrough-bar .wt-paused-badge {
  color: #d4a34f; font-size: 11px; font-weight: 600; letter-spacing: 0.04em;
}

#measure-bar {
  position: absolute;
  top: 60px; left: 24px;
  z-index: 28;
  display: none;
  flex-direction: column;
  background: rgba(30, 36, 44, 0.97);
  backdrop-filter: blur(14px);
  border: 1px solid #3a4554;
  border-radius: 4px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
  user-select: none;
  width: 300px;
  max-height: calc(100% - 80px);
  overflow: hidden;
  font-family: 'Segoe UI', 'Satoshi', sans-serif;
  font-size: 12.5px;
  color: #e4e7eb;
}
#measure-bar.visible { display: flex; }
#measure-bar .mb-header {
  display: flex; align-items: center;
  padding: 6px 10px;
  background: rgba(255,255,255,0.04);
  border-bottom: 1px solid #3a4554;
  cursor: move;
  color: #c7ccd3; font-weight: 600; letter-spacing: 0.02em; font-size: 12px;
}
#measure-bar .mb-header .mb-minus { margin-right: 10px; cursor: pointer; color: #8b949e; }
#measure-bar .mb-header .mb-dock { margin-left: auto; color: #8b949e; cursor: pointer; font-size: 13px; }
#measure-bar .mb-body {
  padding: 10px 12px 4px 12px;
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: 16px; row-gap: 10px;
  align-items: center;
}
#measure-bar .mb-body label { color: #c7ccd3; font-weight: 400; }
#measure-bar .mb-filter {
  display: flex; gap: 4px;
}
#measure-bar .mb-filter button {
  width: 28px; height: 26px;
  display: flex; align-items: center; justify-content: center;
  background: rgba(255,255,255,0.05);
  border: 1px solid transparent;
  border-radius: 3px;
  cursor: pointer;
  color: #7fd4c7;
  transition: border-color 0.1s;
}
#measure-bar .mb-filter button:hover { background: rgba(255,255,255,0.08); }
#measure-bar .mb-filter button.active { border-color: #7fd4c7; background: rgba(127, 212, 199, 0.12); }
#measure-bar .mb-filter svg { width: 18px; height: 18px; }
#measure-bar select {
  background: rgba(20, 24, 30, 0.9);
  color: #e4e7eb;
  border: 1px solid #3a4554;
  border-radius: 3px;
  padding: 3px 6px;
  font: inherit; font-size: 12px;
  width: 100%;
}
#measure-bar .mb-clear {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 24px;
  background: transparent; border: 1px solid transparent;
  border-radius: 3px; color: #4a9eff; cursor: pointer;
  font-size: 15px; padding: 0;
}
#measure-bar .mb-clear:hover { border-color: #4a9eff; background: rgba(74,158,255,0.10); }
#measure-bar .mb-snap-check {
  width: 16px; height: 16px; accent-color: #7fd4c7;
}
#measure-bar .mb-selections {
  border-top: 1px solid #3a4554;
  padding: 6px 0 0 0;
  display: none;
  flex-direction: column;
  overflow-y: auto;
  min-height: 0;
  flex: 1 1 auto;
}
#measure-bar.has-any .mb-selections { display: flex; }
#measure-bar .mb-sel {
  padding: 6px 12px 8px 12px;
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: 16px; row-gap: 4px;
  align-items: center;
}
#measure-bar .mb-sel-header {
  grid-column: 1 / -1;
  display: flex; align-items: center; gap: 6px;
  color: #e4e7eb; font-weight: 600;
  cursor: pointer;
}
#measure-bar .mb-sel-caret { color: #8b949e; display: inline-block; transition: transform 0.12s; }
#measure-bar .mb-sel.collapsed .mb-sel-caret { transform: rotate(-90deg); }
#measure-bar .mb-sel-x {
  margin-left: auto;
  width: 18px; height: 18px;
  border: 0; background: transparent; color: #8b949e;
  cursor: pointer; border-radius: 50%; font-size: 12px;
}
#measure-bar .mb-sel-x:hover { background: rgba(255,255,255,0.08); color: #ff6b6b; }
#measure-bar .mb-sel.collapsed .mb-sel-row { display: none; }
#measure-bar .mb-sel-type {
  grid-column: 1 / -1;
  color: #8b949e; font-size: 11px;
  padding-left: 18px;
}
#measure-bar .mb-sel-row {
  padding-left: 18px;
  display: contents;
}
#measure-bar .mb-sel-row .mb-k { color: #c7ccd3; }
#measure-bar .mb-sel-row .mb-v { color: #e4e7eb; text-align: right; font-family: var(--mono, monospace); font-size: 12px; }

#measure-bar .mb-results {
  border-top: 1px solid #3a4554;
  padding: 8px 12px;
  display: none;
  grid-template-columns: auto 1fr;
  column-gap: 16px; row-gap: 4px;
}
#measure-bar.has-results .mb-results { display: grid; }
#measure-bar .mb-results .mb-k { color: #c7ccd3; }
#measure-bar .mb-results .mb-v { color: #e4e7eb; text-align: right; font-family: var(--mono, monospace); font-size: 12px; }
#measure-bar .mb-results .mb-v.primary { color: #7fd4c7; font-weight: 600; font-size: 14px; }

#measure-bar .mb-footer {
  border-top: 1px solid #3a4554;
  padding: 8px 12px;
  display: flex; align-items: center;
}
#measure-bar .mb-footer .mb-info { color: #8b949e; font-size: 14px; cursor: help; }
#measure-bar .mb-footer .mb-spacer { flex: 1; }
#measure-bar .mb-footer .mb-close {
  background: rgba(255,255,255,0.05); color: #e4e7eb;
  border: 1px solid #3a4554; border-radius: 3px;
  padding: 4px 16px; font: inherit; font-size: 12px; cursor: pointer;
}
#measure-bar .mb-footer .mb-close:hover { background: rgba(255,255,255,0.10); border-color: #4a9eff; }

/* "1" / "2" selection labels in the 3D view (Fusion-style tag) */
.mb-hud-label {
  position: absolute; pointer-events: none; z-index: 31;
  background: #2d3440; color: #e4e7eb;
  padding: 1px 6px; border-radius: 2px;
  border: 1px solid #7fd4c7;
  font-family: 'Segoe UI', sans-serif; font-size: 11px; font-weight: 600;
  transform: translate(-50%, -50%);
  white-space: nowrap;
}
.mb-hud-label.two { border-color: #f06029; }
/* XYZ-axis delta labels \u2014 engineering-standard R/G/B colours. */
.mb-hud-label.axis-x { border-color: #ff5a5a; color: #ff9090; background: rgba(40,12,12,0.92); }
.mb-hud-label.axis-y { border-color: #5ad06f; color: #a0eab0; background: rgba(14,32,18,0.92); }
.mb-hud-label.axis-z { border-color: #5aa0ff; color: #a0c8ff; background: rgba(14,20,34,0.92); }
/* HUD row colour accents for delta-X / Y / Z keys + values. */
#measure-bar .mb-k-dx { color: #ff8080; }
#measure-bar .mb-k-dy { color: #a0eab0; }
#measure-bar .mb-k-dz { color: #a0c8ff; }
#measure-bar .mb-v-dx { color: #ff8080; }
#measure-bar .mb-v-dy { color: #a0eab0; }
#measure-bar .mb-v-dz { color: #a0c8ff; }
#measure-bar.collapsed .mb-body,
#measure-bar.collapsed .mb-selections,
#measure-bar.collapsed .mb-results,
#measure-bar.collapsed .mb-footer { display: none; }

/* Legacy: keep #measure-label for backwards-compat but no longer used */
#measure-label { display: none; }

/* ─── Rich tooltips — 500ms hover delay, multi-line, brand-styled ───
   Applied to any element with `data-tooltip="..."`. Click-targets use
   `data-tooltip-align` (default | "right") to flip anchoring near the
   viewport edge. */
/* Single global tooltip element appended to <body> by JS. Using `position:
   fixed` escapes every ancestor's overflow:hidden and stacking context —
   the only reliable way to render a tooltip from inside a clipping HUD. */
#global-tooltip {
  position: fixed;
  top: 0; left: 0;
  transform: translate(var(--tip-x, 0), var(--tip-y, 0));
  background: rgba(22, 27, 34, 0.98);
  backdrop-filter: blur(16px);
  border: 1px solid rgba(0, 184, 177, 0.35);
  border-radius: 6px;
  padding: 8px 12px;
  font-family: 'Satoshi', 'Segoe UI', sans-serif;
  font-size: 11.5px;
  font-weight: 400;
  line-height: 1.45;
  color: #e4e7eb;
  text-align: left;
  text-transform: none;
  letter-spacing: 0;
  width: max-content;
  max-width: 320px;
  white-space: pre-wrap;
  pointer-events: none;
  opacity: 0;
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55);
  transition: opacity 0.18s ease;
  z-index: var(--tip-z, 99999);
  display: none;
}
#global-tooltip.visible { display: block; }
#global-tooltip.shown   { opacity: 1; }

/* Inspect info card — follows the cursor while Inspect tool is active.
   Uses the same position:fixed + high-z pattern as the global tooltip,
   but is INTERACTIVE (pointer-events: auto) so clicks can pin it. */
#inspect-card {
  position: fixed;
  top: 0; left: 0;
  transform: translate(var(--ins-x, 0), var(--ins-y, 0));
  background: rgba(22, 27, 34, 0.98);
  backdrop-filter: blur(16px);
  border: 1px solid rgba(245, 190, 80, 0.5);   /* amber to match Inspect highlight */
  border-radius: 6px;
  padding: 10px 14px;
  font-family: 'Satoshi', 'Segoe UI', sans-serif;
  font-size: 11.5px;
  color: #e4e7eb;
  width: 320px;
  max-height: 70vh;
  overflow-y: auto;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);
  z-index: 99998;  /* just below global tooltip so hovering a — cell can explain */
  display: none;
  pointer-events: auto;
  cursor: pointer;  /* hint that clicking pins */
}
#inspect-card.visible { display: block; }
#inspect-card.pinned {
  border-color: rgba(245, 190, 80, 0.9);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.7), 0 0 0 2px rgba(245, 190, 80, 0.22);
  cursor: default;
}
#inspect-card .ic-head {
  display: flex; align-items: center; gap: 8px;
  margin-bottom: 8px;
  padding-bottom: 6px;
  border-bottom: 1px solid rgba(255,255,255,0.08);
}
#inspect-card .ic-kind {
  font-size: 9.5px; font-weight: 700; letter-spacing: 0.06em;
  padding: 2px 7px; border-radius: 3px;
  background: rgba(245, 190, 80, 0.18);
  color: #f5be50;
  text-transform: uppercase;
}
#inspect-card .ic-title {
  flex: 1; font-family: var(--mono), monospace; font-size: 13px; font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#inspect-card .ic-body {
  display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px;
}
#inspect-card .ic-k { color: var(--text-dim); font-size: 10.5px; }
#inspect-card .ic-v { color: #e4e7eb; font-size: 11px; font-family: var(--mono), monospace; word-break: break-word; }
#inspect-card .ic-v.muted { color: var(--text-dim); font-style: italic; font-family: 'Satoshi', sans-serif; }
#inspect-card .ic-foot {
  margin-top: 10px; padding-top: 6px;
  border-top: 1px solid rgba(255,255,255,0.06);
  font-size: 10px; color: var(--text-dim); font-style: italic;
}

/* Toast for CLI-driven command feedback */
#toast {
  position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
  background: rgba(13, 17, 23, 0.9); border: 1px solid var(--border);
  color: var(--text-dim); padding: 6px 14px; border-radius: 999px;
  font-size: 11px; opacity: 0; transition: opacity 0.2s;
  pointer-events: none; z-index: 40;
}
#toast.show { opacity: 1; }

/* SVG panzoom */
.svg-viewport { flex: 1 1 auto; position: relative; overflow: hidden; cursor: grab; background: #000; }
.svg-viewport:active { cursor: grabbing; }
.svg-viewport .svg-stage { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
.svg-viewport .svg-stage svg { display: block; }
.empty-state {
  flex: 1; display: flex; align-items: center; justify-content: center;
  color: var(--text-dim); font-size: 13px; padding: 24px; text-align: center;
}
#panel-tsci-live iframe { filter: brightness(0.78); }
.status { font-size: 11px; color: var(--text-dim); padding-left: 4px; }
.status .pill {
  display: inline-block; padding: 1px 6px; border-radius: 8px;
  background: var(--surface2); border: 1px solid var(--border); margin-right: 4px;
}
</style>
</head>
<body>
  <div class="header">
    <div class="tabs-primary">
      <div class="tab-primary active" data-panel="3d">3D</div>
      <div class="tab-primary" data-panel="pcb">PCB</div>
      <div class="tab-primary" data-panel="schematic">Schematic</div>
      <div class="tab-primary" data-panel="parts" data-tooltip="Parts inspector&#10;Verify each component's footprint and 3D body are correctly tied together. Click any part from the list to see it in isolation with pad positions, hole positions, body centroid, and the invariant distances (body-to-hole) at every rotation. If a root component's footprint and cadModel are misaligned, you'll see it here at a glance — no need to reason about rotation math.">Parts</div>
    </div>
    <div class="spacer"></div>
    <span class="status"><span class="pill" id="glb-pill">GLB: …</span></span>
    <button class="btn-rerun" id="btn-rerun" title="Re-run tscircuit build (runs autorouter). Hold Shift to clear manual-edits.json first.">⟳ Re-run autorouter</button>
    <div class="tabs-secondary">
      <div class="tab-secondary" data-panel="tsci-live" title="Raw tscircuit RunFrame — escape hatch for features we haven't wrapped">tsci live</div>
    </div>
  </div>

  <div id="panel-3d" class="panel active">
    <div id="viewer-wrap"></div>
    <div id="viewer-loading">Loading Adom 3D viewer…</div>

    <!-- Floating toolbar: drag the ⋮⋮ grip to move -->
    <div id="toolbar">
      <span class="tb-grip" data-tooltip="Drag this grip to move the toolbar anywhere over the 3D canvas. Hover any button for half a second to see what it does.">⋮⋮</span>
      <button id="tb-ground"    data-tooltip="Toggle ground plane&#10;The surface beneath the board that receives the contact shadow. It's rendered at ~35% opacity (65% transparent) on purpose so you can still see the bottom layer of the board, bottom-side silkscreen, and bottom-mount components straight through it. Turn off if the ground plane is distracting or if it&#39;s getting in the way of a clean screenshot." class="active">▭</button>
      <button id="tb-wireframe" data-tooltip="Toggle wireframe&#10;Show every mesh as wireframe instead of solid. Useful for seeing through chip bodies to what's routed beneath — traces, test points, and pads that would otherwise be hidden under a QFP or SOIC package.">⎚</button>
      <button id="tb-axes"      data-tooltip="Toggle origin axes&#10;Show/hide a world-origin 3-axis gizmo on the board surface: red = +X, green = +Y, blue = +Z. Useful when you're probing pcbX / pcbY positions against the 3D view or need to double-check which way a component is facing.">
        <!-- 3-axis gizmo icon: three arrows from origin. Monochrome SVG per brand rule. -->
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true">
          <line x1="12" y1="12" x2="20" y2="12"/>
          <line x1="12" y1="12" x2="12" y2="4"/>
          <line x1="12" y1="12" x2="5" y2="19"/>
          <path d="M20 12 L17 10.5 M20 12 L17 13.5" />
          <path d="M12 4 L10.5 7 M12 4 L13.5 7" />
          <path d="M5 19 L7.2 17.2 M5 19 L6.8 16.2" />
        </svg>
      </button>
      <div class="tb-sep"></div>
      <button id="tb-measure"   data-tooltip="Measure tool&#10;Opens the Measure HUD. Click two points on the board to get the distance between them (in mm by default). Supports vertex snap, precision control, and secondary units (inches / mils).">
        <!-- Ruler: tick-marked straightedge — custom monochrome SVG per brand rule (no emoji). -->
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true">
          <rect x="2.5" y="8" width="19" height="8" rx="1"/>
          <line x1="5.5"  y1="8" x2="5.5"  y2="12.5"/>
          <line x1="8.5"  y1="8" x2="8.5"  y2="11"/>
          <line x1="11.5" y1="8" x2="11.5" y2="12.5"/>
          <line x1="14.5" y1="8" x2="14.5" y2="11"/>
          <line x1="17.5" y1="8" x2="17.5" y2="12.5"/>
          <line x1="20.5" y1="8" x2="20.5" y2="11"/>
        </svg>
      </button>
      <button id="tb-inspect"   data-tooltip="Inspect tool&#10;Hover any feature on the board to get a labelled info card explaining what it is: chip refdes + footprint + pin count, pad size + net + routed-state, machine contact role (mechanical-only vs wired), board thickness + layer count, silkscreen text, and more. Click the card to pin it so you can move the mouse away; Esc unpins.&#10;&#10;Pulls every field from circuit.json. Fields tscircuit doesn't emit today (surface finish, solder-mask color, MPN, datasheet) show as &#8212; with a hover-tooltip explaining why.">
        <!-- Magnifier with data-point inside: "inspect one feature" — custom monochrome SVG. -->
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true">
          <circle cx="10" cy="10" r="6"/>
          <circle cx="10" cy="10" r="1.5" fill="currentColor" stroke="none"/>
          <line x1="14.5" y1="14.5" x2="20" y2="20"/>
        </svg>
      </button>
      <div class="tb-sep"></div>
      <button id="tb-walkthrough" data-tooltip="Walkthrough Demo&#10;Guided tour of the board: starts with the biggest / most-important chips, then works down to testpoints and silkscreen. Camera auto-pans + zooms to each feature as a narration card explains it. You can still orbit the 3D view during the tour (that auto-pauses it); press Pause, or Space, to hold on a step to read longer.">
        <!-- Clapperboard with play arrow: "start a guided demo" — custom monochrome SVG. -->
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true">
          <path d="M3 8.5l18 -1.8 0 2.6 -18 1.8z"/>
          <path d="M6.5 6.4l1.1 2.1M10.5 6.0l1.1 2.1M14.5 5.6l1.1 2.1"/>
          <rect x="3" y="11" width="18" height="9" rx="1"/>
          <path d="M10 13.5l5 2.5 -5 2.5z" fill="currentColor" stroke="none"/>
        </svg>
      </button>
      <div class="tb-sep"></div>
      <button id="tb-comp"      data-tooltip="Components panel&#10;Show or hide the floating components panel (top-right). The panel groups every chip / passive / testpoint / contact by kind; click a group's ● to hide all items in that group, or expand a group to toggle individual components. Hide a chip like U1 to see the traces routed underneath it.">◨</button>
      <button id="tb-trace"     data-tooltip="Nets / traces panel&#10;Opens the Nets panel and lifts every routed trace off the baked board surface as live, pickable 3D wires. Click a net to highlight every segment on the board, fly the camera to its bounding box, and tint the chip pads it touches. Nets are grouped intelligently — Power (VIN/VCC/3V3/12V), Ground (GND/AGND), Signal (everything else) — exactly like the Components panel groups chips by kind. Heavy mode: trace meshes are only generated while the panel is open; closing the panel reverts to the lightweight baked surface.">⌇</button>
    </div>

    <!-- Walkthrough narration HUD: appears when the tour is active. Drag
         the header to move; ⏸ Pause, ◀ Back, Next ▶, × Close buttons. -->
    <div id="walkthrough-bar" style="display:none;">
      <div class="wt-header" id="wt-header" data-tooltip="Walkthrough HUD&#10;Drag this header to move the card anywhere. Pause (Space) to stop the step timer. Back / Next (arrow keys) steps manually. Close (Esc) ends the tour.">
        <span class="wt-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" width="14" height="14"><path d="M3 8.5l18 -1.8 0 2.6 -18 1.8z"/><path d="M6.5 6.4l1.1 2.1M10.5 6.0l1.1 2.1M14.5 5.6l1.1 2.1"/><rect x="3" y="11" width="18" height="9" rx="1"/><path d="M10 13.5l5 2.5 -5 2.5z" fill="currentColor" stroke="none"/></svg></span>
        <span class="wt-title-bar">Walkthrough</span>
        <span class="wt-progress" id="wt-progress">0 / 0</span>
        <span class="wt-spacer"></span>
        <span class="wt-pause" id="wt-narration-toggle" data-tooltip="Narration on/off&#10;Speaks each step's text aloud (en-US-AndrewNeural). When on, each step waits for the spoken clip to finish before auto-advancing. Persists across reloads.">🔊</span>
        <span class="wt-pause" id="wt-pause" data-tooltip="Pause / Resume (Space)&#10;Pauses the step timer and the camera animation. The walkthrough won't advance until you press again or click Next. Any time you orbit the 3D view it auto-pauses so you can look around freely.">⏸</span>
        <span class="wt-close" id="wt-close" data-tooltip="Close walkthrough (Esc)&#10;Ends the tour and restores normal viewing. The camera stays wherever it is; you can always click 🎬 on the toolbar to start again.">×</span>
      </div>
      <div class="wt-body">
        <div class="wt-step-title" id="wt-step-title"></div>
        <div class="wt-step-text" id="wt-step-text"></div>
      </div>
      <div class="wt-footer">
        <button class="wt-nav" id="wt-back" data-tooltip="Previous step (&larr;)">&#x25C0; Back</button>
        <span class="wt-paused-badge" id="wt-paused-badge" style="display:none;">&#x23F8; Paused</span>
        <span class="wt-spacer"></span>
        <button class="wt-nav" id="wt-next" data-tooltip="Next step (&rarr;)">Next &#x25B6;</button>
      </div>
    </div>

    <!-- Floating component panel: drag header to move, click — to collapse,
         × to hide. Re-open via the ◨ button on the main toolbar.
         Hidden by default on page load — user opens it explicitly. -->
    <div id="comp-overlay" style="display: none;">
      <div class="co-header" id="comp-header" data-tooltip="Components panel&#10;Lists every chip, passive, testpoint, and machine contact the 3D viewer discovered in the current GLB (cross-referenced with circuit.json). Click and drag this header to move the panel.">
        <span>COMPONENTS</span>
        <span class="co-actions">
          <a id="co-all"      data-tooltip="Show all&#10;Make every component visible. Undoes any individual or group hides you've applied. Useful after you've hidden a bunch of chips or testpoints and want to reset." data-tooltip-align="right">all on</a>
          <a id="co-collapse" data-tooltip="Collapse / expand&#10;Collapses the panel down to just this header strip. Click again to expand. Use when you want to keep the panel open but need canvas space." data-tooltip-align="right">—</a>
          <a id="co-hide"     data-tooltip="Hide panel&#10;Hides the whole component panel. Click the ◨ button on the main toolbar to bring it back. Your component-visibility state is preserved." data-tooltip-align="right">×</a>
        </span>
      </div>
      <div class="co-silkmode" id="co-silkmode">
        <span class="co-silkmode-label">Board Surface</span>
        <button class="co-silk-btn active" data-silksource="baked" data-tooltip="Board surface texture on/off&#10;tscircuit bakes every visible surface layer into a single texture per board face and embeds it in the 3d.glb file: the green solder mask, exposed copper pads (the gold that chips solder to), the annular rings around plated vias / through-holes, AND the white silkscreen text &#8212; all composited into one 1024&#215;1024 PNG per face. Toggle this off to see the bare board geometry without any surface artwork.&#10;&#10;Text, pads, or rings too blurry? Ask your AI: &#8220;bump the board surface resolution to 4096&#8221; (or 2048 / 8192). That restarts adom-tsci with `--texture-resolution 4096` and re-bakes the GLB at higher resolution &#8212; every layer in the bucket gets sharper at once. Recommended: 1024 for small boards (&lt; 60 mm), 2048 for 60&#8211;100 mm, 4096 for 100&#8211;200 mm, 8192 for larger than that (slower first-load, sharper everything).">Baked</button>
      </div>
      <div class="co-list" id="co-list"></div>
    </div>

    <!-- Nets / traces panel: opening this lifts every routed trace off
         the baked board surface as live 3D meshes (heavy mode); closing
         reverts to the lightweight baked-only surface. Mirrors the
         Components panel's drag header + group-by-kind layout. -->
    <div id="trace-overlay" style="display: none;">
      <div class="co-header" id="trace-header" data-tooltip="Nets panel&#10;Lists every electrical net the autorouter routed on the board. Power / Ground / Signal groups click to highlight every net in that group; expand a group and click an individual net to highlight just that one. Click and drag this header to move the panel.">
        <span>NETS</span>
        <span class="co-actions">
          <a id="trace-glow" class="active" data-tooltip="Auto-glow x-ray&#10;Auto-selects the largest power/ground net the moment trace mode opens, so the substrate-transparent + HighlightLayer + GlowLayer pulse view is visible immediately — that gorgeous x-ray look the walkthrough demos. Click to toggle off and just see the lifted traces without auto-selection. Click again to re-arm." data-tooltip-align="right">✦</a>
          <a id="trace-clear"    data-tooltip="Clear highlight&#10;Removes any net selection so all rendered traces return to the same neutral color. Useful before picking a different net to compare." data-tooltip-align="right">clear</a>
          <a id="trace-collapse" data-tooltip="Collapse / expand&#10;Collapses the panel down to just this header strip. Click again to expand. Use when you want to keep the panel open but need canvas space." data-tooltip-align="right">—</a>
          <a id="trace-hide"     data-tooltip="Hide panel&#10;Closes the panel and disposes the rendered trace meshes (returns to the lightweight baked board surface). Click the ⌇ button on the main toolbar to bring it back." data-tooltip-align="right">×</a>
        </span>
      </div>
      <div class="co-list" id="trace-list"></div>
    </div>

    <!-- Measure HUD — Fusion 360 layout -->
    <div id="measure-bar">
      <div class="mb-header" id="mb-header" data-tooltip="Measure HUD&#10;Click and drag this header to move the HUD anywhere. Everything below shows what you're measuring and how it's being computed. Hover any control for a description.">
        <span class="mb-minus" id="mb-minus" data-tooltip="Collapse / expand&#10;Collapse the HUD down to just this header strip. Click again to expand. Useful when you need more canvas space but still want to leave the tool on.">−</span>
        <span>MEASURE</span>
        <span class="mb-dock" id="mb-dock" data-tooltip="Dock (not yet implemented)&#10;Will snap the HUD to a screen edge in a future version. For now, just drag the header to reposition freely." data-tooltip-align="right">»</span>
      </div>
      <div class="mb-body">
        <label data-tooltip="Selection filter&#10;Controls what kind of geometry you select when you click the model. The three icons (left→right) are Point, Edge, and Body. Only Point is fully wired up today — Edge and Body fall back to point picks and show a message.">Selection Filter</label>
        <div class="mb-filter">
          <button class="active" data-filter="smart" data-tooltip="Smart (default)&#10;PCB-aware picking. As you hover, the nearest real PCB feature lights up:&#10;  • Pad — SMT pad outline (reports W×H, layer)&#10;  • Hole — plated drill hole (reports Ø)&#10;  • Chip — the whole component body (reports W×D×H, name)&#10;  • Pin — individual chip pin (reports pin ref + dims)&#10;&#10;Click to commit. Much more useful than Point/Edge/Body for real PCB measurements: pad-to-pad gaps, hole-to-hole spacing, chip-pin-to-pad clearance, chip-to-chip spacing. Feature data comes from circuit.json so it's sub-millimetre accurate.">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="3.5" fill="currentColor"/><path d="M12 2 V5 M12 19 V22 M2 12 H5 M19 12 H22 M5 5 L7 7 M17 17 L19 19 M5 19 L7 17 M17 7 L19 5" stroke-width="1.2"/></svg>
          </button>
          <button data-filter="point" data-tooltip="Point&#10;Generic mesh point pick. Click anywhere on the model and the click anchors a point at the nearest mesh vertex. Escape hatch for cases where Smart doesn't cover what you want to measure. For normal PCB work, stick with Smart.">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 7 L12 3 L20 7 L12 11 Z" fill="currentColor" opacity="0.85"/><path d="M4 7 L4 17 L12 21 L12 11 Z M12 11 L12 21 L20 17 L20 7 Z" /></svg>
          </button>
          <button data-filter="edge" data-tooltip="Edge&#10;Click near an edge of a component or mesh to select the whole edge. As you hover, the nearest edge lights up cyan. Selection reports Length. Useful for: chip-pin length, board-edge length, pad dimensions.&#10;&#10;Today this picks the nearest triangle edge in the mesh — smart-mode (coming next) will promote this to real PCB edges (pad outlines, trace widths, hole rings).">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 7 L4 17 L12 21 L20 17 L20 7 L12 3 Z" fill="currentColor" opacity="0.85"/><path d="M4 7 L12 11 L20 7 M12 11 L12 21" stroke="#e4e7eb"/></svg>
          </button>
          <button data-filter="body" data-tooltip="Body&#10;Click a component to select its whole body — the hovered mesh lights up cyan. Selection reports Width / Depth / Height from the bounding box. Useful for: chip package size, 'is this SOIC or SSOP?', chip-to-chip spacing.">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 8 L3 16 L9 19 L9 11 Z M9 11 L9 19 L15 16 L15 8 Z M9 11 L15 8 L9 5 L3 8 Z" fill="currentColor" opacity="0.85"/><path d="M15 8 L15 16 L21 13 L21 5 Z M15 8 L21 5 L15 2 L9 5 Z" fill="currentColor" opacity="0.55"/></svg>
          </button>
        </div>

        <label data-tooltip="Precision&#10;How many decimal places to show for every measured value (distances, coordinates, angles). 0.123 = three decimals (e.g. &quot;28.751 mm&quot;). Use higher precision when you're comparing features millimetres apart; lower when you just need a rough size.">Precision</label>
        <select id="mb-precision" data-tooltip="Precision&#10;Decimal places for every numeric readout below. Setting this changes ALREADY-taken measurements immediately (no re-click needed).">
          <option value="1">0.1</option>
          <option value="2">0.12</option>
          <option value="3" selected>0.123</option>
          <option value="4">0.1234</option>
        </select>

        <label data-tooltip="Secondary units&#10;Show every measurement in a second unit system at the same time. The primary is always millimetres. None = only mm. Inches = also show inches in brackets, e.g. &quot;25.4 mm (1.0000 in)&quot;. mils = thousandths-of-an-inch, the standard unit for PCB trace widths and clearances.">Secondary Units</label>
        <select id="mb-secondary" data-tooltip="Secondary units&#10;Adds a second unit conversion in brackets after every measurement. Defaults to mils (thousandths of an inch) because that's the PCB-industry standard in North America — many EE datasheets, trace-width specs, and clearance requirements are written in mils even when everything else is metric.">
          <option value="none">None</option>
          <option value="in">Inches</option>
          <option value="mil" selected>mils</option>
        </select>

        <label data-tooltip="Clear selection&#10;Removes all current selections, markers, and measurement results — but leaves the Measure tool active so you can pick new points right away. To also close the HUD entirely, use the Close button at the bottom.">Clear Selection</label>
        <button class="mb-clear" id="mb-clear" data-tooltip="Clear selection&#10;Reset to zero selections. Current Distance / Angle / Selection 1,2 sections disappear. The tool stays on.">↶</button>

        <label data-tooltip="Show snap points&#10;When enabled, every snappable vertex within cursor range lights up as a small dot so you can AIM precisely before clicking. Very helpful on dense boards where you want to hit a specific chip pin corner or a testpoint centre.&#10;&#10;Currently stubbed (toast only) — the visualisation is being implemented.">Show Snap Points</label>
        <input type="checkbox" class="mb-snap-check" id="mb-snap-check" data-tooltip="Show snap points&#10;Turns on the live vertex-dot visualisation near the cursor. Off by default to reduce visual clutter."/>
      </div>

      <div class="mb-results">
        <span class="mb-k" data-tooltip="What you're measuring&#10;Plain-English summary of Selection 1 → Selection 2 — e.g. 'board top → board bottom', 'U1 → C3', 'U2.pad5 → via TP_SCK'. Lets you read at a glance whether you picked the things you meant.">Measuring</span>
        <span class="mb-v" id="mb-what" style="font-family: 'Satoshi', sans-serif; font-size: 11.5px; color: #c7ccd3;">—</span>
        <span class="mb-k mb-k-dx" data-tooltip="Delta X&#10;Signed X-axis offset from Selection 1 to Selection 2, in board coordinates (mm). Positive = Selection 2 is to the right of Selection 1 in board-space. This is what you feed a probe workcell as the X move from point 1 to point 2.">&#916;X</span>
        <span class="mb-v mb-v-dx" id="mb-dx">—</span>
        <span class="mb-k mb-k-dy" data-tooltip="Delta Y&#10;Signed Y-axis offset from Selection 1 to Selection 2, in board coordinates (mm). Positive = Selection 2 is further up the board from Selection 1. Feed this to a probe workcell as the Y move from point 1 to point 2.">&#916;Y</span>
        <span class="mb-v mb-v-dy" id="mb-dy">—</span>
        <span class="mb-k mb-k-dz" data-tooltip="Delta Z&#10;Signed Z-axis offset from Selection 1 to Selection 2, in board coordinates (mm). Positive = Selection 2 is higher above the board than Selection 1. Hidden when the two selections share the same height.">&#916;Z</span>
        <span class="mb-v mb-v-dz" id="mb-dz">—</span>
        <span class="mb-k" data-tooltip="Distance&#10;Straight-line Euclidean distance between Selection 1 and Selection 2 in millimetres \u2014 &#8730;(&#916;X&#178; + &#916;Y&#178; + &#916;Z&#178;). Useful for free-space measurements; for probe motion planning the &#916;X / &#916;Y above are more actionable.">Distance</span>
        <span class="mb-v primary" id="mb-distance">—</span>
        <span class="mb-k" data-tooltip="Angle&#10;Angle between the two selections in degrees. For point-to-point this is always 0 (no direction). For edge-to-edge it's the angle between the two edge directions; for face-to-face it's the dihedral angle.">Angle</span>
        <span class="mb-v" id="mb-angle">—</span>
      </div>

      <div class="mb-selections" id="mb-selections"></div>

      <div class="mb-footer">
        <span class="mb-info" data-tooltip="How the measure tool works&#10;Click the 📏 button on the main toolbar to open this HUD. Then:&#10;1. Pick a selection filter (Point / Edge / Body)&#10;2. Click on the model — a &quot;1&quot; tag appears where you clicked&#10;3. Click a second spot — &quot;2&quot; tag appears, Distance/Angle computed&#10;4. Drag the header to move the HUD; click × on a Selection to re-pick that one only; Clear wipes both.&#10;Measurements are in mm by default; turn on Secondary Units to see inches/mils too.">ⓘ</span>
        <span class="mb-spacer"></span>
        <button class="mb-close" id="mb-close" data-tooltip="Close measure&#10;Turn off the Measure tool entirely. HUD disappears, selections cleared, the 📏 button on the main toolbar goes back to inactive. Click it again to re-open the tool." data-tooltip-align="right">Close</button>
      </div>
    </div>

    <!-- 3D viewport selection-number labels (positioned in JS each frame) -->
    <div class="mb-hud-label" id="mb-hud-1" style="display:none;">1</div>
    <div class="mb-hud-label two" id="mb-hud-2" style="display:none;">2</div>
    <div class="mb-hud-label" id="mb-hud-d" style="display:none; border-color: transparent; background: transparent; color: #e4e7eb; font-size: 13px;"></div>
    <!-- XYZ delta axis labels (projected to the midpoint of each dogleg leg) -->
    <div class="mb-hud-label axis-x" id="mb-hud-dx" style="display:none;"></div>
    <div class="mb-hud-label axis-y" id="mb-hud-dy" style="display:none;"></div>
    <div class="mb-hud-label axis-z" id="mb-hud-dz" style="display:none;"></div>

    <div id="measure-label"></div>
    <div id="toast"></div>
  </div>

  <div id="panel-pcb" class="panel">
    <div class="svg-viewport" id="pcb-viewport"><div class="svg-stage" id="pcb-stage"></div></div>
  </div>
  <div id="panel-schematic" class="panel">
    <div class="svg-viewport" id="schematic-viewport"><div class="svg-stage" id="schematic-stage"></div></div>
  </div>
  <div id="panel-tsci-live" class="panel">
    <iframe src="runframe/" title="tscircuit RunFrame"></iframe>
  </div>

  <!-- Parts inspector: verify each component's footprint+cadModel are tied together.
       Left column lists all components from circuit.json; clicking one renders it
       in isolation on the right with 4 rotation variants + invariant distances. -->
  <div id="panel-parts" class="panel">
    <div id="parts-layout">
      <div id="parts-list" style="width:280px;border-right:1px solid var(--border);overflow:auto;padding:8px 0;">
        <div style="padding:8px 12px;color:var(--text-dim);font-size:11px;text-transform:uppercase;letter-spacing:0.04em;">Components</div>
        <div id="parts-list-items"></div>
      </div>
      <div id="parts-detail" style="flex:1;overflow:auto;padding:16px;">
        <div id="parts-detail-empty" style="color:var(--text-dim);padding:40px;text-align:center;font-size:13px;">
          Click any component on the left to inspect its footprint + 3D body geometry.<br/>
          The invariant check (body-to-hole distance) will flag any mismatch that would
          otherwise require a dozen screenshots to catch.
        </div>
        <div id="parts-detail-body" style="display:none;"></div>
      </div>
    </div>
  </div>

<script>
// ─── Bridge-failure banner ─────────────────────────────────────────
// Bug-paid-for-in-blood: the shell used to swallow every fetch() with
// `catch {}`. When the Hydrogen webview iframe mounted us in an opaque
// origin and CORS on the slingshot was missing, every fetch failed
// silently and the viewer hung on "Loading Adom 3D viewer…" with no
// hint why. An hour of debugging later — this banner.
//
// Rule: any fetch to the slingshot gets wrapped in bridgeFetch(). On
// failure, paint a red sticky bar naming the URL + the JS error. The
// user sees the problem in 1 second, not 60 minutes.
(() => {
  let banner = null;
  window.bridgeFail = function(url, err) {
    try {
      const msg = `${url}: ${err && err.message || err}`;
      (window._fetchFailures = window._fetchFailures || []).push(msg);
      if (!banner) {
        banner = document.createElement('div');
        banner.id = 'bridge-fail-banner';
        banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:999999;background:#7a1414;color:#ffd;font:600 12px/1.4 system-ui,sans-serif;padding:8px 14px;border-bottom:2px solid #ff5252;white-space:pre-wrap;max-height:40vh;overflow:auto;cursor:pointer;';
        banner.title = 'Click to dismiss. First offender is the signal — the rest are noise.';
        banner.onclick = () => banner.remove();
        (document.body || document.documentElement).appendChild(banner);
      }
      banner.textContent = `Slingshot bridge offline — fetch failures (${window._fetchFailures.length}):\n` + window._fetchFailures.slice(-6).map(s => '  • ' + s).join('\n') + '\nCORS missing? Origin: ' + (location.origin || '(null)') + '  Path: ' + location.pathname;
    } catch {}
  };
  window.bridgeFetch = async function(url, opts) {
    try {
      const r = await fetch(url, opts);
      return r;
    } catch (e) {
      window.bridgeFail(url, e);
      throw e;
    }
  };
})();

// ─── Console forwarding ─────────────────────────────────────────────
(() => {
  const buf = []; let flushing = null;
  function push(level, args) {
    try {
      const text = args.map(a => a && a.stack ? String(a.stack) : (typeof a === 'string' ? a : (() => { try { return JSON.stringify(a); } catch { return String(a); } })())).join(' ');
      buf.push({ level, text });
      if (!flushing) flushing = setTimeout(flush, 500);
    } catch {}
  }
  async function flush() {
    flushing = null;
    if (!buf.length) return;
    const batch = buf.splice(0, buf.length);
    try { await bridgeFetch('console', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ messages: batch }) }); } catch {}
  }
  const orig = { log: console.log.bind(console), info: console.info.bind(console), warn: console.warn.bind(console), error: console.error.bind(console) };
  console.log = (...a) => { push('log', a); orig.log(...a); };
  console.info = (...a) => { push('log', a); orig.info(...a); };
  console.warn = (...a) => { push('warn', a); orig.warn(...a); };
  console.error = (...a) => { push('error', a); orig.error(...a); };
  window.addEventListener('error', e => push('error', [`${e.message} @ ${e.filename}:${e.lineno}:${e.colno}`, e.error]));
  window.addEventListener('unhandledrejection', e => push('error', ['unhandledrejection:', e.reason]));
})();

// ─── Eval channel ────────────────────────────────────────────────────
setInterval(async () => {
  try {
    const r = await bridgeFetch('eval/pending');
    if (r.status === 204 || !r.ok) return;
    const job = await r.json();
    if (!job || !job.id) return;
    let result;
    try {
      const fn = new (async function(){}).constructor(job.code);
      result = await fn();
      if (result && typeof result === 'object' && 'then' in result) result = await result;
    } catch (e) {
      result = { error: String(e && e.message || e), stack: e && e.stack };
    }
    let safe; try { safe = JSON.parse(JSON.stringify(result ?? null)); } catch { safe = String(result); }
    await bridgeFetch('eval/' + job.id + '/result', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ result: safe }) });
  } catch {}
}, 500);

// ─── Tab switching ───────────────────────────────────────────────────
const panels = ['3d','pcb','schematic','parts','tsci-live'];
function switchTab(name) {
  document.querySelectorAll('.tab-primary, .tab-secondary').forEach(t =>
    t.classList.toggle('active', t.dataset.panel === name));
  panels.forEach(p => {
    const el = document.getElementById('panel-' + p);
    if (el) el.classList.toggle('active', p === name);
  });
  if (name === 'pcb' && !window._pcbLoaded) loadPcb();
  if (name === 'schematic' && !window._schematicLoaded) loadSchematic();
  if (name === '3d' && viewer && viewer.getEngine) {
    // Canvas has zero client size when hidden — force a resize tick.
    setTimeout(() => { try { viewer.getEngine().resize(); } catch {} }, 60);
  }
}
document.querySelectorAll('.tab-primary, .tab-secondary').forEach(t =>
  t.addEventListener('click', () => switchTab(t.dataset.panel)));

// ─── Adom 3D viewer init ─────────────────────────────────────────────
var viewer = null;
var lastGlbMtime = 0;
const viewerLoadingEl = document.getElementById('viewer-loading');

function toast(text) {
  const t = document.getElementById('toast');
  t.textContent = text; t.classList.add('show');
  clearTimeout(window._toastT);
  window._toastT = setTimeout(() => t.classList.remove('show'), 1400);
}
function setGlbPill(text) {
  const el = document.getElementById('glb-pill');
  if (el) el.textContent = text;
}

function loadAlphabet() {
  return new Promise((resolve) => {
    if (window.TscircuitAlphabet) { resolve(); return; }
    const s = document.createElement('script');
    s.src = 'js/tscircuit-alphabet.js';
    s.onload = () => resolve();
    s.onerror = () => resolve();  // non-fatal — silkscreen text just won't render
    document.head.appendChild(s);
  });
}

function loadAdom3D() {
  return new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = 'js/adom-3d-viewer.min.js';
    s.onload = () => resolve();
    s.onerror = () => reject(new Error('Failed to load adom-3d-viewer.min.js'));
    document.head.appendChild(s);
  });
}

async function initViewer() {
  try {
    await loadAdom3D();
    await loadAlphabet();
    // Note: there used to be a `loadBabylonLoaders()` call here that
    // tried to inject Babylon's GLB SceneLoader so /chip-glb/<refdes>.glb
    // could load real KiCad 3D models. Every variant failed because
    // adom-3d-viewer ships a trimmed BABYLON bundle that's missing
    // dependencies the loader UMD needs at module-init time
    // (PBRMaterial, Animation enums, RegisterSceneLoaderPlugin,
    // _DefaultCdnUrl, …). We've moved to footprint-aware primitive
    // recipes (CHIP_SHAPES) which look correct without needing the
    // loader. If the viewer ever exposes `viewer.loadAuxGlb(url, opts)`,
    // re-enable real-GLB loading via that — not via SceneLoader.
    const wrap = document.getElementById('viewer-wrap');
    viewer = window.Adom3DViewer.init(wrap, {
      zUp: true,
      showViewCube: true,
      showGround: true,
      environmentUrl: 'js/environmentSpecular.env',
    });
    window.viewer = viewer;  // expose for eval/ bridge
    // ── Rotation-center sphere: only show on user-initiated orbit ──
    // The adom-3d-viewer draws an `arcRotateIndicatorSphere` at the
    // camera target whenever `detectAndShowRotationSphere()` (called
    // from onBeforeRenderObservable) decides the camera is rotating.
    // That's correct for a user dragging with the mouse — the sphere
    // says "this is your orbit center." But it's wrong for programmatic
    // moves (tour, view preset, camera API, walkthrough): the viewer
    // didn't rotate themselves, the CLI did, so popping the sphere is
    // misleading and clutters demo recordings.
    //
    // Gate it on user pointer state: any rotation that starts outside
    // a pointerdown-on-canvas window doesn't get the sphere.
    try {
      const cam = viewer.getCamera && viewer.getCamera();
      const canvas = document.querySelector('canvas');
      let userInteracting = false;
      let lastPointerUp = 0;
      if (canvas) {
        canvas.addEventListener('pointerdown', () => { userInteracting = true; }, true);
        canvas.addEventListener('pointerup', () => { userInteracting = false; lastPointerUp = Date.now(); }, true);
        canvas.addEventListener('pointerleave', () => { userInteracting = false; lastPointerUp = Date.now(); }, true);
      }
      window._adomTsciIsUserOrbit = () => userInteracting || (Date.now() - lastPointerUp) < 400;
      if (cam && typeof cam.detectAndShowRotationSphere === 'function') {
        const orig = cam.detectAndShowRotationSphere.bind(cam);
        cam.detectAndShowRotationSphere = function (...args) {
          if (!window._adomTsciIsUserOrbit()) {
            // Force-hide if some prior call left it visible.
            const scene = viewer.getScene && viewer.getScene();
            const sph = scene && scene.getMeshByName && scene.getMeshByName('arcRotateIndicatorSphere');
            if (sph) sph.setEnabled(false);
            return;
          }
          return orig.apply(this, args);
        };
      }
    } catch (e) { console.warn('[3d] could not gate rotation-sphere to user input:', e); }
    viewerLoadingEl.classList.add('hidden');
    loadCurrentGlb(true);
  } catch (e) {
    viewerLoadingEl.textContent = 'Viewer init failed: ' + e.message;
    console.error('[3d] viewer init failed', e);
  }
}

async function loadCurrentGlb(force) {
  const r = await fetch('glb/meta');
  if (!r.ok) { setGlbPill('GLB: missing'); return; }
  const meta = await r.json();
  if (!meta.mtime) { setGlbPill('GLB: not built'); return; }
  if (!force && meta.mtime === lastGlbMtime) return;
  lastGlbMtime = meta.mtime;
  setGlbPill('GLB: ' + new Date(meta.mtime).toLocaleTimeString());
  if (!viewer) return;
  try {
    viewer.clearScene();
    await viewer.loadModel('./3d.glb?t=' + meta.mtime);
    const B = window.Adom3DViewer.BABYLON;
    // ── CANONICAL Y-up-GLB → Z-up-scene transform ─────────────────
    // GLB spec is Y-up right-handed. Adom ecosystem is Z-up. When a
    // tscircuit (or any non-KiCad) GLB lands in an Adom-Z-up scene
    // unrotated, the board ends up on its edge / upside-down.
    // Fix: on __root__, scale by (-1, 1, -1) (det=+1, no winding
    // flip — safe for normal maps + text textures) combined with
    // rotate(π/2) around X. Result: board flat on XY, components up.
    // This is the same "Hqe" transform the main Adom 3D viewer and
    // basic-3d.html apply. Documented in adom-tscircuit-skill.
    applyGlbZUpTransform(viewer, 'tscircuit');
    // 65%-transparent ground plane so the bottom of the board stays
    // visible even when the camera is near horizontal. Adom3DViewer
    // internals reset the material alpha after frameModel/setView, so
    // we re-apply on a brief retry loop to make sure 0.35 wins.
    applyGroundTransparency();
    viewer.frameModel();
    setView('isometric');
    for (const ms of [50, 200, 600, 1500]) setTimeout(applyGroundTransparency, ms);
    // Silkscreen-sharpness: tscircuit bakes top + bottom layer colors
    // (copper, solder mask, silkscreen) into a single 1024\u00d71024 texture
    // per side. Default anisotropic filtering of 4 makes text fuzzy at
    // oblique angles; cranking to the GPU max (16 on most hardware) is
    // the biggest readable-text win without re-baking the GLB.
    const scene = viewer.getScene();
    const maxAniso = viewer.getEngine().getCaps().maxAnisotropy || 16;
    let bakedTexRes = null;
    for (const t of (scene.textures || [])) {
      if (!t || !t.url) continue;
      if (t.url.startsWith('data:glb') || (t.name && t.name.includes('Material'))) {
        t.anisotropicFilteringLevel = maxAniso;
        // Grab the actual baked-texture resolution so the HUD can
        // show "Baked \u2014 1024\u00d71024" and the user knows what
        // they're currently viewing.
        if (t.getSize) {
          const s = t.getSize();
          if (s && s.width && (!bakedTexRes || s.width > bakedTexRes)) bakedTexRes = s.width;
        }
      }
    }
    window._bakedTexRes = bakedTexRes;
    const bakedBtn = document.querySelector('.co-silk-btn[data-silksource="baked"]');
    if (bakedBtn && bakedTexRes) bakedBtn.textContent = 'Baked \u2014 ' + bakedTexRes + '\u00d7' + bakedTexRes;
    // Expose each GLB-loaded mesh to the shadow caster list
    const shadowGen = viewer.getShadowGenerator && viewer.getShadowGenerator();
    if (shadowGen) {
      const EXCLUDE = new Set(['shadowGround','__root__','BackgroundHelper','BackgroundSkybox','arcRotateIndicatorSphere']);
      scene.meshes.forEach(m => {
        if (!m || !m.name || EXCLUDE.has(m.name)) return;
        if (!m.getTotalVertices || m.getTotalVertices() === 0) return;
        try { shadowGen.addShadowCaster(m, true); } catch {}
      });
    }
    window._pcbLoaded = false; window._schematicLoaded = false;
    await enumerateComponents();
    // Vector silkscreen overlay was killing the framerate (~20K tubes +
    // 500 pad/hole meshes even after merging). Disabled by default \u2014
    // the baked 1024\u00d71024 texture tscircuit bakes into the GLB is
    // good enough for most purposes and costs essentially nothing.
    // Re-enable with `adom-tsci eval 'buildSilkscreenOverlays()'` if
    // you want to experiment.
    console.log('[3d] GLB loaded, components=' + componentMap.size);
  } catch (e) {
    console.error('[3d] GLB load failed', e);
    setGlbPill('GLB: load failed');
  }
}

// Auto-flip tooltips near the viewport edge. On mouseenter over any
// [data-tooltip] trigger, measure available space and set:
//   data-tooltip-v="top"          if the trigger is within 240px of the viewport bottom
//   data-tooltip-align="right"    if the trigger is within 340px of the viewport right edge
// Without this, tooltips for buttons at the bottom-right of a panel
// clip below the fold and look like "How the measure tool works" with everything
// else hidden.
// Single global tooltip element. Appended to <body> so it escapes every
// ancestor's overflow:hidden and stacking context. All tooltips drive
// this one element — on hover, JS reads data-tooltip from the innermost
// trigger, positions the global element, and fades it in.
const _globalTooltip = (() => {
  const el = document.createElement('div');
  el.id = 'global-tooltip';
  document.body.appendChild(el);
  return el;
})();
let _tipShowTimer = null;
let _tipHideTimer = null;
let _tipCurrentTrigger = null;
let _cursorX = 0, _cursorY = 0;
document.addEventListener('mousemove', (e) => { _cursorX = e.clientX; _cursorY = e.clientY; }, true);

function showGlobalTooltip(trigger) {
  if (!trigger || !trigger.matches || !trigger.matches('[data-tooltip]')) return;
  _tipCurrentTrigger = trigger;
  clearTimeout(_tipShowTimer);
  clearTimeout(_tipHideTimer);
  _tipShowTimer = setTimeout(() => {
    if (_tipCurrentTrigger !== trigger) return;
    _globalTooltip.textContent = trigger.getAttribute('data-tooltip') || '';
    // Tooltips always sit at the topmost layer — above every HUD. The
    // user's rule: a tooltip must never be clipped by any other panel.
    _globalTooltip.style.setProperty('--tip-z', '99999');
    _globalTooltip.classList.add('visible');
    // Force layout so we can measure
    _globalTooltip.style.setProperty('--tip-x', '0px');
    _globalTooltip.style.setProperty('--tip-y', '0px');
    const tipW = _globalTooltip.offsetWidth;
    const tipH = _globalTooltip.offsetHeight;
    const anchor = pickTooltipAnchorNearCursor(trigger, tipW, tipH, _cursorX, _cursorY);
    _globalTooltip.style.setProperty('--tip-x', anchor.x + 'px');
    _globalTooltip.style.setProperty('--tip-y', anchor.y + 'px');
    requestAnimationFrame(() => _globalTooltip.classList.add('shown'));
  }, 500);
}

function hideGlobalTooltip() {
  clearTimeout(_tipShowTimer);
  _tipCurrentTrigger = null;
  _globalTooltip.classList.remove('shown');
  _tipHideTimer = setTimeout(() => _globalTooltip.classList.remove('visible'), 180);
}

// Track the innermost hovered [data-tooltip] — bubbles up through ancestors,
// always picks the most-nested one (Rule 1f: one tooltip at a time, innermost
// wins). mouseout on the trigger or its descendants hides the tooltip.
document.addEventListener('mouseover', (e) => {
  const innermost = e.target && e.target.closest && e.target.closest('[data-tooltip]');
  if (innermost) showGlobalTooltip(innermost);
  else hideGlobalTooltip();
});
document.addEventListener('mouseout', (e) => {
  if (!e.relatedTarget || !e.relatedTarget.closest || !e.relatedTarget.closest('[data-tooltip]')) hideGlobalTooltip();
});
window.addEventListener('scroll', hideGlobalTooltip, true);
window.addEventListener('blur', hideGlobalTooltip);

// Pick a tooltip position near the cursor, offset enough that it never
// covers the cursor itself or the hovered trigger. The tooltip is the
// topmost layer (z=99999) so we don't need to avoid other HUDs — we only
// care about: (a) tooltip stays on-screen; (b) tooltip doesn't sit under
// the cursor or the trigger element.
function pickTooltipAnchorNearCursor(trigger, TIP_W, TIP_H, cx, cy) {
  const OFFSET = 14;
  const panel = trigger.closest('.panel') || document.documentElement;
  const pr = panel.getBoundingClientRect();
  const vL = Math.max(0, pr.left);
  const vT = Math.max(0, pr.top);
  const vR = Math.min(window.innerWidth, pr.right);
  const vB = Math.min(window.innerHeight, pr.bottom);
  const r = trigger.getBoundingClientRect();
  // 4 quadrant candidates relative to cursor.
  const candidates = [
    { x: cx + OFFSET,          y: cy + OFFSET,          q: 'br' },
    { x: cx - TIP_W - OFFSET,  y: cy + OFFSET,          q: 'bl' },
    { x: cx + OFFSET,          y: cy - TIP_H - OFFSET,  q: 'tr' },
    { x: cx - TIP_W - OFFSET,  y: cy - TIP_H - OFFSET,  q: 'tl' },
  ];
  function overlaps(a, b) {
    return !(a.x + TIP_W <= b.left || a.x >= b.right ||
             a.y + TIP_H <= b.top  || a.y >= b.bottom);
  }
  function score(c) {
    let bad = 0;
    // Off-panel penalty (linear per pixel beyond).
    if (c.x < vL + 4)           bad += (vL + 4 - c.x) * 4;
    if (c.x + TIP_W > vR - 4)   bad += (c.x + TIP_W - (vR - 4)) * 4;
    if (c.y < vT + 4)           bad += (vT + 4 - c.y) * 4;
    if (c.y + TIP_H > vB - 4)   bad += (c.y + TIP_H - (vB - 4)) * 4;
    // Trigger overlap (cardinal sin — cursor should see the hovered item).
    if (overlaps(c, r)) bad += 9999;
    return bad;
  }
  candidates.sort((a, b) => score(a) - score(b));
  const best = candidates[0];
  let x = best.x, y = best.y;
  // Clamp to panel. Prefer shift off cursor rather than over it.
  if (x < vL + 4) x = vL + 4;
  else if (x + TIP_W > vR - 4) x = (vR - 4) - TIP_W;
  if (y < vT + 4) y = vT + 4;
  else if (y + TIP_H > vB - 4) y = (vB - 4) - TIP_H;
  return { x, y };
}

// Choose the anchor (data-tooltip-v and data-tooltip-align) that (a) keeps
// the tooltip inside the viewport and (b) doesn't get clipped by any
// OTHER visible floating HUD. Without this, a tooltip on a toolbar button
// would render below-left by default, and the Measure HUD (layered above
// per rule 1d) would clip the left half — leaving an unreadable tooltip.
function pickTooltipAnchorFor(el, TIP_W, TIP_H) {
  const r = el.getBoundingClientRect();
  const panel = el.closest('.panel') || document.documentElement;
  const pr = panel.getBoundingClientRect();
  const vLeft = Math.max(0, pr.left);
  const vTop = Math.max(0, pr.top);
  const vRight = Math.min(window.innerWidth, pr.right);
  const vBot = Math.min(window.innerHeight, pr.bottom);
  const GAP = 8;
  const ownerHud = findAncestorHud(el);
  const ownerRect = ownerHud ? ownerHud.getBoundingClientRect() : null;
  const obstacleRects = [];
  for (const hud of _floatingHuds) {
    if (hud === ownerHud) continue;
    if (getComputedStyle(hud).display === 'none') continue;
    obstacleRects.push(hud.getBoundingClientRect());
  }
  function candidate(vAnchor, hAnchor) {
    let x, y;
    if (hAnchor === 'right') x = r.right - TIP_W;
    else                     x = r.left;
    if (vAnchor === 'top')   y = r.top - TIP_H - GAP;
    else                     y = r.bottom + GAP;
    return { x, y, width: TIP_W, height: TIP_H, vAnchor, hAnchor };
  }
  function overlaps(a, b) {
    return !(a.x + a.width <= b.left || a.x >= b.right ||
             a.y + a.height <= b.top || a.y >= b.bottom);
  }
  function overlapArea(c, b) {
    const ow = Math.max(0, Math.min(c.x + c.width, b.right) - Math.max(c.x, b.left));
    const oh = Math.max(0, Math.min(c.y + c.height, b.bottom) - Math.max(c.y, b.top));
    return ow * oh;
  }
  // The trigger itself — tooltip covering it disrupts "seeing the item
   // you're hovering", the cardinal tooltip sin.
  const triggerRect = r;
  function score(c) {
    let bad = 0;
    if (c.x < vLeft + 4)  bad += (vLeft + 4 - c.x) * 4;
    if (c.x + c.width > vRight - 4) bad += (c.x + c.width - (vRight - 4)) * 4;
    if (c.y < vTop + 4)   bad += (vTop + 4 - c.y) * 4;
    if (c.y + c.height > vBot - 4) bad += (c.y + c.height - (vBot - 4)) * 4;
    // Trigger overlap: the cardinal sin. MASSIVE penalty — tooltip that
    // covers the hovered element makes the user lose their bearings.
    bad += overlapArea(c, triggerRect) * 50;
    // Own-HUD overlap: less bad than hiding the trigger, but still not
    // great — tooltip covers the HUD's other controls.
    if (ownerRect) bad += overlapArea(c, ownerRect) * 0.5;
    // Other HUDs: heavy penalty — covers a different floating panel.
    for (const ob of obstacleRects) bad += overlapArea(c, ob) * 2;
    return bad;
  }
  const candidates = [
    candidate('bottom', 'left'),
    candidate('bottom', 'right'),
    candidate('top', 'left'),
    candidate('top', 'right'),
    // Side anchors next to the trigger.
    { x: r.right + GAP,        y: r.top, width: TIP_W, height: TIP_H, vAnchor: 'side', hAnchor: 'right' },
    { x: r.left - TIP_W - GAP, y: r.top, width: TIP_W, height: TIP_H, vAnchor: 'side', hAnchor: 'left' },
  ];
  // OUTSIDE-OWNER-HUD anchors. Critical for dense HUDs where no spot
  // inside the HUD is free. Place the tooltip just past the HUD's own
  // edge, aligned to the trigger's mid-axis so the pointer/eye can
  // still trace it back to the trigger.
  if (ownerRect) {
    const tipCenterX = Math.max(vLeft + 4, Math.min(vRight - TIP_W - 4, r.left + r.width / 2 - TIP_W / 2));
    const tipCenterY = Math.max(vTop + 4, Math.min(vBot - TIP_H - 4, r.top + r.height / 2 - TIP_H / 2));
    candidates.push(
      { x: ownerRect.right + GAP,          y: tipCenterY,   width: TIP_W, height: TIP_H, vAnchor: 'outside', hAnchor: 'right' },
      { x: ownerRect.left - TIP_W - GAP,   y: tipCenterY,   width: TIP_W, height: TIP_H, vAnchor: 'outside', hAnchor: 'left'  },
      { x: tipCenterX,                      y: ownerRect.bottom + GAP,              width: TIP_W, height: TIP_H, vAnchor: 'outside', hAnchor: 'bottom' },
      { x: tipCenterX,                      y: ownerRect.top - TIP_H - GAP,         width: TIP_W, height: TIP_H, vAnchor: 'outside', hAnchor: 'top' },
    );
  }
  candidates.sort((a, b) => score(a) - score(b));
  const best = candidates[0];
  // Clamp into visible panel, but shove AROUND the trigger rather than
  // OVER it. Goal: tooltip never covers the element the user is hovering.
  let x = best.x, y = best.y;
  if (y < vTop + 4) y = vTop + 4;
  else if (y + best.height > vBot - 4) y = (vBot - 4) - best.height;
  if (x < vLeft + 4) x = vLeft + 4;
  else if (x + best.width > vRight - 4) x = (vRight - 4) - best.width;
  // Post-clamp, if we landed on the trigger, push out the dominant axis.
  const covered = !(x + best.width <= r.left || x >= r.right ||
                    y + best.height <= r.top || y >= r.bottom);
  if (covered) {
    const pushDown = r.bottom + GAP;
    const pushUp   = r.top - best.height - GAP;
    const pushR    = r.right + GAP;
    const pushL    = r.left - best.width - GAP;
    const choices = [
      { x, y: pushDown },
      { x, y: pushUp },
      { x: pushR, y },
      { x: pushL, y },
    ].filter(c => c.x >= vLeft + 4 && c.x + best.width <= vRight - 4
                && c.y >= vTop + 4 && c.y + best.height <= vBot - 4);
    if (choices.length) { x = choices[0].x; y = choices[0].y; }
  }
  return { x, y };
}
function findAncestorHud(el) {
  let cur = el.parentElement;
  while (cur && cur !== document.body) {
    if (_floatingHuds.indexOf(cur) >= 0) return cur;
    cur = cur.parentElement;
  }
  return null;
}

// Find the z-index of the nearest positioned ancestor that has an
// explicit numeric z-index. This is the HUD (or toolbar) that owns the
// tooltip trigger — its tooltip should live one level above it, NOT
// above every other HUD in the scene.
function findAncestorStackZ(el) {
  let cur = el.parentElement;
  while (cur && cur !== document.body) {
    const cs = getComputedStyle(cur);
    if (cs.position !== 'static') {
      const z = parseInt(cs.zIndex, 10);
      if (Number.isFinite(z)) return z;
    }
    cur = cur.parentElement;
  }
  return 1;
}

// Innermost-wins is now handled by showGlobalTooltip's closest() lookup —
// only the innermost [data-tooltip] trigger under the cursor drives the
// single global tooltip element. No per-element suppress attributes needed.

// Helper: make `el` draggable when the user grabs `handleEl`. Uses the
// delta-from-mousedown approach so the point under the cursor stays
// under the cursor regardless of the offset-parent's viewport position.
// Bug we're avoiding: `style.left/top` is relative to the offset-parent;
// `e.clientX/Y` is viewport-relative. Subtracting a viewport offset from
// a viewport coordinate and assigning it to style.top makes the element
// jump up by the offset-parent's viewport Y (~42px for a panel under the
// tab header).
function makeDraggable(el, handleEl, opts) {
  const ignoreSelector = (opts && opts.ignoreSelector) || null;
  handleEl.addEventListener('mousedown', (e) => {
    if (ignoreSelector && e.target.closest(ignoreSelector)) return;
    const rect = el.getBoundingClientRect();
    const startX = e.clientX, startY = e.clientY;
    const startLeft = rect.left, startTop = rect.top;
    const offsetParent = el.offsetParent || document.body;
    const opRect = offsetParent.getBoundingClientRect();
    el.style.transform = 'none';
    // Intentionally DO NOT clamp while dragging — the user may want to
    // park the HUD partially off-screen to get it out of the way.
    function onMove(ev) {
      const newLeftVp = startLeft + (ev.clientX - startX);
      const newTopVp  = startTop  + (ev.clientY - startY);
      el.style.left = (newLeftVp - opRect.left) + 'px';
      el.style.top  = (newTopVp  - opRect.top)  + 'px';
      el.style.right = 'auto';
    }
    function onUp() {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    }
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    e.preventDefault();
  });
}

// Register a HUD so it can be tracked for other responsive behaviour
// (content-size cap, internal scrolling). The user's drag position is
// always respected — we DON'T force it back on-screen, even partially,
// because parking-off-screen is a legitimate UX for getting a HUD out
// of the way.
const _floatingHuds = [];
function registerFloatingHud(el) {
  if (_floatingHuds.indexOf(el) >= 0) return;
  _floatingHuds.push(el);
}

// ─── Canonical Y-up-GLB → Z-up-scene transform ─────────────────────
// FIRST-CLASS helper: make sure any future loader (drag-drop, file
// picker, manifest URL, …) calls this after loadModel(). In Adom's
// Z-up world, a Y-up GLB like tscircuit's lands rotated wrong unless
// you apply this on __root__.
//
// Convention, per the main Adom 3D viewer and the gallia basic-3d.html:
//   - tscircuit / auto / other non-KiCad:  scale(-1,1,-1) + rot(π/2, X)
//   - kicad:  identity (KiCad GLBs are pre-oriented for Adom Z-up)
// `glbSource` matches the `?source=<foo>` convention Basic3dView uses.
function applyGlbZUpTransform(vw, glbSource) {
  const B = window.Adom3DViewer && window.Adom3DViewer.BABYLON;
  const scene = vw && vw.getScene && vw.getScene();
  if (!B || !scene) return;
  scene.meshes.forEach(m => {
    if (m.name !== '__root__') return;
    m.rotationQuaternion = null;
    if (glbSource === 'kicad') {
      m.scaling  = new B.Vector3( 1, 1,  1);
      m.rotation = new B.Vector3(0, 0, 0);
    } else {
      // det(scale)=+1 so no winding flip (normal maps + text stay correct).
      m.scaling  = new B.Vector3(-1, 1, -1);
      m.rotation = new B.Vector3(Math.PI / 2, 0, 0);
    }
    m.computeWorldMatrix(true);
  });
  scene.meshes.forEach(m => m.computeWorldMatrix(true));
}

// ─── Camera presets + toolbar ───────────────────────────────────────
// In Z-up ArcRotateCamera:
//   beta = 0       → looking down +Z (top view)
//   beta = π/2     → horizon (side elevations)
//   beta = π-0.01  → looking up +Z (bottom view)
//   alpha rotates around +Z
// These match the presets in the main Adom 3D viewer + basic-3d.html.
const CAMERA_VIEWS = {
  front:     { alpha:  Math.PI / 2, beta: Math.PI / 2 },
  back:      { alpha: -Math.PI / 2, beta: Math.PI / 2 },
  left:      { alpha:  Math.PI,     beta: Math.PI / 2 },
  right:     { alpha: 0,            beta: Math.PI / 2 },
  top:       { alpha:  Math.PI / 2, beta: 0.01 },
  bottom:    { alpha:  Math.PI / 2, beta: Math.PI - 0.01 },
  isometric: { alpha:  Math.PI / 4, beta: Math.PI / 3 },
  iso:       { alpha:  Math.PI / 4, beta: Math.PI / 3 },
};
let tourInterval = null;
let wireframeOn = false;
let groundOn = true;
let orthoOn = false;

function setView(preset) {
  const v = CAMERA_VIEWS[preset];
  const cam = viewer && viewer.getCamera && viewer.getCamera();
  if (!v || !cam) return;
  // Don't call frameModel() here — Adom3DViewer's frameModel mutates
  // the camera asynchronously over the next render tick, which would
  // override the alpha/beta we set immediately below. The camera's
  // radius is already correct from the initial frameModel() on load;
  // subsequent setView calls just change angle.
  cam.alpha = v.alpha;
  cam.beta = v.beta;
}
function setGround(on) {
  groundOn = on;
  // Adom3DViewer's setGroundVisible() has a side effect of re-framing
  // the camera (calls frameModel internally on some paths). Snapshot
  // the camera pose BEFORE toggling and restore AFTER so the user's
  // current view isn't disrupted by a ground flip.
  const cam = viewer && viewer.getCamera && viewer.getCamera();
  const saved = cam ? {
    alpha: cam.alpha, beta: cam.beta, radius: cam.radius,
    tx: cam.target ? cam.target.x : 0,
    ty: cam.target ? cam.target.y : 0,
    tz: cam.target ? cam.target.z : 0,
  } : null;
  if (viewer && viewer.setGroundVisible) viewer.setGroundVisible(on);
  if (cam && saved) {
    cam.alpha = saved.alpha;
    cam.beta = saved.beta;
    cam.radius = saved.radius;
    if (cam.target) { cam.target.x = saved.tx; cam.target.y = saved.ty; cam.target.z = saved.tz; }
  }
  document.getElementById('tb-ground').classList.toggle('active', on);
  // Adom3DViewer's ground toggle also resets material opacity; re-apply
  // 0.35 alpha so the ground stays 65%-transparent per earlier mandate.
  applyGroundTransparency();
}

function applyGroundTransparency() {
  const scene = viewer && viewer.getScene && viewer.getScene();
  if (!scene) return;
  const g = scene.getMeshByName('shadowGround');
  if (!g || !g.material) return;
  g.material.alpha = 0.35;
  g.material.hasAlpha = true;
  if (g.material.transparencyMode !== undefined) g.material.transparencyMode = 2;
}
function setWireframe(on) {
  wireframeOn = on;
  const scene = viewer && viewer.getScene && viewer.getScene();
  if (!scene) return;
  scene.meshes.forEach(m => {
    if (!m || !m.material) return;
    const apply = mat => { mat.wireframe = on; };
    if (Array.isArray(m.material)) m.material.forEach(apply); else apply(m.material);
  });
  document.getElementById('tb-wireframe').classList.toggle('active', on);
}

// ────── Origin axes gizmo ──────
// Three tubes at world origin on the board top surface:
//   +X red, +Y green, +Z blue. Length = boardDiag × 0.25 so it scales
// with the board. Built on demand the first time setAxes(true) fires.
let _axesMeshes = null;
let axesOn = false;
function setAxes(on) {
  axesOn = on;
  const scene = viewer && viewer.getScene && viewer.getScene();
  if (!scene) { document.getElementById('tb-axes').classList.toggle('active', on); return; }
  const B = window.Adom3DViewer.BABYLON;
  if (on) {
    if (!_axesMeshes) {
      const len = Math.max(10, (window._boardDiag || 50) * 0.25);
      const r = Math.max(0.3, len * 0.015);
      const z0 = (window._boardTopZ != null ? window._boardTopZ : 0.7) + 0.01;
      const axis = (from, to, color, name) => {
        const tube = B.MeshBuilder.CreateTube(name, { path: [from, to], radius: r, tessellation: 8 }, scene);
        const mat = new B.StandardMaterial(name+'_mat', scene);
        mat.disableLighting = true; mat.emissiveColor = color;
        tube.material = mat; tube.renderingGroupId = 3; tube.isPickable = false;
        return tube;
      };
      _axesMeshes = [
        axis(new B.Vector3(0, 0, z0), new B.Vector3(len, 0, z0), new B.Color3(1, 0.2, 0.2), 'axisX'),
        axis(new B.Vector3(0, 0, z0), new B.Vector3(0, len, z0), new B.Color3(0.2, 0.9, 0.2), 'axisY'),
        axis(new B.Vector3(0, 0, z0), new B.Vector3(0, 0, z0 + len), new B.Color3(0.35, 0.55, 1), 'axisZ'),
      ];
    }
    for (const m of _axesMeshes) { if (m.setEnabled) m.setEnabled(true); else m.isVisible = true; }
  } else if (_axesMeshes) {
    for (const m of _axesMeshes) { if (m.setEnabled) m.setEnabled(false); else m.isVisible = false; }
  }
  document.getElementById('tb-axes').classList.toggle('active', on);
}
function setOrtho(on) {
  orthoOn = on;
  if (viewer && viewer.setProjectionMode) viewer.setProjectionMode(on);
  if (viewer && viewer.frameModel) viewer.frameModel();
}

// View-preset buttons (tb-home / tb-top / tb-front / tb-right / tb-iso /
// tb-frame / tb-ortho) were removed from the toolbar in favour of the
// ViewCube, which provides the same functionality with less chrome. The
// setView / setOrtho functions still exist for HTTP/CLI ralph control.
document.getElementById('tb-ground')   .addEventListener('click', () => postToggle('ground'));
document.getElementById('tb-wireframe').addEventListener('click', () => postToggle('wireframe'));
document.getElementById('tb-axes')     .addEventListener('click', () => postToggle('axes'));
document.getElementById('tb-measure')  .addEventListener('click', () => toggleMeasure());
document.getElementById('tb-inspect')  .addEventListener('click', () => toggleInspect());
document.getElementById('tb-walkthrough').addEventListener('click', () => walkthroughToggle());

// Keyboard shortcuts for toolbar actions. View-preset shortcuts
// (H/Z/1/2/3/4/O) were dropped alongside the view buttons — the ViewCube
// handles those now. Camera views are still accessible via the CLI
// (`adom-tsci view top|front|right|iso`) for ralph-loop testing.
//   I → Inspect / Measure
//   G → Ground toggle
//   W → Wireframe toggle
//   C → Components panel
window.addEventListener('keydown', (e) => {
  if (e.ctrlKey || e.metaKey || e.altKey) return;
  const t = e.target;
  if (t && (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
  const panel3d = document.getElementById('panel-3d');
  if (!panel3d || !panel3d.classList.contains('active')) return;
  const key = e.key.toLowerCase();
  // Esc: if Inspect is on AND its card is pinned → unpin the card and
  // leave Inspect enabled. Handled specially here so it doesn't collide
  // with other Esc consumers on the page.
  if (key === 'escape' && window._inspectHandleEsc && window._inspectHandleEsc()) {
    e.preventDefault();
    return;
  }
  // Spacebar: toggle visibility of the currently selected component
  // (set via right-click or left-click on the 3D canvas).
  if ((e.key === ' ' || key === 'spacebar') && window._selectedComponent) {
    e.preventDefault();
    const name = window._selectedComponent;
    const meta = componentMap.get(name);
    if (meta) {
      const cur = componentIsVisible(name);
      setComponentVisibility(name, !cur, false);
    }
    return;
  }
  const map = {
    'm': 'tb-measure',
    'i': 'tb-inspect',
    'g': 'tb-ground',
    'w': 'tb-wireframe',
    'x': 'tb-axes',
    'c': 'tb-comp',
    't': 'tb-trace',
  };
  const btnId = map[key];
  if (!btnId) return;
  const btn = document.getElementById(btnId);
  if (!btn) return;
  btn.click();
  btn.classList.add('kb-flash');
  setTimeout(() => btn.classList.remove('kb-flash'), 200);
  e.preventDefault();
});

// Append the keyboard shortcut suffix to each toolbar button's tooltip
// so hovering reveals the hotkey. Done in JS so the HTML stays clean.
(() => {
  const suffix = {
    'tb-measure':   'M',
    'tb-inspect':   'I',
    'tb-ground':    'G',
    'tb-wireframe': 'W',
    'tb-axes':      'X',
    'tb-comp':      'C',
    'tb-trace':     'T',
  };
  for (const [id, key] of Object.entries(suffix)) {
    const el = document.getElementById(id);
    if (!el) continue;
    const base = el.getAttribute('data-tooltip') || '';
    el.setAttribute('data-tooltip', base + '\n\nShortcut: ' + key);
  }
})();

async function postToggle(name) {
  try {
    const r = await fetch('api/toggle', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name }) });
    const j = await r.json().catch(() => ({}));
    if (j.name === 'ground') setGround(!!j.value);
    if (j.name === 'wireframe') setWireframe(!!j.value);
    if (j.name === 'axes') setAxes(!!j.value);
  } catch {}
}

// Drag toolbar by its grip
(() => {
  const tb = document.getElementById('toolbar');
  makeDraggable(tb, tb.querySelector('.tb-grip'));
  registerFloatingHud(tb);
})();

// ─── Camera command polling (CLI → UI) ─────────────────────────────
setInterval(async () => {
  try {
    const r = await fetch('api/camera-command');
    if (!r.ok) return;
    const cmd = await r.json();
    if (!cmd) return;
    const cam = viewer && viewer.getCamera && viewer.getCamera();
    if (cmd.type === 'set_view') {
      setView(cmd.view); toast('view → ' + cmd.view);
    } else if (cmd.type === 'set_camera') {
      if (cam) {
        if (cmd.alpha != null) cam.alpha = cmd.alpha;
        if (cmd.beta != null)  cam.beta = cmd.beta;
        if (cmd.radius != null) cam.radius = cam.radius * cmd.radius;
      }
      toast('camera');
    } else if (cmd.type === 'toggle') {
      if (cmd.name === 'ground') setGround(!!cmd.value);
      else if (cmd.name === 'wireframe') setWireframe(!!cmd.value);
      else if (cmd.name === 'axes') setAxes(!!cmd.value);
      toast(cmd.name + ': ' + (cmd.value ? 'on' : 'off'));
    } else if (cmd.type === 'toggle_component') {
      setComponentVisibility(cmd.name, !!cmd.visible, /*localOnly*/true);
      toast(cmd.name + ': ' + (cmd.visible ? 'visible' : 'hidden'));
    } else if (cmd.type === 'tour') {
      if (cmd.action === 'start') {
        if (tourInterval) clearInterval(tourInterval);
        tourInterval = setInterval(() => {
          const c = viewer && viewer.getCamera && viewer.getCamera();
          if (c) c.alpha += 0.015;
        }, 50);
      } else if (cmd.action === 'stop') {
        if (tourInterval) { clearInterval(tourInterval); tourInterval = null; }
      }
    } else if (cmd.type === 'walkthrough') {
      // CLI-driven walkthrough control. Mirror the manual toolbar actions.
      if (cmd.action === 'start')   walkthroughStart();
      else if (cmd.action === 'close')  walkthroughClose();
      else if (cmd.action === 'next')   walkthroughNext();
      else if (cmd.action === 'prev')   walkthroughPrev();
      else if (cmd.action === 'pause')  { if (!_wtPaused) walkthroughTogglePause(); }
      else if (cmd.action === 'resume') { if (_wtPaused)  walkthroughTogglePause(); }
    }
  } catch {}
}, 500);

// ─── Measure tool — Fusion 360 HUD layout ───────────────────────────
// Header → Selection Filter | Precision | Secondary Units | Clear | Show Snap Points
// Results (Distance, Angle) once two selections exist
// Per-selection blocks (Selection 1 / Selection 2) with type + props (Length)
// 3D viewport: "1" / "2" tags at selection centroids + floating distance
//   label at midpoint
// One-of-N active-tool state. Tools that drive the hover-highlight
// pipeline (Measure, Inspect) are mutually exclusive — only one can
// own the hover at a time. null = no tool, use default pan/orbit.
let activeTool = null;  // null | 'measure' | 'inspect'
let measureActive = false;
let measureFilter = 'smart';   // smart / point / edge / body
let pcbFeatures = null;        // feature table built from circuit.json (Smart mode)
let measurePrecision = 3;      // decimals
let measureSecondary = 'mil';  // default to mils (PCB industry standard in NA)
let measureShowSnaps = false;
let measureSelections = []; // [{type:'point'|'edge', pos, length?, mesh, marker, tagEl, renderObs}]
let measurePreviewMarker = null;
let measureDistanceObs = null;
let measureDistanceLine = null;
// XYZ-delta dogleg: three coloured tubes connecting s1 \u2192 s2 via
// axis-aligned legs, plus their midpoint screen labels.
let measureDxTube = null;
let measureDyTube = null;
let measureDzTube = null;
let measureDistanceLineDashed = null;

const measureBar = document.getElementById('measure-bar');
const measureSelectionsEl = document.getElementById('mb-selections');
const mbDistance = document.getElementById('mb-distance');
const mbAngle = document.getElementById('mb-angle');
const mbHud1 = document.getElementById('mb-hud-1');
const mbHud2 = document.getElementById('mb-hud-2');
const mbHudD = document.getElementById('mb-hud-d');

function fmt(n, suffix) {
  const s = Number(n).toFixed(measurePrecision);
  let extra = '';
  if (measureSecondary === 'in') extra = ' (' + (n / 25.4).toFixed(measurePrecision + 1) + ' in)';
  else if (measureSecondary === 'mil') extra = ' (' + (n * 1000 / 25.4).toFixed(0) + ' mil)';
  return s + ' ' + (suffix || 'mm') + extra;
}
function disposePreview() {
  if (measurePreviewMarker) { measurePreviewMarker.dispose(); measurePreviewMarker = null; }
}
function disposeSelection(sel) {
  if (sel.marker) sel.marker.dispose();
  if (sel.extraMesh) sel.extraMesh.dispose();
  if (sel.meshRef && highlightLayer) {
    try { highlightLayer.removeMesh(sel.meshRef); } catch {}
  }
  if (sel.overlayMesh) _measureHlClear(sel.overlayMesh);
  if (sel.renderObs) {
    const scene = viewer && viewer.getScene && viewer.getScene();
    if (scene) scene.onBeforeRenderObservable.remove(sel.renderObs);
  }
  if (sel.tagEl) { sel.tagEl.style.display = 'none'; }
}
function clearAllSelections() {
  measureSelections.forEach(disposeSelection);
  measureSelections = [];
  measureBar.classList.remove('has-any', 'has-results');
  mbDistance.textContent = '—';
  mbAngle.textContent = '—';
  if (measureDistanceObs) {
    const scene = viewer && viewer.getScene && viewer.getScene();
    if (scene) scene.onBeforeRenderObservable.remove(measureDistanceObs);
    measureDistanceObs = null;
  }
  disposeDistanceGeometry();
  mbHudD.style.display = 'none';
  const mbHudDx = document.getElementById('mb-hud-dx');
  const mbHudDy = document.getElementById('mb-hud-dy');
  const mbHudDz = document.getElementById('mb-hud-dz');
  if (mbHudDx) mbHudDx.style.display = 'none';
  if (mbHudDy) mbHudDy.style.display = 'none';
  if (mbHudDz) mbHudDz.style.display = 'none';
  renderSelections();
}
function disableMeasure() {
  if (!measureActive) return;
  measureActive = false;
  document.getElementById('tb-measure').classList.remove('active');
  measureBar.classList.remove('visible');
  clearAllSelections();
  disposePreview();
  clearHover();
}
function toggleMeasure() {
  if (activeTool === 'measure') {
    disableMeasure();
    activeTool = null;
    toast('measure: off');
    return;
  }
  // Switching from another tool — stop that one first so it releases
  // the hover pipeline.
  if (activeTool === 'inspect') { disableInspect(); }
  activeTool = 'measure';
  measureActive = true;
  document.getElementById('tb-measure').classList.add('active');
  measureBar.classList.add('visible');
  toast('measure: on');
}

// Vertex-snap helper used for 'point' filter.
function snapToVertex(pick) {
  const B = window.Adom3DViewer.BABYLON;
  if (!pick.pickedMesh) return pick.pickedPoint.clone();
  const mesh = pick.pickedMesh;
  const verts = mesh.getVerticesData && mesh.getVerticesData('position');
  if (!verts) return pick.pickedPoint.clone();
  mesh.computeWorldMatrix(true);
  const world = mesh.getWorldMatrix();
  let best = pick.pickedPoint.clone();
  let bestD2 = Infinity;
  const p = pick.pickedPoint;
  for (let i = 0; i < verts.length; i += 3) {
    const v = B.Vector3.TransformCoordinates(new B.Vector3(verts[i], verts[i+1], verts[i+2]), world);
    const d2 = v.subtract(p).lengthSquared();
    if (d2 < bestD2) { bestD2 = d2; best = v; }
  }
  return (bestD2 < 4) ? best : pick.pickedPoint.clone();
}

function placeMarker(pos, index) {
  const B = window.Adom3DViewer.BABYLON;
  const scene = viewer.getScene();
  const s = B.MeshBuilder.CreateSphere('measureSel' + index, { diameter: 2.0 }, scene);
  s.position = pos;
  s.renderingGroupId = 3;
  s.isPickable = false;
  const mat = new B.StandardMaterial('mMat' + index, scene);
  mat.disableLighting = true;
  mat.emissiveColor = index === 1
    ? new B.Color3(0.5, 0.83, 0.78)
    : new B.Color3(0.94, 0.38, 0.17);
  mat.alpha = 0.55;
  s.material = mat;
  return s;
}

// Adom3DViewer's minified BABYLON export doesn't include Matrix, so
// we keep an identity matrix borrowed from a disabled placeholder mesh.
function getIdentityMatrix() {
  if (window._identityMatrix) return window._identityMatrix;
  const B = window.Adom3DViewer.BABYLON;
  const scene = viewer.getScene();
  const m = B.MeshBuilder.CreateBox('_identityMatrixHolder', { size: 0.0001 }, scene);
  m.setEnabled(false);
  m.isVisible = false;
  m.isPickable = false;
  window._identityMatrix = m.getWorldMatrix();
  return window._identityMatrix;
}

function attachTag(sel, index) {
  const B = window.Adom3DViewer.BABYLON;
  const scene = viewer.getScene();
  const cam = viewer.getCamera();
  const engine = viewer.getEngine();
  const tagEl = index === 1 ? mbHud1 : mbHud2;
  tagEl.style.display = 'block';
  sel.tagEl = tagEl;
  const identity = getIdentityMatrix();
  sel.renderObs = scene.onBeforeRenderObservable.add(() => {
    const vp = cam.viewport.toGlobal(engine.getRenderWidth(), engine.getRenderHeight());
    const proj = B.Vector3.Project(sel.pos, identity, scene.getTransformMatrix(), vp);
    tagEl.style.left = proj.x + 'px';
    tagEl.style.top  = proj.y + 'px';
  });
}

// Public API: commit two Measure selections programmatically by component
// or pad name. Used by `adom-tsci measure A B` (CLI), by demo scripts,
// and by any other AI driver that wants a deterministic measurement
// without synthesizing canvas clicks (synthetic pointer events are
// unreliable for Babylon picking — they usually miss the pad).
//
// The function activates Measure if it isn't already, clears prior
// selections, finds each feature in `pcbFeatures` by exact name, and
// pushes two selections with the appropriate kind + outline mesh. The
// HUD updates via the normal recomputeDistance path.
//
// Returns { ok: true, distance_mm } on success or { error } on failure.
window.measureBetween = function(nameA, nameB) {
  try {
    if (!pcbFeatures || !pcbFeatures.length) return { error: 'pcbFeatures not built yet' };
    if (!measureActive) toggleMeasure();
    // Clear prior picks
    measureSelections.forEach(disposeSelection);
    measureSelections = [];
    const B = window.Adom3DViewer.BABYLON;
    const fA = pcbFeatures.find(f => f.name === nameA);
    const fB = pcbFeatures.find(f => f.name === nameB);
    if (!fA) return { error: `feature "${nameA}" not found` };
    if (!fB) return { error: `feature "${nameB}" not found` };
    const sx = window._mapSx || 1, sy = window._mapSy || 1;
    for (let i = 0; i < 2; i++) {
      const feat = i === 0 ? fA : fB;
      const pos = new B.Vector3(feat.x * sx, feat.y * sy, featureSurfaceZ(feat));
      const selColour = i === 0 ? new B.Color3(0.5, 0.83, 0.78) : new B.Color3(0.94, 0.38, 0.17);
      const outlineMesh = (typeof drawFeatureOutline === 'function') ? drawFeatureOutline(feat, selColour) : null;
      measureSelections.push({
        type: feat.kind, pos, feature: feat,
        marker: placeMarker(pos, i + 1),
        extraMesh: outlineMesh,
      });
    }
    renderSelections();
    recomputeDistance();
    // Report distance for the CLI caller
    const d = B.Vector3.Distance(measureSelections[0].pos, measureSelections[1].pos);
    return { ok: true, from: fA.name, to: fB.name, distance_mm: Math.round(d * 1000) / 1000 };
  } catch (e) {
    return { error: e && e.message ? e.message : String(e) };
  }
};

function renderSelections() {
  measureSelectionsEl.innerHTML = '';
  measureSelections.forEach((sel, i) => {
    const index = i + 1;
    const block = document.createElement('div');
    block.className = 'mb-sel';
    const typeName = sel.type === 'edge' ? 'Edge selected'
                   : sel.type === 'body' ? 'Body selected'
                   : sel.type === 'pad'  ? `Pad · ${sel.feature?.label || ''}`
                   : sel.type === 'hole' ? `Hole · ${sel.feature?.label || ''}`
                   : sel.type === 'chip' ? `Chip · ${sel.feature?.label || ''}`
                   : sel.type === 'pin'  ? `Pin · ${sel.feature?.label || ''}`
                   : sel.type === 'board_edge' ? `Board edge · ${sel.feature?.label || ''}`
                   : 'Point selected';
    let extraRows = '';
    if (sel.type === 'edge' && sel.length != null) {
      extraRows += `<div class="mb-sel-row"><span class="mb-k">Length</span><span class="mb-v">${fmt(sel.length)}</span></div>`;
    }
    if (sel.type === 'body') {
      extraRows += `<div class="mb-sel-row"><span class="mb-k">Width</span><span class="mb-v">${fmt(sel.bboxX)}</span></div>`;
      extraRows += `<div class="mb-sel-row"><span class="mb-k">Depth</span><span class="mb-v">${fmt(sel.bboxZ)}</span></div>`;
      extraRows += `<div class="mb-sel-row"><span class="mb-k">Height</span><span class="mb-v">${fmt(sel.bboxY)}</span></div>`;
    }
    if (sel.feature) {
      const f = sel.feature;
      if (f.kind === 'pad') {
        extraRows += `<div class="mb-sel-row"><span class="mb-k">Width</span><span class="mb-v">${fmt(f.w)}</span></div>`;
        extraRows += `<div class="mb-sel-row"><span class="mb-k">Height</span><span class="mb-v">${fmt(f.h)}</span></div>`;
        extraRows += `<div class="mb-sel-row"><span class="mb-k">Layer</span><span class="mb-v">${f.layer || 'top'}</span></div>`;
      } else if (f.kind === 'hole') {
        extraRows += `<div class="mb-sel-row"><span class="mb-k">Outer Ø</span><span class="mb-v">${fmt(f.diameter)}</span></div>`;
        extraRows += `<div class="mb-sel-row"><span class="mb-k">Drill Ø</span><span class="mb-v">${fmt(f.drillDiameter)}</span></div>`;
      } else if (f.kind === 'chip') {
        extraRows += `<div class="mb-sel-row"><span class="mb-k">Name</span><span class="mb-v">${f.name}</span></div>`;
        extraRows += `<div class="mb-sel-row"><span class="mb-k">Width</span><span class="mb-v">${fmt(f.w)}</span></div>`;
        extraRows += `<div class="mb-sel-row"><span class="mb-k">Height</span><span class="mb-v">${fmt(f.h)}</span></div>`;
      } else if (f.kind === 'board_edge') {
        extraRows += `<div class="mb-sel-row"><span class="mb-k">Length</span><span class="mb-v">${fmt(f.length)}</span></div>`;
        // For board edges, the X/Y/Z "position" is the segment midpoint
        // which is not particularly useful. Show span instead: the axis
        // along which the edge runs + its extent.
        const dx = Math.abs(f.b.x - f.a.x), dy = Math.abs(f.b.y - f.a.y);
        if (dx > dy) {
          extraRows += `<div class="mb-sel-row"><span class="mb-k">Span</span><span class="mb-v">X: ${fmt(Math.min(f.a.x, f.b.x))} \u2192 ${fmt(Math.max(f.a.x, f.b.x))}</span></div>`;
          extraRows += `<div class="mb-sel-row"><span class="mb-k">At Y</span><span class="mb-v">${fmt(f.y)}</span></div>`;
        } else {
          extraRows += `<div class="mb-sel-row"><span class="mb-k">Span</span><span class="mb-v">Y: ${fmt(Math.min(f.a.y, f.b.y))} \u2192 ${fmt(Math.max(f.a.y, f.b.y))}</span></div>`;
          extraRows += `<div class="mb-sel-row"><span class="mb-k">At X</span><span class="mb-v">${fmt(f.x)}</span></div>`;
        }
        // Skip generic X/Y/Z for board edges.
        return;
      }
    }
    block.innerHTML = `
      <div class="mb-sel-header">
        <span class="mb-sel-caret">▾</span>
        <span>Selection ${index}</span>
        <button class="mb-sel-x" title="Remove this selection">×</button>
      </div>
      <div class="mb-sel-type">${typeName}</div>
      ${extraRows}
      <div class="mb-sel-row"><span class="mb-k">X</span><span class="mb-v">${fmt(sel.pos.x)}</span></div>
      <div class="mb-sel-row"><span class="mb-k">Y</span><span class="mb-v">${fmt(sel.pos.y)}</span></div>
      <div class="mb-sel-row"><span class="mb-k">Z</span><span class="mb-v">${fmt(sel.pos.z)}</span></div>
    `;
    const hdr = block.querySelector('.mb-sel-header');
    hdr.addEventListener('click', (e) => {
      if (e.target.classList.contains('mb-sel-x')) return;
      block.classList.toggle('collapsed');
    });
    block.querySelector('.mb-sel-x').addEventListener('click', (e) => {
      e.stopPropagation();
      const removedIndex = measureSelections.indexOf(sel);
      if (removedIndex < 0) return;
      disposeSelection(sel);
      measureSelections.splice(removedIndex, 1);
      // Re-index tags: anything after the removed one shifts down. Our
      // tag elements are keyed 1/2 so rebuild them fresh.
      mbHud1.style.display = 'none';
      mbHud2.style.display = 'none';
      measureSelections.forEach((s, idx) => {
        if (s.renderObs) {
          const scene = viewer.getScene();
          scene.onBeforeRenderObservable.remove(s.renderObs);
          s.renderObs = null;
        }
        attachTag(s, idx + 1);
      });
      recomputeDistance();
      renderSelections();
      if (!measureSelections.length) measureBar.classList.remove('has-any');
    });
    measureSelectionsEl.appendChild(block);
  });
}

// Human-friendly label for a selection — used in the "Measuring: X → Y"
// summary row and the floating 3D distance label.
function measureLabelOf(sel) {
  if (sel.feature) {
    const f = sel.feature;
    if (f.kind === 'pad') return f.label || ((f.owner || '?') + ' pad');
    if (f.kind === 'hole') return 'hole (' + (f.owner || '?') + ')';
    if (f.kind === 'chip') return f.name || '?';
    if (f.kind === 'pin') return f.label || '?';
    if (f.kind === 'board_edge') return f.label || 'board edge';
  }
  if (sel.type === 'edge') return 'mesh edge';
  if (sel.type === 'body') return 'mesh body';
  return 'point (' + sel.pos.x.toFixed(1) + ',' + sel.pos.y.toFixed(1) + ')';
}

function recomputeDistance() {
  const B = window.Adom3DViewer.BABYLON;
  const scene = viewer && viewer.getScene && viewer.getScene();
  if (!scene) return;
  if (measureSelections.length < 2) {
    measureBar.classList.remove('has-results');
    mbDistance.textContent = '—'; mbAngle.textContent = '—';
    const dxEl = document.getElementById('mb-dx');
    const dyEl = document.getElementById('mb-dy');
    const dzEl = document.getElementById('mb-dz');
    if (dxEl) dxEl.textContent = '—';
    if (dyEl) dyEl.textContent = '—';
    if (dzEl) dzEl.textContent = '—';
    const whatEl = document.getElementById('mb-what');
    if (whatEl) whatEl.textContent = '—';
    if (measureDistanceObs) { scene.onBeforeRenderObservable.remove(measureDistanceObs); measureDistanceObs = null; }
    disposeDistanceGeometry();
    mbHudD.style.display = 'none';
    const mbHudDx = document.getElementById('mb-hud-dx');
    const mbHudDy = document.getElementById('mb-hud-dy');
    const mbHudDz = document.getElementById('mb-hud-dz');
    if (mbHudDx) mbHudDx.style.display = 'none';
    if (mbHudDy) mbHudDy.style.display = 'none';
    if (mbHudDz) mbHudDz.style.display = 'none';
    return;
  }
  const s1 = measureSelections[0];
  const s2 = measureSelections[1];
  let d;
  let angleDeg = 0;
  // For two board edges (or line-like features), compute the MINIMUM
  // distance between the two line segments — for parallel segments
  // that's the perpendicular gap (= board width, if top ↔ bottom);
  // for non-parallel that's 0 if they intersect. Also compute the
  // angle between the direction vectors.
  if (s1.feature && s1.feature.kind === 'board_edge' && s2.feature && s2.feature.kind === 'board_edge') {
    const sx = window._mapSx || 1, sy = window._mapSy || 1;
    const a1 = { x: s1.feature.a.x * sx, y: s1.feature.a.y * sy };
    const b1 = { x: s1.feature.b.x * sx, y: s1.feature.b.y * sy };
    const a2 = { x: s2.feature.a.x * sx, y: s2.feature.a.y * sy };
    const b2 = { x: s2.feature.b.x * sx, y: s2.feature.b.y * sy };
    d = segSegDistance2D(a1, b1, a2, b2);
    const v1x = b1.x - a1.x, v1y = b1.y - a1.y;
    const v2x = b2.x - a2.x, v2y = b2.y - a2.y;
    const len1 = Math.hypot(v1x, v1y), len2 = Math.hypot(v2x, v2y);
    if (len1 > 0 && len2 > 0) {
      const cos = Math.abs(v1x * v2x + v1y * v2y) / (len1 * len2);
      angleDeg = Math.acos(Math.min(1, Math.max(0, cos))) * 180 / Math.PI;
    }
  } else {
    d = B.Vector3.Distance(s1.pos, s2.pos);
  }
  // Fusion-style XYZ deltas \u2014 these are what a probe workcell operator
  // actually needs when moving between two testpoints. Board-coord frame:
  // world X = board X, world Y = board Y (after the Y-up\u2192Z-up transform),
  // world Z = height above the board top.
  const dx = s2.pos.x - s1.pos.x;
  const dy = s2.pos.y - s1.pos.y;
  const dz = s2.pos.z - s1.pos.z;
  // Suppress any axis whose delta is effectively zero — zero deltas are
  // noise for the user (they'd have to read a row just to see "+0.000").
  const EPS = 0.001;
  const xAxisVisible = Math.abs(dx) >= EPS;
  const yAxisVisible = Math.abs(dy) >= EPS;
  const zAxisVisible = Math.abs(dz) > 0.1;
  const dxEl = document.getElementById('mb-dx');
  const dyEl = document.getElementById('mb-dy');
  const dzEl = document.getElementById('mb-dz');
  const dxKeyEl = document.querySelector('#measure-bar .mb-k-dx');
  const dyKeyEl = document.querySelector('#measure-bar .mb-k-dy');
  const dzKeyEl = document.querySelector('#measure-bar .mb-k-dz');
  function setRow(keyEl, valEl, visible, text) {
    if (valEl) { valEl.textContent = visible ? text : ''; valEl.style.display = visible ? '' : 'none'; }
    if (keyEl) { keyEl.style.display = visible ? '' : 'none'; }
  }
  setRow(dxKeyEl, dxEl, xAxisVisible, fmtSigned(dx));
  setRow(dyKeyEl, dyEl, yAxisVisible, fmtSigned(dy));
  setRow(dzKeyEl, dzEl, zAxisVisible, fmtSigned(dz));
  mbDistance.textContent = fmt(d);
  mbAngle.textContent = angleDeg.toFixed(Math.max(1, measurePrecision - 1)) + ' deg';
  const whatEl = document.getElementById('mb-what');
  const human = measureLabelOf(s1) + '  \u2192  ' + measureLabelOf(s2);
  if (whatEl) whatEl.textContent = human;
  measureBar.classList.add('has-results');
  mbHudD.style.display = 'block';
  mbHudD.innerHTML = `<span style="color:#7fd4c7">${fmt(d)}</span><br>` +
    `<span style="font-family:Satoshi,sans-serif;font-size:10.5px;color:#c7ccd3;font-weight:400">` +
    `${measureLabelOf(s1)} \u2192 ${measureLabelOf(s2)}</span>`;
  if (measureDistanceObs) scene.onBeforeRenderObservable.remove(measureDistanceObs);
  disposeDistanceGeometry();
  // Dogleg path \u2014 three coloured tubes showing the X-then-Y-then-Z move
  // from s1 to s2. This visualises the offsets a probe workcell needs.
  const cornerXY = new B.Vector3(s2.pos.x, s1.pos.y, s1.pos.z);
  const cornerXYZ = new B.Vector3(s2.pos.x, s2.pos.y, s1.pos.z);
  if (xAxisVisible) measureDxTube = buildAxisTube('measureDx', [s1.pos, cornerXY], new B.Color3(1.0, 0.35, 0.35), scene);
  if (yAxisVisible) measureDyTube = buildAxisTube('measureDy', [cornerXY, cornerXYZ], new B.Color3(0.35, 0.85, 0.45), scene);
  if (zAxisVisible) measureDzTube = buildAxisTube('measureDz', [cornerXYZ, s2.pos], new B.Color3(0.35, 0.65, 1.0), scene);
  // Faint direct-distance line as visual reference for the hypotenuse.
  measureDistanceLineDashed = B.MeshBuilder.CreateTube('measureDirect',
    { path: [s1.pos, s2.pos], radius: 0.08, tessellation: 6 }, scene);
  const directMat = new B.StandardMaterial('measureDirectMat', scene);
  directMat.disableLighting = true;
  directMat.emissiveColor = new B.Color3(0.5, 0.83, 0.78);
  directMat.alpha = 0.35;
  measureDistanceLineDashed.material = directMat;
  measureDistanceLineDashed.renderingGroupId = 3;
  measureDistanceLineDashed.isPickable = false;
  // Floating axis labels at the midpoint of each leg.
  const mbHudDx = document.getElementById('mb-hud-dx');
  const mbHudDy = document.getElementById('mb-hud-dy');
  const mbHudDz = document.getElementById('mb-hud-dz');
  if (xAxisVisible) { mbHudDx.textContent = '\u0394X ' + fmtSigned(dx); mbHudDx.style.display = 'block'; } else mbHudDx.style.display = 'none';
  if (yAxisVisible) { mbHudDy.textContent = '\u0394Y ' + fmtSigned(dy); mbHudDy.style.display = 'block'; } else mbHudDy.style.display = 'none';
  if (zAxisVisible) { mbHudDz.textContent = '\u0394Z ' + fmtSigned(dz); mbHudDz.style.display = 'block'; } else mbHudDz.style.display = 'none';
  const cam = viewer.getCamera();
  const engine = viewer.getEngine();
  const identity = getIdentityMatrix();
  measureDistanceObs = scene.onBeforeRenderObservable.add(() => {
    const vp = cam.viewport.toGlobal(engine.getRenderWidth(), engine.getRenderHeight());
    const project = (worldPos) => B.Vector3.Project(worldPos, identity, scene.getTransformMatrix(), vp);
    // Compute all projected positions first, then stack labels vertically
    // if any pair overlaps. Labels are visually ~26px tall; we space them
    // ~28px apart when collisions happen so each one stays readable.
    const entries = [];
    const midDirect = s1.pos.add(s2.pos).scale(0.5);
    entries.push({ el: mbHudD, p: project(midDirect) });
    if (xAxisVisible) {
      const midX = new B.Vector3((s1.pos.x + s2.pos.x) / 2, s1.pos.y, s1.pos.z);
      entries.push({ el: mbHudDx, p: project(midX) });
    }
    if (yAxisVisible) {
      const midY = new B.Vector3(s2.pos.x, (s1.pos.y + s2.pos.y) / 2, s1.pos.z);
      entries.push({ el: mbHudDy, p: project(midY) });
    }
    if (zAxisVisible) {
      const midZ = new B.Vector3(s2.pos.x, s2.pos.y, (s1.pos.z + s2.pos.z) / 2);
      entries.push({ el: mbHudDz, p: project(midZ) });
    }
    // Resolve collisions by sorting and spreading overlapping labels vertically.
    const H = 28;
    entries.sort((a, b) => a.p.y - b.p.y);
    for (let i = 1; i < entries.length; i++) {
      const prev = entries[i - 1].p;
      const cur = entries[i].p;
      if (Math.abs(cur.x - prev.x) < 180 && cur.y - prev.y < H) {
        cur.y = prev.y + H;
      }
    }
    for (const e of entries) {
      e.el.style.left = e.p.x + 'px';
      e.el.style.top = e.p.y + 'px';
    }
  });
}

function buildAxisTube(name, path, color, scene) {
  const B = window.Adom3DViewer.BABYLON;
  const tube = B.MeshBuilder.CreateTube(name, { path, radius: 0.22, tessellation: 8 }, scene);
  const mat = new B.StandardMaterial(name + 'Mat', scene);
  mat.disableLighting = true;
  mat.emissiveColor = color;
  mat.alpha = 0.92;
  tube.material = mat;
  tube.renderingGroupId = 3;
  tube.isPickable = false;
  return tube;
}
function disposeDistanceGeometry() {
  if (measureDistanceLine) { measureDistanceLine.dispose(); measureDistanceLine = null; }
  if (measureDistanceLineDashed) { measureDistanceLineDashed.dispose(); measureDistanceLineDashed = null; }
  if (measureDxTube) { measureDxTube.dispose(); measureDxTube = null; }
  if (measureDyTube) { measureDyTube.dispose(); measureDyTube = null; }
  if (measureDzTube) { measureDzTube.dispose(); measureDzTube = null; }
}
// Format a signed delta value \u2014 always shows a leading + or \u2212 so the
// sign is unambiguous in a probe-planning context.
function fmtSigned(n) {
  const s = n >= 0 ? '+' : '\u2212';
  return s + fmt(Math.abs(n));
}

// Track the mouse-down position so we can distinguish a single click
// (commit selection) from a drag (orbit camera). Without this, every
// orbit ended with an accidental selection marker placement.
// ALSO track an `isDragging` flag — while the pointer is held, we
// skip hover work (scene.pick + mesh create/dispose) entirely, because
// those interfere with Babylon's ArcRotateCamera pointer processing
// and freeze orbit mid-drag.
let mouseDownInViewer = null;
let isDraggingInViewer = false;
document.getElementById('viewer-wrap').addEventListener('mousedown', (e) => {
  mouseDownInViewer = { x: e.clientX, y: e.clientY };
  isDraggingInViewer = true;
});
window.addEventListener('mouseup', () => { isDraggingInViewer = false; });
window.addEventListener('pointerup', () => { isDraggingInViewer = false; });
window.addEventListener('pointercancel', () => { isDraggingInViewer = false; });

// Click: commit whatever the hover was previewing as a selection.
// Only treat as a click if the mouse moved less than 5px from mousedown
// (otherwise it's a camera orbit / pan and should NOT place a marker).
// Trace-mode click: when the Nets panel is open and measure is OFF,
// clicking a rendered trace tube selects that net (highlight + frame).
window.addEventListener('click', (e) => {
  if (!_traceModeActive || measureActive || !viewer) return;
  const wrap = document.getElementById('viewer-wrap');
  if (!wrap || !wrap.contains(e.target)) return;
  if (e.target.closest('#trace-overlay') || e.target.closest('#toolbar') || e.target.closest('#measure-bar') || e.target.closest('#comp-overlay')) return;
  if (mouseDownInViewer) {
    const dx = e.clientX - mouseDownInViewer.x;
    const dy = e.clientY - mouseDownInViewer.y;
    if (dx*dx + dy*dy > 25) return;
  }
  const scene = viewer.getScene();
  // First try: pick a trace tube directly (predicate by `_netId`).
  let pick = scene.pick(scene.pointerX, scene.pointerY, (m) => !!m._netId);
  if (pick && pick.hit && pick.pickedMesh && pick.pickedMesh._netId) {
    selectNet(pick.pickedMesh._netId);
    return;
  }
  // Fall back: pick ANY mesh under the cursor and look up the nearest
  // pcb_port to that hit point. This is the "click on a chip pad / pin
  // → highlight that net" workflow — user shouldn't have to find a
  // visible trace tube first.
  pick = scene.pick(scene.pointerX, scene.pointerY);
  if (!pick || !pick.hit || !pick.pickedPoint) return;
  const sx = window._mapSx || 1, sy = window._mapSy || 1;
  // pcb_port stored coords are in mm; my world is mm * sx with y-flip
  // matching the trace renderer's convention (y=seg.y * sy).
  const netKey = _traceFindNetAtPoint(pick.pickedPoint.x, pick.pickedPoint.y, 1.5);
  if (netKey) selectNet(netKey);
});

window.addEventListener('click', (e) => {
  if (!measureActive || !viewer) return;
  const wrap = document.getElementById('viewer-wrap');
  if (!wrap.contains(e.target)) return;
  if (e.target.closest('#measure-bar') || e.target.closest('#toolbar') || e.target.closest('#comp-overlay')) return;
  if (measureSelections.length >= 2) return;
  // Skip if this was a drag, not a click.
  if (mouseDownInViewer) {
    const dx = e.clientX - mouseDownInViewer.x;
    const dy = e.clientY - mouseDownInViewer.y;
    mouseDownInViewer = null;
    if (dx*dx + dy*dy > 25) return;  // > 5px of movement = drag, ignore
  }
  const B = window.Adom3DViewer.BABYLON;
  const scene = viewer.getScene();
  const pick = scene.pick(scene.pointerX, scene.pointerY);
  if (!pick.hit) return;
  const index = measureSelections.length + 1;
  let sel = null;
  if (measureFilter === 'smart') {
    // Prefer a nearby PCB feature (pad/hole/chip/board edge). If nothing
    // is close, fall back to a plain point at the hit location so clicks
    // never silently fail — the user still gets a marker where they clicked.
    const feat = hoverPreviewFeature || findNearestPcbFeature(pick.pickedPoint);
    if (feat) {
      const sx = window._mapSx || 1, sy = window._mapSy || 1;
      const pos = new B.Vector3(feat.x * sx, feat.y * sy, featureSurfaceZ(feat));
      const selColour = index === 1 ? new B.Color3(0.5, 0.83, 0.78) : new B.Color3(0.94, 0.38, 0.17);
      const outlineMesh = drawFeatureOutline(feat, selColour);
      // No marker sphere for CHIPS — the renderOverlay tint already
      // marks the whole component clearly; an additional 2mm sphere on
      // top would obscure the chip body. For non-chip features
      // (pad / hole / silkscreen / board edge) the small sphere is
      // still a useful "you clicked here" anchor.
      const showMarker = feat.kind !== 'chip';
      sel = {
        type: feat.kind, pos,
        feature: feat,
        marker: showMarker ? placeMarker(pos, index) : null,
        extraMesh: outlineMesh,
      };
      if (hoverPreviewFeatureMesh) { hoverPreviewFeatureMesh.dispose(); hoverPreviewFeatureMesh = null; hoverPreviewFeature = null; }
    } else {
      const pos = pick.pickedPoint.clone();
      sel = { type: 'point', pos, marker: placeMarker(pos, index) };
    }
  } else if (measureFilter === 'point') {
    const pos = snapToVertex(pick);
    sel = { type: 'point', pos, marker: placeMarker(pos, index) };
  } else if (measureFilter === 'edge') {
    // Use the currently-hovered edge info if available, else recompute.
    const info = hoverPreviewEdgeInfo || ((() => {
      const e2 = findNearestEdgeOnMesh(pick.pickedMesh, pick.pickedPoint);
      return e2 ? { a: e2.a, b: e2.b, length: e2.length } : null;
    })());
    if (!info) return;
    const midpoint = info.a.add(info.b).scale(0.5);
    // Lock in the edge as a selection. Marker lives at the midpoint;
    // also draw a persistent colored line along the edge so user sees
    // what got picked.
    const lineMesh = B.MeshBuilder.CreateTube('measureEdge' + index,
      { path: [info.a, info.b], radius: 0.25, tessellation: 8 }, scene);
    const matL = new B.StandardMaterial('mEdgeSel' + index, scene);
    matL.disableLighting = true;
    matL.emissiveColor = index === 1 ? new B.Color3(0.5, 0.83, 0.78) : new B.Color3(0.94, 0.38, 0.17);
    lineMesh.material = matL;
    lineMesh.renderingGroupId = 3;
    lineMesh.isPickable = false;
    sel = {
      type: 'edge', pos: midpoint, length: info.length,
      marker: placeMarker(midpoint, index),
      extraMesh: lineMesh,
    };
    // Dispose hover highlight so it doesn't linger.
    if (hoverPreviewEdgeMesh) { hoverPreviewEdgeMesh.dispose(); hoverPreviewEdgeMesh = null; hoverPreviewEdgeInfo = null; }
  } else if (measureFilter === 'body') {
    const mesh = pick.pickedMesh;
    if (!mesh) return;
    mesh.computeWorldMatrix(true);
    const bi = mesh.getBoundingInfo();
    const bb = bi.boundingBox;
    const centre = bb.centerWorld.clone();
    const dims = bb.extendSizeWorld.scale(2); // full bbox dims
    // Body selections: NO sphere marker — the renderOverlay tint already
    // marks the whole chip clearly, and a 2mm sphere ends up sitting on
    // top of the chip looking like a giant glowing blob obscuring it.
    sel = {
      type: 'body', pos: centre,
      bboxX: dims.x, bboxY: dims.y, bboxZ: dims.z,
      marker: null,
      meshRef: mesh,
    };
    // Keep the body highlighted persistently with renderOverlay
    // (translucent tint, NOT a HighlightLayer fill). Chip stays visible.
    const baseC = index === 1 ? new B.Color3(0.5, 0.83, 0.78) : new B.Color3(0.94, 0.38, 0.17);
    _measureHlApply(mesh, baseC);
    sel.overlayMesh = mesh;
    hoverPreviewBodyMesh = null; // don't remove the committed highlight on next hover
  }
  if (!sel) return;
  measureSelections.push(sel);
  attachTag(sel, index);
  measureBar.classList.add('has-any');
  recomputeDistance();
  renderSelections();
});

// ─── Hover highlight — what you'd pick if you clicked right now ──
// Point filter → translucent sphere at nearest vertex
// Edge filter  → bright line along nearest edge of the hit mesh
// Body filter  → outline + highlight-layer glow on the hit mesh
// Commits via click handler (separate listener).
let hoverPreviewPoint = null; // Vector3 (for point mode)
let hoverPreviewEdgeMesh = null; // Lines mesh (for edge mode)
let hoverPreviewEdgeInfo = null; // { meshName, a, b, length }
let hoverPreviewBodyMesh = null; // the picked body mesh (for body mode)
let hoverPreviewFeatureMesh = null; // outline mesh for smart-mode hover
let hoverPreviewFeature = null;     // the current smart-mode feature
// getHighlightLayer is now a thin alias to the strong-glow singleton
// (_getHighlightLayer) so EVERY caller — measure-tool body hover,
// inspect, click-to-select, walkthrough, group highlight — produces
// the same gorgeous teal glow halo. Previously this was its own
// `isStroke: true` (thin outline) HighlightLayer; that's been retired
// in favour of one consistent visual language for "this is selected".
let highlightLayer = null;
function getHighlightLayer() {
  const B = window.Adom3DViewer.BABYLON;
  const scene = viewer.getScene();
  if (typeof _getHighlightLayer === 'function') {
    highlightLayer = _getHighlightLayer(B, scene);
    return highlightLayer;
  }
  // Fallback for very early callers that fire before _getHighlightLayer
  // exists (load-order edge case): make our own with the same glow
  // params instead of the old thin-stroke version.
  if (!highlightLayer) {
    highlightLayer = new B.HighlightLayer('measureHL', scene, {
      blurHorizontalSize: 4,
      blurVerticalSize: 4,
      mainTextureRatio: 1.0,
    });
    highlightLayer.innerGlow = false;
    highlightLayer.outerGlow = true;
    if ('blurMaxIntensity' in highlightLayer) highlightLayer.blurMaxIntensity = 1.5;
  }
  return highlightLayer;
}

function clearHover() {
  disposePreview();
  hoverPreviewPoint = null;
  if (hoverPreviewEdgeMesh) { hoverPreviewEdgeMesh.dispose(); hoverPreviewEdgeMesh = null; }
  hoverPreviewEdgeInfo = null;
  if (hoverPreviewBodyMesh) {
    if (highlightLayer) { try { highlightLayer.removeMesh(hoverPreviewBodyMesh); } catch {} }
    _measureHlClear(hoverPreviewBodyMesh);
    hoverPreviewBodyMesh = null;
  }
  if (hoverPreviewFeatureMesh) { hoverPreviewFeatureMesh.dispose(); hoverPreviewFeatureMesh = null; }
  hoverPreviewFeature = null;
  clearSnapDots();
  // Inspect-mode hover highlight + card. When mouse leaves the viewer
  // (or a drag starts, or tools switch), the amber outline + card must
  // go away too — otherwise the card looks permanently stuck on the
  // last-hovered feature. Pinned cards are preserved on purpose.
  if (typeof disposeInspectHover === 'function') disposeInspectHover();
  if (typeof inspectCardPinned !== 'undefined' && !inspectCardPinned) {
    if (typeof hideInspectCard === 'function') hideInspectCard();
  }
}

// Show Snap Points — small cyan spheres at every unique vertex of the
// hovered mesh, filtered to those within ~8mm of the cursor hit. Uses a
// pool of meshes that get repositioned each hover tick instead of
// create/dispose churn.
const SNAP_DOT_POOL_SIZE = 40;
const snapDotPool = [];
function ensureSnapDotPool() {
  if (snapDotPool.length) return;
  const B = window.Adom3DViewer.BABYLON;
  const scene = viewer.getScene();
  const mat = new B.StandardMaterial('snapDotMat', scene);
  mat.disableLighting = true;
  mat.emissiveColor = new B.Color3(0.5, 0.95, 0.9);
  mat.alpha = 0.9;
  for (let i = 0; i < SNAP_DOT_POOL_SIZE; i++) {
    const d = B.MeshBuilder.CreateSphere('snapDot' + i, { diameter: 0.6 }, scene);
    d.material = mat;
    d.renderingGroupId = 3;
    d.isPickable = false;
    d.setEnabled(false);
    snapDotPool.push(d);
  }
}
function clearSnapDots() {
  for (const d of snapDotPool) d.setEnabled(false);
}
function updateSnapDots(mesh, hitPoint) {
  if (!mesh || !hitPoint) { clearSnapDots(); return; }
  ensureSnapDotPool();
  const B = window.Adom3DViewer.BABYLON;
  const positions = mesh.getVerticesData && mesh.getVerticesData('position');
  if (!positions) { clearSnapDots(); return; }
  mesh.computeWorldMatrix(true);
  const W = mesh.getWorldMatrix();
  const radius2 = 8 * 8;
  const seen = new Set();
  let n = 0;
  for (let i = 0; i < positions.length && n < SNAP_DOT_POOL_SIZE; i += 3) {
    const v = B.Vector3.TransformCoordinates(
      new B.Vector3(positions[i], positions[i+1], positions[i+2]), W);
    const d2 = B.Vector3.DistanceSquared(v, hitPoint);
    if (d2 > radius2) continue;
    const key = v.x.toFixed(2) + ',' + v.y.toFixed(2) + ',' + v.z.toFixed(2);
    if (seen.has(key)) continue;
    seen.add(key);
    const dot = snapDotPool[n++];
    dot.position = v;
    dot.setEnabled(true);
  }
  for (let i = n; i < SNAP_DOT_POOL_SIZE; i++) snapDotPool[i].setEnabled(false);
}

// ─── Smart-mode feature table builder ──────────────────────────────
// Walk circuit.json and extract every measurable PCB feature. Each
// feature has a mm-accurate (x, y, z≈0) position + shape + attributes
// specific to its kind. This is the authoritative geometry — the GLB
// mesh is just a visualisation.
async function buildPcbFeatureTable() {
  try {
    const r = await fetch('circuit.json');
    if (!r.ok) return null;
    const arr = await r.json();
    const sources = new Map();
    const pcbById = new Map();      // pcb_component_id → source name
    // Inspect-mode lookup index. Shared on `window._inspectIndex` so
    // Inspect's renderer can resolve any circuit.json entity by id
    // without re-walking the array on every hover. Built in the same
    // traversal as the feature table so circuit.json is parsed once.
    const idx = {
      sourceComponentById:  new Map(),
      pcbComponentById:     new Map(),
      cadByPcbId:           new Map(),
      sourcePortById:       new Map(),
      pcbPortById:           new Map(),
      sourceNetById:        new Map(),
      silkscreenTextById:   new Map(),
      smtPadByPortId:       new Map(),
      platedHoleByPortId:   new Map(),
      portsByPcbComponent:  new Map(),   // pcb_component_id → source_port[]
      netBySourcePortId:    new Map(),
      pcbBoard:             null,
      sourceTraces:         [],
      viaList:              [],
    };
    for (const e of arr) {
      if (e.type === 'source_component') {
        sources.set(e.source_component_id, e.name || '?');
        idx.sourceComponentById.set(e.source_component_id, e);
      }
    }
    for (const e of arr) {
      if (e.type === 'pcb_component') {
        pcbById.set(e.pcb_component_id, sources.get(e.source_component_id) || '?');
        idx.pcbComponentById.set(e.pcb_component_id, e);
      }
      else if (e.type === 'source_port')        idx.sourcePortById.set(e.source_port_id, e);
      else if (e.type === 'pcb_port')           idx.pcbPortById.set(e.pcb_port_id, e);
      else if (e.type === 'source_net')         idx.sourceNetById.set(e.source_net_id, e);
      else if (e.type === 'cad_component')      idx.cadByPcbId.set(e.pcb_component_id, e);
      else if (e.type === 'pcb_silkscreen_text') idx.silkscreenTextById.set(e.pcb_silkscreen_text_id, e);
      else if (e.type === 'source_trace')       idx.sourceTraces.push(e);
      else if (e.type === 'pcb_board')          idx.pcbBoard = e;
    }
    // Group source_ports by pcb_component so we can answer "how many pins
    // does this chip have?" at hover time. source_port.source_component_id
    // plus the source_component_id→pcb_component reverse lookup.
    const pcbIdBySourceId = new Map();
    for (const [pcbId, pcb] of idx.pcbComponentById) pcbIdBySourceId.set(pcb.source_component_id, pcbId);
    for (const sp of idx.sourcePortById.values()) {
      const pcbId = pcbIdBySourceId.get(sp.source_component_id);
      if (!pcbId) continue;
      let arr2 = idx.portsByPcbComponent.get(pcbId);
      if (!arr2) { arr2 = []; idx.portsByPcbComponent.set(pcbId, arr2); }
      arr2.push(sp);
    }
    // Derive net-by-port: every source_trace says "these ports are all
    // on this net". Walk each trace's connected_source_port_ids and
    // assign the first net_id. Ports with no net entry fall through
    // to a tracesByPortId map so Inspect can still show the port-to-
    // port connection (e.g. "U1.pin1 → MC1.pin1") when tscircuit
    // doesn't emit an explicit source_net for a direct trace.
    idx.tracesByPortId = new Map();  // port_id → source_trace[]
    for (const tr of idx.sourceTraces) {
      const netIds = tr.connected_source_net_ids || [];
      const primary = netIds[0] || null;
      for (const portId of (tr.connected_source_port_ids || [])) {
        if (primary && !idx.netBySourcePortId.has(portId)) {
          idx.netBySourcePortId.set(portId, primary);
        }
        let arr2 = idx.tracesByPortId.get(portId);
        if (!arr2) { arr2 = []; idx.tracesByPortId.set(portId, arr2); }
        arr2.push(tr);
      }
    }
    window._inspectIndex = idx;
    const features = [];
    for (const e of arr) {
      if (e.type === 'pcb_smtpad') {
        const owner = pcbById.get(e.pcb_component_id) || 'board';
        if (e.pcb_port_id) idx.smtPadByPortId.set(e.pcb_port_id, e);
        features.push({
          kind: 'pad',
          x: e.x, y: e.y, z: 0.01,
          w: e.width || (e.radius ? e.radius * 2 : 0.5),
          h: e.height || (e.radius ? e.radius * 2 : 0.5),
          shape: e.shape || 'rect',
          radius: e.radius,
          layer: e.layer || 'top',
          owner,
          label: owner + (e.port_hints && e.port_hints[0] ? '.' + e.port_hints[0] : ''),
          // Inspect-mode backlinks:
          pcbComponentId: e.pcb_component_id,
          pcbPortId: e.pcb_port_id,
          pcbSmtpadId: e.pcb_smtpad_id,
          isCoveredWithSolderMask: !!e.is_covered_with_solder_mask,
          portHints: e.port_hints,
        });
      } else if (e.type === 'pcb_plated_hole') {
        if (e.pcb_port_id) idx.platedHoleByPortId.set(e.pcb_port_id, e);
        features.push({
          kind: 'hole',
          x: e.x, y: e.y, z: 0,
          diameter: e.outer_diameter || e.hole_diameter || 1.0,
          drillDiameter: e.hole_diameter || e.outer_diameter || 0.8,
          owner: pcbById.get(e.pcb_component_id) || 'board',
          label: (pcbById.get(e.pcb_component_id) || 'hole'),
          // Inspect-mode backlinks:
          pcbComponentId: e.pcb_component_id,
          pcbPortId: e.pcb_port_id,
          pcbPlatedHoleId: e.pcb_plated_hole_id,
          layers: e.layers || [],
          isPlated: true,
        });
      } else if (e.type === 'pcb_hole') {
        features.push({
          kind: 'hole',
          x: e.x, y: e.y, z: 0,
          diameter: e.hole_diameter || 1.0,
          drillDiameter: e.hole_diameter || 1.0,
          owner: pcbById.get(e.pcb_component_id) || 'board',
          label: pcbById.get(e.pcb_component_id) || 'hole',
        });
      } else if (e.type === 'pcb_component') {
        const c = e.center || {};
        features.push({
          kind: 'chip',
          x: c.x, y: c.y, z: 0.6,  // sit slightly above the board so outline is visible
          w: e.width || 1, h: e.height || 1,
          rotation: e.rotation || 0,
          name: sources.get(e.source_component_id) || '?',
          label: sources.get(e.source_component_id) || '?',
          pcbId: e.pcb_component_id,
          pcbComponentId: e.pcb_component_id,
          sourceComponentId: e.source_component_id,
          layer: e.layer || 'top',
        });
      } else if (e.type === 'pcb_via') {
        // Vias look like small plated holes. Not all boards have them
        // (iCE40 example has none), but when present Inspect wants the
        // drill + outer + net + which layers are bridged.
        idx.viaList.push(e);
        features.push({
          kind: 'via',
          x: e.x, y: e.y, z: 0,
          diameter: e.outer_diameter || e.hole_diameter || 0.6,
          drillDiameter: e.hole_diameter || 0.3,
          fromLayer: e.from_layer,
          toLayer: e.to_layer,
          layers: e.layers || [],
          sourceNetId: e.source_net_id,
          pcbViaId: e.pcb_via_id,
          owner: 'via',
          label: 'via',
        });
      } else if (e.type === 'pcb_silkscreen_text') {
        // Treat silkscreen text as a small rectangular feature so
        // findNearestPcbFeature can pick it like a chip, but render the
        // outline as a tight box around the text.
        const anchor = e.anchor_position || { x: 0, y: 0 };
        const fontSize = e.font_size || 0.5;
        const textLen = (e.text || '').length;
        features.push({
          kind: 'silk',
          x: anchor.x, y: anchor.y, z: 0.01,
          w: Math.max(1, fontSize * textLen * 0.7),
          h: Math.max(0.5, fontSize * 1.2),
          layer: e.layer || 'top',
          text: e.text || '',
          fontSize,
          pcbComponentId: e.pcb_component_id,
          pcbSilkscreenTextId: e.pcb_silkscreen_text_id,
          owner: pcbById.get(e.pcb_component_id) || 'board',
          label: '"' + (e.text || '') + '"',
        });
      } else if (e.type === 'pcb_board') {
        // Prefer width/height for a clean 4-edge rectangle (tscircuit
        // emits an 80-point rounded-corner outline otherwise, which is
        // too noisy for edge-to-edge measurement). Fall back to the
        // outline polyline only for non-rectangular boards.
        let edgePolyline = null;
        let labels = null;
        if (typeof e.width === 'number' && typeof e.height === 'number') {
          const cx = (e.center && e.center.x) || 0;
          const cy = (e.center && e.center.y) || 0;
          const hw = e.width / 2, hh = e.height / 2;
          edgePolyline = [
            { x: cx - hw, y: cy - hh },
            { x: cx + hw, y: cy - hh },
            { x: cx + hw, y: cy + hh },
            { x: cx - hw, y: cy + hh },
          ];
          labels = ['board bottom', 'board right', 'board top', 'board left'];
        } else if (Array.isArray(e.outline) && e.outline.length >= 2) {
          edgePolyline = e.outline;
        }
        if (edgePolyline) {
          for (let i = 0; i < edgePolyline.length; i++) {
            const a = edgePolyline[i];
            const b = edgePolyline[(i + 1) % edgePolyline.length];
            const midX = (a.x + b.x) / 2, midY = (a.y + b.y) / 2;
            const len = Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);
            features.push({
              kind: 'board_edge',
              x: midX, y: midY, z: 0.05,
              a: { x: a.x, y: a.y }, b: { x: b.x, y: b.y },
              length: len,
              label: labels ? labels[i] : ('board edge ' + (i + 1)),
            });
          }
        }
      }
    }
    return features;
  } catch { return null; }
}

// Find the closest feature to a hit point. PCB features live on z≈0
// so the XY distance is the main criterion; Z mismatch is allowed (the
// user may click a point above the board surface and still want the
// pad directly below). Tolerance is per-kind.
function findNearestPcbFeature(hitPoint, filter) {
  if (!pcbFeatures || !pcbFeatures.length) return null;
  // Convert world hit point → pcb mm coords (inverse of orientation / scale).
  // For now assume orientationSx=1, orientationSy=1, scale=1 (common case).
  // TODO: persist orientationSx/Sy/scale from enumerateComponents.
  const hx = hitPoint.x * (window._mapSx || 1);
  const hy = hitPoint.y * (window._mapSy || 1);
  const kindTol = { pad: 3, hole: 3, via: 1.5, silk: 2, chip: 8, pin: 2, board_edge: 5 };
  let best = null, bestScore = Infinity;
  for (const f of pcbFeatures) {
    if (filter && filter !== 'any' && f.kind !== filter) continue;
    let d, areaPenalty;
    if (f.kind === 'hole' || f.kind === 'via') {
      d = Math.max(0, Math.sqrt((hx - f.x)**2 + (hy - f.y)**2) - f.diameter / 2);
      areaPenalty = Math.PI * (f.diameter/2)**2;
    } else if (f.kind === 'board_edge') {
      // Distance from (hx, hy) to the line segment a→b.
      const ax = f.a.x, ay = f.a.y, bx = f.b.x, by = f.b.y;
      const dx = bx - ax, dy = by - ay;
      const len2 = dx*dx + dy*dy;
      const t = len2 > 0 ? Math.max(0, Math.min(1, ((hx - ax)*dx + (hy - ay)*dy) / len2)) : 0;
      const px = ax + t * dx, py = ay + t * dy;
      d = Math.sqrt((hx - px)**2 + (hy - py)**2);
      // Board edges are long and should LOSE to smaller features if
      // the click is over a chip or pad. Give them a big area penalty.
      areaPenalty = Math.sqrt(len2) * 4;
    } else {
      const dx = Math.max(0, Math.abs(hx - f.x) - f.w / 2);
      const dy = Math.max(0, Math.abs(hy - f.y) - f.h / 2);
      d = Math.sqrt(dx*dx + dy*dy);
      areaPenalty = f.w * f.h;
    }
    // Prefer smaller features when hit point is close to multiple.
    const score = d + areaPenalty * 0.001;
    if (d <= (kindTol[f.kind] || 2) && score < bestScore) {
      bestScore = score;
      best = f;
    }
  }
  return best;
}

// Minimum distance between two 2D line segments. 0 if they intersect;
// otherwise the min of (each endpoint to the other segment's line).
function pointToSegDistance2D(p, a, b) {
  const vx = b.x - a.x, vy = b.y - a.y;
  const len2 = vx*vx + vy*vy;
  const t = len2 > 0 ? Math.max(0, Math.min(1, ((p.x - a.x)*vx + (p.y - a.y)*vy) / len2)) : 0;
  const qx = a.x + t * vx, qy = a.y + t * vy;
  return Math.hypot(p.x - qx, p.y - qy);
}
function segSegDistance2D(a1, b1, a2, b2) {
  // Parallel or non-intersecting case: min of the 4 endpoint-to-segment distances.
  return Math.min(
    pointToSegDistance2D(a1, a2, b2),
    pointToSegDistance2D(b1, a2, b2),
    pointToSegDistance2D(a2, a1, b1),
    pointToSegDistance2D(b2, a1, b1),
  );
}

// Draw an outline around a feature on the PCB plane. Outline is in
// world coords (xy from the feature, z slightly above the pad).
// Return the world-space Z the feature's outline / marker should sit at.
// Top-layer features go just above the board's top surface; bottom-layer
// features go just below the bottom surface. Falls back to feature.z if
// the board range hasn't been measured yet.
function featureSurfaceZ(feature) {
  const top = window._boardTopZ;
  const bot = window._boardBottomZ;
  if (!Number.isFinite(top) || !Number.isFinite(bot)) return feature.z || 0.5;
  const layer = (feature && feature.layer) || 'top';
  const GAP = 0.1;
  if (layer === 'bottom') return bot - GAP;
  return top + GAP;
}

function drawFeatureOutline(feature, colorOverride) {
  const B = window.Adom3DViewer.BABYLON;
  const scene = viewer.getScene();
  const sx = window._mapSx || 1, sy = window._mapSy || 1;
  const wx = feature.x * sx, wy = feature.y * sy;
  let color = colorOverride || new B.Color3(0.5, 0.95, 0.9);
  // When the measure tool is active, use translucent renderOverlay on
  // the chip's meshes so it tints (recognizable selection color) but
  // doesn't get hidden under a giant glowing blob. When NOT in measure
  // mode, use the fat HighlightLayer halo for the consistent app-wide
  // "selected chip" presence.
  const useMeasureLayer = typeof measureActive !== 'undefined' && measureActive;
  if (feature.kind === 'chip' && feature.name) {
    const meta = componentMap.get(feature.name);
    if (meta) {
      try { _promoteComponent(feature.name); } catch {}
      if (useMeasureLayer) {
        const tinted = [];
        for (const m of meta.meshes) {
          if (!m || !m.getClassName) continue;
          if (m.getClassName() === 'InstancedMesh') continue;
          _measureHlApply(m, color);
          tinted.push(m);
        }
        return {
          _adomTsciHlGroup: true,
          dispose: () => { for (const m of tinted) _measureHlClear(m); },
        };
      }
      const hl = getHighlightLayer();
      if (hl) {
        const added = [];
        for (const m of meta.meshes) {
          if (!m || !m.getClassName) continue;
          if (m.getClassName() === 'InstancedMesh') continue;
          try { hl.addMesh(m, color); added.push(m); } catch {}
        }
        return {
          _adomTsciHlGroup: true,
          dispose: () => {
            for (const m of added) {
              try { hl.removeMesh(m); } catch {}
            }
          },
        };
      }
    }
    // Fall through to bbox outline if we couldn't resolve to a real
    // mesh (synthesised cuboid case, or feature without componentMap entry).
  }
  // Anchor outlines to the actual board surface — top or bottom layer
  // depending on the feature's `layer` attribute. Floating outlines at
  // feature.z (which is near world z=0) appear stuck mid-thickness when
  // the board sits above z=0.
  const z = featureSurfaceZ(feature);
  let mesh;
  if (feature.kind === 'hole' || feature.kind === 'via') {
    // Circle outline
    const n = 32;
    const pts = [];
    for (let i = 0; i <= n; i++) {
      const a = (i / n) * Math.PI * 2;
      pts.push(new B.Vector3(wx + (feature.diameter/2) * Math.cos(a), wy + (feature.diameter/2) * Math.sin(a), z));
    }
    mesh = B.MeshBuilder.CreateTube('featOutline', { path: pts, radius: 0.12, tessellation: 6 }, scene);
  } else if (feature.kind === 'board_edge') {
    // Straight-segment highlight along the two endpoints.
    const p0 = new B.Vector3(feature.a.x * sx, feature.a.y * sy, z);
    const p1 = new B.Vector3(feature.b.x * sx, feature.b.y * sy, z);
    mesh = B.MeshBuilder.CreateTube('featOutline', { path: [p0, p1], radius: 0.25, tessellation: 8 }, scene);
  } else {
    // Rect outline (pad or chip)
    const hw = feature.w / 2, hh = feature.h / 2;
    const corners = [
      new B.Vector3(wx - hw, wy - hh, z),
      new B.Vector3(wx + hw, wy - hh, z),
      new B.Vector3(wx + hw, wy + hh, z),
      new B.Vector3(wx - hw, wy + hh, z),
      new B.Vector3(wx - hw, wy - hh, z),
    ];
    // Use a thin box border instead of a line so it's visible at zoom.
    mesh = B.MeshBuilder.CreateTube('featOutline', { path: corners, radius: 0.12, tessellation: 6 }, scene);
  }
  const mat = new B.StandardMaterial('featOutlineMat', scene);
  mat.disableLighting = true;
  mat.emissiveColor = color;
  mesh.material = mat;
  mesh.renderingGroupId = 3;
  mesh.isPickable = false;
  return mesh;
}

// ─────────────────────────────────────────────────────────────
// Inspect tool — hover any PCB feature, get a labelled info card.
// Sibling to Measure: same picker + outline primitives, different
// downstream: instead of committing selections, it renders a rich
// card explaining what the feature is.
// ─────────────────────────────────────────────────────────────

// The info card lives directly in <body> so it escapes any HUD
// overflow clipping. Same pattern as #global-tooltip.
const _inspectCard = (() => {
  const el = document.createElement('div');
  el.id = 'inspect-card';
  document.body.appendChild(el);
  return el;
})();

let inspectActive = false;
let inspectCardPinned = false;
let lastInspectFeature = null;  // reference-identity short-circuit
let lastInspectWasBoard = false;
let _inspectHoverMesh = null;
const INSPECT_AMBER = { r: 0.95, g: 0.75, b: 0.2 };

function disposeInspectHover() {
  if (_inspectHoverMesh) { try { _inspectHoverMesh.dispose(); } catch {} _inspectHoverMesh = null; }
}
function hideInspectCard() {
  _inspectCard.classList.remove('visible', 'pinned');
  lastInspectFeature = null;
  lastInspectWasBoard = false;
}

function disableInspect() {
  if (!inspectActive) return;
  inspectActive = false;
  inspectCardPinned = false;
  document.getElementById('tb-inspect').classList.remove('active');
  disposeInspectHover();
  hideInspectCard();
}
function toggleInspect() {
  if (activeTool === 'inspect') {
    disableInspect();
    activeTool = null;
    toast('inspect: off');
    return;
  }
  // Stop any other hover-owning tool first.
  if (activeTool === 'measure') { disableMeasure(); }
  activeTool = 'inspect';
  inspectActive = true;
  inspectCardPinned = false;
  document.getElementById('tb-inspect').classList.add('active');
  toast('inspect: on — hover any feature');
}

// Esc handler hook called from the page-wide keydown listener:
// returns true if Esc was consumed (i.e. pinned card was unpinned).
window._inspectHandleEsc = function () {
  if (!inspectActive) return false;
  // Esc in Inspect mode: always turn the whole tool off. If a card is
  // pinned we also drop the pin on the way out. One-shot Esc = clean
  // exit, no two-step "unpin then exit" dance (which users hated).
  disableInspect();
  activeTool = null;
  return true;
};

// Track cursor for card positioning. We piggy-back on the global
// tooltip's already-installed listener via _cursorX / _cursorY.
function positionInspectCard() {
  const OFFSET = 14;
  const cardW = _inspectCard.offsetWidth || 320;
  const cardH = _inspectCard.offsetHeight || 200;
  let x = _cursorX + OFFSET;
  let y = _cursorY + OFFSET;
  if (x + cardW > window.innerWidth - 8) x = _cursorX - cardW - OFFSET;
  if (y + cardH > window.innerHeight - 8) y = window.innerHeight - cardH - 8;
  if (y < 8) y = 8;
  _inspectCard.style.setProperty('--ins-x', x + 'px');
  _inspectCard.style.setProperty('--ins-y', y + 'px');
}

// ─── Card content ──────────────────────────────────────────────
// Every card: kind chip + title header, k/v grid, optional footnote.
// Unknown values: <span class="ic-v muted" title="...">&mdash;</span>

const _kindLabel = {
  chip: 'Chip', pad: 'Pad', hole: 'Hole', via: 'Via',
  silk: 'Silkscreen', board_edge: 'Board Edge', pin: 'Pin',
};

function _row(k, v, vTitle) {
  const vEsc = v == null ? '<span class="ic-v muted">&mdash;</span>'
             : `<span class="ic-v"${vTitle ? ` title="${_esc(vTitle)}"` : ''}>${_esc(v)}</span>`;
  return `<span class="ic-k">${_esc(k)}</span>${vEsc}`;
}
function _rowMuted(k, msg) {
  return `<span class="ic-k">${_esc(k)}</span><span class="ic-v muted" title="${_esc(msg)}">&mdash;</span>`;
}
function _esc(s) {
  return String(s == null ? '' : s)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function _mm(v, digits) { return Number(v).toFixed(digits == null ? 2 : digits) + ' mm'; }

// Resolve a port's electrical connection. tscircuit circuit.json
// emits two flavours of source_trace:
//   - trace with connected_source_net_ids → a named net like VBUS / GND
//   - trace with only connected_source_port_ids → a direct point-to-
//     point hop (e.g. "U1.pin1 to MC1.pin1") that never got promoted
//     to a named net.
// Inspect reports whichever one applies; `type: 'none'` = airwire.
function _inspectLookupConnection(sourcePortId) {
  const idx = window._inspectIndex;
  if (!idx || !sourcePortId) return { type: 'none' };
  const netId = idx.netBySourcePortId.get(sourcePortId);
  if (netId) {
    const net = idx.sourceNetById.get(netId);
    const flags = [];
    if (net && net.is_ground) flags.push('GND');
    if (net && net.is_power) flags.push('power');
    if (net && net.is_positive_voltage_source) flags.push('V+');
    return { type: 'net', name: (net && net.name) || '?', flags };
  }
  const traces = idx.tracesByPortId && idx.tracesByPortId.get(sourcePortId);
  if (traces && traces.length) {
    // Collect every other port name involved in this port's traces.
    const others = [];
    for (const tr of traces) {
      for (const otherId of (tr.connected_source_port_ids || [])) {
        if (otherId === sourcePortId) continue;
        const osp = idx.sourcePortById.get(otherId);
        if (!osp) continue;
        const oscid = osp.source_component_id;
        const pcbId = [...idx.pcbComponentById.entries()].find(([, p]) => p.source_component_id === oscid);
        const refdes = pcbId ? (idx.sourceComponentById.get(oscid) || {}).name || '?' : '?';
        const pinName = osp.name || (osp.pin_number != null ? 'pin' + osp.pin_number : '?');
        others.push(refdes + '.' + pinName);
      }
    }
    return { type: 'direct', others, traceCount: traces.length };
  }
  return { type: 'none' };
}
// Resolve the source_port for a feature (pad or hole) so we can pull
// pin name + pin number + net.
function _inspectResolvePort(feature) {
  const idx = window._inspectIndex;
  if (!idx || !feature.pcbPortId) return null;
  const pcbPort = idx.pcbPortById.get(feature.pcbPortId);
  if (!pcbPort || !pcbPort.source_port_id) return null;
  return idx.sourcePortById.get(pcbPort.source_port_id) || null;
}

function _inspectRenderChip(feat) {
  const idx = window._inspectIndex;
  const pcb = idx.pcbComponentById.get(feat.pcbComponentId);
  const src = pcb ? idx.sourceComponentById.get(pcb.source_component_id) : null;
  const cad = idx.cadByPcbId.get(feat.pcbComponentId);
  const ports = idx.portsByPcbComponent.get(feat.pcbComponentId) || [];
  const kicad = (pcb && pcb.metadata && pcb.metadata.kicad_footprint) || {};
  const fpRef = kicad.Reference || kicad.reference || null;
  const fpDesc = kicad.Description || kicad.description || null;
  const jlc = src && src.supplier_part_numbers && src.supplier_part_numbers.jlcpcb && src.supplier_part_numbers.jlcpcb[0];
  const modelBase = cad && cad.model_step_url ? cad.model_step_url.split('/').pop() : null;
  const ftype = src ? src.ftype : null;
  const kindName = ftype === 'simple_chip' ? 'Chip'
                 : ftype === 'simple_resistor' ? 'Resistor'
                 : ftype === 'simple_capacitor' ? 'Capacitor'
                 : ftype === 'simple_inductor' ? 'Inductor'
                 : ftype === 'simple_diode' ? 'Diode'
                 : ftype === 'simple_led' ? 'LED'
                 : 'Component';
  const rows = [];
  rows.push(_row('Refdes', feat.name));
  if (ftype) rows.push(_row('Type', kindName + ' (' + ftype + ')'));
  if (fpRef) rows.push(_row('Footprint', fpRef));
  if (fpDesc) rows.push(_row('Description', fpDesc));
  if (src && src.resistance != null) rows.push(_row('Resistance', src.resistance + ' Ω'));
  if (src && src.capacitance != null) rows.push(_row('Capacitance', src.capacitance + ' F'));
  if (src && src.inductance != null) rows.push(_row('Inductance', src.inductance + ' H'));
  rows.push(_row('Pins', ports.length || '—'));
  rows.push(_row('Package', _mm(feat.w) + ' × ' + _mm(feat.h)));
  rows.push(_row('Layer', feat.layer || 'top'));
  rows.push(_row('Rotation', (feat.rotation || 0) + '°'));
  rows.push(_row('Position', '(' + _mm(feat.x, 2) + ', ' + _mm(feat.y, 2) + ')'));
  if (jlc) rows.push(_row('JLCPCB #', jlc));
  else rows.push(_rowMuted('JLCPCB #', 'source_component has no supplier_part_numbers.jlcpcb entry'));
  rows.push(_rowMuted('MPN', "Not in circuit.json today. Filed upstream as TSCIRCUIT_FEATURE_REQUESTS.md §5."));
  rows.push(_rowMuted('Manufacturer', "Not in circuit.json today."));
  rows.push(_rowMuted('Datasheet', "Not in circuit.json today."));
  if (modelBase) rows.push(_row('3D model', modelBase));
  return {
    kind: kindName.toUpperCase(),
    title: feat.name,
    rows,
    foot: 'Click card to pin. Esc to unpin.',
  };
}
function _inspectRenderPad(feat) {
  const idx = window._inspectIndex;
  const sp = _inspectResolvePort(feat);
  const conn = sp ? _inspectLookupConnection(sp.source_port_id) : { type: 'none' };
  const pinName = sp ? (sp.name || (sp.pin_number != null ? 'pin' + sp.pin_number : null)) : null;
  const label = feat.owner + (pinName ? '.' + pinName : (feat.portHints && feat.portHints[0] ? '.' + feat.portHints[0] : ''));
  const rows = [];
  rows.push(_row('Pad', label));
  if (sp && sp.pin_number != null) rows.push(_row('Pin #', sp.pin_number));
  rows.push(_row('Shape', feat.shape || 'rect'));
  rows.push(_row('Size', _mm(feat.w) + ' × ' + _mm(feat.h)));
  rows.push(_row('Layer', feat.layer));
  rows.push(_row('Solder mask', feat.isCoveredWithSolderMask ? 'covered' : 'opening (exposed copper)'));
  rows.push(_row('Parent', feat.owner));
  _inspectAppendConnectionRows(conn, rows);
  rows.push(_row('Position', '(' + _mm(feat.x, 2) + ', ' + _mm(feat.y, 2) + ')'));
  return { kind: 'PAD', title: label, rows, foot: 'Click card to pin. Esc to unpin.' };
}

function _inspectAppendConnectionRows(conn, rows) {
  if (conn.type === 'net') {
    const flagStr = conn.flags.length ? '  [' + conn.flags.join(', ') + ']' : '';
    rows.push(_row('Net', conn.name + flagStr));
    rows.push(_row('Connected', 'per schematic'));
  } else if (conn.type === 'direct') {
    const others = conn.others.slice(0, 4);
    const summary = others.join(', ') + (conn.others.length > 4 ? ` (+${conn.others.length - 4} more)` : '');
    rows.push(_row('Connects to', summary || '(trace without peer)'));
    rows.push(_row('Via', conn.traceCount === 1 ? '1 direct trace' : (conn.traceCount + ' direct traces')));
  } else {
    rows.push(_rowMuted('Net', 'No source_trace references this port — literal airwire.'));
  }
}
function _inspectRenderHole(feat) {
  const idx = window._inspectIndex;
  const sp = _inspectResolvePort(feat);
  const conn = sp ? _inspectLookupConnection(sp.source_port_id) : { type: 'none' };
  // Machine-contact heuristic: a plated hole whose parent footprint
  // Reference matches MountingHole / Mechanical_*, OR whose refdes
  // starts MP/MC (machine pins/posts in adom-tsci convention).
  let role = null;
  let kindLabel = 'HOLE';
  const pcb = idx.pcbComponentById.get(feat.pcbComponentId);
  const kicad = (pcb && pcb.metadata && pcb.metadata.kicad_footprint) || {};
  const fpRef = kicad.Reference || kicad.reference || '';
  const isMechByRef = /^(Mechanical_|MountingHole|TestPoint)/i.test(fpRef);
  const isMechByName = /^(MP|MC)\d+/i.test(feat.owner || '');
  if (isMechByRef || isMechByName) {
    kindLabel = 'MACHINE CONTACT';
    role = (conn.type === 'net' || conn.type === 'direct') ? 'Mechanical + electrical' : 'Mechanical only';
  } else if (sp) {
    kindLabel = 'PLATED HOLE';
  }
  const rows = [];
  rows.push(_row('Label', feat.owner));
  rows.push(_row('Drill Ø', _mm(feat.drillDiameter, 3)));
  rows.push(_row('Pad Ø', _mm(feat.diameter, 3)));
  rows.push(_row('Plated', feat.isPlated ? 'yes' : 'no'));
  if (feat.layers && feat.layers.length) rows.push(_row('Layers', feat.layers.join(', ')));
  if (fpRef) rows.push(_row('Footprint', fpRef));
  if (role) rows.push(_row('Role', role));
  _inspectAppendConnectionRows(conn, rows);
  rows.push(_row('Position', '(' + _mm(feat.x, 2) + ', ' + _mm(feat.y, 2) + ')'));
  return { kind: kindLabel, title: feat.owner, rows, foot: 'Click card to pin. Esc to unpin.' };
}
function _inspectRenderVia(feat) {
  const idx = window._inspectIndex;
  const net = feat.sourceNetId ? idx.sourceNetById.get(feat.sourceNetId) : null;
  const rows = [];
  rows.push(_row('Drill Ø', _mm(feat.drillDiameter, 3)));
  rows.push(_row('Pad Ø', _mm(feat.diameter, 3)));
  if (feat.fromLayer && feat.toLayer) rows.push(_row('Bridges', feat.fromLayer + ' → ' + feat.toLayer));
  else if (feat.layers && feat.layers.length) rows.push(_row('Layers', feat.layers.join(', ')));
  rows.push(_row('Net', net ? (net.name || '?') : null));
  rows.push(_row('Position', '(' + _mm(feat.x, 2) + ', ' + _mm(feat.y, 2) + ')'));
  return { kind: 'VIA', title: 'Via', rows, foot: 'Physical trace visible in the PCB tab.' };
}
function _inspectRenderSilk(feat) {
  const rows = [];
  rows.push(_row('Text', '"' + feat.text + '"'));
  rows.push(_row('Layer', feat.layer));
  rows.push(_row('Font size', _mm(feat.fontSize, 2)));
  rows.push(_row('Labels', feat.owner !== 'board' ? feat.owner : 'board-level'));
  rows.push(_row('Position', '(' + _mm(feat.x, 2) + ', ' + _mm(feat.y, 2) + ')'));
  return { kind: 'SILKSCREEN', title: feat.text, rows, foot: 'Printed in the Board Surface texture.' };
}
function _inspectRenderBoard() {
  const idx = window._inspectIndex;
  const board = idx && idx.pcbBoard;
  const rows = [];
  if (!board) {
    rows.push(_rowMuted('Board', 'no pcb_board entity in circuit.json'));
    return { kind: 'BOARD', title: 'PCB', rows, foot: '' };
  }
  const w = board.width, h = board.height;
  const center = board.center || { x: 0, y: 0 };
  const outlinePts = Array.isArray(board.outline) ? board.outline.length : 0;
  rows.push(_row('Dimensions', (w != null && h != null) ? (_mm(w) + ' × ' + _mm(h)) : null));
  rows.push(_row('Thickness', board.thickness != null ? _mm(board.thickness, 2) : null));
  rows.push(_row('Layers', board.num_layers != null ? board.num_layers : null));
  rows.push(_row('Outline', outlinePts ? (outlinePts + ' points') : 'rectangle'));
  rows.push(_row('Center', '(' + _mm(center.x || 0, 2) + ', ' + _mm(center.y || 0, 2) + ')'));
  rows.push(_row('Components', idx.pcbComponentById.size));
  rows.push(_row('Nets', idx.sourceNetById.size));
  const tsDefault = 'Not in circuit.json today — filed upstream as TSCIRCUIT_FEATURE_REQUESTS.md §4. tscircuit\'s JLCPCB-stackup default is HASL finish / green mask / 1 oz copper.';
  rows.push(_rowMuted('Surface finish', tsDefault));
  rows.push(_rowMuted('Solder-mask color', tsDefault));
  rows.push(_rowMuted('Solder-mask thickness', tsDefault));
  rows.push(_rowMuted('Copper weight', tsDefault));
  if (window._bakedTexRes) rows.push(_row('Baked texture', window._bakedTexRes + '×' + window._bakedTexRes));
  return { kind: 'BOARD', title: 'PCB Board', rows, foot: 'Click card to pin. Esc to unpin.' };
}

// Inspect-on-trace: when the user hovers a rendered trace mesh
// (wire/via/pad/plated-hole) while Inspect is active, show a Net
// info card with name, layer, group, kind under cursor, and the
// ports this net touches. The mesh's _netId + _traceKind tell us
// what's under the cursor specifically.
function _inspectRenderNet(feat) {
  const net = feat.net;
  const kind = feat.kind || 'wire';
  const layer = feat.layer || (kind === 'via' ? '—' : 'top');
  const rows = [];
  rows.push(_row('Net', net.name));
  rows.push(_row('Group', net.group));
  rows.push(_row('Kind under cursor', kind));
  rows.push(_row('Layer', layer));
  rows.push(_row('Ports (' + net.portRefs.length + ')', net.portRefs.join(' • ')));
  rows.push(_row('Routed pcb_traces', net.pcbTraceIds.length));
  rows.push(_row('Pads on net', (net.pads || []).length));
  return { kind: 'NET', title: net.name, rows, foot: 'Click the row in the Nets panel to isolate this net. Click card to pin. Esc to unpin.' };
}
function renderInspectCard(feat) {
  const data = feat === 'board' ? _inspectRenderBoard()
             : feat && feat.__netInspect ? _inspectRenderNet(feat)
             : feat.kind === 'chip' ? _inspectRenderChip(feat)
             : feat.kind === 'pad'  ? _inspectRenderPad(feat)
             : feat.kind === 'hole' ? _inspectRenderHole(feat)
             : feat.kind === 'via'  ? _inspectRenderVia(feat)
             : feat.kind === 'silk' ? _inspectRenderSilk(feat)
             : { kind: (feat.kind || 'FEATURE').toUpperCase(), title: feat.label || '—', rows: [], foot: '' };
  const foot = inspectCardPinned ? 'Pinned. Esc or click elsewhere to unpin.' : (data.foot || '');
  _inspectCard.innerHTML = `
    <div class="ic-head">
      <span class="ic-kind">${_esc(data.kind)}</span>
      <span class="ic-title">${_esc(data.title || '')}</span>
    </div>
    <div class="ic-body">${data.rows.join('')}</div>
    ${foot ? `<div class="ic-foot">${_esc(foot)}</div>` : ''}
  `;
  _inspectCard.classList.add('visible');
  positionInspectCard();
}

// Public API: programmatically inspect a named feature. Drives the
// same render path as hover, but bypasses canvas pick synthesis (which
// is unreliable for CLI-driven demos — pointer events from eval often
// miss the pad). Used by `adom-tsci inspect <feature>` and by AI agents
// that want to show "the Inspect card for U1" or "for MC_USB_DP" in
// a recorded demo without flaky hovers.
//
// feature name must match `pcbFeatures[*].name` exactly. Pass the
// literal string "board" to pin the board-level info card.
//
// Returns { ok: true, kind, title } on success, { error } on failure.
window.inspectFeature = function(name) {
  try {
    if (!pcbFeatures || !pcbFeatures.length) return { error: 'pcbFeatures not built yet' };
    if (!inspectActive) toggleInspect();
    // Drop any prior hover outline
    disposeInspectHover();
    if (name === 'board') {
      lastInspectFeature = null;
      lastInspectWasBoard = true;
      renderInspectCard('board');
    } else {
      const feat = pcbFeatures.find(f => f.name === name);
      if (!feat) return { error: `feature "${name}" not found` };
      const B = window.Adom3DViewer.BABYLON;
      _inspectHoverMesh = drawFeatureOutline(feat, new B.Color3(INSPECT_AMBER.r, INSPECT_AMBER.g, INSPECT_AMBER.b));
      lastInspectFeature = feat;
      lastInspectWasBoard = false;
      renderInspectCard(feat);
    }
    // Pin the card so it stays visible without a hovering mouse — the
    // whole point of CLI-driven inspect is a deterministic, screenshot-
    // friendly display.
    inspectCardPinned = true;
    _inspectCard.classList.add('pinned');
    if (lastInspectFeature) renderInspectCard(lastInspectFeature);
    else if (lastInspectWasBoard) renderInspectCard('board');
    // Report what got inspected
    if (name === 'board') return { ok: true, kind: 'BOARD', title: 'PCB Board' };
    const f = pcbFeatures.find(x => x.name === name);
    return { ok: true, kind: (f.kind || '').toUpperCase(), title: f.label || f.name };
  } catch (e) {
    return { error: e && e.message ? e.message : String(e) };
  }
};

// Public API: clear any pinned inspect card, leave the tool active.
// Useful between consecutive `inspect` CLI calls in a demo sequence.
window.inspectClear = function() {
  disposeInspectHover();
  inspectCardPinned = false;
  _inspectCard.classList.remove('pinned');
  hideInspectCard();
  return { ok: true };
};

// ── Public API for the `adom-tsci highlight` CLI ──
// Thin wrappers around highlightComponents / walkthroughFlyTo so an AI
// (or a test ralph loop) can deterministically:
//   1. clear the highlight
//   2. screenshot the off state
//   3. highlight a kind + optionally fit camera
//   4. screenshot the on state
//   5. diff / eyeball
// See adom-tsci/SKILL.md "Walkthrough highlight rules" for the invariants.

window.highlightGroup = function(kind) {
  try {
    if (typeof componentMap === 'undefined') return { error: 'componentMap not ready' };
    const names = [];
    for (const [name, meta] of componentMap) {
      if (meta.kind === kind) names.push(name);
    }
    highlightComponents(names);
    return { ok: true, kind: kind, count: names.length, names: names };
  } catch (e) { return { error: e && e.message ? e.message : String(e) }; }
};

window.highlightGroupWithFit = function(kind) {
  try {
    const result = window.highlightGroup(kind);
    if (result.error || result.count === 0) return result;
    // Fit the camera to enclose every component of this kind. Reuse the
    // walkthrough focus-kind='components' path so the camera math is the
    // same as a real walkthrough step.
    walkthroughFlyTo({ kind: 'components', names: result.names, zoomTight: true });
    return result;
  } catch (e) { return { error: e && e.message ? e.message : String(e) }; }
};

window.clearHighlight = function() {
  try {
    highlightComponents([]);
    return { ok: true };
  } catch (e) { return { error: e && e.message ? e.message : String(e) }; }
};

window._handleInspectHover = function () {
  if (inspectCardPinned) return;   // hold the pinned card, don't pick
  const scene = viewer.getScene();
  const pick = scene.pick(scene.pointerX, scene.pointerY);
  if (!pick.hit) { disposeInspectHover(); hideInspectCard(); return; }
  // Net-aware: if the picked mesh belongs to the trace renderer
  // (wire / via / pad / plated-hole), show a "Net" info card with
  // name + ports + group + per-mesh-kind. This is the inspect-on-trace
  // integration.
  const pm = pick.pickedMesh;
  if (pm && pm._netId && typeof _traceNets !== 'undefined') {
    const net = _traceNets.find(n => n.id === pm._netId);
    if (net) {
      const featSig = '__net__:' + net.id + ':' + (pm._traceKind || 'wire');
      if (lastInspectFeature === featSig) { positionInspectCard(); return; }
      disposeInspectHover();
      lastInspectFeature = featSig;
      lastInspectWasBoard = false;
      renderInspectCard({ __netInspect: true, net: net, kind: pm._traceKind || 'wire', layer: pm._traceLayer });
      return;
    }
  }
  const feat = findNearestPcbFeature(pick.pickedPoint);
  if (feat) {
    if (lastInspectFeature === feat) { positionInspectCard(); return; }
    disposeInspectHover();
    const B = window.Adom3DViewer.BABYLON;
    _inspectHoverMesh = drawFeatureOutline(feat, new B.Color3(INSPECT_AMBER.r, INSPECT_AMBER.g, INSPECT_AMBER.b));
    lastInspectFeature = feat;
    lastInspectWasBoard = false;
    renderInspectCard(feat);
    return;
  }
  // No feature in range — fall back to board info if the pick hit
  // board geometry (huge flat mesh by enumerateComponents' definition).
  const m = pick.pickedMesh;
  const isBoard = m && (() => {
    m.computeWorldMatrix && m.computeWorldMatrix(true);
    const bi = m.getBoundingInfo && m.getBoundingInfo();
    if (!bi) return false;
    const sx = bi.boundingBox.extendSizeWorld.x * 2;
    const sy = bi.boundingBox.extendSizeWorld.y * 2;
    const sz = bi.boundingBox.extendSizeWorld.z * 2;
    return sx > 80 && sy > 80 && sz < 10;
  })();
  if (isBoard) {
    if (lastInspectWasBoard) { positionInspectCard(); return; }
    disposeInspectHover();
    lastInspectFeature = null;
    lastInspectWasBoard = true;
    renderInspectCard('board');
    return;
  }
  disposeInspectHover();
  hideInspectCard();
};

// Click on the card → pin. Click on the canvas (viewer-wrap) → unpin
// if pinned, else no-op (hover already drives card).
_inspectCard.addEventListener('click', (e) => {
  if (!inspectActive) return;
  if (!inspectCardPinned) {
    inspectCardPinned = true;
    _inspectCard.classList.add('pinned');
    // Re-render so the footer switches to the pinned copy.
    if (lastInspectFeature) renderInspectCard(lastInspectFeature);
    else if (lastInspectWasBoard) renderInspectCard('board');
  }
  e.stopPropagation();
});
document.getElementById('viewer-wrap').addEventListener('click', () => {
  if (!inspectActive) return;
  if (inspectCardPinned) {
    inspectCardPinned = false;
    _inspectCard.classList.remove('pinned');
    hideInspectCard();
    disposeInspectHover();
  }
});

// Find the mesh edge closest to `hitPoint` by iterating every triangle
// edge of the picked mesh. Returns {a, b, length, d2} for the winner.
// O(triangles) per call — fine for a single PCB component mesh.
function findNearestEdgeOnMesh(mesh, hitPoint) {
  const B = window.Adom3DViewer.BABYLON;
  mesh.computeWorldMatrix(true);
  const W = mesh.getWorldMatrix();
  const positions = mesh.getVerticesData('position');
  const indices = mesh.getIndices();
  if (!positions || !indices) return null;
  let best = null, bestD2 = Infinity;
  const tmpA = new B.Vector3(), tmpB = new B.Vector3();
  for (let i = 0; i < indices.length; i += 3) {
    const tri = [indices[i], indices[i+1], indices[i+2]];
    for (let e = 0; e < 3; e++) {
      const ia = tri[e], ib = tri[(e+1)%3];
      tmpA.set(positions[ia*3], positions[ia*3+1], positions[ia*3+2]);
      tmpB.set(positions[ib*3], positions[ib*3+1], positions[ib*3+2]);
      const a = B.Vector3.TransformCoordinates(tmpA, W);
      const b = B.Vector3.TransformCoordinates(tmpB, W);
      const ab = b.subtract(a);
      const ap = hitPoint.subtract(a);
      const ab2 = B.Vector3.Dot(ab, ab);
      const t = ab2 > 0 ? Math.max(0, Math.min(1, B.Vector3.Dot(ap, ab) / ab2)) : 0;
      const proj = a.add(ab.scale(t));
      const d2 = B.Vector3.DistanceSquared(hitPoint, proj);
      if (d2 < bestD2) { bestD2 = d2; best = { a, b, length: ab.length() }; }
    }
  }
  return best;
}

(() => {
  const wrap = document.getElementById('viewer-wrap');
  // Throttle the hover picker to 60ms (~16 Hz) so camera orbits stay
  // buttery-smooth. And skip entirely while the user is dragging
  // (orbit/pan/zoom in progress) — scene.pick + mesh mutation during
  // a drag breaks Babylon's pointer capture and freezes the camera.
  let lastHover = 0;
  wrap.addEventListener('mousemove', () => {
    if (isDraggingInViewer) { clearHover(); return; }  // don't pick while orbiting
    const now = performance.now();
    if (now - lastHover < 60) return;
    lastHover = now;
    if (!viewer) { clearHover(); return; }
    // Inspect tool drives its own hover pipeline (amber outline + info
    // card) before the measure branch. It runs every 60 ms like measure.
    if (activeTool === 'inspect') {
      if (window._handleInspectHover) window._handleInspectHover();
      return;
    }
    if (!measureActive || measureSelections.length >= 2) { clearHover(); return; }
    const scene = viewer.getScene();
    const pick = scene.pick(scene.pointerX, scene.pointerY);
    if (!pick.hit) { clearHover(); return; }
    const B = window.Adom3DViewer.BABYLON;
    if (measureShowSnaps) updateSnapDots(pick.pickedMesh, pick.pickedPoint);
    else clearSnapDots();
    if (measureFilter === 'smart') {
      // Find the nearest PCB feature to the hit point.
      const feat = findNearestPcbFeature(pick.pickedPoint);
      if (!feat) {
        if (hoverPreviewFeatureMesh) { hoverPreviewFeatureMesh.dispose(); hoverPreviewFeatureMesh = null; }
        hoverPreviewFeature = null;
        return;
      }
      if (hoverPreviewFeature && hoverPreviewFeature === feat) return; // same feature
      if (hoverPreviewFeatureMesh) hoverPreviewFeatureMesh.dispose();
      hoverPreviewFeature = feat;
      hoverPreviewFeatureMesh = drawFeatureOutline(feat);
    } else if (measureFilter === 'point') {
      if (hoverPreviewBodyMesh) { try { getHighlightLayer().removeMesh(hoverPreviewBodyMesh); } catch {} _measureHlClear(hoverPreviewBodyMesh); hoverPreviewBodyMesh = null; }
      if (hoverPreviewEdgeMesh) { hoverPreviewEdgeMesh.dispose(); hoverPreviewEdgeMesh = null; hoverPreviewEdgeInfo = null; }
      const pos = snapToVertex(pick);
      hoverPreviewPoint = pos;
      if (!measurePreviewMarker) {
        // Bigger (2.5mm) so it's visible on a ~128mm-wide PCB. White with
        // bright cyan rim accent so it reads as a "target" the user can aim at.
        measurePreviewMarker = B.MeshBuilder.CreateSphere('measurePreview', { diameter: 2.5 }, scene);
        const mat = new B.StandardMaterial('mPreview', scene);
        mat.disableLighting = true;
        mat.emissiveColor = new B.Color3(0.5, 0.95, 0.90);
        mat.alpha = 0.85;
        measurePreviewMarker.material = mat;
        measurePreviewMarker.renderingGroupId = 3;
        measurePreviewMarker.isPickable = false;   // don't let the preview block subsequent picks
      }
      measurePreviewMarker.position = pos;
    } else if (measureFilter === 'edge') {
      disposePreview(); hoverPreviewPoint = null;
      if (hoverPreviewBodyMesh) { try { getHighlightLayer().removeMesh(hoverPreviewBodyMesh); } catch {} _measureHlClear(hoverPreviewBodyMesh); hoverPreviewBodyMesh = null; }
      const edge = findNearestEdgeOnMesh(pick.pickedMesh, pick.pickedPoint);
      if (!edge) { if (hoverPreviewEdgeMesh) { hoverPreviewEdgeMesh.dispose(); hoverPreviewEdgeMesh = null; } hoverPreviewEdgeInfo = null; return; }
      hoverPreviewEdgeInfo = { meshName: pick.pickedMesh.name, a: edge.a, b: edge.b, length: edge.length };
      if (hoverPreviewEdgeMesh) { hoverPreviewEdgeMesh.dispose(); }
      // Draw the highlight as a thin TUBE (visible on thick edges like
      // chip pins and board traces) rather than an infinitely-thin Lines
      // primitive that's only 1px wide regardless of zoom.
      hoverPreviewEdgeMesh = B.MeshBuilder.CreateTube('measureHoverEdge', {
        path: [edge.a, edge.b], radius: 0.25, tessellation: 8,
      }, scene);
      const matE = new B.StandardMaterial('mEdgeHover', scene);
      matE.disableLighting = true;
      matE.emissiveColor = new B.Color3(0.5, 0.95, 0.9);
      hoverPreviewEdgeMesh.material = matE;
      hoverPreviewEdgeMesh.renderingGroupId = 3;
      hoverPreviewEdgeMesh.alwaysSelectAsActiveMesh = true;
      hoverPreviewEdgeMesh.isPickable = false;
    } else if (measureFilter === 'body') {
      disposePreview(); hoverPreviewPoint = null;
      if (hoverPreviewEdgeMesh) { hoverPreviewEdgeMesh.dispose(); hoverPreviewEdgeMesh = null; hoverPreviewEdgeInfo = null; }
      if (hoverPreviewBodyMesh && hoverPreviewBodyMesh !== pick.pickedMesh) {
        _measureHlClear(hoverPreviewBodyMesh);
      }
      hoverPreviewBodyMesh = pick.pickedMesh;
      // Measure-tool body-mode hover preview: translucent overlay tint,
      // NOT a HighlightLayer fill. Chip stays visible.
      _measureHlApply(pick.pickedMesh, new B.Color3(0.5, 0.83, 0.78));
    }
  });
  wrap.addEventListener('mouseleave', clearHover);
})();

// Wire HUD controls
(() => {
  // Selection filter
  measureBar.querySelectorAll('[data-filter]').forEach(btn => btn.addEventListener('click', () => {
    measureFilter = btn.dataset.filter;
    measureBar.querySelectorAll('[data-filter]').forEach(x => x.classList.toggle('active', x === btn));
    clearHover();  // previous-filter's preview shouldn't linger
    toast('filter: ' + measureFilter);
  }));
  document.getElementById('mb-precision').addEventListener('change', (e) => {
    measurePrecision = parseInt(e.target.value, 10);
    renderSelections();
    recomputeDistance();
  });
  document.getElementById('mb-secondary').addEventListener('change', (e) => {
    measureSecondary = e.target.value;
    renderSelections();
    recomputeDistance();
  });
  document.getElementById('mb-snap-check').addEventListener('change', (e) => {
    measureShowSnaps = e.target.checked;
    if (!measureShowSnaps) clearSnapDots();
    toast('Show snap points: ' + (measureShowSnaps ? 'on' : 'off'));
  });
  document.getElementById('mb-clear').addEventListener('click', clearAllSelections);
  document.getElementById('mb-close').addEventListener('click', toggleMeasure);
  document.getElementById('mb-minus').addEventListener('click', () => measureBar.classList.toggle('collapsed'));
  // ESC: step back one selection at a time.
  //   - 2 selections \u2192 drop the 2nd (keep the 1st, so user can re-pick 2).
  //   - 1 selection  \u2192 drop the 1st (back to 0).
  //   - 0 selections \u2192 close the HUD entirely.
  // Only if the tool is on — don't steal ESC from other widgets.
  window.addEventListener('keydown', (e) => {
    if (e.key !== 'Escape') return;
    if (!measureActive) return;
    if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.isContentEditable)) return;
    if (measureSelections.length > 0) {
      const sel = measureSelections.pop();
      disposeSelection(sel);
      // Rebuild tag observers so the remaining selection's tag number
      // stays correct (always re-indexed from 1).
      if (mbHud1) mbHud1.style.display = 'none';
      if (mbHud2) mbHud2.style.display = 'none';
      measureSelections.forEach((s, idx) => {
        if (s.renderObs) {
          const scene = viewer && viewer.getScene && viewer.getScene();
          if (scene) scene.onBeforeRenderObservable.remove(s.renderObs);
          s.renderObs = null;
        }
        attachTag(s, idx + 1);
      });
      renderSelections();
      recomputeDistance();
      if (!measureSelections.length) measureBar.classList.remove('has-any');
      toast(measureSelections.length ? ('selection ' + (measureSelections.length + 1) + ' removed') : 'all selections cleared');
    } else {
      toggleMeasure();
    }
  });
  // Drag header to move the HUD around. Ignore clicks on the − / » icons.
  makeDraggable(
    document.getElementById('measure-bar'),
    document.getElementById('mb-header'),
    { ignoreSelector: '#mb-minus, #mb-dock' },
  );
  // Double-clicking the drag handle collapses / expands the HUD \u2014 same as
  // hitting the \u2212 icon, but without having to hit a small target.
  document.getElementById('mb-header').addEventListener('dblclick', (e) => {
    if (e.target.closest('#mb-minus, #mb-dock')) return;
    measureBar.classList.toggle('collapsed');
    e.preventDefault();
  });
  registerFloatingHud(document.getElementById('measure-bar'));
})();

// ─── Component panel (floating) ────────────────────────────────────
const componentMap = new Map();

// Classification is DETERMINISTIC and DECLARED, not guessed. The source
// is circuit.json's `source_component.ftype` (emitted by tscircuit build
// from the JSX tag the author wrote: <capacitor/>, <resistor/>, etc.).
// Regex-on-refdes was the old heuristic — it misclassified any author
// who named a capacitor "CB_FILTER" (matched /^R/) or a test-point
// network "TP_LINE_1" used as a contact. ftype is authoritative.
//
// If you see a misclassification today, the fix is to correct the JSX
// tag in the board source, NOT to tweak a regex here. See adom-tsci's
// SKILL.md "Walkthrough highlight rules" — rule 5 — for why.
const _FTYPE_TO_KIND = {
  'simple_chip':      'chip',
  'simple_capacitor': 'capacitor',
  'simple_resistor':  'resistor',
  'simple_inductor':  'inductor',
  'simple_crystal':   'crystal',
  'simple_diode':     'diode',
  'simple_led':       'diode',
  'simple_transistor':'transistor',
  'simple_mosfet':    'transistor',
  'simple_connector': 'connector',
  'simple_power':     'power',
  'simple_ground':    'power',
  'simple_testpoint': 'testpoint',
  'simple_test_point':'testpoint',
  // tscircuit renders <testpoint> / <contact> / <hole> / <mounting_pin>
  // as simple_chip today — they're disambiguated by refdes prefix in
  // classifyFallback() below. When tscircuit adds dedicated ftypes
  // ('simple_testpoint', 'simple_contact'), add them here and retire
  // the prefix fallback.
};

function classifyFromRefdes(name) {
  // Fallback ONLY for refdes classes that tscircuit doesn't distinguish
  // via ftype today (testpoint / contact / mounting). When tscircuit
  // emits unique ftypes for these, delete this function and switch the
  // ftype map above to authoritative-only.
  if (/^TP/i.test(name))     return 'testpoint';
  if (/^MC/i.test(name))     return 'contact';
  if (/^MP\d+$/i.test(name)) return 'mounting';
  return null;
}

function classifyComponent(name, ftype) {
  // 1) Refdes prefix FIRST for the kinds tscircuit doesn't distinguish
  //    via ftype (testpoint / contact / mounting). tscircuit emits
  //    `simple_chip` for <testpoint/>, <contact/>, and <mountingpin/>,
  //    so letting the ftype map win would lose those kinds. The
  //    prefix check is the disambiguator until tscircuit ships real
  //    ftypes for these (tracked in TSCIRCUIT_FEATURE_REQUESTS).
  const prefixKind = classifyFromRefdes(name);
  if (prefixKind) return prefixKind;
  // 2) Authoritative — ftype from the JSX tag the author wrote.
  if (ftype && _FTYPE_TO_KIND[ftype]) return _FTYPE_TO_KIND[ftype];
  // 3) Unknown ftype — warn loudly so board authors see it, classify
  //    conservatively as 'misc' (excluded from kind-based highlights).
  if (ftype) {
    console.warn(
      '[classify] unknown ftype "' + ftype + '" for ' + name +
      ' — add mapping to _FTYPE_TO_KIND in shell.html, or the component ' +
      'won\'t appear in Components HUD groups or kind-based walkthrough highlights.'
    );
  }
  return 'misc';
}

// Kept for back-compat — anything still calling classify(name) gets
// the name-only fallback (testpoint / contact / mounting only). Any
// regex-driven classifier callsite we find should be migrated to
// classifyComponent(name, ftype).
function classify(name) {
  return classifyFromRefdes(name) || 'misc';
}

async function fetchPcbComponents() {
  try {
    const r = await fetch('circuit.json');
    if (!r.ok) return [];
    const arr = await r.json();
    const sources = new Map();
    const ftypes = new Map();
    for (const e of arr) if (e.type === 'source_component') {
      sources.set(e.source_component_id, e.name || '?');
      ftypes.set(e.source_component_id, e.ftype || '');
    }
    const out = [];
    for (const e of arr) {
      if (e.type !== 'pcb_component') continue;
      const name = sources.get(e.source_component_id) || '?';
      const ftype = ftypes.get(e.source_component_id) || '';
      const c = e.center || {};
      out.push({
        name,
        kind: classifyComponent(name, ftype),
        ftype,
        x: c.x, y: c.y, w: e.width || 1, h: e.height || 1,
        rotation: e.rotation || 0,  // degrees, used by the chip-shape synthesiser to keep pin-1 in the right corner
      });
    }
    return out;
  } catch { return []; }
}

// Render silkscreen as TRUE VECTOR geometry: Babylon tubes for every
// path, circle, and text glyph. Source data: circuit.json's
// pcb_silkscreen_path / pcb_silkscreen_circle / pcb_silkscreen_text +
// @tscircuit/alphabet's lineAlphabet (same stroke font tscircuit uses
// for gerber generation, so these meshes match what a fab prints).
// Stays resolution-independent at any zoom \u2014 unlike the 1024\u00d71024
// baked texture inside the GLB.
let _silkscreenMeshes = [];
let _silkscreenMeshesTop = [];
let _silkscreenMeshesBot = [];
let _solderMaskMeshesTop = [];
let _solderMaskMeshesBot = [];
// Vector overlay is off by default (performance). Baked texture from
// tscircuit's GLB is the only silkscreen source in normal operation.
let _silkSourceVector = false;
let _silkSourceBaked = true;
let _silkLayerTop = true;
let _silkLayerBot = true;
let _maskLayerTop = true;
let _maskLayerBot = true;
const _solderMaskMeshes = [];  // compat, union of top+bot (populated below)

// Silkscreen mode, driven by the Components-HUD toggle. Can also be
// called via CLI: `adom-tsci eval 'setSilkscreenMode("baked")'`.
// Silkscreen source checkboxes \u2014 each independently on/off.
window.setSilkSourceVisible = function(source, on) {
  if (source === 'vector') _silkSourceVector = !!on;
  else if (source === 'baked') _silkSourceBaked = !!on;
  _applySilkVisibility();
  _syncToggleButtons();
};
window.setSilkLayerVisible = function(layer, on) {
  if (layer === 'top') _silkLayerTop = !!on;
  else if (layer === 'bottom') _silkLayerBot = !!on;
  _applySilkVisibility();
  _syncToggleButtons();
};
window.setMaskLayerVisible = function(side, on) {
  if (side === 'top') _maskLayerTop = !!on;
  else if (side === 'bottom') _maskLayerBot = !!on;
  _applySilkVisibility();
  _syncToggleButtons();
};
// Back-compat shim for the earlier three-way mode API + CLI.
window.setSilkscreenMode = function(mode) {
  _silkSourceVector = (mode === 'vector' || mode === 'both');
  _silkSourceBaked  = (mode === 'baked'  || mode === 'both');
  _applySilkVisibility();
  _syncToggleButtons();
};
function _syncToggleButtons() {
  for (const btn of document.querySelectorAll('.co-silk-btn')) {
    const s = btn.dataset.silksource;
    btn.classList.toggle('active', s === 'vector' ? _silkSourceVector : _silkSourceBaked);
  }
  for (const btn of document.querySelectorAll('.co-silklayer-btn')) {
    const l = btn.dataset.silklayer;
    btn.classList.toggle('active', l === 'top' ? _silkLayerTop : _silkLayerBot);
  }
  for (const btn of document.querySelectorAll('.co-mask-btn')) {
    const s = btn.dataset.maskside;
    btn.classList.toggle('active', s === 'top' ? _maskLayerTop : _maskLayerBot);
  }
}
function _applySilkVisibility() {
  for (const m of _silkscreenMeshesTop) if (m.setEnabled) m.setEnabled(_silkSourceVector && _silkLayerTop);
  for (const m of _silkscreenMeshesBot) if (m.setEnabled) m.setEnabled(_silkSourceVector && _silkLayerBot);
  // Green solder-mask halves are part of the VECTOR rendering \u2014 only
  // visible when the vector source is on (the baked texture already
  // paints green onto the substrate faces).
  for (const m of _solderMaskMeshesTop) if (m.setEnabled) m.setEnabled(_silkSourceVector && _maskLayerTop);
  for (const m of _solderMaskMeshesBot) if (m.setEnabled) m.setEnabled(_silkSourceVector && _maskLayerBot);
  const scene = viewer && viewer.getScene && viewer.getScene();
  if (scene) {
    for (const t of (scene.textures || [])) {
      if (!t || !t.name) continue;
      if (t.name.includes('Material')) t.level = _silkSourceBaked ? 1 : 0;
    }
  }
}
// Back-compat for the earlier debug commands.
window.setVectorSilkscreenVisible = (on) => window.setSilkscreenMode(on ? 'both' : 'baked');
window.setBakedTextureVisible = (on) => window.setSilkscreenMode(on ? 'both' : 'vector');
async function buildSilkscreenOverlays() {
  const B = window.Adom3DViewer && window.Adom3DViewer.BABYLON;
  if (!B || !viewer) return;
  const scene = viewer.getScene();
  // Dispose any previous overlay.
  for (const m of _silkscreenMeshes) { try { m.dispose(); } catch {} }
  for (const m of _solderMaskMeshes) { try { m.dispose(); } catch {} }
  _silkscreenMeshes = [];
  _solderMaskMeshes.length = 0;
  let arr;
  try { arr = await fetch('circuit.json').then(r => r.json()); } catch { return; }
  const topZ = (window._boardTopZ || 0.7) + 0.03;
  const botZ = (window._boardBottomZ || -0.7) - 0.03;
  const matTop = _silkMat('silkTop', new B.Color3(0.92, 0.92, 0.92), scene);
  const matBot = _silkMat('silkBot', new B.Color3(0.92, 0.92, 0.92), scene);
  // Green solder-mask backing planes for vector mode. Board width/height
  // come from pcb_board entry so the masks match the actual substrate.
  const pcbBoard = arr.find(e => e.type === 'pcb_board');
  const bw = (pcbBoard && pcbBoard.width) || 130;
  const bh = (pcbBoard && pcbBoard.height) || 130;
  const maskMat = _silkMat('maskGreen', new B.Color3(0.035, 0.18, 0.07), scene);
  // Top + bottom mask halves. Each is the full XY footprint and half the
  // substrate thickness, so hiding one exposes the board interior from
  // that side. Together they still occlude far-side silk via depth test.
  const topSurface = (window._boardTopZ || 0.7);
  const botSurface = (window._boardBottomZ || -0.7);
  const midZ = (topSurface + botSurface) / 2;
  const halfH = (topSurface - midZ) - 0.005;
  _solderMaskMeshesTop = [];
  _solderMaskMeshesBot = [];
  _solderMaskMeshes.length = 0;
  try {
    const topBody = B.MeshBuilder.CreateBox('maskBodyTop', { width: bw, height: bh, depth: halfH }, scene);
    topBody.position = new B.Vector3(0, 0, midZ + halfH / 2);
    topBody.material = maskMat;
    topBody.isPickable = false; topBody.renderingGroupId = 0;
    _solderMaskMeshesTop.push(topBody); _solderMaskMeshes.push(topBody);
    const botBody = B.MeshBuilder.CreateBox('maskBodyBot', { width: bw, height: bh, depth: halfH }, scene);
    botBody.position = new B.Vector3(0, 0, midZ - halfH / 2);
    botBody.material = maskMat;
    botBody.isPickable = false; botBody.renderingGroupId = 0;
    _solderMaskMeshesBot.push(botBody); _solderMaskMeshes.push(botBody);
  } catch {}
  // Pre-build a flat list of polylines per layer so we can batch them
  // into a single MERGED tube where possible (many small tubes kill
  // draw-call budget on dense boards).
  const polysTop = [];
  const polysBot = [];
  const stroke = 0.11;
  for (const e of arr) {
    const toList = e.layer === 'bottom' ? polysBot : polysTop;
    if (e.type === 'pcb_silkscreen_path' && e.route && e.route.length >= 2) {
      toList.push({ pts: e.route.map(p => [p.x, p.y]), w: e.stroke_width || stroke });
    } else if (e.type === 'pcb_silkscreen_circle' && e.center && e.radius) {
      const N = Math.max(12, Math.ceil(e.radius * 24));
      const pts = [];
      for (let i = 0; i <= N; i++) {
        const a = i / N * Math.PI * 2;
        pts.push([e.center.x + Math.cos(a) * e.radius, e.center.y + Math.sin(a) * e.radius]);
      }
      toList.push({ pts, w: e.stroke_width || stroke * 0.6 });
    } else if (e.type === 'pcb_silkscreen_text') {
      const glyphLines = _textToLines(e);
      // Alphabet's strokeWidthRatio = 0.09 relative to font_size \u2014 use
      // this so our text matches tscircuit's own gerber stroke width.
      const textStroke = (e.font_size || 0.5) * 0.09;
      for (const seg of glyphLines) toList.push({ pts: seg, w: textStroke });
    }
  }
  _silkscreenMeshesTop = [];
  _silkscreenMeshesBot = [];
  const mergedTop = _renderPolysMerged(polysTop, topZ, matTop, 'silkTop', scene);
  const mergedBot = _renderPolysMerged(polysBot, botZ, matBot, 'silkBot', scene);
  if (mergedTop) _silkscreenMeshesTop.push(mergedTop);
  if (mergedBot) _silkscreenMeshesBot.push(mergedBot);
  // Copper pads + plated hole rings as filled vector geometry. Same
  // source of truth the fab's gerber files use (pcb_smtpad,
  // pcb_plated_hole, pcb_hole in circuit.json). Stays sharp at any
  // zoom \u2014 the baked 1024\u00d71024 texture renders the pads as blurry
  // rectangles, vector pads are crisp rects / discs.
  const copperTop = _silkMat('copperTop', new B.Color3(0.92, 0.78, 0.32), scene);
  const copperBot = _silkMat('copperBot', new B.Color3(0.92, 0.78, 0.32), scene);
  const maskTop   = _silkMat('maskTop',   new B.Color3(0.08, 0.18, 0.10), scene);
  let pads = 0, holes = 0;
  for (const e of arr) {
    const isTop = (e.layer !== 'bottom');
    const padZ = isTop ? topZ - 0.01 : botZ + 0.01;
    const mat = isTop ? copperTop : copperBot;
    if (e.type === 'pcb_smtpad') {
      const m = _renderPad(e, padZ, mat, scene);
      if (m) (isTop ? _silkscreenMeshesTop : _silkscreenMeshesBot).push(m);
      pads++;
    } else if (e.type === 'pcb_plated_hole' || e.type === 'pcb_hole') {
      const [mTop, mBot] = _renderHole(e, topZ, botZ, copperTop, maskTop, scene);
      if (mTop) _silkscreenMeshesTop.push(mTop);
      if (mBot) _silkscreenMeshesBot.push(mBot);
      holes++;
    }
  }
  console.log('[3d] silkscreen vector: top=' + polysTop.length + ' bot=' + polysBot.length + ' polylines; pads=' + pads + ' holes=' + holes);
  // Camera-orientation-driven visibility: hide the FAR face's silkscreen
  // so it doesn't ghost through the board substrate at oblique angles.
  _wireSilkCameraVisibility(scene);
}

function _wireSilkCameraVisibility(scene) {
  // Previously toggled top-vs-bottom silkscreen visibility based on
  // cam.beta to stop the far-side layer ghosting through the board.
  // With the opaque green solder-mask planes in place, Babylon's
  // depth buffer handles occlusion naturally \u2014 the observer was
  // turning off the CORRECT-side silk for some camera angles, which
  // is why the bottom view was showing top-layer content. Leaving the
  // function as a no-op for back-compat; depth testing wins.
}
let _silkCamObs = null;

// SMT pad: flat copper rect or circle sitting on the board surface.
function _renderPad(e, z, mat, scene) {
  const B = window.Adom3DViewer.BABYLON;
  const shape = e.shape || 'rect';
  let mesh = null;
  if (shape === 'rect' || shape === 'rotated_rect' || shape === 'oval') {
    const w = e.width || (e.radius ? e.radius * 2 : 0.5);
    const h = e.height || (e.radius ? e.radius * 2 : 0.5);
    mesh = B.MeshBuilder.CreateBox('pad', { width: w, height: h, depth: 0.02 }, scene);
    mesh.position = new B.Vector3(e.x, e.y, z);
    if (e.ccw_rotation || e.rotation) {
      mesh.rotation = new B.Vector3(0, 0, (e.ccw_rotation || e.rotation) * Math.PI / 180);
    }
  } else if (shape === 'circle') {
    const r = e.radius || 0.3;
    // Approximate filled circle with a short tube (stacked rings).
    // CreateDisc may not be exported by the minified Babylon bundle, so
    // fall back to a cylinder-like thin tube.
    try {
      mesh = B.MeshBuilder.CreateDisc('pad', { radius: r, tessellation: 24 }, scene);
      mesh.position = new B.Vector3(e.x, e.y, z);
      mesh.rotation = new B.Vector3(Math.PI / 2, 0, 0);
    } catch {
      // Fallback: tiny box of equivalent area.
      mesh = B.MeshBuilder.CreateBox('pad', { width: r * 2, height: r * 2, depth: 0.02 }, scene);
      mesh.position = new B.Vector3(e.x, e.y, z);
    }
  }
  if (mesh) {
    mesh.material = mat;
    mesh.renderingGroupId = 1;
    mesh.isPickable = false;
    _silkscreenMeshes.push(mesh);
  }
  return mesh;
}

// Plated through-hole or plain hole: an outer copper ring on both
// faces + a dark inner bore. `outer_diameter` + `hole_diameter` in
// circuit.json give the concentric geometry directly.
function _renderHole(e, topZ, botZ, copperMat, maskMat, scene) {
  const B = window.Adom3DViewer.BABYLON;
  const outer = (e.outer_diameter || e.hole_diameter || 1.0) / 2;
  const inner = (e.hole_diameter || e.outer_diameter || 0.6) / 2;
  const ringW = Math.max(0.04, outer - inner);
  const ringR = (outer + inner) / 2;
  const rings = [];
  for (const z of [topZ - 0.005, botZ + 0.005]) {
    const N = 24;
    const path = [];
    for (let i = 0; i <= N; i++) {
      const a = i / N * Math.PI * 2;
      path.push(new B.Vector3(e.x + Math.cos(a) * ringR, e.y + Math.sin(a) * ringR, z));
    }
    const ring = B.MeshBuilder.CreateTube('hole', { path, radius: ringW / 2, tessellation: 4, cap: 0 }, scene);
    ring.material = copperMat;
    ring.renderingGroupId = 1;
    ring.isPickable = false;
    _silkscreenMeshes.push(ring);
    rings.push(ring);
  }
  return rings;
}
function _silkMat(name, color, scene) {
  const B = window.Adom3DViewer.BABYLON;
  const mat = new B.StandardMaterial(name, scene);
  mat.disableLighting = true;
  mat.emissiveColor = color;
  return mat;
}
// Build every polyline as an individual tube then merge them into a
// single mesh to cut draw-call count from ~21K to 1 per layer. At
// ~21K tubes the per-draw-call overhead dominates; merging takes FPS
// from 11 \u2192 60+ on typical hardware.
function _renderPolysMerged(polys, z, mat, prefix, scene) {
  const B = window.Adom3DViewer.BABYLON;
  if (!polys.length) return null;
  const tubes = [];
  for (let i = 0; i < polys.length; i++) {
    const { pts, w } = polys[i];
    if (pts.length < 2) continue;
    const path = pts.map(([x, y]) => new B.Vector3(x, y, z));
    const tube = B.MeshBuilder.CreateTube(prefix + '_' + i,
      { path, radius: Math.max(0.03, w / 2), tessellation: 4, cap: 0 }, scene);
    tubes.push(tube);
  }
  if (!tubes.length) return null;
  const merged = B.Mesh && B.Mesh.MergeMeshes
    ? B.Mesh.MergeMeshes(tubes, true, true, undefined, false, true)
    : null;
  if (!merged) {
    // Fallback: if MergeMeshes isn't exported, keep individual tubes.
    for (const t of tubes) {
      t.material = mat;
      t.renderingGroupId = 1;
      t.isPickable = false;
      _silkscreenMeshes.push(t);
    }
    return null;
  }
  merged.name = prefix + '_merged';
  merged.material = mat;
  merged.renderingGroupId = 1;
  merged.isPickable = false;
  _silkscreenMeshes.push(merged);
  return merged;
}

function _renderPolys(polys, z, mat, prefix, scene, layerArr) {
  const B = window.Adom3DViewer.BABYLON;
  for (let i = 0; i < polys.length; i++) {
    const { pts, w } = polys[i];
    if (pts.length < 2) continue;
    const path = pts.map(([x, y]) => new B.Vector3(x, y, z));
    const tube = B.MeshBuilder.CreateTube(prefix + '_' + i,
      { path, radius: Math.max(0.03, w / 2), tessellation: 4, cap: 0 }, scene);
    tube.material = mat;
    tube.renderingGroupId = 1;
    tube.isPickable = false;
    _silkscreenMeshes.push(tube);
    if (layerArr) layerArr.push(tube);
  }
}
// Convert a pcb_silkscreen_text entry to a list of polylines in board
// mm coordinates. Uses @tscircuit/alphabet's lineAlphabet which is the
// same stroke font tscircuit bakes into gerbers.
function _textToLines(e) {
  const alpha = window.TscircuitAlphabet;
  if (!alpha) return [];
  // Bottom-layer silkscreen is printed on the underside of the board
  // so when you FLIP the physical board to look at the bottom, the
  // text reads normally. In 3D space (looking UP at the bottom face
  // from below), that means we need to mirror the text horizontally
  // so it reads correctly from that viewing angle.
  const mirrorX = (e.layer === 'bottom');
  const lineAlphabet = alpha.lineAlphabet || alpha.glyphLineAlphabet || {};
  const advance = alpha.glyphAdvanceRatio || {};
  const fallbackAdvance = alpha.glyphWidthRatio || 0.692;
  const spaceAdv = alpha.spaceWidthRatio || 0.692;
  const text = (e.text || '') + '';
  const size = e.font_size || 0.5;
  const x0 = e.anchor_position && e.anchor_position.x || 0;
  const y0 = e.anchor_position && e.anchor_position.y || 0;
  const align = e.anchor_alignment || 'center';
  // Measure width in glyph-ratio units first so we can center/align.
  let widthUnits = 0;
  for (const ch of text) {
    if (ch === ' ') widthUnits += spaceAdv;
    else widthUnits += (advance[ch] != null ? advance[ch] : fallbackAdvance);
  }
  const width = widthUnits * size;
  let startX = x0;
  if (align === 'center') startX -= width / 2;
  else if (align === 'right') startX -= width;
  const rot = (e.ccw_rotation || 0) * Math.PI / 180;
  const cos = Math.cos(rot), sin = Math.sin(rot);
  // Glyphs' internal Y goes down; Babylon world Y goes up. Flip Y when
  // emitting vertices. Also the glyphs are at unit scale where
  // y \u2208 [~0.25, ~1.0]; we anchor vertical center on y0.
  const yMid = 0.625;   // glyph vertical centroid in normalized units
  const out = [];
  let penX = startX;
  for (const ch of text) {
    if (ch === ' ') { penX += spaceAdv * size; continue; }
    const glyph = lineAlphabet[ch] || lineAlphabet[ch.toUpperCase()] || [];
    const adv = (advance[ch] != null ? advance[ch] : fallbackAdvance) * size;
    for (const seg of glyph) {
      const lx1 = penX + seg.x1 * size;
      const ly1 = y0 + (yMid - seg.y1) * size;
      const lx2 = penX + seg.x2 * size;
      const ly2 = y0 + (yMid - seg.y2) * size;
      // Rotate about the text anchor.
      let dx1 = lx1 - x0, dy1 = ly1 - y0;
      let dx2 = lx2 - x0, dy2 = ly2 - y0;
      // Mirror X for bottom-layer text so it reads correctly when viewed
      // from below the board.
      if (mirrorX) { dx1 = -dx1; dx2 = -dx2; }
      const rx1 = x0 + dx1 * cos - dy1 * sin;
      const ry1 = y0 + dx1 * sin + dy1 * cos;
      const rx2 = x0 + dx2 * cos - dy2 * sin;
      const ry2 = y0 + dx2 * sin + dy2 * cos;
      out.push([[rx1, ry1], [rx2, ry2]]);
    }
    penX += adv;
  }
  return out;
}

// ─── Testpoint pad synth (top-level so it's accessible from anywhere) ─
// Reads pcb_smtpad geometry from circuit.json — exact radius for circle
// pads, exact width/height for rectangle. Synthesises a thin pickable
// disc/box mesh on top of the baked TP pad so the picker can hit it.
let _tpPadCachePromise = null;
function _loadTestpointPadCache() {
  if (_tpPadCachePromise) return _tpPadCachePromise;
  _tpPadCachePromise = fetch('circuit.json').then(r => r.json()).then(j => {
    const sources = new Map();
    for (const e of j) if (e.type === 'source_component') sources.set(e.source_component_id, e.name);
    const pcbToRefdes = new Map();
    for (const e of j) if (e.type === 'pcb_component') {
      const n = sources.get(e.source_component_id);
      if (n) pcbToRefdes.set(e.pcb_component_id, n);
    }
    const out = new Map();
    for (const e of j) if (e.type === 'pcb_smtpad') {
      const owner = pcbToRefdes.get(e.pcb_component_id);
      if (!owner) continue;
      out.set(owner, {
        shape: e.shape, radius: e.radius,
        width: e.width, height: e.height,
        x: e.x, y: e.y, layer: e.layer,
      });
    }
    return out;
  }).catch(() => new Map());
  return _tpPadCachePromise;
}
async function _synthTestpointPad(refdes) {
  const _B = window.Adom3DViewer && window.Adom3DViewer.BABYLON;
  const scene = viewer && viewer.getScene && viewer.getScene();
  if (!_B || !scene) return;
  const cache = await _loadTestpointPadCache();
  const pad = cache.get(refdes);
  if (!pad) { console.warn('[testpoint synth] no smtpad for', refdes); return; }
  const sx = window._mapSx || 1, sy = window._mapSy || 1;
  const cx = pad.x * sx;
  const cy = pad.y * sy;
  const cz = (window._boardTopZ || 0.7);
  // TP discs need to MATCH the baked-pad gold visually: thin (so they
  // hug the board surface, not stick up like a button), 50% transparent
  // (so the underlying baked gold pad still reads through), and pure
  // copper-gold emissive (no lighting interaction — otherwise the
  // physical-light scene blows the diffuse to white).
  const OVERSIZE = 1.05;
  const DISC_HEIGHT = 0.015;
  let mesh;
  if (pad.shape === 'circle' && typeof pad.radius === 'number') {
    mesh = _B.MeshBuilder.CreateCylinder('synth-tp-' + refdes, {
      diameter: pad.radius * 2 * OVERSIZE,
      height: DISC_HEIGHT,
      tessellation: 32,
    }, scene);
    mesh.rotation.x = Math.PI / 2;
  } else if (pad.shape === 'rect' || pad.shape === 'rectangle') {
    mesh = _B.MeshBuilder.CreateBox('synth-tp-' + refdes, {
      width: (pad.width || 1) * OVERSIZE,
      height: (pad.height || 1) * OVERSIZE,
      depth: DISC_HEIGHT,
    }, scene);
  } else {
    mesh = _B.MeshBuilder.CreateBox('synth-tp-' + refdes, {
      width: 0.84, height: 0.84, depth: DISC_HEIGHT,
    }, scene);
  }
  mesh.position.set(cx, cy, cz + 0.02);
  const mat = new _B.StandardMaterial('synth-tp-mat-' + refdes, scene);
  mat.disableLighting = true;
  mat.emissiveColor = new _B.Color3(0.92, 0.70, 0.30);   // pad gold
  mat.alpha = 0.5;
  mat.transparencyMode = 2;   // ALPHABLEND
  mat.backFaceCulling = false;
  // No depth-write: alpha-blended pad mustn't z-fight with the baked
  // pad pixels underneath (causes shimmer when the camera moves which
  // reads as "throbbing").
  mat.disableDepthWrite = true;
  mesh.material = mat;
  // Exclude from the scene's GlowLayer bloom pass — without this, the
  // gold emissive at intensity 1.5 produces a hot bright halo around
  // every TP which reads as a strobing glow during camera animation.
  if (typeof _glowLayer !== 'undefined' && _glowLayer) {
    try { _glowLayer.addExcludedMesh(mesh); } catch {}
  }
  mesh.isPickable = true;
  const meta = componentMap.get(refdes);
  if (meta) meta.meshes = [mesh];
}

async function enumerateComponents() {
  const scene = viewer && viewer.getScene && viewer.getScene();
  if (!scene || !scene.meshes) return;
  const pcbComps = await fetchPcbComponents();
  componentMap.clear();
  const EXCLUDE_EXACT = new Set(['shadowGround','__root__','BackgroundHelper','BackgroundSkybox','arcRotateIndicatorSphere','_identityMatrixHolder']);
  // Pass 1: collect candidate meshes with their world AABBs.
  const meshes = [];
  scene.meshes.forEach(m => {
    if (!m || !m.name || EXCLUDE_EXACT.has(m.name)) return;
    const nl = m.name.toLowerCase();
    if (nl.includes('viewcube') || nl.includes('indicator') || nl.includes('background') || nl.includes('skybox')) return;
    if (nl.startsWith('snapdot') || nl.startsWith('measure') || nl.startsWith('feat')) return;
    if (!m.getTotalVertices || m.getTotalVertices() === 0) return;
    m.computeWorldMatrix(true);
    const bi = m.getBoundingInfo && m.getBoundingInfo();
    if (!bi) return;
    const bb = bi.boundingBox;
    const c = bb.centerWorld;
    const sx2 = bb.extendSizeWorld.x * 2;
    const sy2 = bb.extendSizeWorld.y * 2;
    const sz2 = bb.extendSizeWorld.z * 2;
    meshes.push({ mesh: m, cx: c.x, cy: c.y, cz: c.z, w: sx2, h: sy2, depth: sz2,
                  minX: bb.minimumWorld.x, maxX: bb.maximumWorld.x,
                  minY: bb.minimumWorld.y, maxY: bb.maximumWorld.y,
                  minZ: bb.minimumWorld.z, maxZ: bb.maximumWorld.z });
  });
  if (!pcbComps.length || !meshes.length) { renderComponentList(); return; }

  // Separate BOARD meshes explicitly: they're wide-and-flat (XY > 80mm
  // AND Z extent < 10mm). Mesh names in the tscircuit GLB have no
  // refdes ("Box0_primitive0" etc), so this geometric filter is the
  // only reliable way to exclude the substrate from component buckets.
  // Board vs components: the board is the largest thin-flat mesh.
  // Previous hardcoded "> 80 × > 80 mm" matched iCE40 only; a 16×16 mm
  // LM555 fell entirely into componentCandidates, which broke the
  // mesh→component mapping (pads got attributed to the board substrate).
  // New rule: any thin-flat mesh (Z < 10 mm, XY within 5× aspect) is a
  // board candidate; the ONE with the largest XY area wins.
  let boardPick = null;
  let bestArea = 0;
  for (const m of meshes) {
    if (m.depth >= 10) continue;
    if (m.w < 2 || m.h < 2) continue;
    const ratio = Math.max(m.w, m.h) / Math.min(m.w, m.h);
    if (ratio > 5) continue;
    const area = m.w * m.h;
    if (area > bestArea) { bestArea = area; boardPick = m; }
  }
  const boardMeshes = [];
  const componentCandidates = [];
  for (const m of meshes) {
    if (m === boardPick) boardMeshes.push(m);
    else componentCandidates.push(m);
  }
  // Infer GLB→circuit.json scale from comp XY extent.
  const meshMax = componentCandidates.reduce((v, m) => Math.max(v, Math.abs(m.cx), Math.abs(m.cy)), 0);
  const pcbMax  = pcbComps.reduce((v, p) => Math.max(v, Math.abs(p.x), Math.abs(p.y)), 0);
  const scale = (pcbMax > 0 && meshMax > 0) ? (meshMax / pcbMax) : 1;
  // Pre-scale component centers + footprints to world space.
  const scaledPcb = pcbComps.map(p => ({ ...p, sx: p.x * scale, sy: p.y * scale,
                                         sw: (p.w || 1) * scale, sh: (p.h || 1) * scale }));

  // Orientation probe — chips/MPs at corners are fixed landmarks.
  const mounts = scaledPcb.filter(p => /^MP\d+$/.test(p.name));
  const probes = mounts.length >= 2 ? mounts : scaledPcb.slice(0, 8);
  let orientationSx = 1, orientationSy = 1, bestScore = Infinity;
  for (const sx of [1, -1]) for (const sy of [1, -1]) {
    let err = 0, n = 0;
    for (const p of probes) {
      let min = Infinity;
      for (const m of componentCandidates) {
        const dx = m.cx - sx * p.sx, dy = m.cy - sy * p.sy;
        const d2 = dx*dx + dy*dy;
        if (d2 < min) min = d2;
      }
      err += Math.sqrt(min); n++;
    }
    const avg = n ? err/n : Infinity;
    if (avg < bestScore) { bestScore = avg; orientationSx = sx; orientationSy = sy; }
  }
  const kindByName = new Map(); for (const p of pcbComps) kindByName.set(p.name, p.kind);

  // For each pcb_component, claim the meshes whose world-XY bounding
  // box CONTAINS the component's center AND whose XY footprint is at
  // most N\u00d7 larger than the component's declared footprint. This
  // footprint-containment test beats nearest-centroid because a big
  // mesh (say, a chip body spanning 5mm) doesn't get wrongly
  // assigned to a tiny neighbouring MC pad whose center it encloses.
  const groups = new Map();
  const claimed = new Set();  // mesh indices that were claimed
  // Sort components by footprint area ascending so SMALL components
  // (testpoints, MCs) win over bigger ones (chips) if a mesh's bbox
  // technically contains both centers \u2014 the more specific match wins.
  const sortedByArea = scaledPcb.map((p, i) => ({ p, i,
    area: Math.max(0.01, p.sw * p.sh) }))
    .sort((a, b) => a.area - b.area);

  for (const { p } of sortedByArea) {
    const world = { x: orientationSx * p.sx, y: orientationSy * p.sy };
    const hw = Math.max(p.sw, 0.1) / 2, hh = Math.max(p.sh, 0.1) / 2;
    // Accept meshes whose XY footprint is at most 3\u00d7 bigger in each
    // dimension than the declared footprint \u2014 filters out the board.
    // Connectors (refdes /^J/) get a MUCH looser size cap: a USB-C
    // pad-row footprint is only ~7x0.3 mm but the 3D body is ~8x7 mm,
    // so the body mesh would otherwise be rejected as "too big" for
    // the narrow pad row. 20 mm absolute cap keeps actual board
    // substrates out of component buckets while letting receptacle
    // bodies match their pads.
    const isConnector = /^J[\d_]/i.test(p.name);
    const maxW = isConnector ? 20 : (hw * 6);
    const maxH = isConnector ? 20 : (hh * 6);
    // Accept meshes whose center is within the footprint (+ small margin).
    // The floor is kind-aware:
    //   - connectors (refdes starting with J): 4.5mm. Directional
    //     parts like USB-C have cadModel positionOffsets that put the
    //     3D body ~2.5mm forward of the back-row SMT pads.
    //   - everything else: 1.5mm. Tight floor prevents testpoints,
    //     MCs, and passives packed 2-3mm apart from stealing each
    //     other's meshes.
    const floor = isConnector ? 4.5 : 1.5;
    const marginX = Math.max(hw * 1.5, floor * scale);
    const marginY = Math.max(hh * 1.5, floor * scale);
    const claimedMeshes = [];
    for (let i = 0; i < componentCandidates.length; i++) {
      if (claimed.has(i)) continue;
      const m = componentCandidates[i];
      if (m.w > maxW || m.h > maxH) continue;
      if (Math.abs(m.cx - world.x) > marginX) continue;
      if (Math.abs(m.cy - world.y) > marginY) continue;
      claimedMeshes.push(i);
    }
    if (claimedMeshes.length) {
      for (const i of claimedMeshes) claimed.add(i);
      groups.set(p.name, claimedMeshes.map(i => componentCandidates[i].mesh));
    }
  }

  // Anything still unclaimed OR flagged as board substrate goes into "Board".
  const boardBucket = [...boardMeshes.map(m => m.mesh)];
  for (let i = 0; i < componentCandidates.length; i++) {
    if (!claimed.has(i)) boardBucket.push(componentCandidates[i].mesh);
  }
  if (boardBucket.length) groups.set('Board', boardBucket);
  for (const [name, ms] of groups) {
    const kind = name === 'Board' ? 'board' : (kindByName.get(name) || 'misc');
    componentMap.set(name, { name, kind, meshes: ms });
    // Testpoints: tscircuit renders each one as a ghost cuboid on top
    // of the pad \u2014 the *pad* is the part that matters, the box is
    // visual noise. Hide the whole Test points group by default on
    // load; users can re-enable via the Components HUD if they want
    // them back.
    if (kind === 'testpoint') {
      // tscircuit's ghost cuboid is visual noise — hide it.
      for (const m of ms) { if (m.setEnabled) m.setEnabled(false); else m.isVisible = false; }
      // The actual TP pad is baked into the board's silkscreen+copper
      // texture, so there's no real mesh for the picker to hit.
      // Synthesise a thin pad-shaped mesh from the EXACT geometry in
      // circuit.json (`pcb_smtpad`) — owner-matched by refdes — so the
      // fake pad is dimensionally correct (no guessed diameters).
      // The synth runs async because it has to fetch + parse
      // circuit.json; it gets installed onto componentMap.meshes once
      // ready. Hover / click / highlight code reads meta.meshes lazily
      // so this race is safe.
      _synthTestpointPad(name);
    }
  }
  const mapped = groups.size - (groups.has('Board') ? 1 : 0);
  const leftover = groups.get('Board') ? groups.get('Board').length : 0;
  console.log('[3d] mapping: sx=' + orientationSx + ' sy=' + orientationSy +
              ' scale=' + scale.toFixed(3) +
              ' named=' + mapped + '/' + pcbComps.length +
              ' board=' + leftover);
  // Persist orientation + scale for Smart-mode feature-table lookups.
  window._mapSx = orientationSx * scale;
  window._mapSy = orientationSy * scale;
  // Compute board top / bottom Z from whatever meshes look like the PCB
  // substrate (wide-and-flat): the hover / selection outlines then sit on
  // the ACTUAL surface instead of floating mid-thickness.
  //
  // Previous heuristic hardcoded "width AND height > 80 mm" — that matches
  // the iCE40 128 mm board and ALSO matches nothing smaller. A 16×16 mm
  // board like the LM555 was invisible to this loop, so the outlines
  // defaulted to feature.z (≈0, dead-center of the board's thickness)
  // which looks like the teal ring is floating at middle-Z.
  //
  // New heuristic: pick the mesh with the LARGEST XY footprint whose Z
  // extent is thin (< 10 mm; PCBs are almost always ≤ 2 mm). Works for
  // any board from 4×4 mm jellybean molecules to 300×300 mm panels.
  {
    let bestArea = 0;
    let bMin = Infinity, bMax = -Infinity;
    let bestDiag = 0;
    for (const m of scene.meshes) {
      if (!m.getBoundingInfo || !m.getTotalVertices || m.getTotalVertices() === 0) continue;
      const nl = (m.name || '').toLowerCase();
      if (nl.includes('skybox') || nl.includes('ground') || nl.includes('background')) continue;
      if (nl.includes('viewcube') || nl.includes('indicator')) continue;
      if (nl.startsWith('snapdot') || nl.startsWith('measure') || nl.startsWith('feat')) continue;
      m.computeWorldMatrix(true);
      const bb = m.getBoundingInfo().boundingBox;
      const sz = bb.extendSizeWorld;
      const szX = sz.x * 2, szY = sz.y * 2, szZ = sz.z * 2;
      // Board criteria: thin (< 10 mm Z) AND flat-aspect-ratio
      // (X and Y within 5× of each other — rules out long pin rails).
      if (szZ >= 10) continue;
      if (szX < 2 || szY < 2) continue;   // ignore vias, tiny SMT stuff
      const ratio = Math.max(szX, szY) / Math.min(szX, szY);
      if (ratio > 5) continue;            // ignore long thin rails
      const area = szX * szY;
      if (area > bestArea) {
        bestArea = area;
        bMin = bb.minimumWorld.z;
        bMax = bb.maximumWorld.z;
        bestDiag = Math.sqrt(szX * szX + szY * szY);
      }
    }
    if (Number.isFinite(bMin) && Number.isFinite(bMax)) {
      window._boardBottomZ = bMin;
      window._boardTopZ = bMax;
      window._boardDiag = bestDiag;   // used by walkthrough kind:'all'/'view'/'silkscreen'
      console.log('[3d] board z range: ' + bMin.toFixed(3) + ' .. ' + bMax.toFixed(3) +
                  ' (area ' + bestArea.toFixed(0) + ' mm², diag ' + bestDiag.toFixed(1) + ' mm)');
    }
  }
  // ── Synthesised placeholder bodies for chips tscircuit silently omits ──
  // Sometimes a tscircuit build doesn't emit a 3D body mesh for a
  // `<chip>` (e.g. SOT-23-6 on the TPS562201 example — circuit.json has
  // U1 with ftype=simple_chip at a valid pcb_component position, but
  // the GLB has no mesh near U1's centroid). The centroid-match loop
  // above can't claim what isn't there, so U1 vanishes from
  // componentMap and every downstream feature (highlight / toggle /
  // walkthrough kindAll=chip) treats it as non-existent.
  //
  // Policy: any pcb_component whose name didn't get a mesh, AND whose
  // source_component.ftype == simple_chip, AND whose name isn't a
  // prefix-disambiguated kind (MC_/MP/TP_), gets a synthesised black
  // cuboid sized from the pcb_component footprint. Placed at the
  // correct XY, sitting on the board top, 1 mm thick. Registered in
  // componentMap with kind='chip' so everything downstream works.
  //
  // Future enhancement: replace the cuboid with a real GLB from
  // service-kicad → step2glb for the footprint's kicad 3D model. That
  // makes the board look correct without the board author needing to
  // import a cadModel in their TSX. Tracked in TSCIRCUIT_FEATURE_REQUESTS
  // § 3 (default 3D models for common SMD packages).
  const _synthesisedMeshes = [];
  const _B = window.Adom3DViewer && window.Adom3DViewer.BABYLON;
  // Footprint → primitive recipe table. Used by the chip synthesiser
  // when the real GLB pipeline can't load (the trimmed BABYLON bundle
  // has no SceneLoader). Keys are the lower-case tscircuit footprint
  // strings the server returns from /chip-footprint/<refdes>. Values:
  //   bodyW/H/Z   — body box dimensions in mm
  //   leadSides   — 2 (SOT, SOIC, TSSOP) or 4 (QFP, QFN)
  //   leadCount   — total pin count (split evenly across sides)
  //   leadW/H/Z   — lead box dimensions
  //   leadOverhang — how far leads stick out from the body edge
  //   pin1Corner  — 'tl' / 'tr' / 'bl' / 'br' for the indicator dot
  //
  // Sourced from KiCad footprint .kicad_mod files (chipfit-validated for
  // SOT-23-6). Add a new row whenever you hit a new package — the cuboid
  // path will log "footprint=X — add to CHIP_SHAPES" so you know which.
  const CHIP_SHAPES = {
    'sot23':   { bodyW: 2.9, bodyH: 1.4, bodyZ: 1.1, leadSides: 2, leadCount: 3, leadW: 0.4, leadH: 0.5, leadZ: 0.4, leadOverhang: 0.5, pin1Corner: 'tl' },
    'sot23_3': { bodyW: 2.9, bodyH: 1.4, bodyZ: 1.1, leadSides: 2, leadCount: 3, leadW: 0.4, leadH: 0.5, leadZ: 0.4, leadOverhang: 0.5, pin1Corner: 'tl' },
    'sot23_5': { bodyW: 2.9, bodyH: 1.6, bodyZ: 1.1, leadSides: 2, leadCount: 5, leadW: 0.4, leadH: 0.5, leadZ: 0.4, leadOverhang: 0.5, pin1Corner: 'tl' },
    'sot23_6': { bodyW: 2.9, bodyH: 1.6, bodyZ: 1.1, leadSides: 2, leadCount: 6, leadW: 0.4, leadH: 0.5, leadZ: 0.4, leadOverhang: 0.5, pin1Corner: 'tl' },
    'sot23-6': { bodyW: 2.9, bodyH: 1.6, bodyZ: 1.1, leadSides: 2, leadCount: 6, leadW: 0.4, leadH: 0.5, leadZ: 0.4, leadOverhang: 0.5, pin1Corner: 'tl' },
    'soic8':   { bodyW: 4.9, bodyH: 3.9, bodyZ: 1.5, leadSides: 2, leadCount: 8, leadW: 0.5, leadH: 0.7, leadZ: 0.5, leadOverhang: 0.7, pin1Corner: 'tl' },
    'soic14':  { bodyW: 8.7, bodyH: 3.9, bodyZ: 1.5, leadSides: 2, leadCount: 14, leadW: 0.5, leadH: 0.7, leadZ: 0.5, leadOverhang: 0.7, pin1Corner: 'tl' },
    'tssop8':  { bodyW: 3.0, bodyH: 3.0, bodyZ: 1.0, leadSides: 2, leadCount: 8, leadW: 0.3, leadH: 0.5, leadZ: 0.3, leadOverhang: 0.5, pin1Corner: 'tl' },
    'lqfp32':  { bodyW: 7.0, bodyH: 7.0, bodyZ: 1.4, leadSides: 4, leadCount: 32, leadW: 0.4, leadH: 0.7, leadZ: 0.4, leadOverhang: 0.7, pin1Corner: 'tl' },
    'lqfp48':  { bodyW: 7.0, bodyH: 7.0, bodyZ: 1.4, leadSides: 4, leadCount: 48, leadW: 0.27, leadH: 0.7, leadZ: 0.4, leadOverhang: 0.7, pin1Corner: 'tl' },
    'lqfp64':  { bodyW: 10.0, bodyH: 10.0, bodyZ: 1.4, leadSides: 4, leadCount: 64, leadW: 0.27, leadH: 0.7, leadZ: 0.4, leadOverhang: 0.7, pin1Corner: 'tl' },
    'qfn20':   { bodyW: 4.0, bodyH: 4.0, bodyZ: 0.9, leadSides: 4, leadCount: 20, leadW: 0.25, leadH: 0.4, leadZ: 0.3, leadOverhang: 0.05, pin1Corner: 'tl' },
  };
  // Inferred recipe from a footprint string + the pcb_component's
  // bounding-box dimensions, for footprints not in CHIP_SHAPES.
  // Pattern-matches the family from the prefix (sot, soic, tssop, lqfp,
  // qfp, qfn) and the pin count from the digit suffix. Body dimensions
  // come from the actual pcb_component width/height (so the inferred
  // chip looks the right SIZE for this board's specific package), while
  // lead width/spacing scales with pin count.
  //
  // Falls back to null if the footprint doesn't match any known family
  // — caller then drops to the cuboid placeholder.
  function _inferShape(footprint, pcW, pcH) {
    if (!footprint) return null;
    const fp = footprint.toLowerCase();
    let m;
    // QFN / DFN — 4-side leadless, very small overhang
    if ((m = fp.match(/^(qfn|dfn)[_\-]?(\d+)/))) {
      const n = parseInt(m[2], 10);
      if (n < 4 || n % 4 !== 0) return null;
      const sz = Math.max(2, Math.min(pcW, pcH) * 0.75);
      return { bodyW: sz, bodyH: sz, bodyZ: 0.9, leadSides: 4, leadCount: n, leadW: Math.max(0.18, sz / n * 0.6), leadH: 0.35, leadZ: 0.3, leadOverhang: 0.05, pin1Corner: 'tl' };
    }
    // LQFP / TQFP / QFP — 4-side gull-wing, real overhang
    if ((m = fp.match(/^(lqfp|tqfp|qfp)[_\-]?(\d+)/))) {
      const n = parseInt(m[2], 10);
      if (n < 4 || n % 4 !== 0) return null;
      const sz = Math.max(3, Math.min(pcW, pcH) * 0.55);
      return { bodyW: sz, bodyH: sz, bodyZ: 1.4, leadSides: 4, leadCount: n, leadW: Math.max(0.2, sz / n * 0.6), leadH: 0.7, leadZ: 0.4, leadOverhang: 0.7, pin1Corner: 'tl' };
    }
    // SOIC / SO — wide 2-side, 1.27 mm pitch, body ≈ 4 mm wide
    if ((m = fp.match(/^(soic|so)[_\-]?(\d+)/))) {
      const n = parseInt(m[2], 10);
      if (n < 2 || n % 2 !== 0) return null;
      const len = Math.max(3, pcW * 0.7);
      return { bodyW: len, bodyH: 3.9, bodyZ: 1.5, leadSides: 2, leadCount: n, leadW: 0.5, leadH: 0.7, leadZ: 0.5, leadOverhang: 0.7, pin1Corner: 'tl' };
    }
    // TSSOP / SSOP — narrow 2-side, 0.65 mm pitch, body ≈ 3 mm wide
    if ((m = fp.match(/^(tssop|ssop|msop)[_\-]?(\d+)/))) {
      const n = parseInt(m[2], 10);
      if (n < 2 || n % 2 !== 0) return null;
      const len = Math.max(3, pcW * 0.7);
      return { bodyW: len, bodyH: 3.0, bodyZ: 1.0, leadSides: 2, leadCount: n, leadW: 0.3, leadH: 0.5, leadZ: 0.3, leadOverhang: 0.5, pin1Corner: 'tl' };
    }
    // SOT-23 family (already covered specifically, but catch generic sotN)
    if ((m = fp.match(/^sot[_\-]?23[_\-]?(\d+)?/))) {
      const n = m[1] ? parseInt(m[1], 10) : 3;
      return { bodyW: 2.9, bodyH: n > 3 ? 1.6 : 1.4, bodyZ: 1.1, leadSides: 2, leadCount: n, leadW: 0.4, leadH: 0.5, leadZ: 0.4, leadOverhang: 0.5, pin1Corner: 'tl' };
    }
    return null;
  }
  // Build a synthetic chip from a CHIP_SHAPES recipe. Returns the array
  // of created meshes (body + lead boxes + pin-1 dot).
  function _buildChipFromRecipe(B, scene, refdes, r, px, py, pz, rotationDeg) {
    const meshes = [];
    // Same trick as the highlight markers: exclude the 52,845-watt
    // physical-units spotlight from any StandardMaterial we make,
    // otherwise every channel clamps to 1.0 and the dark plastic body
    // renders pure white. (Previous cuboid placeholders rendered fine
    // because they were behind silkscreen overlays — these new shapes
    // sit higher and catch the full light hit.)
    const brightLights = (scene.lights || []).filter(l => (l.intensity || 0) > 100);
    const bodyMat = new B.StandardMaterial('synth-body-' + refdes, scene);
    bodyMat.diffuseColor = new B.Color3(0.10, 0.10, 0.11);
    bodyMat.specularColor = new B.Color3(0.18, 0.18, 0.18);
    bodyMat.excludedLights = brightLights;
    const leadMat = new B.StandardMaterial('synth-lead-' + refdes, scene);
    leadMat.diffuseColor = new B.Color3(0.78, 0.78, 0.80);
    leadMat.specularColor = new B.Color3(0.6, 0.6, 0.6);
    leadMat.specularPower = 96;
    leadMat.excludedLights = brightLights;
    const dotMat = new B.StandardMaterial('synth-pin1-' + refdes, scene);
    dotMat.diffuseColor = new B.Color3(0.95, 0.95, 0.95);
    dotMat.emissiveColor = new B.Color3(0.55, 0.55, 0.55);
    dotMat.excludedLights = brightLights;
    // Apply pcbRotation in software. We tried TransformNode parenting
    // but B.TransformNode isn't exposed under that friendly name on the
    // viewer's minified BABYLON bundle. Manual sin/cos rotation of each
    // child's local (lx, ly) → (px + lx·cos - ly·sin, py + lx·sin + ly·cos)
    // is portable and avoids any bundle-shape assumption.
    const ang = ((rotationDeg || 0) * Math.PI) / 180;
    const cosA = Math.cos(ang);
    const sinA = Math.sin(ang);
    const place = (mesh, lx, ly, lz) => {
      const wx = px + lx * cosA - ly * sinA;
      const wy = py + lx * sinA + ly * cosA;
      mesh.position.set(wx, wy, pz + lz);
      mesh.rotation.z = ang;
    };
    // Body
    const body = B.MeshBuilder.CreateBox('synth-' + refdes, {
      width: r.bodyW, height: r.bodyH, depth: r.bodyZ,
    }, scene);
    place(body, 0, 0, r.bodyZ / 2);
    body.material = bodyMat;
    meshes.push(body);
    // Leads
    if (r.leadSides === 2) {
      const perSide = r.leadCount / 2;
      const pitch = r.bodyH / perSide;
      const xOff = r.bodyW / 2 + r.leadOverhang / 2;
      for (let side = 0; side < 2; side++) {
        const sx = side === 0 ? -1 : 1;
        for (let i = 0; i < perSide; i++) {
          const ly = -r.bodyH / 2 + pitch / 2 + i * pitch;
          const lead = B.MeshBuilder.CreateBox('synth-lead-' + refdes + '-' + side + '-' + i, {
            width: r.leadOverhang, height: r.leadW, depth: r.leadZ,
          }, scene);
          place(lead, sx * xOff, ly, r.leadZ / 2);
          lead.material = leadMat;
          meshes.push(lead);
        }
      }
    } else if (r.leadSides === 4) {
      const perSide = r.leadCount / 4;
      const pitchX = (r.bodyW - r.leadW) / perSide;
      const pitchY = (r.bodyH - r.leadW) / perSide;
      for (let i = 0; i < perSide; i++) {
        const lx = -r.bodyW / 2 + r.leadW / 2 + i * pitchX + (pitchX - r.leadW) / 2;
        for (const sy of [-1, 1]) {
          const lead = B.MeshBuilder.CreateBox('synth-lead-' + refdes + '-h-' + sy + '-' + i, {
            width: r.leadW, height: r.leadOverhang, depth: r.leadZ,
          }, scene);
          place(lead, lx, sy * (r.bodyH / 2 + r.leadOverhang / 2), r.leadZ / 2);
          lead.material = leadMat;
          meshes.push(lead);
        }
      }
      for (let i = 0; i < perSide; i++) {
        const ly = -r.bodyH / 2 + r.leadW / 2 + i * pitchY + (pitchY - r.leadW) / 2;
        for (const sx of [-1, 1]) {
          const lead = B.MeshBuilder.CreateBox('synth-lead-' + refdes + '-v-' + sx + '-' + i, {
            width: r.leadOverhang, height: r.leadW, depth: r.leadZ,
          }, scene);
          place(lead, sx * (r.bodyW / 2 + r.leadOverhang / 2), ly, r.leadZ / 2);
          lead.material = leadMat;
          meshes.push(lead);
        }
      }
    }
    // Pin-1 dot — small disc on the body's top face at the indicated corner.
    const dotR = Math.min(r.bodyW, r.bodyH) * 0.08;
    const inset = dotR * 2.2;
    const cornerX = (r.pin1Corner === 'tl' || r.pin1Corner === 'bl') ? -r.bodyW / 2 + inset : r.bodyW / 2 - inset;
    const cornerY = (r.pin1Corner === 'tl' || r.pin1Corner === 'tr') ? r.bodyH / 2 - inset : -r.bodyH / 2 + inset;
    const dot = B.MeshBuilder.CreateDisc('synth-pin1-' + refdes, {
      radius: dotR, tessellation: 16,
    }, scene);
    place(dot, cornerX, cornerY, r.bodyZ + 0.01);
    dot.material = dotMat;
    meshes.push(dot);
    return meshes;
  }
  // Each missing chip: try real GLB from service-kicad + step2glb first
  // (via the server's /chip-glb/<refdes>.glb endpoint). If that fails
  // for any reason — 404 (no footprint prop, no kicad mapping, no 3D
  // model) OR the Babylon SceneLoader isn't bundled in this viewer —
  // fall back to the black cuboid placeholder. Both paths end up in
  // componentMap with kind='chip' so downstream code doesn't care
  // which one was used.
  //
  // Known limitation (follow-up): the adom-3d-viewer package doesn't
  // currently expose Babylon's SceneLoader, so the real-GLB path
  // catches an "ImportMeshAsync undefined" error and falls back to
  // cuboid. The endpoint is built and validated (curl returns a valid
  // glTF2 binary verified against adom-chipfit) — only the client-side
  // load is blocked. Fix when the viewer package bundles `@babylonjs/loaders`
  // or exposes viewer.loadAuxGlb(url, { position, parent }).
  const _synthChipPromises = [];
  for (const pc of pcbComps) {
    if (!_B) break;
    if (componentMap.has(pc.name)) continue;
    if (pc.kind !== 'chip') continue;
    const px = pc.x * (window._mapSx || 1);
    const py = pc.y * (window._mapSy || 1);
    const pz = (window._boardTopZ || 0.7);
    _synthChipPromises.push((async () => {
      // Footprint-aware shape fallback. Ask the server for the chip's
      // tscircuit footprint string (parsed out of lib/*.tsx), then look
      // it up in CHIP_SHAPES below for a primitive recipe (body + N
      // leads + pin-1 dot). Falls through to a featureless cuboid only
      // when the footprint is unknown OR not in our shape table.
      //
      // (We used to try /chip-glb/<refdes>.glb here for a real KiCad
      // STEP-converted GLB, but the viewer's trimmed BABYLON has no
      // working SceneLoader — every variant of the loader UMD failed
      // at module-init time. The /chip-glb endpoint still exists for
      // when adom-3d-viewer one day exposes a `loadAuxGlb()` API.)
      let footprint = null;
      try {
        const r = await fetch('chip-footprint/' + encodeURIComponent(pc.name));
        if (r.ok) footprint = (await r.json()).footprint || null;
      } catch {}
      // 1. Hand-curated CHIP_SHAPES table — exact dimensions per package.
      // 2. _inferShape() — pattern-match family + pin count from
      //    the footprint string + use pcb_component bbox for body size.
      // 3. Cuboid fallback.
      let recipe = footprint ? CHIP_SHAPES[footprint] : null;
      let recipeSource = 'shape';
      if (!recipe && footprint) {
        recipe = _inferShape(footprint, pc.w || 1, pc.h || 1);
        if (recipe) recipeSource = 'inferred';
      }
      if (recipe) {
        const meshes = _buildChipFromRecipe(_B, scene, pc.name, recipe, px, py, pz, pc.rotation);
        for (const m of meshes) _synthesisedMeshes.push(m);
        componentMap.set(pc.name, {
          name: pc.name, kind: 'chip', meshes, synthesised: true, source: recipeSource + ':' + footprint,
        });
        console.log('[3d] ' + recipeSource + ' recipe (' + footprint + ') for "' + pc.name + '" at (' + pc.x + ',' + pc.y + ')');
        return;
      }
      // Cuboid fallback (footprint not in CHIP_SHAPES — extend the table).
      const wx = Math.max(0.8, (pc.w || 1)) * Math.abs(window._mapSx || 1);
      const wy = Math.max(0.8, (pc.h || 1)) * Math.abs(window._mapSy || 1);
      const wz = 1.0;
      const body = _B.MeshBuilder.CreateBox('synth-' + pc.name, { width: wx, height: wy, depth: wz }, scene);
      body.position.set(px, py, pz + wz / 2);
      const bodyMat = new _B.StandardMaterial('synth-mat-' + pc.name, scene);
      bodyMat.diffuseColor = new _B.Color3(0.10, 0.10, 0.11);
      bodyMat.specularColor = new _B.Color3(0.2, 0.2, 0.2);
      body.material = bodyMat;
      _synthesisedMeshes.push(body);
      componentMap.set(pc.name, {
        name: pc.name, kind: 'chip', meshes: [body], synthesised: true, source: 'cuboid',
      });
      console.log('[3d] cuboid placeholder for "' + pc.name + '" at (' + pc.x + ',' + pc.y + ') (footprint=' + (footprint || 'unknown') + ' — add to CHIP_SHAPES)');
    })());
  }
  // Wait for all async GLB loads before continuing (they populate componentMap
  // which downstream code expects to be complete).
  if (_synthChipPromises.length) await Promise.all(_synthChipPromises);
  renderComponentList();
  postComponentList();
  // Build the smart-mode feature table in parallel.
  buildPcbFeatureTable().then(feats => {
    pcbFeatures = feats || [];
    console.log('[3d] smart features: ' + pcbFeatures.length);
  });
}

function postComponentList() {
  const body = { components: Array.from(componentMap.entries()).map(([name, { kind }]) => ({ name, kind })) };
  fetch('api/components', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).catch(()=>{});
}
function componentVisible(meta) {
  // Ghosted (5% alpha) still counts as "visible" for picking; use the
  // hidden-set as truth. Fallback to mesh isEnabled for components
  // that pre-date the ghost model (shouldn't happen but be safe).
  if (!meta) return true;
  // Find the name by reverse-lookup — this fn is called with meta,
  // so we reach into the set directly.
  for (const [name, m] of componentMap) if (m === meta) return !window._hiddenComponents.has(name);
  return meta.meshes.some(m => m.isEnabled ? m.isEnabled() : true);
}
// Persistent collapse state per group across re-renders. Defaulted to
// "everything collapsed" so first-time users see a compact overview and
// drill in on demand.
const groupCollapsed = new Set([
  'chip','crystal','connector','diode','transistor','inductor',
  'resistor','capacitor','testpoint','contact','mounting','board','misc',
]);

function renderComponentList() {
  const list = document.getElementById('co-list');
  list.innerHTML = '';
  const kindOrder = ['chip','crystal','connector','diode','transistor','inductor','resistor','capacitor','testpoint','contact','mounting','board','misc'];
  const kindLabel = {
    chip:'Chips', crystal:'Crystals', connector:'Connectors',
    diode:'Diodes', transistor:'Transistors', inductor:'Inductors',
    resistor:'Resistors', capacitor:'Capacitors',
    testpoint:'Test points', contact:'Machine contacts',
    mounting:'Mounting', board:'Board', misc:'Misc',
  };
  // Bucket components by kind, preserving numeric-natural intra-kind order.
  const byKind = new Map();
  for (const [name, meta] of componentMap) {
    const k = meta.kind || 'misc';
    if (!byKind.has(k)) byKind.set(k, []);
    byKind.get(k).push([name, meta]);
  }
  for (const arr of byKind.values()) {
    arr.sort((a, b) => a[0].localeCompare(b[0], undefined, { numeric: true }));
  }
  for (const kind of kindOrder) {
    const arr = byKind.get(kind);
    if (!arr || !arr.length) continue;
    const visCount = arr.filter(([, m]) => componentVisible(m)).length;
    const allVis = visCount === arr.length;
    const allHidden = visCount === 0;
    const hdr = document.createElement('div');
    hdr.className = 'co-group';
    if (groupCollapsed.has(kind)) hdr.classList.add('collapsed');
    if (allHidden) hdr.classList.add('all-hidden');
    else if (!allVis) hdr.classList.add('mixed');
    const eyeTip = allVis
      ? `Click to HIDE every ${kindLabel[kind] || kind} in the scene at once. Useful for "hide every testpoint so I can see the board clearly" or similar bulk operations.`
      : allHidden
      ? `Click to SHOW every ${kindLabel[kind] || kind} in the scene at once. Restores any items you've individually hidden within this group.`
      : `Partially hidden — ${visCount}/${arr.length} visible. Click once to show ALL items in this group.`;
    hdr.innerHTML = `
      <span class="co-caret" data-tooltip="Collapse / expand&#10;Collapse or expand the list of individual items in this group. Your show/hide choices are preserved across collapses." data-tooltip-align="right">▾</span>
      <span class="co-group-name">${kindLabel[kind] || kind}</span>
      <span class="co-group-count" data-tooltip="${visCount} of ${arr.length} ${kindLabel[kind] || kind} currently visible in the 3D scene." data-tooltip-align="right">${visCount}/${arr.length}</span>
      <span class="co-group-eye" data-tooltip="${eyeTip}" data-tooltip-align="right">${allVis ? '●' : allHidden ? '○' : '◐'}</span>
    `;
    // Click caret / name → collapse. Click eye → master toggle. Click elsewhere → collapse.
    const eye = hdr.querySelector('.co-group-eye');
    const caret = hdr.querySelector('.co-caret');
    const gname = hdr.querySelector('.co-group-name');
    eye.addEventListener('click', (e) => {
      e.stopPropagation();
      const shouldShow = !allVis;  // if all visible, hide all; else show all
      for (const [n] of arr) setComponentVisibility(n, shouldShow, /*localOnly*/false);
    });
    const toggleCollapse = () => {
      if (groupCollapsed.has(kind)) groupCollapsed.delete(kind);
      else groupCollapsed.add(kind);
      renderComponentList();
    };
    caret.addEventListener('click', toggleCollapse);
    gname.addEventListener('click', toggleCollapse);
    // Highlight every component in this group while the row is hovered.
    hdr.addEventListener('mouseenter', () => highlightComponents(arr.map(([n]) => n)));
    hdr.addEventListener('mouseleave', () => highlightComponents([]));
    list.appendChild(hdr);
    if (groupCollapsed.has(kind)) continue;
    for (const [name, meta] of arr) {
      const row = document.createElement('div');
      row.className = 'co-row';
      const vis = componentVisible(meta);
      if (!vis) row.classList.add('hidden-component');
      const rowTip = vis
        ? `${name} is VISIBLE. Click to hide it. Useful for hiding a chip like U1 to see the traces routed beneath it.`
        : `${name} is HIDDEN. Click to show it again.`;
      row.setAttribute('data-tooltip', rowTip);
      row.setAttribute('data-tooltip-align', 'right');
      const friendly = friendlyComponentName(name);
      const secondary = friendly.secondary ? `<span class="co-name-secondary">${friendly.secondary}</span>` : '';
      row.innerHTML = `<span class="co-dot"></span><span class="co-name">${friendly.primary}${secondary}</span>`;
      // Single-click toggles visibility. Double-click zooms the camera
      // to the component (using the walkthrough's fly-to). The 250 ms
      // window lets a real double-click cancel the pending single click.
      let _clickTimer = null;
      row.addEventListener('click', () => {
        if (_clickTimer) return;  // a click is already pending
        _clickTimer = setTimeout(() => {
          _clickTimer = null;
          setComponentVisibility(name, !vis, /*localOnly*/false);
        }, 250);
      });
      row.addEventListener('dblclick', () => {
        if (_clickTimer) { clearTimeout(_clickTimer); _clickTimer = null; }
        walkthroughFlyTo({ kind: 'component', name, zoomTight: true });
        toast('zoomed to ' + name);
      });
      row.addEventListener('mouseenter', () => highlightComponents([name]));
      row.addEventListener('mouseleave', () => highlightComponents([]));
      list.appendChild(row);
    }
  }
}
// Friendly-name registry. Populated from circuit.json supplier part
// numbers + a hand-authored override map for LCSC part numbers (tscircuit
// doesn't carry manufacturer part names, only JLCPCB LCSC codes). Goal:
// show "U1 \u2014 iCE40HX1K FPGA" instead of a bare "U1" in the panel.
const LCSC_NAMES = {
  'C1519043': { mpn: 'iCE40HX1K-VQ100', desc: 'Lattice iCE40 FPGA (LQFP-100)' },
  'C27882':   { mpn: 'FT2232HL',         desc: 'FTDI USB-to-dual-UART/MPSSE bridge (LQFP-64)' },
  'C2456211': { mpn: 'W25Q16JVSIQ',      desc: 'Winbond 16 Mb SPI config flash (SOIC-8)' },
  'C150746':  { mpn: 'AMS1117-3.3',      desc: '3.3 V LDO regulator (SOT-223)' },
};
const REFDES_HINTS = {
  'MP1': 'Corner mounting post (magnetic cradle align)',
  'MP2': 'Corner mounting post (magnetic cradle align)',
  'MP3': 'Corner mounting post (magnetic cradle align)',
  'MP4': 'Corner mounting post (magnetic cradle align)',
  'J1_GND': 'USB-C shield / chassis ground',
  'J1_CC1': 'USB-C CC1 configuration channel',
  'Y1':  'Clock crystal',
  'MC_5V':       'Broken-out 5 V rail',
  'MC_3V3':      'Broken-out 3.3 V rail',
  'MC_1V2':      'Broken-out 1.2 V core rail',
  'MC_GND':      'Broken-out ground',
  'MC_USB_VBUS': 'Broken-out USB VBUS',
  'MC_USB_GND':  'Broken-out USB ground',
  'MC_USB_DP':   'Broken-out USB D+',
  'MC_USB_DM':   'Broken-out USB D\u2212',
  'TP_USB_DP':   'Testpoint \u2014 USB D+',
  'TP_USB_DM':   'Testpoint \u2014 USB D\u2212',
  'TP_USB_DP_FT':'Testpoint \u2014 USB D+ (after FT2232 bridge)',
  'TP_USB_DM_FT':'Testpoint \u2014 USB D\u2212 (after FT2232 bridge)',
  'TP_SCK':  'Testpoint \u2014 SPI clock',
  'TP_SS':   'Testpoint \u2014 SPI chip-select',
  'TP_SO':   'Testpoint \u2014 SPI serial out',
  'TP_SI':   'Testpoint \u2014 SPI serial in',
  'TP_CRESET':'Testpoint \u2014 FPGA config reset',
  'TP_CDONE':'Testpoint \u2014 FPGA config done',
  'TP_TXD':  'Testpoint \u2014 UART TX',
  'TP_RXD':  'Testpoint \u2014 UART RX',
  'TP_5V':   'Testpoint \u2014 5 V rail',
  'TP_3V3':  'Testpoint \u2014 3.3 V rail',
  'TP_1V2':  'Testpoint \u2014 1.2 V core rail',
  'TP_GND':  'Testpoint \u2014 ground',
};
const _componentNameLookup = new Map();  // refdes -> LCSC part code
async function _loadFriendlyNames() {
  try {
    const arr = await fetch('circuit.json').then(r => r.json());
    for (const e of arr) {
      if (e.type !== 'source_component') continue;
      const name = e.name;
      const lcsc = e.supplier_part_numbers && e.supplier_part_numbers.jlcpcb && e.supplier_part_numbers.jlcpcb[0];
      if (name && lcsc) _componentNameLookup.set(name, lcsc);
    }
  } catch {}
}
_loadFriendlyNames();
function friendlyComponentName(refdes) {
  const lcsc = _componentNameLookup.get(refdes);
  const hint = REFDES_HINTS[refdes];
  if (lcsc && LCSC_NAMES[lcsc]) {
    return { primary: refdes + ' \u2014 ' + LCSC_NAMES[lcsc].mpn, secondary: LCSC_NAMES[lcsc].desc };
  }
  if (hint) return { primary: refdes, secondary: hint };
  if (lcsc) return { primary: refdes, secondary: 'LCSC ' + lcsc };
  return { primary: refdes, secondary: null };
}

// Highlight the meshes belonging to the given component names. Pass an
// empty array to clear. We CLONE the material per highlighted mesh (not
// mutate in place) because tscircuit's GLB shares a single material
// across many meshes \u2014 tinting the shared material in place would
// bleed colour onto the whole board, not just the target component.
// ── Walkthrough highlight — the "this is what we're pointing at" visual ──
//
// Rules (these are deterministic; don't "improve" them casually):
//   1. Stroke + emissive colour is ADOM TEAL — rgb(0.35, 0.75, 0.70).
//      NEVER amber/yellow — amber is the Inspect tool's colour and mixing
//      the two breaks the "teal = being pointed at, amber = being probed"
//      language. Kept in sync with the app's teal accent.
//   2. For every highlighted refdes the function MUST clear all prior
//      highlights first (material clone restore, HighlightLayer mesh
//      removal, testpoint-disc disposal). Otherwise passes through the
//      walkthrough bleed previous steps' glow into the current step.
//   3. Testpoints have NO 3D mesh of their own — tscircuit renders them
//      as a flat pad in the board texture. `highlightComponents` MUST
//      synthesise a cyan marker disc at each testpoint's board-surface
//      position; otherwise the user can't tell anything is highlighted.
//      The disc mesh is tagged + tracked in `_highlightMarkers` so the
//      next highlightComponents() call can dispose it.
//   4. An empty/missing `names` list clears everything. Nothing stays
//      pinned between transitions.
//
// If you touch this function, update the matching section in
// adom-tsci/SKILL.md ("## 🎯 Walkthrough highlight rules").

const _compHighlightBackup = new Map();  // mesh -> { origMat, cloneMat }
const _highlightMarkers = [];            // synthesised 3D markers (discs + spheres)
let _compHighlightLayer = null;          // Babylon HighlightLayer for the stroke glow

const HIGHLIGHT_TEAL   = { r: 0.35, g: 0.75, b: 0.70 };  // stroke + chip emissive
const TESTPOINT_CYAN   = { r: 0.40, g: 0.90, b: 0.95 };  // testpoint disc colour

function _disposeHighlightMarkers() {
  while (_highlightMarkers.length) {
    const d = _highlightMarkers.pop();
    if (!d) continue;
    // CRITICAL: distinguish synthesised marker meshes (testpoint discs,
    // legacy fallback arrows) from the real component meshes we add to
    // HighlightLayer. Disposing a real component mesh would delete the
    // chip itself. Synthesised markers have name starts with hl- or tp-;
    // every real component is in componentMap.
    const isSynthMarker = d.name && (d.name.startsWith('hl-') || d.name.startsWith('tp-marker'));
    if (isSynthMarker) {
      try { d.dispose(); } catch {}
    }
    // Real component meshes: removeAllMeshes() on the HighlightLayer
    // (called by highlightComponents) clears them — no per-mesh action here.
  }
}

function _makeTestpointDisc(B, scene, x, y, z) {
  // Small cyan disc floating just above the board surface at the testpoint
  // position. 1.6 mm diameter, ~0.05 mm thick, emissive so it reads
  // regardless of lighting. renderingGroupId=1 → draws on top of the board.
  const disc = B.MeshBuilder.CreateCylinder('tp-marker', {
    diameter: 1.6, height: 0.05, tessellation: 24,
  }, scene);
  disc.position.x = x;
  disc.position.y = y;
  disc.position.z = z + 0.1;  // hover 0.1 mm over the board top surface
  // Babylon cylinders are Y-up by default; board surface is Z-up — rotate.
  disc.rotation.x = Math.PI / 2;
  const mat = new B.StandardMaterial('tp-marker-mat', scene);
  mat.emissiveColor = new B.Color3(TESTPOINT_CYAN.r, TESTPOINT_CYAN.g, TESTPOINT_CYAN.b);
  mat.diffuseColor = new B.Color3(TESTPOINT_CYAN.r, TESTPOINT_CYAN.g, TESTPOINT_CYAN.b);
  mat.alpha = 0.85;
  disc.material = mat;
  disc.renderingGroupId = 1;
  disc.isPickable = false;
  return disc;
}

function _makePbrTealMat(B, scene, name) {
  // Teal highlight material — real PBR, same shader family as the board.
  //
  // The viewer's BABYLON ships PBRMaterial under a MINIFIED export key
  // (e.g. `_i`). `new B.PBRMaterial()` throws because there's no friendly
  // alias on the BABYLON namespace. But every board mesh in the scene
  // is already shaded with PBRMaterial, so we steal the constructor off
  // an existing PBR instance:
  //
  //   scene.materials.find(m => 'albedoColor' in m).constructor
  //
  // Tuned to look like a clean dielectric teal orb that picks up the
  // same environment reflections as the board (so the marker reads as
  // "part of the scene", not "drawn on top in a different shader"):
  //   metallic    = 0.0    pure dielectric so albedoColor dominates
  //   roughness   = 0.4    glossy but not chrome — soft env reflection
  //   envIntensity= 0.45   board uses 1.0; we damp to keep teal visible
  //   albedoColor = teal   base color
  //   emissiveColor = small teal lift so the marker reads teal even on
  //                   its shadowed face (keeps teal under the 0.2 hemi)
  //   excludedLights = [spotLight]  // 52,845-watt physical light is for
  //                                 // the board's own PBR shader and
  //                                 // overdrives our smaller orb
  const teal = new B.Color3(HIGHLIGHT_TEAL.r, HIGHLIGHT_TEAL.g, HIGHLIGHT_TEAL.b);
  let PbrCtor = null;
  try {
    const sample = scene.materials.find(m => m && 'albedoColor' in m && typeof m.metallic === 'number');
    if (sample) PbrCtor = sample.constructor;
  } catch {}
  let mat;
  if (PbrCtor) {
    mat = new PbrCtor(name, scene);
    mat.albedoColor = teal;
    mat.metallic = 0.0;
    mat.roughness = 0.4;
    mat.environmentIntensity = 0.45;
    mat.emissiveColor = new B.Color3(teal.r * 0.18, teal.g * 0.18, teal.b * 0.18);
    mat.alpha = 1.0;
  } else {
    // Fallback: StandardMaterial. Will look flat-shaded but at least
    // teal — better than the default white we'd get if construction
    // throws further down.
    mat = new B.StandardMaterial(name, scene);
    mat.diffuseColor = new B.Color3(teal.r * 0.6, teal.g * 0.6, teal.b * 0.6);
    mat.emissiveColor = new B.Color3(teal.r * 0.55, teal.g * 0.55, teal.b * 0.55);
    mat.specularColor = new B.Color3(0, 0, 0);
    mat.ambientColor = teal;
  }
  // Exclude the 52k-watt physical spotlight so it doesn't blast the
  // marker. PBR with environmentIntensity > 0 reads as "physically lit"
  // even when the only direct light is the gentle 0.2 hemispheric.
  try {
    const lights = scene.lights || [];
    mat.excludedLights = lights.filter(l => (l.intensity || 0) > 100);
  } catch {}
  return mat;
}

function _makeFloatingMarker(B, scene, meta, name) {
  // 4 mm tall teal arrow pointing tip-down at the component, drawn as a
  // single cone. Replaces the previous square+stem+sphere stack — too
  // tall, too busy. The arrow alone is enough to mark the part because
  // its tip sits 0.3 mm above the highest mesh of the component, so
  // there's no ambiguity about which part it's pointing at.
  //
  // Architecture is unchanged: we NEVER touch the component mesh, so
  // shared-instance bleed (R1/R2/R3 sharing one source mesh+material)
  // is impossible — the arrow is its own mesh with its own material.
  if (!meta || !meta.meshes || !meta.meshes.length) return null;
  let cx = 0, cy = 0;
  for (const m of meta.meshes) {
    if (!m.getBoundingInfo) continue;
    m.computeWorldMatrix(true);
    const bb = m.getBoundingInfo().boundingBox;
    cx += bb.centerWorld.x;
    cy += bb.centerWorld.y;
  }
  cx /= meta.meshes.length;
  cy /= meta.meshes.length;
  // Find the TRUE top-of-geometry at (cx, cy) by raycasting straight
  // down from far above. meta.meshes for InstancedMesh families is
  // often just the footprint pads — it skips the chip body that ships
  // as a separate mesh in the GLB (e.g. OBJBox* for caps/resistors).
  // Without the raycast, topZ ends up ≈board surface and the arrow tip
  // ends up *inside* the chip body, then renderingGroupId=1 paints
  // over it so the bug is invisible. Drop renderingGroupId AND find
  // the real top, so depth testing actually works.
  let topZ = (window._boardTopZ || 0.7) + 0.5;
  try {
    const RAY_FROM_Z = 2000;
    const ray = new B.Ray(
      new B.Vector3(cx, cy, RAY_FROM_Z),
      new B.Vector3(0, 0, -1),
      RAY_FROM_Z + 50
    );
    // Predicate: skip our own markers, the skybox, ground, and the
    // rotation indicator. Anything else with verts is fair game.
    const HIT_SKIP = /^(hl-|shadowGround|BackgroundSkybox|arcRotateIndicator|__root__)/;
    const hit = scene.pickWithRay(ray, (m) =>
      m && m.name && !HIT_SKIP.test(m.name) &&
      m.isPickable !== false && m.isEnabled() &&
      m.getTotalVertices && m.getTotalVertices() > 0
    );
    if (hit && hit.hit && hit.pickedPoint) topZ = hit.pickedPoint.z;
  } catch {}

  // Material first — throws if the bundle is missing PBR, before we
  // create any geometry that would leak as default-white if we already
  // built it.
  const mat = _makePbrTealMat(B, scene, 'hl-mat-' + name);

  // 3D arrow = cone HEAD (the pointer) + cylinder TAIL (the shaft).
  // An arrow without a tail is a cone, not an arrow — it has to read
  // unambiguously as "arrow pointing down at this thing".
  //
  // Default cylinder/cone axis is along +Y. After rotation.x = π/2 the
  // +Y end maps to +Z (up) and -Y to -Z (down).
  //
  // Layout (Z, mm above topZ of component):
  //   tipGap = 0.3                        cone tip (down)
  //   tipGap + headH = 0.3 + 1.5 = 1.8    cone base / tail bottom
  //   tipGap + headH + tailH = 4.3        tail top
  // Total arrow height (tip to tail-top) = headH + tailH = 4.0 mm.
  const tipGap  = 0.3;
  const headH   = 1.5;
  const tailH   = 2.5;
  const baseDiam = 1.6;          // wide end of the cone (also tail diameter)
  const tailDiam = baseDiam * 0.45;
  const head = B.MeshBuilder.CreateCylinder('hl-arrowhead-' + name, {
    height: headH,
    diameterTop: baseDiam,       // ends up UP after X-rotation
    diameterBottom: 0,           // tip — ends up DOWN
    tessellation: 16,
  }, scene);
  head.rotation.x = Math.PI / 2;
  head.position.set(cx, cy, topZ + tipGap + headH / 2);
  const tail = B.MeshBuilder.CreateCylinder('hl-arrowtail-' + name, {
    height: tailH,
    diameter: tailDiam,
    tessellation: 12,
  }, scene);
  tail.rotation.x = Math.PI / 2;
  tail.position.set(cx, cy, topZ + tipGap + headH + tailH / 2);
  head.material = mat;
  tail.material = mat;
  for (const m of [head, tail]) {
    // Stay in the default rendering group so the depth buffer is shared
    // with the board + components. If the camera angle puts a tall part
    // in front of the arrow tail, the part should occlude the arrow —
    // not the other way around. (Earlier versions used renderingGroupId=1
    // to "pop" the marker over everything; that masked the bug where
    // topZ was wrong and the arrow tip ended up inside the chip body.)
    m.isPickable = false;
  }
  // Cast a shadow onto the board so the arrow reads as a real 3D object
  // hovering above the part — humans use the shadow's offset and
  // softness to judge height. Without it the arrow looks like a 2D
  // sticker. The viewer's shadowGenerator is per-spotLight; addShadowCaster
  // is idempotent, safe to call repeatedly on a marker.
  try {
    const shadowGen = window.viewer && window.viewer.getShadowGenerator && window.viewer.getShadowGenerator();
    if (shadowGen && shadowGen.addShadowCaster) {
      shadowGen.addShadowCaster(head, true);
      shadowGen.addShadowCaster(tail, true);
    }
  } catch {}
  return { head, tail };
}

// Lazy scene-level HighlightLayer (one shared instance, configured once).
// Used by highlightComponents() to draw a teal stroke around the actual
// component mesh — instead of the synthetic cone+tail+sphere arrows we
// faked when the trimmed BABYLON bundle didn't expose HighlightLayer.
//
// Sibling-bleed avoidance: highlightComponents() auto-promotes each
// target via _promoteComponent() BEFORE addMesh, so adding R1 to the
// HighlightLayer can't bleed to R2/R3 because R1 is its own standalone
// Mesh + cloned material at that point.
let _hlLayer = null;
// Separate HighlightLayer for measure tool — THIN outline stroke only.
// The main _hlLayer has blur 8 + GlowLayer pulse on top, which produces
// a fat glowing sphere that obscures the chip. Measure tool needs to
// SHOW which thing is selected without HIDING it under the glow.
// Measure-tool selection visual: instead of a HighlightLayer (which
// paints the entire mesh surface and obscures the chip — a giant
// glowing blob), use Babylon's per-mesh renderOverlay. That tints the
// mesh translucently in the selection color so the chip's actual body
// (text, leads, package) remains visible underneath. No GlowLayer
// involvement, no halo bloom, no obscuring.
function _measureHlApply(mesh, color) {
  if (!mesh) return;
  try {
    mesh.renderOverlay = true;
    mesh.overlayColor = color;
    mesh.overlayAlpha = 0.35;
  } catch {}
}
function _measureHlClear(mesh) {
  if (!mesh) return;
  try { mesh.renderOverlay = false; } catch {}
}
function _getHighlightLayer(B, scene) {
  if (_hlLayer && _hlLayer._scene === scene) return _hlLayer;
  if (!B.HighlightLayer) return null;
  // Maximum-strength glow: wide blur for a fat halo, BOTH inner and
  // outer glow so the chip itself reads as glowing not just outlined,
  // and a parallel GlowLayer for additive bloom on top — gives the
  // selected chip an unmistakable "lit up" presence.
  _hlLayer = new B.HighlightLayer('adom-tsci-hl', scene, {
    blurHorizontalSize: 8,
    blurVerticalSize: 8,
    mainTextureRatio: 1.0,
  });
  _hlLayer.innerGlow = true;
  _hlLayer.outerGlow = true;
  if ('blurMaxIntensity' in _hlLayer) _hlLayer.blurMaxIntensity = 3.0;
  // Companion GlowLayer (additive bloom). Selected meshes get an
  // emissive boost via the highlight material clone, and GlowLayer
  // turns that emissive into a soft bloom on top of the HighlightLayer
  // halo — much more visible than HighlightLayer alone.
  if (B.GlowLayer && !_glowLayer) {
    _glowLayer = new B.GlowLayer('adom-tsci-glow', scene, {
      mainTextureRatio: 1.0,
      blurKernelSize: 64,
    });
    _glowLayer.intensity = 1.5;
    // Exclude every existing synth-tp-* mesh from the bloom pass —
    // synth_tp gold emissive at intensity 1.5 produces a strobe halo
    // (read as "throbbing"). Done here because synth-tp meshes are
    // created BEFORE the GlowLayer exists (lazy init), so the
    // exclusion call inside _synthTestpointPad silently no-ops.
    try {
      for (const m of scene.meshes) {
        if (/^synth-tp-/.test(m.name || '')) _glowLayer.addExcludedMesh(m);
      }
    } catch {}
  }
  return _hlLayer;
}
let _glowLayer = null;

// Throb the highlight layer over time so the halo pulses — much
// harder to miss. Babylon's HighlightLayer doesn't expose
// blurMaxIntensity on this minified bundle, but the per-mesh halo
// COLOR is honoured. Cycle each highlighted mesh's color between dim
// and bright teal at ~1.5 Hz. GlowLayer.intensity also pulses for
// secondary bloom.
let _throbActive = false;
let _throbBaselineGlow = 1.5;
let _throbMeshes = [];     // [{mesh, baseColor: B.Color3}]
let _throbB = null;        // BABYLON
function _startThrob(B) {
  _throbActive = true;
  _throbB = B;
  if (!_throbStarted) {
    _throbStarted = true;
    const start = performance.now();
    const tick = () => {
      if (!_throbActive) { requestAnimationFrame(tick); return; }
      const t = (performance.now() - start) / 1000;
      // 0.35..1.0 — keeps a clearly visible glow even at minimum so
      // the halo never fully vanishes between throbs.
      const k = 0.35 + 0.65 * (1 + Math.sin(t * Math.PI * 1.5)) / 2;
      if (_hlLayer && _throbMeshes.length && _throbB) {
        for (const e of _throbMeshes) {
          const c = new _throbB.Color3(e.base.r * k, e.base.g * k, e.base.b * k);
          try {
            // Babylon's HighlightLayer caches per-mesh color; remove + re-add
            // is the only reliable way to update it across versions.
            _hlLayer.removeMesh(e.mesh);
            _hlLayer.addMesh(e.mesh, c);
          } catch {}
        }
      }
      if (_glowLayer) _glowLayer.intensity = _throbBaselineGlow * (0.5 + k);
      requestAnimationFrame(tick);
    };
    requestAnimationFrame(tick);
  }
}
let _throbStarted = false;
function _stopThrob() {
  _throbActive = false;
  _throbMeshes = [];
  if (_glowLayer) _glowLayer.intensity = _throbBaselineGlow;
}
function _registerThrobMesh(mesh, baseColor) {
  _throbMeshes.push({ mesh, base: baseColor.clone ? baseColor.clone() : baseColor });
}

// Zoom the camera so the bounding box of every highlighted component
// fits the frame. Used by walkthrough + group-highlight CLI + click
// select so the user actually sees what's being called out.
//
// Smooth tween (ease-in-out cubic, 700ms) instead of an instant snap.
// Babylon ArcRotateCamera has target / radius / alpha / beta — we
// lerp target + radius from current to fitted, leaving alpha/beta
// alone so the user keeps their viewing angle. Cancels the previous
// in-flight tween so back-to-back highlights don't fight.
let _camTweenAbort = null;
function _fitCameraToSet(B, scene, names) {
  if (!names || !names.length) return;
  let n = 0;
  let minX = Infinity, minY = Infinity, minZ = Infinity;
  let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
  let cx = 0, cy = 0, cz = 0;
  for (const name of names) {
    const meta = componentMap.get(name);
    if (!meta || !meta.meshes) continue;
    for (const m of meta.meshes) {
      if (!m.computeWorldMatrix) continue;
      m.computeWorldMatrix(true);
      const bb = m.getBoundingInfo().boundingBox;
      cx += bb.centerWorld.x; cy += bb.centerWorld.y; cz += bb.centerWorld.z; n++;
      minX = Math.min(minX, bb.minimumWorld.x); maxX = Math.max(maxX, bb.maximumWorld.x);
      minY = Math.min(minY, bb.minimumWorld.y); maxY = Math.max(maxY, bb.maximumWorld.y);
      minZ = Math.min(minZ, bb.minimumWorld.z); maxZ = Math.max(maxZ, bb.maximumWorld.z);
    }
  }
  if (!n) return;
  cx /= n; cy /= n; cz /= n;
  const cam = viewer && viewer.getCamera && viewer.getCamera();
  if (!cam) return;
  const span = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
  const targetRadius = Math.max(8, span * 2.2);
  const targetVec = new B.Vector3(cx, cy, cz);
  // If current is essentially the destination, no tween needed.
  const startTarget = cam.target.clone ? cam.target.clone() : new B.Vector3(cam.target.x, cam.target.y, cam.target.z);
  const startRadius = cam.radius;
  const dist = Math.hypot(targetVec.x - startTarget.x, targetVec.y - startTarget.y, targetVec.z - startTarget.z);
  const dr = Math.abs(targetRadius - startRadius);
  if (dist < 0.05 && dr < 0.05) return;
  if (_camTweenAbort) _camTweenAbort.aborted = true;
  const abort = { aborted: false };
  _camTweenAbort = abort;
  const start = performance.now();
  const dur = 700;
  const ease = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
  const step = () => {
    if (abort.aborted) return;
    const t = Math.min(1, (performance.now() - start) / dur);
    const k = ease(t);
    cam.target = new B.Vector3(
      startTarget.x + (targetVec.x - startTarget.x) * k,
      startTarget.y + (targetVec.y - startTarget.y) * k,
      startTarget.z + (targetVec.z - startTarget.z) * k,
    );
    cam.radius = startRadius + (targetRadius - startRadius) * k;
    if (t < 1) requestAnimationFrame(step);
  };
  requestAnimationFrame(step);
}

function highlightComponents(names) {
  const B = window.Adom3DViewer && window.Adom3DViewer.BABYLON;
  if (!B) return;
  const scene = viewer && viewer.getScene && viewer.getScene();
  _disposeHighlightMarkers();
  const hl = _getHighlightLayer(B, scene);
  if (hl && hl.removeAllMeshes) {
    try { hl.removeAllMeshes(); } catch {}
  }
  // Clear the throb registration too — otherwise the previous step's
  // meshes keep pulsing even after the new highlight is set (because
  // their entries are still in _throbMeshes and the throb tick keeps
  // re-adding them to the highlight layer with cycling colours).
  _throbMeshes = [];
  if (!names || names.length === 0) return;

  const teal = new B.Color3(HIGHLIGHT_TEAL.r, HIGHLIGHT_TEAL.g, HIGHLIGHT_TEAL.b);

  for (const name of names) {
    const meta = componentMap.get(name);
    if (!meta) continue;

    // Testpoints now have a real synthesised disc mesh (see
    // enumerateComponents) — same HighlightLayer path as chips so
    // they glow exactly like every other component.

    // Real chips/passives: promote first (so we own this component's
    // material independent of its InstancedMesh siblings), then add to
    // the scene's HighlightLayer for a real teal glow stroke around
    // exactly this component.
    if (hl) {
      try { _promoteComponent(name); } catch {}
      for (const m of meta.meshes) {
        if (!m || !m.getClassName) continue;
        if (m.getClassName() === 'InstancedMesh') continue;
        try {
          hl.addMesh(m, teal);
          _highlightMarkers.push(m);
          _registerThrobMesh(m, teal);
        } catch (e) {
          console.warn('[highlight] addMesh failed for', name, m.name, e);
        }
      }
    } else {
      // Fallback path if HighlightLayer isn't available — synthetic
      // cone+tail arrow we used before the vendored bundle landed.
      try {
        const marker = _makeFloatingMarker(B, scene, meta, name);
        if (marker) {
          if (marker.head) _highlightMarkers.push(marker.head);
          if (marker.tail) _highlightMarkers.push(marker.tail);
        }
      } catch (e) {
        console.warn('[highlight] failed to place floating marker for', name, e);
      }
    }
  }
  // Throb the glow so the highlighted component pulses — much harder
  // for the user to overlook a small SMD passive when the halo is
  // breathing in and out. Stop throbbing when nothing's selected.
  if (names && names.length) {
    // Skip our auto-fit when the walkthrough is driving — walkthrough's
    // own flyTo runs immediately after this call and would fight a
    // simultaneous tween, producing the harsh jumps the user reported.
    // Outside of walkthrough (CLI highlight, click-select), fit smoothly.
    if (!window._wtActive) _fitCameraToSet(B, scene, names);
    _startThrob(B);
  } else {
    _stopThrob();
  }
}

// Visibility history — each entry is {name, prevVisible}. Ctrl+Z
// pops and restores. "Hidden" now means 5% alpha (ghosted) not fully
// disabled, so you can still see the component outline and right-
// click to restore it. Meshes stay enabled; alpha changes only.
window._visibilityHistory = window._visibilityHistory || [];
window._hiddenComponents = window._hiddenComponents || new Set();

// Board-colored discs drawn on top of BAKED testpoint pads so we can
// visually "hide" them. tscircuit bakes testpoint copper into the board
// surface texture; there's no per-testpoint mesh we can disable. The
// only way to make the pad disappear is to cover it with a same-colored
// disc. Tracked here so setComponentVisibility(show) can dispose them.
const _testpointCoverDiscs = new Map();  // refdes -> disc mesh

function _makeTestpointCoverDisc(B, scene, x, y, z) {
  // Dark-green disc matching the FR4 soldermask colour, sitting 0.05 mm
  // above the board top so it wins the z-fight against the baked texture.
  const disc = B.MeshBuilder.CreateCylinder('tp-cover', {
    diameter: 2.2, height: 0.04, tessellation: 24,
  }, scene);
  disc.position.set(x, y, z + 0.05);
  disc.rotation.x = Math.PI / 2;
  const mat = new B.StandardMaterial('tp-cover-mat', scene);
  // Soldermask green, matte. Matches the dominant board colour closely
  // enough that a human skimming the board can't find the covered pad.
  mat.diffuseColor = new B.Color3(0.08, 0.32, 0.18);
  mat.specularColor = new B.Color3(0, 0, 0);
  disc.material = mat;
  disc.renderingGroupId = 1;
  disc.isPickable = false;
  return disc;
}

// Bake src's WORLD transform onto dst's LOCAL transform. dst must have
// parent=null so its local IS its world.
//
// Reads from absolutePosition / absoluteRotationQuaternion /
// absoluteScaling (which Babylon exposes as getters on every mesh)
// rather than decomposing the world matrix manually — the trimmed
// viewer bundle does not export `B.Quaternion` under that friendly
// name, so the manual-decompose path threw "is not a constructor"
// and silently fell through the eject loop's catch, leaving the
// original InstancedMesh siblings undisposed.
function _bakeWorldTransform(B, src, dst) {
  src.computeWorldMatrix(true);
  if (src.absolutePosition) dst.position = src.absolutePosition.clone();
  if (src.absoluteRotationQuaternion) {
    dst.rotationQuaternion = src.absoluteRotationQuaternion.clone();
  }
  if (src.absoluteScaling) dst.scaling = src.absoluteScaling.clone();
}

// Promote a single component from InstancedMesh sibling to a standalone
// Mesh with its own material, so per-component mutations (alpha-hide,
// per-instance colour, wireframe, …) don't bleed to its siblings.
//
// Default rendering uses InstancedMesh: tscircuit packs every R0402 into
// one source mesh + one shared material, which is fast (one draw call
// per package family) but means mutating the material affects every
// instance. Promotion swaps a single instance for an independent clone,
// preserving the same world transform so the user sees no visual change
// until we apply the per-component mutation.
//
// Idempotent — promoting an already-promoted component is a no-op.
// Sticky — once promoted, the component stays standalone for the
// session (clearer state, simple mental model; demote-on-reset is a
// future enhancement if memory becomes an issue with hundreds of
// promoted components).
function _promoteComponent(name) {
  const meta = componentMap.get(name);
  if (!meta || meta.promoted) return false;
  const B = window.Adom3DViewer && window.Adom3DViewer.BABYLON;
  const scene = viewer && viewer.getScene && viewer.getScene();
  if (!B || !scene) return false;
  // Build a usage map of materials → mesh count, so we can detect
  // when a component's material is shared with siblings (the source-Mesh
  // case where one component owns the InstancedMesh source mesh and the
  // material is shared with all its instances).
  const matUsage = new Map();
  for (const m of scene.meshes) {
    if (!m.material) continue;
    matUsage.set(m.material, (matUsage.get(m.material) || 0) + 1);
  }
  const cloned = [];
  for (const inst of meta.meshes) {
    try {
      const isInstanced =
        typeof inst.getClassName === 'function' && inst.getClassName() === 'InstancedMesh';
      if (isInstanced) {
        // InstancedMesh sibling → clone source mesh + material, BAKE
        // the instance's world transform into the clone's local
        // (clone has no parent → local IS world). Critical: we must
        // use the WORLD transform, not inst.position which is local
        // and meaningless if inst has a parent that holds the offset.
        const src = inst.sourceMesh;
        if (!src || typeof src.clone !== 'function') {
          cloned.push(inst);
          continue;
        }
        const standalone = src.clone(src.name + '__promoted_' + name);
        standalone.isPickable = inst.isPickable;
        standalone.renderingGroupId = inst.renderingGroupId;
        standalone.parent = null;
        _bakeWorldTransform(B, inst, standalone);
        if (src.material && typeof src.material.clone === 'function') {
          standalone.material = src.material.clone(src.material.name + '__promoted_' + name);
        } else {
          standalone.material = src.material;
        }
        try { inst.dispose(); } catch {}
        cloned.push(standalone);
      } else if (inst.material && matUsage.get(inst.material) > 1
                 && typeof inst.material.clone === 'function') {
        // Source-Mesh case: tscircuit makes ONE source mesh per package
        // family + N InstancedMesh siblings, and the source itself maps
        // to ONE specific component. The source mesh is class 'Mesh'
        // but its material is shared with every InstancedMesh sibling.
        //
        // InstancedMesh inherits material at render time via
        // `sourceMesh.material`, so JUST cloning our material doesn't
        // help — the siblings would re-pick up the clone. We must FIRST
        // eject every InstancedMesh sibling: convert each to a
        // standalone Mesh that owns its own material reference, so when
        // we clone the source's material, only this component is
        // affected.
        const origMat = inst.material;
        const siblings = scene.meshes.filter(m => m !== inst &&
          m.getClassName && m.getClassName() === 'InstancedMesh' &&
          m.sourceMesh === inst && m.material === origMat);
        for (const sib of siblings) {
          try {
            const sibClone = inst.clone(inst.name + '__ejected_' + sib.name);
            sibClone.parent = null;
            sibClone.isPickable = sib.isPickable;
            sibClone.renderingGroupId = sib.renderingGroupId;
            // Bake sib's WORLD transform into sibClone's local. inst.clone()
            // copies inst's local position which is meaningless without
            // inst's parent transform.
            _bakeWorldTransform(B, sib, sibClone);
            // Each ejected sibling gets its OWN cloned material so a
            // future per-sibling mutation (hide R1) doesn't bleed to
            // OTHER siblings that also keep `origMat`. We can't share
            // origMat across all ejected siblings because then mutating
            // one walks back up the shared reference. Renders identical
            // because the clone is a value-copy of the same material.
            sibClone.material = (origMat && typeof origMat.clone === 'function')
              ? origMat.clone(origMat.name + '__ejected_' + sib.name)
              : origMat;
            // Update any componentMap entry that referenced this sibling
            // so its meta.meshes points at the new standalone clone.
            for (const otherMeta of componentMap.values()) {
              if (!otherMeta || !otherMeta.meshes) continue;
              const idx = otherMeta.meshes.indexOf(sib);
              if (idx >= 0) otherMeta.meshes[idx] = sibClone;
            }
            try { sib.dispose(); } catch {}
          } catch (e) {
            console.warn('[promote] eject sibling failed', sib && sib.name, e);
          }
        }
        // Now clone OUR material — siblings have their own references
        // to origMat, so cloning here only affects this component.
        inst.material = origMat.clone(origMat.name + '__promoted_' + name);
        cloned.push(inst);
      } else {
        // Synthesised mesh, board surface, anything not in a shared
        // family — leave alone.
        cloned.push(inst);
      }
    } catch (e) {
      console.warn('[promote] failed for', name, e);
      cloned.push(inst);
    }
  }
  meta.meshes = cloned;
  meta.promoted = true;
  return true;
}

// Public CLI surface — `adom-tsci toggle-component R1 --promote` calls this.
window.promoteComponent = function(name) {
  return { ok: _promoteComponent(name), promoted: !!(componentMap.get(name) && componentMap.get(name).promoted) };
};

function setComponentVisibility(name, visible, localOnly) {
  const meta = componentMap.get(name);
  if (!meta) return;
  const wasVisible = !window._hiddenComponents.has(name);
  if (wasVisible === visible) return;  // no-op
  // Record for undo (skip localOnly poll-driven restores to avoid loops)
  if (!localOnly) {
    window._visibilityHistory.push({ name, prevVisible: wasVisible });
    if (window._visibilityHistory.length > 50) window._visibilityHistory.shift();
  }
  if (visible) window._hiddenComponents.delete(name);
  else window._hiddenComponents.add(name);

  const B = window.Adom3DViewer && window.Adom3DViewer.BABYLON;
  const scene = viewer && viewer.getScene && viewer.getScene();

  // ── Testpoint hide is special. Alpha=0.20 doesn't visibly disappear
  //    because the synth disc sits over a baked TP pad of similar
  //    colour — you'd see right through to "the same thing", looking
  //    like hide didn't work. So for testpoints, "hide" = repaint the
  //    synth disc dark-soldermask-green (covering the baked pad too)
  //    and "show" = restore the gold metal colour. Alpha stays at 1
  //    so the depth pipeline doesn't see the disc as transparent
  //    (which would hide the user-visible colour change). Picking still
  //    works because the mesh is still there.
  if (meta.kind === 'testpoint') {
    for (const m of meta.meshes) {
      try {
        if (!m.material) continue;
        if (visible) {
          // Restore gold metal
          m.material.diffuseColor.set(0.85, 0.65, 0.30);
          m.material.emissiveColor.set(0.15, 0.10, 0.04);
        } else {
          // Soldermask-green cover
          m.material.diffuseColor.set(0.08, 0.32, 0.18);
          m.material.emissiveColor.set(0, 0, 0);
        }
        m.material.alpha = 1.0;
        m.material.transparencyMode = 0;
      } catch {}
    }
  } else {
    // ── INVARIANTS (DO NOT REGRESS):
    //   1. Hide is GHOSTED (alpha=0.20), not full-disable. Picker must
    //      still hit the ghost so a second right-click can unhide.
    //   2. Hiding R1 must NOT bleed to R2/R3 — promote the instance to
    //      a standalone Mesh + cloned material first, then mutate.
    //
    // tscircuit packs every R0402 into one source mesh + one shared
    // material via InstancedMesh. Mutating material.alpha on a sibling
    // walks all the way up to the source — the entire family ghosts.
    // _promoteComponent() swaps this single instance for an independent
    // clone with its own material, so the alpha write hits ONLY R1.
    // The clone preserves the InstancedMesh's exact world transform so
    // the user sees zero visual change between promoted and not-promoted
    // (until we apply the per-component mutation on the next line).
    _promoteComponent(name);
    const alpha = visible ? 1.0 : 0.20;
    for (const m of meta.meshes) {
      try {
        if (m.material) {
          if (m.material.alpha === undefined) m.material.alpha = 1.0;
          m.material.alpha = alpha;
          // transparencyMode controls depth-write. ALPHABLEND (2) disables
          // depth-writes, so a previously-ghosted material that's been
          // restored to alpha=1 would STILL be in ALPHABLEND mode and let
          // silkscreen text behind it bleed through. Reset to OPAQUE (0)
          // when fully visible so the chip body re-occludes everything
          // behind it correctly.
          if (alpha < 1) {
            m.material.transparencyMode = 2;
          } else {
            m.material.transparencyMode = 0;
          }
        } else if (m.visibility !== undefined) {
          m.visibility = alpha;
        }
      } catch {}
    }
  }
  renderComponentList();
  if (!localOnly) {
    fetch('api/toggle-component', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name, visible }) }).catch(()=>{});
  }
}

// Public API for the `adom-tsci toggle-group` CLI: hide or show every
// component of a given kind. Mirrors the highlightGroup / clearHighlight
// pair so the ralph-loop CLI works for visibility too.
window.toggleGroup = function(kind, visible) {
  try {
    if (typeof componentMap === 'undefined') return { error: 'componentMap not ready' };
    const names = [];
    for (const [name, meta] of componentMap) {
      if (meta.kind === kind) names.push(name);
    }
    for (const name of names) setComponentVisibility(name, visible, /*localOnly*/false);
    return { ok: true, kind, count: names.length, visible, names };
  } catch (e) { return { error: e && e.message ? e.message : String(e) }; }
};

// Exposed for HUD queries: consider a component "visible" if it's NOT
// in the ghosted set. This replaces the old `m.isEnabled()` check.
function componentIsVisible(name) {
  return !window._hiddenComponents.has(name);
}

// Ctrl+Z / Cmd+Z — undo the last visibility change.
window.addEventListener('keydown', (e) => {
  if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z') return;
  const t = e.target;
  if (t && (t.tagName === 'INPUT' || t.tagName === 'SELECT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
  const last = window._visibilityHistory.pop();
  if (!last) return;
  e.preventDefault();
  setComponentVisibility(last.name, last.prevVisible, true);
});

// ─── Walkthrough Demo ──────────────────────────────────────────────
// Guided tour of the board. Script is ordered by visual weight so a
// first-time viewer builds a mental model: big chips first, then smaller,
// then the physical test infrastructure (corner MCs → ring MCs →
// testpoints) and finally the silkscreen story. The script references
// component names that exist in the current GLB — missing components are
// quietly skipped rather than throwing.
// Walkthrough steps. Mutable: if the project directory ships a
// `walkthrough.json` at its root, we replace the hardcoded array with
// the project-specific one at load time (see loadProjectWalkthrough
// below). The default below is the iCE40 reference tour — it plays
// only when a project has no walkthrough.json of its own, which is
// a strong sign the project author forgot to write one (see the
// adom-tscircuit skill's "every board needs its own walkthrough" rule).
let WALKTHROUGH = [
  {
    id: 'intro',
    title: 'iCE40HX1K USB dev board',
    text: 'A 128 \u00d7 128 mm dev board exposing every useful signal from a Lattice iCE40HX1K FPGA. The tour walks through the chips, the corner alignment posts, the edge-ring machine contacts that let a probe cradle talk to it, the test points, and the silkscreen cheat-sheet on the bottom layer. Orbit the view anytime \u2014 that auto-pauses the tour so you can look around.',
    focus: { kind: 'all' },
  },
  {
    id: 'fpga',
    title: 'U1 \u2014 iCE40HX1K FPGA',
    text: 'The biggest chip on the board and the reason it exists: an LQFP-100 Lattice iCE40HX1K-VQ100. 95 of its 100 pins fan out to the edge ring of machine-contact pads so a probe cradle can stimulate every signal.',
    focus: { kind: 'component', name: 'U1', zoomTight: true },
    highlight: ['U1'],
  },
  {
    id: 'usb-bridge',
    title: 'U2 \u2014 FT2232HL USB bridge',
    text: 'FTDI FT2232HL in a 64-pin LQFP, the second-largest chip. It bridges USB-C to dual UART + MPSSE, so the host can flash the FPGA and talk to it over the same cable.',
    focus: { kind: 'component', name: 'U2', zoomTight: true },
    highlight: ['U2'],
  },
  {
    id: 'flash',
    title: 'U3 \u2014 W25Q16 configuration flash',
    text: 'SOIC-8 W25Q16 holding the FPGA bitstream. On boot the FPGA clocks bits out of this flash over SPI; without it the FPGA is an empty cell on every power-up.',
    focus: { kind: 'component', name: 'U3', zoomTight: true },
    highlight: ['U3'],
  },
  {
    id: 'usb-contacts',
    title: 'USB-C \u2014 broken out as contacts',
    text: 'The USB-C receptacle brings in VBUS, GND, D+, and D-. Rather than only expose them through the connector, each is also broken out as a machine contact (MC_USB_VBUS, MC_USB_GND, MC_USB_DP, MC_USB_DM) and as a testpoint (TP_USB_*). A probe cradle or a wire-bent jig can drive or read USB signals without plugging anything in.',
    focus: { kind: 'components', names: ['MC_USB_VBUS', 'MC_USB_GND', 'MC_USB_DP', 'MC_USB_DM'], zoomTight: true },
    highlight: ['MC_USB_VBUS', 'MC_USB_GND', 'MC_USB_DP', 'MC_USB_DM'],
  },
  {
    id: 'corner-mps',
    title: 'Corner mounting posts',
    text: 'Four mounting posts (MP1\u2013MP4) sit at the board corners. They do two jobs: fix the board into its test cradle with magnets, and give the cradle the only four positions where rotation and skew both go to zero degrees of freedom. Put the alignment features anywhere else and the board can rock.',
    focus: { kind: 'components', names: ['MP1', 'MP2', 'MP3', 'MP4'] },
    highlight: ['MP1', 'MP2', 'MP3', 'MP4'],
  },
  {
    id: 'edge-contacts',
    title: 'Edge-ring machine contacts',
    text: '108 MC pads ring the board perimeter, one per broken-out FPGA signal plus power rails. A bed-of-nails probe fixture hits all of them simultaneously, so automated test can stimulate and read every signal without plugging cables into connectors.',
    focus: { kind: 'kindAll', componentKind: 'contact', flyover: true },
    highlight: [],
    minMs: 13500,
  },
  {
    id: 'testpoints',
    title: 'Test points',
    text: '116 TPn pads, one per broken-out signal, on the top layer. Smaller than the machine contacts and easier to stab with a pogo probe during bench debug. Labelled with their signal name in silkscreen.',
    focus: { kind: 'kindAll', componentKind: 'testpoint', flyover: true },
    highlight: [],
    minMs: 13500,
  },
  {
    id: 'silkscreen',
    title: 'Bottom-layer silkscreen cheat-sheet',
    text: 'Flip the board over: the bottom layer is a 40-line FPGA 101 introduction \u2014 what an LUT is, what a flip-flop is, how config loading works. Baked into the silkscreen so a student can learn the part without a datasheet in hand.',
    focus: { kind: 'silkscreen' },
    highlight: [],
  },
  {
    id: 'outro',
    title: 'Tour complete',
    text: 'That\u2019s the board. From here: the ViewCube at the top right rotates, the Components panel lets you hide any chip to see the traces underneath, and \ud83d\udccf Measure (I) gives you \u0394X / \u0394Y / \u0394Z between any two points. Happy hacking.',
    focus: { kind: 'view', view: 'iso' },
  },
];

// Replace the hardcoded iCE40 walkthrough with project-specific steps
// from <project>/walkthrough.json if that file exists. The fetch races
// GLB load — whichever finishes second wins; walkthroughStart() always
// reads the current WALKTHROUGH so late-arriving project steps take
// effect on the next tour start. Schema is a JSON array of the same
// shape as the hardcoded steps above.
async function loadProjectWalkthrough() {
  try {
    const r = await fetch('walkthrough.json');
    if (!r.ok) return;  // no project walkthrough — keep default
    const j = await r.json();
    // Accept both the legacy array form AND the object-with-steps form.
    // The object form carries a `_meta` block with provenance that we use
    // to catch the "wrong walkthrough for this project" bug — see below.
    let steps = null;
    let meta = null;
    if (Array.isArray(j)) {
      steps = j;
    } else if (j && Array.isArray(j.steps)) {
      steps = j.steps;
      meta = j._meta || null;
    }
    if (!steps || steps.length === 0) return;

    // Validate step shape
    for (const s of steps) {
      if (!s.id || !s.title || !s.text || !s.focus) {
        console.warn('[walkthrough] invalid step (missing id/title/text/focus):', s);
        return;
      }
    }

    // ── Provenance check — deterministic fix for the copy-paste-from-
    //    another-board bug. The CLI's `adom-tsci walkthrough-gen` stamps
    //    _meta.project_name into the file at generation time. Here we
    //    cross-check it against /state.project_name; any mismatch
    //    renders a persistent red banner at the top of the viewer so the
    //    author can't miss it. Legacy array-form walkthroughs skip the
    //    check (for now) but trigger a softer "no provenance" warning.
    let expectedProject = null;
    try {
      const sr = await fetch('state');
      if (sr.ok) {
        const s = await sr.json();
        expectedProject = s.project_name || s.project_display_name || null;
      }
    } catch {}
    if (meta && meta.project_name && expectedProject &&
        meta.project_name !== expectedProject &&
        // project_display_name strips / replaces punctuation, so be lenient:
        meta.project_name.replace(/[^a-z0-9]/gi, '').toLowerCase() !==
        expectedProject.replace(/[^a-z0-9]/gi, '').toLowerCase()) {
      _renderWalkthroughProvenanceBanner({
        severity: 'error',
        expected: expectedProject,
        got: meta.project_name,
      });
      console.error(
        '[walkthrough] PROVENANCE MISMATCH: walkthrough.json says project="' +
        meta.project_name + '" but live project is "' + expectedProject +
        '". This walkthrough was generated for a different board. ' +
        'Regenerate with: adom-tsci walkthrough-gen'
      );
      // Load the steps anyway so the user can see what's wrong, but
      // the banner makes it obvious the content is mismatched.
    } else if (!meta || !meta.project_name) {
      _renderWalkthroughProvenanceBanner({
        severity: 'warn',
        expected: expectedProject,
        got: null,
      });
      console.warn(
        '[walkthrough] no _meta.project_name provenance — can\'t verify ' +
        'this walkthrough belongs to this board. Regenerate with: ' +
        'adom-tsci walkthrough-gen'
      );
    }

    WALKTHROUGH = steps;
    console.log('[walkthrough] loaded ' + steps.length + ' project-specific steps');
  } catch (e) {
    console.warn('[walkthrough] load failed (using default):', e && e.message);
  }
}

// Persistent banner that catches the "walkthrough from a different board"
// bug. Clicking dismisses it for the session; fixing the walkthrough and
// reloading removes it for good.
function _renderWalkthroughProvenanceBanner({severity, expected, got}) {
  if (document.getElementById('wt-provenance-banner')) return;
  const b = document.createElement('div');
  b.id = 'wt-provenance-banner';
  const bg = severity === 'error' ? 'rgba(248,81,73,0.92)' : 'rgba(212,170,62,0.92)';
  const title = severity === 'error'
    ? '⚠ Walkthrough is from a different board'
    : 'Walkthrough has no provenance — unverified';
  const body = severity === 'error'
    ? 'walkthrough.json says project="' + (got || '?') + '" but this project is "' +
      (expected || '?') + '". Fix: run <code>adom-tsci walkthrough-gen</code> in the project dir.'
    : 'This walkthrough.json has no <code>_meta.project_name</code>, so the viewer ' +
      'can\'t verify it belongs to this board. Fix: run <code>adom-tsci walkthrough-gen</code>.';
  b.style.cssText =
    'position:fixed;top:0;left:0;right:0;z-index:100000;' +
    'background:' + bg + ';color:#0d1117;padding:10px 16px;' +
    'font-family:system-ui,sans-serif;font-size:12.5px;' +
    'display:flex;align-items:center;gap:12px;cursor:pointer;' +
    'box-shadow:0 2px 8px rgba(0,0,0,0.3);';
  b.innerHTML = '<strong style="font-size:13px;">' + title + '</strong>' +
                '<span style="flex:1;">' + body + '</span>' +
                '<span style="opacity:0.7;font-size:11px;">click to dismiss</span>';
  b.addEventListener('click', () => b.remove());
  document.body.appendChild(b);
}
// Fire-and-forget; completes well before the user clicks the 🎬 button.
loadProjectWalkthrough();

let _wtActive = false;
let _wtIndex = 0;
let _wtPaused = false;
let _wtStepTimer = null;
let _wtStepStartedAt = 0;
let _wtStepDurationMs = 0;
let _wtProgressRaf = null;
let _wtCamAnim = null;
let _wtCancelOnUser = null;
// Walkthrough narration state. Audio is fetched per step from /api/tts
// (server proxies to the local adom-tts service, mp3 cached on disk by
// {text+voice+rate} hash). Step duration becomes max(minMs, audio_dur+500).
// Persisted preference: localStorage adom-tsci.wt.narration ('1' | '0').
let _wtNarrationAudio = null;
let _wtNarrationStepIdx = -1;
let _wtNarrationFetchAbort = null;
let _wtNarrationEnabled = (() => {
  try { const v = localStorage.getItem('adom-tsci.wt.narration'); return v === null ? true : v === '1'; }
  catch { return true; }
})();

const _wtBar = document.getElementById('walkthrough-bar');
const _wtTitle = document.getElementById('wt-step-title');
const _wtText = document.getElementById('wt-step-text');
const _wtProgressEl = document.getElementById('wt-progress');
const _wtPauseBtn = document.getElementById('wt-pause');
const _wtCloseBtn = document.getElementById('wt-close');
const _wtBackBtn = document.getElementById('wt-back');
const _wtNextBtn = document.getElementById('wt-next');
const _wtPausedBadge = document.getElementById('wt-paused-badge');

function walkthroughToggle() {
  if (_wtActive) walkthroughClose();
  else walkthroughStart();
}

function walkthroughStart() {
  _wtActive = true;
  _wtIndex = 0;
  _wtPaused = false;
  _wtBar.style.display = 'block';
  document.getElementById('tb-walkthrough').classList.add('active');
  // Shrink the 3D canvas so it doesn't extend BEHIND the docked narration
  // bar \u2014 otherwise `frameModel()` frames the board assuming a taller
  // canvas and the subject ends up biased upward from the visible centre.
  _walkthroughResizeCanvas(true);
  walkthroughRenderStep();
}

function _walkthroughResizeCanvas(active) {
  const wrap = document.getElementById('viewer-wrap');
  if (!wrap) return;
  // Measure bar height once it's rendered; fall back to 120px.
  const bar = document.getElementById('walkthrough-bar');
  const barH = (active && bar) ? Math.ceil(bar.getBoundingClientRect().height) : 0;
  wrap.style.bottom = barH + 'px';
  // Nudge Babylon to pick up the new canvas size.
  requestAnimationFrame(() => {
    try { viewer && viewer.getEngine && viewer.getEngine().resize(); } catch {}
  });
}

function walkthroughClose() {
  _wtActive = false;
  _wtBar.style.display = 'none';
  document.getElementById('tb-walkthrough').classList.remove('active');
  _walkthroughResizeCanvas(false);
  clearTimeout(_wtStepTimer); _wtStepTimer = null;
  if (_wtProgressRaf) cancelAnimationFrame(_wtProgressRaf); _wtProgressRaf = null;
  if (_wtCamAnim) { try { _wtCamAnim.stop(); } catch {} _wtCamAnim = null; }
  _wtStopCinematicOrbit();
  _wtNarrationStop();
  if (_wtCancelOnUser) {
    const canvas = viewer && viewer.getEngine && viewer.getEngine().getRenderingCanvas();
    if (canvas) canvas.removeEventListener('pointerdown', _wtCancelOnUser);
    _wtCancelOnUser = null;
  }
  highlightComponents([]);
  _walkthroughPushStatus();
}

function walkthroughNext() { if (!_wtActive) return; if (_wtIndex < WALKTHROUGH.length - 1) { _wtIndex++; walkthroughRenderStep(); } else walkthroughClose(); }
function walkthroughPrev() { if (!_wtActive) return; if (_wtIndex > 0) { _wtIndex--; walkthroughRenderStep(); } }

// ── Narration ──────────────────────────────────────────────────────────
// Each step fetches its TTS clip on entry, plays it through an HTMLAudio
// element, and stretches the step's auto-advance timer to match the clip
// length. Cached server-side so revisits are instant. Pause/Resume/Next/
// Prev/Close all go through these helpers so audio never outlives the step
// it belongs to.
function _wtNarrationStop() {
  if (_wtNarrationFetchAbort) { try { _wtNarrationFetchAbort.abort(); } catch {} _wtNarrationFetchAbort = null; }
  if (_wtNarrationAudio) {
    try { _wtNarrationAudio.pause(); } catch {}
    try { if (_wtNarrationAudio.src) URL.revokeObjectURL(_wtNarrationAudio.src); } catch {}
    _wtNarrationAudio = null;
  }
  _wtNarrationStepIdx = -1;
}
function _wtNarrationPauseIfPlaying() {
  if (_wtNarrationAudio && !_wtNarrationAudio.paused) { try { _wtNarrationAudio.pause(); } catch {} }
}
function _wtNarrationResumeIfPaused() {
  if (_wtNarrationAudio && _wtNarrationAudio.paused) { try { _wtNarrationAudio.play(); } catch {} }
}
async function _wtNarrationStart(stepIdx, text, onMetadata) {
  _wtNarrationStop();
  if (!_wtNarrationEnabled || !text) return;
  const myIdx = stepIdx;
  _wtNarrationStepIdx = myIdx;
  _wtNarrationFetchAbort = (typeof AbortController !== 'undefined') ? new AbortController() : null;
  let blob;
  try {
    const r = await fetch('api/tts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text }),
      signal: _wtNarrationFetchAbort ? _wtNarrationFetchAbort.signal : undefined,
    });
    if (!r.ok) { console.warn('[walkthrough] narration fetch failed', r.status); return; }
    blob = await r.blob();
  } catch (e) {
    if (!e || e.name !== 'AbortError') console.warn('[walkthrough] narration fetch error', e);
    return;
  }
  if (_wtNarrationStepIdx !== myIdx) return; // step changed mid-fetch
  const url = URL.createObjectURL(blob);
  const audio = new Audio(url);
  audio.preload = 'auto';
  _wtNarrationAudio = audio;
  audio.addEventListener('loadedmetadata', () => {
    if (_wtNarrationStepIdx !== myIdx) return;
    if (typeof onMetadata === 'function' && isFinite(audio.duration)) {
      onMetadata(audio.duration * 1000);
    }
  });
  audio.addEventListener('error', () => { console.warn('[walkthrough] audio playback error'); });
  try { await audio.play(); } catch (e) { console.warn('[walkthrough] audio play blocked (autoplay policy?)', e); }
}
function walkthroughSetNarration(on) {
  _wtNarrationEnabled = !!on;
  try { localStorage.setItem('adom-tsci.wt.narration', _wtNarrationEnabled ? '1' : '0'); } catch {}
  const btn = document.getElementById('wt-narration-toggle');
  if (btn) { btn.textContent = _wtNarrationEnabled ? '🔊' : '🔇'; btn.title = _wtNarrationEnabled ? 'Mute narration' : 'Unmute narration'; }
  if (!_wtNarrationEnabled) _wtNarrationStop();
}
window.walkthroughSetNarration = walkthroughSetNarration;

function walkthroughTogglePause() {
  if (!_wtActive) return;
  _wtPaused = !_wtPaused;
  _wtBar.classList.toggle('paused', _wtPaused);
  _wtPausedBadge.style.display = _wtPaused ? 'inline' : 'none';
  if (_wtPaused) {
    clearTimeout(_wtStepTimer); _wtStepTimer = null;
    if (_wtProgressRaf) cancelAnimationFrame(_wtProgressRaf); _wtProgressRaf = null;
    _wtStopCinematicOrbit();
    _wtNarrationPauseIfPlaying();
  } else {
    const remaining = Math.max(500, _wtStepDurationMs - (performance.now() - _wtStepStartedAt));
    _wtStepStartedAt = performance.now() - (_wtStepDurationMs - remaining);
    _wtStartStepTimer(remaining);
    _wtNarrationResumeIfPaused();
    // Resume: re-fly to the step's target (user might have moved it)
    // which itself restarts the orbit on completion.
    const step = WALKTHROUGH[_wtIndex];
    if (step) walkthroughFlyTo(step.focus);
  }
  _walkthroughPushStatus();
}

function walkthroughRenderStep() {
  const step = WALKTHROUGH[_wtIndex];
  if (!step) return;
  _wtTitle.textContent = step.title;
  _wtText.textContent = step.text;
  _wtProgressEl.textContent = (_wtIndex + 1) + ' / ' + WALKTHROUGH.length;
  _wtBackBtn.disabled = (_wtIndex === 0);
  _wtNextBtn.textContent = (_wtIndex === WALKTHROUGH.length - 1) ? 'Finish' : 'Next \u25b6';
  // Stop any orbit from the previous step before the new fly-to begins.
  _wtStopCinematicOrbit();
  // Resolve the highlight list. If the author set it explicitly, use that.
  // Otherwise, when the focus is `kindAll` on a particular componentKind
  // (testpoints, contacts, etc), auto-populate the highlight list with
  // every component of that kind — so testpoint steps always get their
  // cyan marker discs, even if the author forgot to add a highlight array.
  // Deterministic default: the step's visual focus and its highlight set
  // agree by construction.
  let resolvedHighlight = step.highlight;
  if (!resolvedHighlight && step.focus && step.focus.kind === 'kindAll' && step.focus.componentKind) {
    resolvedHighlight = [];
    for (const [name, meta] of componentMap) {
      if (meta.kind === step.focus.componentKind) resolvedHighlight.push(name);
    }
  }
  highlightComponents(resolvedHighlight || []);
  walkthroughFlyTo(step.focus);
  // Initial duration is the text-length fallback (used when narration is
  // disabled or the TTS service isn't reachable). Once the audio metadata
  // arrives we recompute as max(minMs, audio_duration + 500ms post-roll)
  // and extend the timer + progress bar in place.
  const sentences = (step.text.match(/[.!?](\s|$)/g) || []).length || 1;
  const fallbackDur = Math.max(
    step.minMs || 0,
    2500 + step.text.length * 45 + sentences * 300
  );
  _wtStepDurationMs = fallbackDur;
  _wtStepStartedAt = performance.now();
  _wtBar.classList.remove('paused');
  _wtPaused = false;
  _wtPausedBadge.style.display = 'none';
  _wtStartStepTimer(fallbackDur);
  _walkthroughPushStatus();
  const myStepIdx = _wtIndex;
  _wtNarrationStart(myStepIdx, step.text, (audioMs) => {
    if (_wtIndex !== myStepIdx || !_wtActive || _wtPaused) return;
    const newDur = Math.max(step.minMs || 0, audioMs + 500);
    if (newDur === _wtStepDurationMs) return;
    const elapsed = performance.now() - _wtStepStartedAt;
    _wtStepDurationMs = newDur;
    const remaining = Math.max(500, newDur - elapsed);
    _wtStartStepTimer(remaining);
  });
}

function _walkthroughPushStatus() {
  const step = WALKTHROUGH[_wtIndex] || {};
  const body = {
    active: _wtActive,
    step: _wtIndex + 1,
    total: WALKTHROUGH.length,
    paused: _wtPaused,
    currentStepId: step.id || null,
    title: step.title || null,
  };
  fetch('api/walkthrough-status', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }).catch(()=>{});
}

function _wtStartStepTimer(ms) {
  clearTimeout(_wtStepTimer);
  if (_wtProgressRaf) cancelAnimationFrame(_wtProgressRaf);
  _wtStepTimer = setTimeout(() => {
    _wtStepTimer = null;
    if (_wtActive && !_wtPaused) walkthroughNext();
  }, ms);
  const tick = () => {
    if (!_wtActive || _wtPaused) return;
    const pct = Math.min(100, (performance.now() - _wtStepStartedAt) / _wtStepDurationMs * 100);
    _wtBar.style.setProperty('--wt-progress-pct', pct + '%');
    if (pct < 100) _wtProgressRaf = requestAnimationFrame(tick);
  };
  _wtProgressRaf = requestAnimationFrame(tick);
}

function walkthroughFlyTo(focus) {
  if (!viewer || !focus) return;
  const scene = viewer.getScene();
  const cam = viewer.getCamera();
  if (!scene || !cam) return;
  const target = walkthroughComputeTarget(focus);
  if (!target) return;
  if (_wtCamAnim) { try { _wtCamAnim.stop(); } catch {} _wtCamAnim = null; }
  const canvas = viewer.getEngine().getRenderingCanvas();
  if (_wtCancelOnUser) canvas.removeEventListener('pointerdown', _wtCancelOnUser);
  _wtCancelOnUser = () => {
    if (_wtCamAnim) { try { _wtCamAnim.stop(); } catch {} _wtCamAnim = null; }
    _wtStopCinematicOrbit();
    if (_wtActive && !_wtPaused) walkthroughTogglePause();
  };
  canvas.addEventListener('pointerdown', _wtCancelOnUser, { once: true });

  // Waypoint flyover: chain-animate through multiple poses when the
  // target describes a scan path (contact ring, testpoint grid). Used
  // so the camera visibly SCANS instead of staring at a static iso.
  if (Array.isArray(target.waypoints) && target.waypoints.length > 0) {
    _wtRunWaypoints(scene, cam, target.waypoints);
    return;
  }

  // Single-target: ease-out cubic over DURATION_MS, then gentle orbit.
  // Adom3DViewer's minified BABYLON doesn't export Animation /
  // EasingFunction so we hand-roll the tween.
  const start = _camSnapshot(cam);
  const DURATION_MS = 1000;
  const t0 = performance.now();
  const obs = scene.onBeforeRenderObservable.add(() => {
    const t = Math.min(1, (performance.now() - t0) / DURATION_MS);
    const e = 1 - Math.pow(1 - t, 3);  // ease-out cubic
    _camApplyLerp(cam, start, target, e);
    if (t >= 1) {
      scene.onBeforeRenderObservable.remove(obs);
      _wtCamAnim = null;
      _wtStartCinematicOrbit();
    }
  });
  _wtCamAnim = { stop: () => scene.onBeforeRenderObservable.remove(obs) };
}

function _camSnapshot(cam) {
  return {
    alpha: cam.alpha, beta: cam.beta, radius: cam.radius,
    target: {
      x: cam.target ? cam.target.x : 0,
      y: cam.target ? cam.target.y : 0,
      z: cam.target ? cam.target.z : 0,
    },
  };
}
function _camApplyLerp(cam, a, b, e) {
  cam.alpha = a.alpha + (b.alpha - a.alpha) * e;
  cam.beta  = a.beta  + (b.beta  - a.beta)  * e;
  cam.radius = a.radius + (b.radius - a.radius) * e;
  if (cam.target) {
    cam.target.x = a.target.x + (b.target.x - a.target.x) * e;
    cam.target.y = a.target.y + (b.target.y - a.target.y) * e;
    cam.target.z = a.target.z + (b.target.z - a.target.z) * e;
  }
}

// Chain-animate through N waypoints, ease-in-out per leg. Used by the
// contact-ring and testpoint flyover steps. No orbit between legs --
// the scan itself IS the motion for the step. After the final waypoint
// we kick off the gentle orbit so the last frame isn't motionless.
function _wtRunWaypoints(scene, cam, waypoints) {
  const LEG_MS = 1800;
  let legIndex = 0;
  let legStart = null;
  let legT0 = performance.now();
  const obs = scene.onBeforeRenderObservable.add(() => {
    if (!_wtActive || _wtPaused) return;
    if (legStart === null) legStart = _camSnapshot(cam);
    const t = Math.min(1, (performance.now() - legT0) / LEG_MS);
    const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
    _camApplyLerp(cam, legStart, waypoints[legIndex], e);
    if (t >= 1) {
      legIndex++;
      if (legIndex >= waypoints.length) {
        scene.onBeforeRenderObservable.remove(obs);
        _wtCamAnim = null;
        _wtStartCinematicOrbit();
        return;
      }
      legStart = _camSnapshot(cam);
      legT0 = performance.now();
    }
  });
  _wtCamAnim = { stop: () => scene.onBeforeRenderObservable.remove(obs) };
}

// Slow orbit around the current camera target. ArcRotateCamera, so we
// just increment alpha over time. A gentle beta wobble adds a sense of
// depth. Tied to a scene.onBeforeRenderObservable that gets torn down
// on step change / user interaction.
let _wtOrbitObs = null;
let _wtOrbitT0 = 0;
function _wtStartCinematicOrbit() {
  _wtStopCinematicOrbit();
  if (!_wtActive || _wtPaused || !viewer) return;
  const scene = viewer.getScene();
  const cam = viewer.getCamera();
  if (!scene || !cam) return;
  _wtOrbitT0 = performance.now();
  const startAlpha = cam.alpha;
  const startBeta = cam.beta;
  // Orbital speed: ~30 seconds per full turn. Slow enough that it's a
  // camera drift, not a merry-go-round.
  const ORBIT_RATE = (2 * Math.PI) / 30000; // rad/ms
  const BETA_AMPLITUDE = 0.08; // small wobble, ±0.08 rad (~4.6\u00b0)
  const BETA_PERIOD_MS = 12000;
  _wtOrbitObs = scene.onBeforeRenderObservable.add(() => {
    if (!_wtActive || _wtPaused) return;
    const dt = performance.now() - _wtOrbitT0;
    cam.alpha = startAlpha + dt * ORBIT_RATE;
    cam.beta = startBeta + Math.sin(dt / BETA_PERIOD_MS * Math.PI * 2) * BETA_AMPLITUDE;
  });
}
function _wtStopCinematicOrbit() {
  if (_wtOrbitObs) {
    const scene = viewer && viewer.getScene && viewer.getScene();
    if (scene) scene.onBeforeRenderObservable.remove(_wtOrbitObs);
    _wtOrbitObs = null;
  }
}

function walkthroughComputeTarget(focus) {
  const B = window.Adom3DViewer && window.Adom3DViewer.BABYLON;
  if (!B) return null;
  const scene = viewer.getScene();
  const cam = viewer.getCamera();
  function boxFromMeshes(meshes) {
    let minX = Infinity, minY = Infinity, minZ = Infinity;
    let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
    for (const m of meshes) {
      if (!m || !m.getBoundingInfo) continue;
      m.computeWorldMatrix(true);
      const bb = m.getBoundingInfo().boundingBox;
      minX = Math.min(minX, bb.minimumWorld.x); maxX = Math.max(maxX, bb.maximumWorld.x);
      minY = Math.min(minY, bb.minimumWorld.y); maxY = Math.max(maxY, bb.maximumWorld.y);
      minZ = Math.min(minZ, bb.minimumWorld.z); maxZ = Math.max(maxZ, bb.maximumWorld.z);
    }
    if (!Number.isFinite(minX)) return null;
    const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2, cz = (minZ + maxZ) / 2;
    const diag = Math.sqrt((maxX - minX) ** 2 + (maxY - minY) ** 2 + (maxZ - minZ) ** 2);
    return { center: new B.Vector3(cx, cy, cz), diag };
  }
  function poseFor(center, diag, view, padMul) {
    let alpha = Math.PI / 4, beta = Math.PI / 3;
    if (view === 'top')    { alpha = Math.PI / 2; beta = 0.01; }
    if (view === 'front')  { alpha = Math.PI / 2; beta = Math.PI / 2; }
    if (view === 'right')  { alpha = 0;           beta = Math.PI / 2; }
    if (view === 'iso')    { alpha = Math.PI / 4; beta = Math.PI / 3; }
    if (view === 'bottom') { alpha = Math.PI / 2; beta = Math.PI - 0.05; }
    const pad = padMul != null ? padMul : 1.8;
    const radius = Math.max(4, diag * pad);
    return { alpha, beta, radius, target: center };
  }
  // Use the real board diagonal (computed from the largest thin-flat
  // mesh at GLB-load time) so the walkthrough frames ANY board size.
  // 150 mm was hardcoded for the 128 mm iCE40 tour and left a 16 mm
  // LM555 or 48 mm RP2040 looking like a speck in the corner. Falls
  // back to 150 only if the board wasn't detected (very unusual).
  const boardDiag = window._boardDiag || 150;
  if (focus.kind === 'view') {
    return poseFor(new B.Vector3(0, 0, 0), boardDiag, focus.view);
  }
  if (focus.kind === 'silkscreen') {
    // Close-up of the bottom silkscreen. 60 % of the board diagonal
    // keeps bottom-face text readable on small AND large boards.
    return poseFor(new B.Vector3(0, 0, -0.7), boardDiag * 0.6, 'bottom', 0.8);
  }
  if (focus.kind === 'all') {
    return poseFor(new B.Vector3(0, 0, 0), boardDiag, 'iso');
  }
  if (focus.kind === 'netsModeOff') {
    // Close the Nets panel if open + frame iso. Used by walkthrough
    // outro to dispose the heavy meshes and return to the baked board.
    try {
      const overlay = document.getElementById('trace-overlay');
      if (overlay && overlay.style.display !== 'none') {
        const tb = document.getElementById('tb-trace');
        if (tb) tb.click();
      }
    } catch {}
    return poseFor(new B.Vector3(0, 0, 0), boardDiag, 'iso');
  }
  if (focus.kind === 'netsMode') {
    // Just open the Nets panel + frame iso; no specific net selected.
    // Used by the nets-intro walkthrough step to demonstrate the panel
    // before iterating individual nets.
    try {
      const overlay = document.getElementById('trace-overlay');
      if (overlay && overlay.style.display === 'none') {
        const tb = document.getElementById('tb-trace');
        if (tb) tb.click();
      }
      setTimeout(() => {
        if (typeof selectNet === 'function') selectNet(null);
      }, 350);
    } catch {}
    return poseFor(new B.Vector3(0, 0, 0), boardDiag, 'iso');
  }
  if (focus.kind === 'net') {
    // Open the Nets panel if it isn't already, then select the named
    // net. selectNet runs its own smooth camera tween onto the net's
    // bbox (now allowed to fire during walkthrough — see selectNet).
    // After selectNet's ~600ms tween settles, kick off the cinematic
    // orbit so the net step has the same slow rotating-board motion
    // every other walkthrough step gets — user explicitly asked for
    // "keep rotating the board slowly" on the nets walkthrough steps.
    try {
      const overlay = document.getElementById('trace-overlay');
      if (overlay && overlay.style.display === 'none') {
        const tb = document.getElementById('tb-trace');
        if (tb) tb.click();
      }
      setTimeout(() => {
        if (typeof _traceNets === 'undefined' || typeof selectNet !== 'function') return;
        const want = focus.name;
        const net = _traceNets.find(n => n.name === want || n.id === want);
        if (net) selectNet(net.id);
      }, 350);
      setTimeout(() => {
        if (_wtActive && !_wtPaused) _wtStartCinematicOrbit();
      }, 1100);
    } catch {}
    return null;
  }
  if (focus.kind === 'component') {
    const meta = componentMap.get(focus.name);
    if (!meta) return null;
    const bb = boxFromMeshes(meta.meshes);
    if (!bb) return null;
    // zoomTight: pack the camera right up against the chip so it fills
    // most of the frame, not distant-iso-framing.
    const pad = focus.zoomTight ? 1.3 : 1.8;
    return poseFor(bb.center, Math.max(bb.diag, 4), 'iso', pad);
  }
  if (focus.kind === 'components') {
    const meshes = [];
    for (const n of focus.names) {
      const meta = componentMap.get(n);
      if (meta) for (const m of meta.meshes) meshes.push(m);
    }
    const bb = boxFromMeshes(meshes);
    if (!bb) return null;
    const pad = focus.zoomTight ? 1.2 : 1.6;
    return poseFor(bb.center, Math.max(bb.diag, 10), 'top', pad);
  }
  if (focus.kind === 'kindAll') {
    // Collect every component that has meta.kind === focus.componentKind
    // (e.g. all "contact" or all "testpoint" meshes). Note: the nested
    // key is `componentKind`, NOT `kind`, to avoid a duplicate-key
    // collision in the focus object literal — earlier versions of the
    // walkthrough wrote `{ kind: 'kind', kind: 'contact' }` which JS
    // interprets as `{ kind: 'contact' }`, making this branch dead.
    const meshes = [];
    const centres = [];
    for (const [name, meta] of componentMap) {
      if (meta.kind === focus.componentKind) {
        for (const m of meta.meshes) meshes.push(m);
        if (meta.meshes[0] && meta.meshes[0].getBoundingInfo) {
          meta.meshes[0].computeWorldMatrix(true);
          const c = meta.meshes[0].getBoundingInfo().boundingBox.centerWorld;
          centres.push({ x: c.x, y: c.y, z: c.z, name });
        }
      }
    }
    const bb = boxFromMeshes(meshes);
    if (!bb) return null;
    // Tight zoom on the actual set bbox — was Math.max(bb.diag, 60)
    // which forced a 60-mm radius even when the contact ring is 12 mm
    // across, leaving the contacts as tiny dots in the centre of a
    // mostly-empty frame. The user wants to actually SEE what's being
    // called out.
    const base = poseFor(bb.center, Math.max(bb.diag, 8), 'iso', 1.3);
    // Flyover: return multiple waypoints so the camera moves across
    // several individual pads instead of staring at a static iso view.
    // Pick 6 evenly-spaced waypoints around the ring (or across the
    // testpoint grid) so the camera does a visible "scan" during the
    // step's duration.
    if (focus.flyover && centres.length >= 6) {
      const N = 6;
      const step = Math.floor(centres.length / N);
      const waypoints = [];
      for (let i = 0; i < N; i++) {
        const c = centres[i * step];
        // Zoom tight on each pad (~12 mm field of view) at an iso angle.
        waypoints.push(poseFor(new (window.Adom3DViewer.BABYLON.Vector3)(c.x, c.y, c.z + 1.5), 12, 'iso', 0.9));
      }
      return { ...base, waypoints };
    }
    return base;
  }
  return null;
}

// Wire HUD controls. The walkthrough bar is docked to the bottom of the
// 3D panel (not draggable and not collapsible) \u2014 docking keeps it out
// of the way of the board view while still being readable.
_wtPauseBtn.addEventListener('click', walkthroughTogglePause);
_wtCloseBtn.addEventListener('click', walkthroughClose);
_wtBackBtn.addEventListener('click', walkthroughPrev);
_wtNextBtn.addEventListener('click', walkthroughNext);
{
  const _nb = document.getElementById('wt-narration-toggle');
  if (_nb) {
    _nb.textContent = _wtNarrationEnabled ? '🔊' : '🔇';
    _nb.addEventListener('click', () => walkthroughSetNarration(!_wtNarrationEnabled));
  }
}

// Keyboard during walkthrough.
window.addEventListener('keydown', (e) => {
  if (!_wtActive) return;
  if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.isContentEditable)) return;
  if (e.key === ' ' || e.key === 'Space' || e.key === 'Spacebar') { walkthroughTogglePause(); e.preventDefault(); return; }
  if (e.key === 'ArrowRight') { walkthroughNext(); e.preventDefault(); return; }
  if (e.key === 'ArrowLeft') { walkthroughPrev(); e.preventDefault(); return; }
  if (e.key === 'Escape') { walkthroughClose(); e.preventDefault(); return; }
});

// ─── Floating panel: drag + collapse + hide (re-opened via toolbar ◨) ─
// Floating panel: drag + collapse + hide (re-opened via toolbar ◨)
(() => {
  const overlay = document.getElementById('comp-overlay');
  const header = document.getElementById('comp-header');
  const collapseBtn = document.getElementById('co-collapse');
  const hideBtn = document.getElementById('co-hide');
  const tbCompBtn = document.getElementById('tb-comp');
  const allBtn = document.getElementById('co-all');
  makeDraggable(overlay, header, { ignoreSelector: 'a' });
  header.addEventListener('dblclick', (e) => {
    if (e.target.closest('a')) return;
    overlay.classList.toggle('collapsed');
    e.preventDefault();
  });
  registerFloatingHud(overlay);
  function setPanelVisible(show) {
    overlay.style.display = show ? 'flex' : 'none';
    tbCompBtn.classList.toggle('active', show);
  }
  collapseBtn.addEventListener('click', (e) => { e.preventDefault(); overlay.classList.toggle('collapsed'); });
  hideBtn.addEventListener('click', (e) => { e.preventDefault(); setPanelVisible(false); });
  tbCompBtn.addEventListener('click', () => setPanelVisible(overlay.style.display === 'none'));
  allBtn.addEventListener('click', (e) => { e.preventDefault(); for (const [n] of componentMap) setComponentVisibility(n, true, false); });
  // Silkscreen source checkboxes (Vector / Baked, independent).
  for (const btn of overlay.querySelectorAll('.co-silk-btn')) {
    btn.addEventListener('click', () => {
      const s = btn.dataset.silksource;
      const currently = (s === 'vector' ? _silkSourceVector : _silkSourceBaked);
      window.setSilkSourceVisible(s, !currently);
      toast(s + ' silkscreen: ' + (!currently ? 'on' : 'off'));
    });
  }
  // Vector-layer isolation toggles (Top / Bottom).
  for (const btn of overlay.querySelectorAll('.co-silklayer-btn')) {
    btn.addEventListener('click', () => {
      const layer = btn.dataset.silklayer;
      const currently = (layer === 'top' ? _silkLayerTop : _silkLayerBot);
      if (window.setSilkLayerVisible) window.setSilkLayerVisible(layer, !currently);
      toast(layer + ' vectors: ' + (!currently ? 'on' : 'off'));
    });
  }
  // Solder-mask side toggles (Top / Bottom).
  for (const btn of overlay.querySelectorAll('.co-mask-btn')) {
    btn.addEventListener('click', () => {
      const side = btn.dataset.maskside;
      const currently = (side === 'top' ? _maskLayerTop : _maskLayerBot);
      if (window.setMaskLayerVisible) window.setMaskLayerVisible(side, !currently);
      toast(side + ' mask: ' + (!currently ? 'on' : 'off'));
    });
  }
})();

// ─── Nets / traces panel: drag + collapse + hide + tb-trace toolbar.
// Opening the panel mounts trace meshes (heavy mode); closing disposes
// them and the user is back to the lightweight baked-only board.
(() => {
  const overlay = document.getElementById('trace-overlay');
  const header = document.getElementById('trace-header');
  const collapseBtn = document.getElementById('trace-collapse');
  const hideBtn = document.getElementById('trace-hide');
  const clearBtn = document.getElementById('trace-clear');
  const glowBtn = document.getElementById('trace-glow');
  const tbBtn = document.getElementById('tb-trace');
  if (!overlay || !tbBtn) return;
  makeDraggable(overlay, header, { ignoreSelector: 'a' });
  header.addEventListener('dblclick', (e) => {
    if (e.target.closest('a')) return;
    overlay.classList.toggle('collapsed');
    e.preventDefault();
  });
  registerFloatingHud(overlay);
  // Auto-glow x-ray — on by default. When trace mode opens, picks the
  // most visually-impressive net (largest power/ground rail, falling
  // back to the largest signal net) and selects it. That triggers the
  // HighlightLayer + GlowLayer pulse on top of the substrate-transparent
  // x-ray view — the gorgeous shot the walkthrough demos. User can
  // toggle this off via the ✦ button if they want pure traces with no
  // auto-selection.
  let _autoGlowEnabled = true;
  async function _autoSelectMostInterestingNet() {
    if (!_autoGlowEnabled) return;
    // Poll for _traceNets to populate — enableTraceMode is async and
    // can take a couple seconds on cold cache (circuit.json fetch +
    // mesh generation). 30 × 100ms = 3s budget.
    for (let i = 0; i < 30; i++) {
      if (typeof _traceNets !== 'undefined' && _traceNets && _traceNets.length) break;
      await new Promise(r => setTimeout(r, 100));
    }
    if (!_autoGlowEnabled) return;  // user could have toggled off mid-wait
    if (typeof _traceNets === 'undefined' || !_traceNets || !_traceNets.length) return;
    const groupRank = { power: 0, ground: 1, signal: 2, other: 3 };
    // Sort by (group rank ASC, port count DESC) — biggest power/ground
    // net wins; if no power/ground, biggest signal net by port count.
    const ranked = [..._traceNets].sort((a, b) => {
      const ga = groupRank[a.group] ?? 9;
      const gb = groupRank[b.group] ?? 9;
      if (ga !== gb) return ga - gb;
      return (b.ports?.length || 0) - (a.ports?.length || 0);
    });
    const pick = ranked[0];
    if (pick && typeof selectNet === 'function') selectNet(pick.id);
  }
  function setPanelVisible(show) {
    overlay.style.display = show ? 'flex' : 'none';
    tbBtn.classList.toggle('active', show);
    if (show) {
      // enableTraceMode is async in shape (awaits a circuit.json fetch
      // in some paths) — _autoSelectMostInterestingNet polls for
      // _traceNets to be populated before picking.
      enableTraceMode();
      _autoSelectMostInterestingNet();
    } else {
      disableTraceMode();
    }
  }
  collapseBtn.addEventListener('click', (e) => { e.preventDefault(); overlay.classList.toggle('collapsed'); });
  hideBtn.addEventListener('click', (e) => { e.preventDefault(); setPanelVisible(false); });
  tbBtn.addEventListener('click', () => setPanelVisible(overlay.style.display === 'none'));
  clearBtn.addEventListener('click', (e) => { e.preventDefault(); selectNet(null); });
  if (glowBtn) {
    glowBtn.addEventListener('click', (e) => {
      e.preventDefault();
      _autoGlowEnabled = !_autoGlowEnabled;
      glowBtn.classList.toggle('active', _autoGlowEnabled);
      if (_autoGlowEnabled) {
        // Re-arming: if no net is currently selected, auto-pick one now.
        if (typeof _traceSelectedNetId === 'undefined' || !_traceSelectedNetId) {
          _autoSelectMostInterestingNet();
        }
      } else {
        // Disarming: clear current selection so the user sees raw traces.
        if (typeof selectNet === 'function') selectNet(null);
      }
    });
  }
})();

// ─── Nets / traces mode ─────────────────────────────────────────────
// Heavy mode: when the Nets panel is open, every routed pcb_trace is
// rendered as a live 3D tube on top of the baked board surface so the
// user can pick / highlight individual nets. Closing the panel disposes
// these meshes and the user is back to the lightweight baked-only board
// (analogous to instanced chips → non-instanced when in any select mode).
let _traceModeActive = false;
let _traceCircuit = null;             // cached circuit.json
let _traceNets = [];                  // [{id, name, group, traceIds:[pcb_trace_id], bbox}]
let _traceMeshes = new Map();         // pcb_trace_id → [Mesh]
let _traceSelectedNetId = null;
const _TRACE_GROUPS = ['power', 'ground', 'signal', 'other'];
const _TRACE_GROUP_LABEL = { power: 'Power', ground: 'Ground', signal: 'Signal', other: 'Other' };
// Color is per-LAYER (EDA convention: top=red, bottom=blue). HUD groups
// stay Power/Ground/Signal as organizational categories — color carries
// the layer signal in the 3D view. Resting traces use deep saturation,
// not full luminance — when stacked with the scene's GlowLayer bloom
// pass, full-luminance emissive blows out into white. We also actively
// exclude every trace mesh from the GlowLayer (see _traceExcludeFromGlow)
// so the colors below are what you actually SEE — no extra bloom on top.
// Trace meshes are excluded from the scene's GlowLayer (see
// _traceExcludeFromGlow) so colors below are literal RGB, no bloom
// doubling. Tuned to be clearly visible (red / blue read at a glance)
// without being blown-out white-saturated like the original.
const _TRACE_LAYER_COLOR = {
  top:    { r: 0.70, g: 0.18, b: 0.14 },   // visible red (top, resting)
  bottom: { r: 0.14, g: 0.32, b: 0.75 },   // visible blue (bottom, resting)
};
// Selected = BRIGHTER variant of the SAME layer hue, never a
// different hue. Top is red — selected gets hotter / more saturated
// red, never orange. Bottom is blue — selected gets electric / more
// saturated blue, never cyan. The user explicitly called out that
// orange (top selected) and cyan (bottom selected) made layer
// identification ambiguous; "selection" is supposed to be a
// brightness change inside one hue, not a hue shift to a different
// color family.
const _TRACE_LAYER_COLOR_BRIGHT = {
  top:    { r: 1.00, g: 0.18, b: 0.10 },   // hotter red, NOT orange
  bottom: { r: 0.20, g: 0.45, b: 1.00 },   // electric blue, NOT cyan
};
// Via copper plating — dark copper tone for resting state. Excluded
// from the GlowLayer bloom pass so the literal value renders.
const _TRACE_VIA_COLOR = { r: 0.20, g: 0.13, b: 0.07 };
// When a via is part of the SELECTED net, it switches to PURPLE so
// it visually pops apart from both (a) the resting copper baseline
// and (b) the red/blue wire highlights — the user can instantly see
// which vias belong to the highlighted path.
const _TRACE_VIA_SELECTED_COLOR = { r: 0.55, g: 0.18, b: 0.85 };
const _TRACE_BASE_ALPHA = 0.82;
const _TRACE_DIMMED_ALPHA = 0.08;     // other nets fade hard when one is selected
const _TRACE_SELECTED_ALPHA = 1.00;
let _traceCircuitPromise = null;
async function _traceFetchCircuit() {
  if (_traceCircuit) return _traceCircuit;
  if (!_traceCircuitPromise) {
    _traceCircuitPromise = fetch('circuit.json').then(r => r.ok ? r.json() : null).then(j => { _traceCircuit = j; return j; }).catch(() => null);
  }
  return _traceCircuitPromise;
}
function _traceClassifyNet(name) {
  const n = (name || '').toUpperCase();
  if (/(^|[._-])GND($|[._-])|VSS|AGND|DGND|GND[0-9]/.test(n)) return 'ground';
  if (/(^|[._-])(VIN|VCC|VDD|V\+|V_?BAT|VOUT|VBUS|3V3|3\.3V|5V|12V|24V|VRAW)($|[._-])/.test(n)) return 'power';
  if (/(EN|FB|SW|BST|RESET|CLK|SDA|SCL|TX|RX|MISO|MOSI|SCK|NSS|CS|INT|IRQ|MOSI|MISO|RXD|TXD)/.test(n)) return 'signal';
  if (/^(NET|N\$|UNNAMED)/.test(n)) return 'other';
  return 'signal';
}
function _traceFriendlyNetName(portRefs) {
  // portRefs is an array of "U1.VIN" / "C1.pin1" strings. Prefer entries
  // whose pin-name looks like a power/ground/signal label (named pin) over
  // bare numeric pins.
  const pri = portRefs.find(p => /\.(VIN|VCC|VDD|VOUT|GND|VSS|EN|SW|BST|FB|RESET|CLK|SDA|SCL|TX|RX|MISO|MOSI|SCK|CS)$/i.test(p));
  if (pri) return pri.split('.').pop().toUpperCase();
  // Fall back to a "U1.pin1 ↔ C1.pin1" style label
  const sorted = portRefs.slice().sort();
  if (sorted.length >= 2) return sorted[0] + ' ↔ ' + sorted[1];
  if (sorted.length === 1) return sorted[0];
  return 'unnamed';
}
function _traceBuildNets() {
  const cj = _traceCircuit;
  if (!cj) return;
  // Build lookup tables.
  const portsByCompId = new Map();
  const portByPortId = new Map();
  for (const e of cj) {
    if (e.type === 'source_port') {
      portByPortId.set(e.source_port_id, e);
    }
  }
  const compById = new Map();
  for (const e of cj) {
    if (e.type === 'source_component') compById.set(e.source_component_id, e);
  }
  const portRefName = (portId) => {
    const p = portByPortId.get(portId); if (!p) return null;
    const c = compById.get(p.source_component_id); if (!c) return null;
    const pinTag = (p.port_hints || []).find(h => /^(VIN|VCC|VDD|VOUT|GND|VSS|EN|SW|BST|FB|RESET|CLK|SDA|SCL|TX|RX|MISO|MOSI|SCK|CS)$/i.test(h)) || ('pin' + (p.pin_number || ''));
    return c.name + '.' + pinTag;
  };
  // Group source_traces by subcircuit_connectivity_map_key.
  const netsByKey = new Map();  // key → { ports:Set, sourceTraceIds:Set, displayNames:[] }
  for (const e of cj) {
    if (e.type !== 'source_trace') continue;
    const key = e.subcircuit_connectivity_map_key || ('st_' + e.source_trace_id);
    if (!netsByKey.has(key)) netsByKey.set(key, { key, ports: new Set(), sourceTraceIds: new Set(), displayNames: [] });
    const n = netsByKey.get(key);
    n.sourceTraceIds.add(e.source_trace_id);
    if (e.display_name) n.displayNames.push(e.display_name);
    for (const pid of (e.connected_source_port_ids || [])) {
      const ref = portRefName(pid); if (ref) n.ports.add(ref);
    }
  }
  // Map source_trace_id → pcb_trace entries (a pcb_trace's connection_name
  // joins one or more source_trace_ids with `__`).
  const pcbTracesBySrcId = new Map();
  for (const e of cj) {
    if (e.type !== 'pcb_trace') continue;
    const cn = e.connection_name || '';
    const srcIds = cn.split('__').filter(s => /^source_trace_/.test(s));
    for (const sid of srcIds) {
      if (!pcbTracesBySrcId.has(sid)) pcbTracesBySrcId.set(sid, []);
      pcbTracesBySrcId.get(sid).push(e);
    }
  }
  // source_port_id → netKey  (for click-on-pin → net lookup)
  const sourcePortToNetKey = new Map();
  for (const n of netsByKey.values()) {
    for (const ref of n.ports) {
      // ref is "U1.VIN" / "C1.pin1" — we also need source_port_id mapping
    }
  }
  // Walk source_traces again to fill source_port_id → netKey.
  for (const e of cj) {
    if (e.type !== 'source_trace') continue;
    const key = e.subcircuit_connectivity_map_key || ('st_' + e.source_trace_id);
    for (const pid of (e.connected_source_port_ids || [])) {
      sourcePortToNetKey.set(pid, key);
    }
  }
  // pcb_port at world (x,y) → netKey, for spatial click lookup.
  const pcbPortLocations = [];
  for (const e of cj) {
    if (e.type !== 'pcb_port') continue;
    const netKey = sourcePortToNetKey.get(e.source_port_id);
    if (!netKey) continue;
    pcbPortLocations.push({ x: e.x, y: e.y, netKey, pcb_port_id: e.pcb_port_id });
  }
  // pcb_smtpad / pcb_plated_hole pcb_port_id → netKey, for direct mesh
  // → net lookup (each pad's center is also indexable spatially).
  const pcbPortIdToNetKey = new Map();
  for (const loc of pcbPortLocations) pcbPortIdToNetKey.set(loc.pcb_port_id, loc.netKey);
  _tracePcbPortLocations = pcbPortLocations;
  _tracePcbPortIdToNetKey = pcbPortIdToNetKey;
  // pcb_smtpads grouped by netKey via pcb_port_id → source_port_id →
  // netKey. This is THE pad-to-net link used to render copper pads as
  // part of the highlighted path. Covers regular SMT pads, machine-
  // contact pads, AND test-point pads — they're all pcb_smtpad rows.
  const pcbSmtpadsByNet = new Map();
  for (const e of cj) {
    if (e.type !== 'pcb_smtpad') continue;
    if (!e.pcb_port_id) continue;
    const netKey = pcbPortIdToNetKey.get(e.pcb_port_id);
    if (!netKey) continue;
    if (!pcbSmtpadsByNet.has(netKey)) pcbSmtpadsByNet.set(netKey, []);
    pcbSmtpadsByNet.get(netKey).push(e);
  }
  const nets = [];
  for (const n of netsByKey.values()) {
    const portRefs = Array.from(n.ports);
    const name = _traceFriendlyNetName(portRefs);
    const group = _traceClassifyNet(name + ' ' + portRefs.join(' '));
    const pcbTraceIds = new Set();
    for (const sid of n.sourceTraceIds) {
      for (const t of (pcbTracesBySrcId.get(sid) || [])) pcbTraceIds.add(t.pcb_trace_id);
    }
    if (pcbTraceIds.size === 0) continue;  // no physical routing → skip
    const pads = pcbSmtpadsByNet.get(n.key) || [];
    nets.push({ id: n.key, name, group, portRefs, pcbTraceIds: Array.from(pcbTraceIds), pads });
  }
  // Sort: power > ground > signal > other; within group by name.
  const order = (g) => _TRACE_GROUPS.indexOf(g);
  nets.sort((a, b) => order(a.group) - order(b.group) || a.name.localeCompare(b.name, undefined, { numeric: true }));
  _traceNets = nets;
}
// Spatial lookup: given a world (x, y), return the netKey of the
// nearest pcb_port within `tolMm` mm, or null. Used by the trace-mode
// click handler so clicking on a chip pad / pin selects the connected
// net (workflow: "I see this pad — what net is it on?").
let _tracePcbPortLocations = [];
let _tracePcbPortIdToNetKey = new Map();
function _traceFindNetAtPoint(worldX, worldY, tolMm) {
  if (!_tracePcbPortLocations.length) return null;
  const sx = window._mapSx || 1, sy = window._mapSy || 1;
  // pcb_port locations are in mm; world coords are mm * sx. Compare
  // in world units.
  const tolWorld = (tolMm || 1.5) * sx;
  let best = null, bestD = Infinity;
  for (const p of _tracePcbPortLocations) {
    const dx = (p.x * sx) - worldX;
    const dy = (p.y * sy) - worldY;
    const d2 = dx*dx + dy*dy;
    if (d2 < bestD) { bestD = d2; best = p; }
  }
  if (best && bestD <= tolWorld * tolWorld) return best.netKey;
  return null;
}
function _traceFindPcbTrace(pcbTraceId) {
  if (!_traceCircuit) return null;
  for (const e of _traceCircuit) {
    if (e.type === 'pcb_trace' && e.pcb_trace_id === pcbTraceId) return e;
  }
  return null;
}
// Compute the real board substrate Z extents from the actual mesh
// bounding box. window._boardBottomZ is set wrongly elsewhere in the
// app (mirrored to +topZ instead of negated), so we can't trust it for
// trace placement. Cache once per mode-enable.
let _traceBoardZ = null;
function _traceComputeBoardZ() {
  const scene = viewer && viewer.getScene && viewer.getScene();
  if (!scene) return { top: 0.7, bottom: -0.7 };
  let top = -Infinity, bottom = Infinity;
  for (const m of scene.meshes) {
    if (!/^Box0_primitive[012]$/.test(m.name || '')) continue;
    m.computeWorldMatrix(true);
    const bb = m.getBoundingInfo().boundingBox;
    if (bb.maximumWorld.z > top) top = bb.maximumWorld.z;
    if (bb.minimumWorld.z < bottom) bottom = bb.minimumWorld.z;
  }
  if (!Number.isFinite(top))    top = 0.7;
  if (!Number.isFinite(bottom)) bottom = -0.7;
  return { top, bottom };
}
// Real copper thickness: 1oz = 35 micron = 0.035mm. PCB copper sits
// ON TOP of the FR4 substrate as a thin slab. Top-layer copper goes
// from boardTop → boardTop+_COPPER_THICKNESS_MM. Bottom copper goes
// from boardBottom → boardBottom-_COPPER_THICKNESS_MM.
const _COPPER_THICKNESS_MM = 0.035;
function _traceLayerZ(layer) {
  if (!_traceBoardZ) _traceBoardZ = _traceComputeBoardZ();
  // Trace polygons / pads render at the TOP face of the copper slab
  // (i.e. the visible side from outside the board).
  if (layer === 'bottom') return _traceBoardZ.bottom - _COPPER_THICKNESS_MM;
  return _traceBoardZ.top + _COPPER_THICKNESS_MM;
}
// The scene's main GlowLayer runs a bloom pass on every emissive
// mesh in the scene. RESTING traces stay excluded so the dusky
// red/blue/copper colors render literally (no bloom amplification).
// SELECTED traces get RE-INCLUDED so they pick up a ~10% bloom
// halo — gives the highlighted path a subtle glow that reads as
// "active" without going to a glaring white blob like before.
function _traceExcludeFromGlow(mesh) {
  if (typeof _glowLayer === 'undefined' || !_glowLayer) return;
  try { _glowLayer.addExcludedMesh(mesh); } catch {}
}
function _traceIncludeInGlow(mesh) {
  if (typeof _glowLayer === 'undefined' || !_glowLayer) return;
  try { _glowLayer.removeExcludedMesh(mesh); } catch {}
}
// Build a closed 2D boundary for a polyline of width `w` with rounded
// half-disc endcaps and miter joins. Returns array of {x,y}. The result
// can be fed directly to earcut for triangulation.
function _traceBuildOutline(points, w) {
  if (points.length < 2) return null;
  const r = w / 2;
  const N_ARC = 14;            // 14 segments per half-circle endcap
  const MITER_LIMIT = 6;       // beyond this, fall back to bevel
  // Per-vertex outward offsets on each side of the polyline.
  const left = [], right = [];
  for (let i = 0; i < points.length; i++) {
    const p = points[i];
    let nx, ny;
    if (i === 0 || i === points.length - 1) {
      const a = i === 0 ? p : points[i-1];
      const b = i === 0 ? points[i+1] : p;
      const dx = b.x - a.x, dy = b.y - a.y;
      const L = Math.hypot(dx, dy) || 1;
      nx = -dy / L; ny = dx / L;
      left.push({ x: p.x + nx * r, y: p.y + ny * r });
      right.push({ x: p.x - nx * r, y: p.y - ny * r });
    } else {
      const prev = points[i-1], next = points[i+1];
      const d1x = p.x - prev.x, d1y = p.y - prev.y;
      const L1 = Math.hypot(d1x, d1y) || 1;
      const n1x = -d1y / L1, n1y = d1x / L1;
      const d2x = next.x - p.x, d2y = next.y - p.y;
      const L2 = Math.hypot(d2x, d2y) || 1;
      const n2x = -d2y / L2, n2y = d2x / L2;
      let mx = n1x + n2x, my = n1y + n2y;
      const m = Math.hypot(mx, my) || 1;
      mx /= m; my /= m;
      const cosHalf = mx * n1x + my * n1y;
      const miterLen = r / Math.max(0.001, cosHalf);
      if (miterLen <= r * MITER_LIMIT) {
        left.push({ x: p.x + mx * miterLen, y: p.y + my * miterLen });
        right.push({ x: p.x - mx * miterLen, y: p.y - my * miterLen });
      } else {
        // Bevel: two vertices, one for each incoming segment normal.
        left.push({ x: p.x + n1x * r, y: p.y + n1y * r });
        left.push({ x: p.x + n2x * r, y: p.y + n2y * r });
        right.push({ x: p.x - n1x * r, y: p.y - n1y * r });
        right.push({ x: p.x - n2x * r, y: p.y - n2y * r });
      }
    }
  }
  // Build closed boundary: walk left[] forward, half-circle at end,
  // walk right[] backward, half-circle at start. The arcs are
  // generated using the actual segment direction so adjacent segments
  // mate cleanly.
  const boundary = [];
  for (const p of left) boundary.push(p);
  // End-cap arc at points[last]
  const pE = points[points.length - 1];
  const dE = (() => {
    const a = points[points.length - 2], b = pE;
    const dx = b.x - a.x, dy = b.y - a.y;
    const L = Math.hypot(dx, dy) || 1;
    return { x: dx / L, y: dy / L };
  })();
  const angE = Math.atan2(dE.y, dE.x);  // points outward from end of trace
  // arc sweeps from +90deg (left side) to -90deg (right side) around angE
  for (let i = 1; i < N_ARC; i++) {
    const t = i / N_ARC;
    const a = angE + Math.PI / 2 - Math.PI * t;
    boundary.push({ x: pE.x + Math.cos(a) * r, y: pE.y + Math.sin(a) * r });
  }
  for (let i = right.length - 1; i >= 0; i--) boundary.push(right[i]);
  // Start-cap arc at points[0]
  const pS = points[0];
  const dS = (() => {
    const a = pS, b = points[1];
    const dx = b.x - a.x, dy = b.y - a.y;
    const L = Math.hypot(dx, dy) || 1;
    return { x: dx / L, y: dy / L };
  })();
  const angS = Math.atan2(dS.y, dS.x) + Math.PI;  // outward at start = opposite of segment dir
  for (let i = 1; i < N_ARC; i++) {
    const t = i / N_ARC;
    const a = angS + Math.PI / 2 - Math.PI * t;
    boundary.push({ x: pS.x + Math.cos(a) * r, y: pS.y + Math.sin(a) * r });
  }
  return boundary;
}
function _traceBuildPolygonMesh(scene, B, points2d, width, z, name) {
  const earcut = window.Adom3DViewer && window.Adom3DViewer.earcut;
  if (!earcut) return null;
  const outline = _traceBuildOutline(points2d, width);
  if (!outline || outline.length < 3) return null;
  const flat = new Array(outline.length * 2);
  for (let i = 0; i < outline.length; i++) { flat[i*2] = outline[i].x; flat[i*2+1] = outline[i].y; }
  const indices = earcut(flat);
  if (!indices || !indices.length) return null;
  const positions = new Array(outline.length * 3);
  const normals = new Array(outline.length * 3);
  for (let i = 0; i < outline.length; i++) {
    positions[i*3]     = outline[i].x;
    positions[i*3 + 1] = outline[i].y;
    positions[i*3 + 2] = z;
    normals[i*3]     = 0;
    normals[i*3 + 1] = 0;
    normals[i*3 + 2] = 1;
  }
  const mesh = new B.Mesh(name, scene);
  const vd = new B.VertexData();
  vd.positions = positions;
  vd.indices = indices;
  vd.normals = normals;
  vd.applyToMesh(mesh);
  return mesh;
}
function _traceMakeBaseMaterial(scene, B, layerKey) {
  const c = _TRACE_LAYER_COLOR[layerKey] || _TRACE_LAYER_COLOR.top;
  const mat = new B.StandardMaterial('traceMat-' + layerKey, scene);
  mat.disableLighting = true;
  // Resting traces use FULL layer color so red/blue is unmistakable.
  mat.emissiveColor = new B.Color3(c.r, c.g, c.b);
  mat.alpha = _TRACE_BASE_ALPHA;
  mat.backFaceCulling = false;
  return mat;
}
function _traceMakeViaMaterial(scene, B) {
  const c = _TRACE_VIA_COLOR;
  const mat = new B.StandardMaterial('traceMat-via', scene);
  mat.disableLighting = true;
  mat.emissiveColor = new B.Color3(c.r, c.g, c.b);
  mat.alpha = 1.0;
  return mat;
}
// Build a flat ANNULAR copper pad (donut) at z, centered at (cx,cy).
// Outer radius = outerR, inner radius = innerR (the drilled bore).
// Earcut handles the hole via the holeIndices argument.
function _traceMakeAnnulus(scene, B, cx, cy, z, innerR, outerR, segments, name) {
  const earcut = window.Adom3DViewer && window.Adom3DViewer.earcut;
  if (!earcut) return null;
  const flat = [];
  // Outer ring (CCW)
  for (let i = 0; i < segments; i++) {
    const a = (i / segments) * Math.PI * 2;
    flat.push(cx + Math.cos(a) * outerR, cy + Math.sin(a) * outerR);
  }
  // Inner ring as a HOLE (CW winding for earcut hole)
  for (let i = segments - 1; i >= 0; i--) {
    const a = (i / segments) * Math.PI * 2;
    flat.push(cx + Math.cos(a) * innerR, cy + Math.sin(a) * innerR);
  }
  // earcut signature: earcut(data, holeIndices, dim).
  //   data         = flat 2D coord array, length = totalVerts × 2
  //   holeIndices  = array of VERTEX indices where each hole starts
  //   dim          = 2 (for 2D coords)
  // Total verts here = 2*segments (outer + inner). Inner hole starts
  // at vertex index `segments`.
  const indices = earcut(flat, [segments], 2);
  const positions = new Array((segments * 2) * 3);
  const normals = new Array((segments * 2) * 3);
  for (let i = 0; i < segments * 2; i++) {
    positions[i*3]     = flat[i*2];
    positions[i*3 + 1] = flat[i*2 + 1];
    positions[i*3 + 2] = z;
    normals[i*3]       = 0;
    normals[i*3 + 1]   = 0;
    normals[i*3 + 2]   = 1;
  }
  const mesh = new B.Mesh(name, scene);
  const vd = new B.VertexData();
  vd.positions = positions;
  vd.indices = indices;
  vd.normals = normals;
  vd.applyToMesh(mesh);
  return mesh;
}
// Render every pcb_plated_hole as REAL plated-through geometry:
//   - Annular copper pad on TOP layer (donut: outer_diameter / hole_diameter)
//   - Annular copper pad on BOTTOM layer (same)
//   - Hollow copper SLEEVE plating the inside of the drilled bore.
//     Real plating is ~30 micron thick; we render it as a single
//     no-cap cylinder at the bore surface (the visible inside-of-hole
//     copper). The bore itself is NOT a mesh — empty space, so you
//     can see through the via from the side.
// All sizes raw from circuit.json — outer_diameter, hole_diameter.
// Plated through-holes are now rendered PER-NET (their copper is
// part of the net's conductive path). pcb_plated_hole has pcb_port_id
// → maps to netKey via _tracePcbPortIdToNetKey. Holes that don't
// belong to any traced net (orphans / mounting) are still rendered
// but tagged as 'orphan' so they fade out completely on selection.
function _traceRenderPlatedHolesForNet(net, scene, B, meshes) {
  if (!_traceCircuit) return;
  const sx = window._mapSx || 1, sy = window._mapSy || 1;
  const topZ = _traceLayerZ('top');
  const botZ = _traceLayerZ('bottom');
  const height = topZ - botZ;
  const midZ = (topZ + botZ) * 0.5;
  if (!scene._traceLayerMats) return;  // base mats must exist
  // Per-net pad/sleeve push: walk pcb_plated_holes, filter to ones on
  // THIS net.
  for (const e of _traceCircuit) {
    if (e.type !== 'pcb_plated_hole') continue;
    if (e.shape && e.shape !== 'circle') continue;
    if (!e.pcb_port_id) continue;
    const netKey = _tracePcbPortIdToNetKey.get(e.pcb_port_id);
    if (netKey !== net.id) continue;
    const wx = e.x * sx, wy = e.y * sy;
    const outerR = ((e.outer_diameter || 1.6) * sx) * 0.5;
    const drillR = ((e.hole_diameter  || 1.0) * sx) * 0.5;
    // Top annular pad
    const topPad = _traceMakeAnnulus(scene, B, wx, wy, topZ + 0.005, drillR, outerR, 32, 'plateTop-' + e.pcb_plated_hole_id);
    if (topPad) {
      topPad.material = scene._traceLayerMats.via;
      topPad.renderingGroupId = 0;
      topPad.isPickable = true;
      topPad._netId = net.id;
      topPad._traceKind = 'via';   // share via highlight treatment (purple on select)
      _traceExcludeFromGlow(topPad);
      meshes.push(topPad);
    }
    const botPad = _traceMakeAnnulus(scene, B, wx, wy, botZ - 0.005, drillR, outerR, 32, 'plateBot-' + e.pcb_plated_hole_id);
    if (botPad) {
      botPad.material = scene._traceLayerMats.via;
      botPad.renderingGroupId = 0;
      botPad.isPickable = true;
      botPad._netId = net.id;
      botPad._traceKind = 'via';
      _traceExcludeFromGlow(botPad);
      meshes.push(botPad);
    }
    const sleeve = B.MeshBuilder.CreateCylinder('plateSleeve-' + e.pcb_plated_hole_id, {
      height, diameter: drillR * 2, tessellation: 24,
      cap: B.Mesh.NO_CAP,
    }, scene);
    sleeve.rotation.x = Math.PI / 2;
    sleeve.position = new B.Vector3(wx, wy, midZ);
    sleeve.material = scene._traceLayerMats.via;
    sleeve.renderingGroupId = 0;
    sleeve.isPickable = true;
    sleeve._netId = net.id;
    sleeve._traceKind = 'via';
    _traceExcludeFromGlow(sleeve);
    meshes.push(sleeve);
  }
}
// Render orphan plated-holes (mounting holes not on any net) as a
// shared dim copper backdrop. Same look as before but no per-net
// tagging since they have no net membership.
let _plateHoleMeshes = null;
function _traceRenderOrphanPlatedHoles(scene, B) {
  if (!_traceCircuit) return;
  const sx = window._mapSx || 1, sy = window._mapSy || 1;
  const topZ = _traceLayerZ('top');
  const botZ = _traceLayerZ('bottom');
  const height = topZ - botZ;
  const midZ = (topZ + botZ) * 0.5;
  if (!scene._plateHoleMats) {
    const copper = new B.StandardMaterial('plateOrphanMat', scene);
    copper.disableLighting = true;
    copper.emissiveColor = new B.Color3(0.20, 0.13, 0.07);
    copper.alpha = 1.0;
    copper.backFaceCulling = false;
    scene._plateHoleMats = { copper };
  }
  const meshes = [];
  for (const e of _traceCircuit) {
    if (e.type !== 'pcb_plated_hole') continue;
    if (e.shape && e.shape !== 'circle') continue;
    if (e.pcb_port_id && _tracePcbPortIdToNetKey.get(e.pcb_port_id)) continue;  // belongs to a net — handled per-net
    const wx = e.x * sx, wy = e.y * sy;
    const outerR = ((e.outer_diameter || 1.6) * sx) * 0.5;
    const drillR = ((e.hole_diameter  || 1.0) * sx) * 0.5;
    const topPad = _traceMakeAnnulus(scene, B, wx, wy, topZ + 0.005, drillR, outerR, 32, 'plateOrphanTop-' + e.pcb_plated_hole_id);
    if (topPad) {
      topPad.material = scene._plateHoleMats.copper;
      topPad.renderingGroupId = 0; topPad.isPickable = false;
      _traceExcludeFromGlow(topPad); meshes.push(topPad);
    }
    const botPad = _traceMakeAnnulus(scene, B, wx, wy, botZ - 0.005, drillR, outerR, 32, 'plateOrphanBot-' + e.pcb_plated_hole_id);
    if (botPad) {
      botPad.material = scene._plateHoleMats.copper;
      botPad.renderingGroupId = 0; botPad.isPickable = false;
      _traceExcludeFromGlow(botPad); meshes.push(botPad);
    }
    const sleeve = B.MeshBuilder.CreateCylinder('plateOrphanSleeve-' + e.pcb_plated_hole_id, {
      height, diameter: drillR * 2, tessellation: 24, cap: B.Mesh.NO_CAP,
    }, scene);
    sleeve.rotation.x = Math.PI / 2;
    sleeve.position = new B.Vector3(wx, wy, midZ);
    sleeve.material = scene._plateHoleMats.copper;
    sleeve.renderingGroupId = 0; sleeve.isPickable = false;
    _traceExcludeFromGlow(sleeve); meshes.push(sleeve);
  }
  _plateHoleMeshes = meshes;
}
function _traceRenderNet(net, scene, B) {
  const sx = window._mapSx || 1, sy = window._mapSy || 1;
  const meshes = [];
  // Cache shared base materials (one per layer + one for vias). selectNet
  // clones to per-mesh when a net is highlighted so we can recolor it
  // without affecting other meshes that share the same material.
  scene._traceLayerMats = scene._traceLayerMats || {};
  if (!scene._traceLayerMats.top)    scene._traceLayerMats.top    = _traceMakeBaseMaterial(scene, B, 'top');
  if (!scene._traceLayerMats.bottom) scene._traceLayerMats.bottom = _traceMakeBaseMaterial(scene, B, 'bottom');
  if (!scene._traceLayerMats.via)    scene._traceLayerMats.via    = _traceMakeViaMaterial(scene, B);
  // Helper: walk a pcb_trace.route, splitting into per-layer per-width
  // polylines. Vias terminate the current polyline, get their own
  // cylinder mesh, and start a new polyline on the to_layer.
  for (const tid of net.pcbTraceIds) {
    const trace = _traceFindPcbTrace(tid);
    if (!trace || !trace.route) continue;
    let polyline = [];
    let layer = 'top';
    let width = null;
    const flush = () => {
      if (polyline.length >= 2 && width != null) {
        const z = _traceLayerZ(layer);
        // EXACT width — no floor. Width is mm in circuit.json; sx maps
        // mm → world units (typically 1:1).
        const w = width * sx;
        const mesh = _traceBuildPolygonMesh(scene, B, polyline, w, z, 'netTrace-' + tid + '-' + meshes.length);
        if (mesh) {
          mesh.material = scene._traceLayerMats[layer === 'bottom' ? 'bottom' : 'top'];
          // renderingGroupId 0 (default) so depth-testing against chip
          // meshes works — chips occlude traces beneath them. The
          // x-rayed substrate has disableDepthWrite=true so it doesn't
          // z-occlude the traces from above.
          mesh.renderingGroupId = 0;
          mesh.isPickable = true;
          mesh._netId = net.id;
          mesh._traceLayer = layer;
          mesh._traceKind = 'wire';
          _traceExcludeFromGlow(mesh);
          meshes.push(mesh);
        }
      }
      polyline = [];
    };
    for (const seg of trace.route) {
      if (seg.route_type === 'wire') {
        const segLayer = seg.layer || layer;
        const segWidth = seg.width || 0.15;
        if (polyline.length && (segLayer !== layer || segWidth !== width)) {
          // Width or layer change: emit current polyline, start a new
          // one anchored at this point so the two pieces share an edge.
          const last = polyline[polyline.length - 1];
          flush();
          polyline.push(last);
        }
        layer = segLayer;
        width = segWidth;
        polyline.push({ x: seg.x * sx, y: seg.y * sy });
      } else if (seg.route_type === 'via') {
        // End the current polyline at the via point.
        const wx = seg.x * sx, wy = seg.y * sy;
        polyline.push({ x: wx, y: wy });
        flush();
        // Via barrel (silver plated) — cylinder along Z.
        const fromL = seg.from_layer || 'top', toL = seg.to_layer || 'bottom';
        const fromZ = _traceLayerZ(fromL);
        const toZ = _traceLayerZ(toL);
        // Real via geometry: hollow copper SLEEVE plating the drilled
        // bore (no caps — both surfaces visible, bore interior empty
        // so you can see through). Same physics as a plated through-
        // hole, just smaller diameter. via_diameter from circuit.json
        // is the COPPER diameter (drilled bore + plating); the bore
        // itself is conventionally ~75% of via_diameter for tscircuit
        // routing. Use exact via_diameter as the sleeve outer surface.
        const dia = (seg.via_diameter || 0.3) * sx;
        const sleeveH = Math.abs(toZ - fromZ);
        const cyl = B.MeshBuilder.CreateCylinder('netVia-' + tid + '-' + meshes.length, {
          height: sleeveH, diameter: dia, tessellation: 16,
          cap: B.Mesh.NO_CAP,
        }, scene);
        cyl.rotation.x = Math.PI / 2;
        cyl.position = new B.Vector3(wx, wy, (fromZ + toZ) * 0.5);
        cyl.material = scene._traceLayerMats.via;
        cyl.renderingGroupId = 0;
        cyl.isPickable = true;
        cyl._netId = net.id;
        cyl._traceKind = 'via';
        _traceExcludeFromGlow(cyl);
        meshes.push(cyl);
        // New polyline starts at the via on the to_layer.
        layer = toL;
        polyline.push({ x: wx, y: wy });
      }
    }
    flush();
  }
  // ---- pcb_plated_holes for THIS net (machine contacts, plated TPs) ----
  // The through-hole's copper (annular pads + sleeve) is part of the
  // net's conductive path → highlight as part of the selected net.
  _traceRenderPlatedHolesForNet(net, scene, B, meshes);
  // ---- pcb_smtpads (regular pads + test points + machine contacts) ----
  // Render each pad as a flat copper polygon at its layer's z. Selection
  // and dim treatment are the same as wires (handled in selectNet via
  // the _traceKind === 'pad' branch).
  for (const pad of (net.pads || [])) {
    if (!pad.x && pad.x !== 0) continue;
    const padLayer = pad.layer === 'bottom' ? 'bottom' : 'top';
    // Slight z offset above the wire layer so pads sit ON TOP of any
    // wire polygon ending at the pad center — visually reads as the
    // pad being the "destination" copper, not buried under the wire.
    const z = _traceLayerZ(padLayer) + 0.005;
    let mesh = null;
    if (pad.shape === 'circle') {
      const r = ((pad.radius || pad.diameter / 2 || 0.4)) * sx;
      mesh = _traceBuildCirclePadMesh(scene, B, pad.x * sx, pad.y * sy, z, r, 24, 'netPad-' + pad.pcb_smtpad_id);
    } else {
      // rect (default)
      const w = (pad.width || 1.0) * sx;
      const h = (pad.height || 0.6) * sy;
      mesh = _traceBuildRectPadMesh(scene, B, pad.x * sx, pad.y * sy, z, w, h, 'netPad-' + pad.pcb_smtpad_id);
    }
    if (mesh) {
      mesh.material = scene._traceLayerMats[padLayer];
      mesh.renderingGroupId = 0;
      mesh.isPickable = true;
      mesh._netId = net.id;
      mesh._traceLayer = padLayer;
      mesh._traceKind = 'pad';
      _traceExcludeFromGlow(mesh);
      meshes.push(mesh);
    }
  }
  if (meshes.length) _traceMeshes.set(net.id, meshes);
}
// Flat rectangle pad — 4 corners at z, normal +Z.
function _traceBuildRectPadMesh(scene, B, cx, cy, z, w, h, name) {
  const hw = w * 0.5, hh = h * 0.5;
  const positions = [
    cx - hw, cy - hh, z,
    cx + hw, cy - hh, z,
    cx + hw, cy + hh, z,
    cx - hw, cy + hh, z,
  ];
  const indices = [0, 1, 2,  0, 2, 3];
  const normals  = [0,0,1, 0,0,1, 0,0,1, 0,0,1];
  const mesh = new B.Mesh(name, scene);
  const vd = new B.VertexData();
  vd.positions = positions; vd.indices = indices; vd.normals = normals;
  vd.applyToMesh(mesh);
  return mesh;
}
// Flat circle pad — N-segment fan at z, normal +Z.
function _traceBuildCirclePadMesh(scene, B, cx, cy, z, r, segments, name) {
  const positions = [cx, cy, z];
  const normals = [0, 0, 1];
  for (let i = 0; i < segments; i++) {
    const a = (i / segments) * Math.PI * 2;
    positions.push(cx + Math.cos(a) * r, cy + Math.sin(a) * r, z);
    normals.push(0, 0, 1);
  }
  const indices = [];
  for (let i = 0; i < segments; i++) {
    const a = i + 1;
    const b = ((i + 1) % segments) + 1;
    indices.push(0, a, b);
  }
  const mesh = new B.Mesh(name, scene);
  const vd = new B.VertexData();
  vd.positions = positions; vd.indices = indices; vd.normals = normals;
  vd.applyToMesh(mesh);
  return mesh;
}
function _traceDisposeAll() {
  for (const arr of _traceMeshes.values()) {
    for (const m of arr) {
      try {
        // Dispose per-mesh per-net materials (selection / dim) too —
        // they were created on demand and aren't shared.
        if (m.material && m.material._perNet) { try { m.material.dispose(); } catch {} }
        m.dispose();
      } catch {}
    }
  }
  _traceMeshes.clear();
  // Plated through-hole meshes (machine contacts, mounting, etc.) are
  // global to the board — disposed alongside the trace meshes.
  if (_plateHoleMeshes) {
    for (const m of _plateHoleMeshes) { try { m.dispose(); } catch {} }
    _plateHoleMeshes = null;
  }
  const scene = viewer && viewer.getScene && viewer.getScene();
  if (scene && scene._traceLayerMats) {
    for (const k of Object.keys(scene._traceLayerMats)) {
      try { scene._traceLayerMats[k].dispose(); } catch {}
    }
    scene._traceLayerMats = null;
  }
  if (scene && scene._plateHoleMats) {
    if (scene._plateHoleMats.copper)    { try { scene._plateHoleMats.copper.dispose(); }    catch {} }
    if (scene._plateHoleMats.copperDim) { try { scene._plateHoleMats.copperDim.dispose(); } catch {} }
    if (scene._plateHoleMats.ring)      { try { scene._plateHoleMats.ring.dispose(); }      catch {} }
    if (scene._plateHoleMats.hole)      { try { scene._plateHoleMats.hole.dispose(); }      catch {} }
    scene._plateHoleMats = null;
  }
  _traceBoardZ = null;
}
// X-ray mode: when trace mode is active we fade the board substrate
// (top mask + side rim + bottom mask) so the user can see vias going
// through the board AND bottom-layer traces from a top-down view. Each
// affected mesh's original alpha + transparencyMode is captured on the
// way in and restored on the way out so toggling is idempotent.
const _XRAY_BOARD_NAMES = /^Box0_primitive[012]$/;
const _XRAY_ALPHA = 0.30;
let _xrayState = null;     // [{mesh, prevAlpha, prevTransparency, prevBackface}, ...]
function _enableBoardXray() {
  const scene = viewer && viewer.getScene && viewer.getScene();
  if (!scene) return;
  if (_xrayState) return;   // already on
  _xrayState = [];
  for (const m of scene.meshes) {
    if (!_XRAY_BOARD_NAMES.test(m.name || '')) continue;
    if (!m.material) continue;
    _xrayState.push({
      mesh: m,
      prevAlpha: m.material.alpha,
      prevTransparency: m.material.transparencyMode,
      prevBackface: m.material.backFaceCulling,
      prevDisableDepthWrite: m.material.disableDepthWrite,
    });
    try {
      m.material.alpha = _XRAY_ALPHA;
      m.material.transparencyMode = 2;  // ALPHABLEND
      m.material.backFaceCulling = false;
      // Substrate must NOT write to the depth buffer — otherwise it
      // z-occludes the trace polygons that sit just above the top
      // mask, AND it lets traces sneak THROUGH chip meshes (because
      // the depth-test prep gets disrupted). With depth-write off,
      // the substrate is visible (alpha-blended) but doesn't block
      // depth — chips still write depth and properly occlude any
      // trace underneath them.
      m.material.disableDepthWrite = true;
    } catch {}
  }
}
function _disableBoardXray() {
  if (!_xrayState) return;
  for (const s of _xrayState) {
    try {
      s.mesh.material.alpha = s.prevAlpha;
      s.mesh.material.transparencyMode = s.prevTransparency;
      s.mesh.material.backFaceCulling = s.prevBackface;
      s.mesh.material.disableDepthWrite = !!s.prevDisableDepthWrite;
    } catch {}
  }
  _xrayState = null;
}
// While trace mode is active we drop GlowLayer.intensity to ~10% of
// the highlight system's default (1.5 → 0.15) so the bloom on the
// SELECTED trace path is a subtle halo, not a glaring blob. Restored
// on disableTraceMode.
let _traceSavedGlowIntensity = null;
// Chips need to sit ABOVE the copper layer when trace mode is on,
// otherwise the rendered copper visually appears UNDER the chip body
// in x-ray view. We lift every non-board, non-trace mesh by
// _COPPER_THICKNESS_MM on enable and restore on disable.
let _traceLiftedMeshes = null;
function _traceLiftChipsForCopper() {
  const scene = viewer && viewer.getScene && viewer.getScene();
  if (!scene || _traceLiftedMeshes) return;
  _traceLiftedMeshes = [];
  for (const m of scene.meshes) {
    const name = m.name || '';
    // Skip the board substrate (it's where copper attaches), trace /
    // via / pad meshes (those are OUR copper), shadow ground, the
    // ViewCube, and Babylon internals.
    if (/^Box0_primitive[012]$/.test(name)) continue;
    if (/^netTrace-|^netVia-|^netPad-|^plateRing-|^plateBot-|^plateTop-|^plateSleeve-/.test(name)) continue;
    if (/^shadowGround|^viewCube|^skybox|^__/.test(name)) continue;
    if (!m.position) continue;
    _traceLiftedMeshes.push({ mesh: m, prevZ: m.position.z });
    try { m.position.z = m.position.z + _COPPER_THICKNESS_MM; } catch {}
  }
}
function _traceUnliftChips() {
  if (!_traceLiftedMeshes) return;
  for (const s of _traceLiftedMeshes) {
    try { s.mesh.position.z = s.prevZ; } catch {}
  }
  _traceLiftedMeshes = null;
}
// Hide the synth-tp-<refdes> discs while trace mode is active. They
// were originally added as clickable visual stand-ins for test points
// (since the baked surface was the only thing showing pad copper).
// Now that trace mode renders pcb_smtpads (including TP pads, circle
// 0.8mm) in actual copper color tied to their net, the white synth
// disc is redundant AND opaquely covers the new copper pad. Hide on
// enable, restore on disable.
let _traceHiddenSynthTPs = null;
function _traceHideSynthTPs() {
  const scene = viewer && viewer.getScene && viewer.getScene();
  if (!scene || _traceHiddenSynthTPs) return;
  _traceHiddenSynthTPs = [];
  for (const m of scene.meshes) {
    if (!/^synth-tp-/.test(m.name || '')) continue;
    _traceHiddenSynthTPs.push({ mesh: m, prevVisible: m.isVisible });
    try { m.isVisible = false; } catch {}
  }
}
function _traceShowSynthTPs() {
  if (!_traceHiddenSynthTPs) return;
  for (const s of _traceHiddenSynthTPs) {
    try { s.mesh.isVisible = s.prevVisible; } catch {}
  }
  _traceHiddenSynthTPs = null;
}
async function enableTraceMode() {
  if (_traceModeActive) return;
  _traceModeActive = true;
  const scene = viewer && viewer.getScene && viewer.getScene();
  if (!scene) { _traceModeActive = false; return; }
  await _traceFetchCircuit();
  if (!_traceCircuit) { toast('nets: circuit.json unavailable'); _traceModeActive = false; return; }
  _traceBuildNets();
  const B = window.Adom3DViewer.BABYLON;
  if (typeof _glowLayer !== 'undefined' && _glowLayer) {
    _traceSavedGlowIntensity = _glowLayer.intensity;
    _glowLayer.intensity = 0.15;
  }
  // Lift every chip / passive / standoff / etc. by the copper
  // thickness so they sit ABOVE the rendered copper layer, not below
  // it. Real PCB physics: copper sits on FR4, components solder on
  // top of copper.
  _traceLiftChipsForCopper();
  // Hide the synth-tp-<refdes> white discs from the older
  // pre-trace-mode rendering — the new pcb_smtpad path renders the
  // test point's actual copper, and the white disc opaquely covers it.
  _traceHideSynthTPs();
  // Render plated through-holes BEFORE the trace polylines so trace
  // tubes can sit on top visually but vias still poke through cleanly
  // (they all share renderingGroupId 3).
  _traceRenderOrphanPlatedHoles(scene, B);
  for (const net of _traceNets) _traceRenderNet(net, scene, B);
  _traceRenderList();
  _enableBoardXray();
  const holes = _plateHoleMeshes ? _plateHoleMeshes.length / 2 : 0;
  toast('nets: ' + _traceNets.length + ' • plated holes: ' + holes + ' • board x-ray');
}
function disableTraceMode() {
  if (!_traceModeActive) return;
  _traceModeActive = false;
  _traceSelectedNetId = null;
  _traceDisposeAll();
  _disableBoardXray();
  if (typeof _glowLayer !== 'undefined' && _glowLayer && _traceSavedGlowIntensity !== null) {
    _glowLayer.intensity = _traceSavedGlowIntensity;
    _traceSavedGlowIntensity = null;
  }
  _traceUnliftChips();
  _traceShowSynthTPs();
  const list = document.getElementById('trace-list');
  if (list) list.innerHTML = '';
  toast('nets: off (back to baked surface)');
}
function selectNet(netId) {
  _traceSelectedNetId = netId;
  const scene = viewer && viewer.getScene && viewer.getScene();
  const B = window.Adom3DViewer.BABYLON;
  // ---- traces + vias (per-net) ----
  for (const [nid, arr] of _traceMeshes.entries()) {
    const isSel = nid === netId;
    const isDim = netId && !isSel;
    for (const m of arr) {
      const isVia = m._traceKind === 'via';
      const layerKey = m._traceLayer === 'bottom' ? 'bottom' : 'top';
      // Only the SELECTED net's meshes get included in the bloom pass.
      // Everyone else stays excluded → exact-color, no halo.
      if (isSel) _traceIncludeInGlow(m);
      else _traceExcludeFromGlow(m);
      const ensurePerNetMat = (suffix) => {
        if (!m.material || !m.material._perNet) {
          const mat = new B.StandardMaterial('traceMat-' + suffix + '-' + nid, scene);
          mat.disableLighting = true;
          mat._perNet = true;
          mat.backFaceCulling = false;
          m.material = mat;
        }
        return m.material;
      };
      if (isVia) {
        if (isSel) {
          const mat = ensurePerNetMat('via-sel');
          mat.emissiveColor = new B.Color3(_TRACE_VIA_SELECTED_COLOR.r, _TRACE_VIA_SELECTED_COLOR.g, _TRACE_VIA_SELECTED_COLOR.b);
          mat.alpha = 1.0;
        } else if (isDim) {
          const mat = ensurePerNetMat('via-dim');
          mat.emissiveColor = new B.Color3(_TRACE_VIA_COLOR.r, _TRACE_VIA_COLOR.g, _TRACE_VIA_COLOR.b);
          mat.alpha = _TRACE_DIMMED_ALPHA;
        } else {
          m.material = scene._traceLayerMats.via;
        }
        continue;
      }
      const restColor = _TRACE_LAYER_COLOR[layerKey];
      const brightColor = _TRACE_LAYER_COLOR_BRIGHT[layerKey];
      if (isSel) {
        const mat = ensurePerNetMat('sel');
        mat.emissiveColor = new B.Color3(brightColor.r, brightColor.g, brightColor.b);
        mat.alpha = _TRACE_SELECTED_ALPHA;
      } else if (isDim) {
        const mat = ensurePerNetMat('dim');
        mat.emissiveColor = new B.Color3(restColor.r, restColor.g, restColor.b);
        mat.alpha = _TRACE_DIMMED_ALPHA;
      } else {
        m.material = scene._traceLayerMats[layerKey];
      }
    }
  }
  // ---- plated through-holes (not part of any net) ----
  // When ANY net is selected, fade the through-holes so the highlighted
  // net is visually isolated. Restore on clear.
  if (_plateHoleMeshes && scene && scene._plateHoleMats) {
    if (netId) {
      // Use a per-mode dim material so we don't mutate the shared one.
      if (!scene._plateHoleMats.copperDim) {
        const dim = new B.StandardMaterial('plateCopperDim', scene);
        dim.disableLighting = true;
        dim.emissiveColor = new B.Color3(_TRACE_VIA_COLOR.r, _TRACE_VIA_COLOR.g, _TRACE_VIA_COLOR.b);
        dim.alpha = _TRACE_DIMMED_ALPHA;
        dim.backFaceCulling = false;
        scene._plateHoleMats.copperDim = dim;
      }
      for (const m of _plateHoleMeshes) m.material = scene._plateHoleMats.copperDim;
    } else {
      for (const m of _plateHoleMeshes) m.material = scene._plateHoleMats.copper;
    }
  }
  // Frame camera on the selected net's bbox.
  if (netId) {
    const arr = _traceMeshes.get(netId);
    if (arr && arr.length && scene && scene.activeCamera) {
      const B2 = B;
      let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
      for (const m of arr) {
        m.computeWorldMatrix(true);
        const bb = m.getBoundingInfo().boundingBox;
        const lo = bb.minimumWorld, hi = bb.maximumWorld;
        if (lo.x < minX) minX = lo.x; if (lo.y < minY) minY = lo.y; if (lo.z < minZ) minZ = lo.z;
        if (hi.x > maxX) maxX = hi.x; if (hi.y > maxY) maxY = hi.y; if (hi.z > maxZ) maxZ = hi.z;
      }
      const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2, cz = (minZ + maxZ) / 2;
      const span = Math.max(maxX - minX, maxZ - minZ);
      const cam = scene.activeCamera;
      const newTarget = new B2.Vector3(cx, cy, cz);
      const newRadius = Math.max(span * 1.4, (cam.lowerRadiusLimit || 0.5));
      // Smooth tween — runs in BOTH walkthrough and free-roam modes
      // (the walkthrough's net-X steps explicitly want this zoom).
      const startT = cam.target.clone();
      const startR = cam.radius;
      const t0 = performance.now();
      const dur = 600;
      const easeOut = (x) => 1 - Math.pow(1 - x, 3);
      const step = () => {
        const t = Math.min(1, (performance.now() - t0) / dur);
        const k = easeOut(t);
        cam.target = B2.Vector3.Lerp(startT, newTarget, k);
        cam.radius = startR + (newRadius - startR) * k;
        if (t < 1) requestAnimationFrame(step);
      };
      requestAnimationFrame(step);
    }
  }
  // Redraw HUD selection highlights.
  for (const row of document.querySelectorAll('#trace-list .co-row')) {
    row.classList.toggle('selected', row.dataset.netid === netId);
  }
}
function _traceRenderList() {
  const list = document.getElementById('trace-list');
  if (!list) return;
  list.innerHTML = '';
  const byGroup = new Map();
  for (const net of _traceNets) {
    if (!byGroup.has(net.group)) byGroup.set(net.group, []);
    byGroup.get(net.group).push(net);
  }
  for (const g of _TRACE_GROUPS) {
    const arr = byGroup.get(g); if (!arr || !arr.length) continue;
    const hdr = document.createElement('div');
    hdr.className = 'co-group';
    hdr.innerHTML = `
      <span class="co-caret">▾</span>
      <span class="co-group-name">${_TRACE_GROUP_LABEL[g]}</span>
      <span class="co-group-count">${arr.length}</span>
      <span class="co-group-eye" data-tooltip="Click to highlight every net in this group at once" data-tooltip-align="right">●</span>
    `;
    const caret = hdr.querySelector('.co-caret');
    const gname = hdr.querySelector('.co-group-name');
    const eye = hdr.querySelector('.co-group-eye');
    const toggleCollapse = () => { hdr.classList.toggle('collapsed'); for (const r of list.querySelectorAll('.co-row[data-group="' + g + '"]')) r.style.display = hdr.classList.contains('collapsed') ? 'none' : ''; };
    caret.addEventListener('click', (e) => { e.stopPropagation(); toggleCollapse(); });
    gname.addEventListener('click', toggleCollapse);
    eye.addEventListener('click', (e) => { e.stopPropagation(); /* group-highlight: select first; chained selection is heavy */ if (arr[0]) selectNet(arr[0].id); });
    list.appendChild(hdr);
    for (const net of arr) {
      const row = document.createElement('div');
      row.className = 'co-row';
      row.dataset.netid = net.id;
      row.dataset.group = g;
      row.setAttribute('data-tooltip', net.portRefs.join(' • ') || net.name);
      row.setAttribute('data-tooltip-align', 'right');
      row.innerHTML = `<span>${net.name}</span><span class="net-meta">${net.portRefs.length}p</span>`;
      row.addEventListener('click', () => selectNet(net.id));
      list.appendChild(row);
    }
  }
}
window.enableTraceMode = enableTraceMode;
window.disableTraceMode = disableTraceMode;
window.selectNet = selectNet;

// ─── SVG panzoom + loaders ──────────────────────────────────────────
function setupPanzoom(viewportId, stageId) {
  const viewport = document.getElementById(viewportId);
  const stage = document.getElementById(stageId);
  let scale = 1, tx = 0, ty = 0;
  function apply() { stage.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`; }
  function fit() {
    const svg = stage.querySelector('svg'); if (!svg) return;
    const vp = viewport.getBoundingClientRect();
    const w = parseFloat(svg.getAttribute('width')) || svg.viewBox?.baseVal?.width || 800;
    const h = parseFloat(svg.getAttribute('height')) || svg.viewBox?.baseVal?.height || 600;
    scale = Math.min(vp.width / w, vp.height / h) * 0.92;
    tx = (vp.width - w * scale) / 2; ty = (vp.height - h * scale) / 2; apply();
  }
  viewport.addEventListener('wheel', (e) => {
    e.preventDefault();
    const f = e.deltaY < 0 ? 1.15 : 1/1.15;
    const rect = viewport.getBoundingClientRect();
    const mx = e.clientX - rect.left, my = e.clientY - rect.top;
    tx = mx - f * (mx - tx); ty = my - f * (my - ty); scale *= f; apply();
  }, { passive: false });
  let dragging=false, sx=0, sy=0, stx=0, sty=0;
  viewport.addEventListener('mousedown', (e) => { if (e.button !== 0) return; dragging=true; sx=e.clientX; sy=e.clientY; stx=tx; sty=ty; });
  window.addEventListener('mousemove', (e) => { if (!dragging) return; tx = stx + (e.clientX - sx); ty = sty + (e.clientY - sy); apply(); });
  window.addEventListener('mouseup', () => dragging = false);
  return { fit };
}
const pcbPz = setupPanzoom('pcb-viewport', 'pcb-stage');
const schPz = setupPanzoom('schematic-viewport', 'schematic-stage');

async function loadPcb() {
  try {
    const r = await fetch('pcb.svg?t=' + Date.now());
    if (!r.ok) { document.getElementById('pcb-stage').innerHTML = '<div class="empty-state">PCB not built yet — run <code>bunx tsci build lib/index.tsx --svgs</code></div>'; return; }
    document.getElementById('pcb-stage').innerHTML = await r.text();
    window._pcbLoaded = true;
    requestAnimationFrame(() => pcbPz.fit());
  } catch (e) { document.getElementById('pcb-stage').innerHTML = `<div class="empty-state">PCB load failed: ${e.message}</div>`; }
}
async function loadSchematic() {
  try {
    const r = await fetch('schematic.svg?t=' + Date.now());
    if (!r.ok) { document.getElementById('schematic-stage').innerHTML = '<div class="empty-state">Schematic not built yet — run <code>bunx tsci build lib/index.tsx --svgs</code></div>'; return; }
    document.getElementById('schematic-stage').innerHTML = await r.text();
    window._schematicLoaded = true;
    requestAnimationFrame(() => schPz.fit());
  } catch (e) { document.getElementById('schematic-stage').innerHTML = `<div class="empty-state">Schematic load failed: ${e.message}</div>`; }
}

// ─── Re-run autorouter button ───────────────────────────────────────
const btnRerun = document.getElementById('btn-rerun');
btnRerun.addEventListener('click', async (e) => {
  btnRerun.disabled = true;
  const clean = e.shiftKey;
  const label = btnRerun.textContent;
  btnRerun.textContent = clean ? '⟳ Running (clean)…' : '⟳ Running…';
  try {
    const resp = await fetch('rerun' + (clean ? '?clean=1' : ''), { method: 'POST' });
    if (!resp.ok && resp.status !== 204) {
      btnRerun.textContent = '⚠ Failed';
      setTimeout(() => { btnRerun.textContent = label; }, 2500);
      return;
    }
    btnRerun.textContent = clean ? '⟳ Re-ran (clean)' : '⟳ Re-ran';
    setTimeout(() => { loadCurrentGlb(true); window._pcbLoaded = false; window._schematicLoaded = false; }, 2000);
    setTimeout(() => { btnRerun.textContent = label; }, 2500);
  } catch (err) {
    btnRerun.textContent = '⚠ Error';
    setTimeout(() => { btnRerun.textContent = label; }, 2500);
  } finally {
    btnRerun.disabled = false;
  }
});

setInterval(() => loadCurrentGlb(false), 3000);
initViewer();

// ─── Parts tab — per-component footprint+body inspector ─────────────
// Verifies the "footprint and cadModel are tied together" invariant:
// for any component, compute body-to-pad and body-to-hole distances,
// and predict them at every pcbRotation. If distances change under
// rotation, the root component is broken.
(() => {
  const layoutCSS = `
    #panel-parts { padding: 0; }
    #parts-layout { display: flex; height: 100%; }
    .parts-row { padding: 6px 12px; cursor: pointer; font-size: 12px; border-bottom: 1px solid transparent; }
    .parts-row:hover { background: rgba(120,160,255,0.08); }
    .parts-row.active { background: rgba(120,160,255,0.16); border-left: 2px solid var(--accent); }
    .parts-row .kind { color: var(--text-dim); font-size: 10px; text-transform: uppercase; margin-right: 6px; letter-spacing: 0.04em; }
    .parts-detail-header { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
    .parts-detail-sub { color: var(--text-dim); font-size: 12px; margin-bottom: 18px; }
    .parts-kv { display: grid; grid-template-columns: 160px 1fr; gap: 4px 12px; font-size: 12px; margin-bottom: 18px; }
    .parts-kv .k { color: var(--text-dim); }
    .parts-rot-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 12px; }
    .parts-rot-cell { border: 1px solid var(--border); border-radius: 6px; padding: 10px; font-size: 11px; font-family: ui-monospace,monospace; }
    .parts-rot-cell .title { font-weight: 600; font-size: 12px; margin-bottom: 6px; font-family: inherit; color: var(--accent); }
    .parts-invariant-ok { color: #4ade80; }
    .parts-invariant-fail { color: #f87171; }
  `;
  const style = document.createElement('style'); style.textContent = layoutCSS; document.head.appendChild(style);

  let _circuitJsonCache = null;
  async function fetchCircuit() {
    if (_circuitJsonCache) return _circuitJsonCache;
    const r = await fetch('circuit.json'); if (!r.ok) return null;
    _circuitJsonCache = await r.json(); return _circuitJsonCache;
  }

  // Rotate local (x,y) by θ degrees CCW to world delta.
  const rot = (x, y, θ) => {
    const r = θ * Math.PI / 180;
    return { x: x * Math.cos(r) - y * Math.sin(r), y: x * Math.sin(r) + y * Math.cos(r) };
  };

  async function renderPartsList() {
    const container = document.getElementById('parts-list-items');
    if (!container) return;
    const cj = await fetchCircuit(); if (!cj) { container.textContent = 'circuit.json unavailable'; return; }
    // Sort by kind then name. Skip passives + testpoints + MCs (too many, low value here).
    const chips = cj.filter(x => x.type === 'source_component' && ['simple_chip','simple_connector','simple_crystal','simple_diode'].includes(x.ftype || ''));
    // Also include components by refdes pattern if ftype missing.
    const seen = new Set(chips.map(c => c.name));
    for (const c of cj) {
      if (c.type === 'source_component' && !seen.has(c.name) && /^[JUDQYL]\d/.test(c.name || '')) {
        chips.push(c); seen.add(c.name);
      }
    }
    chips.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
    container.innerHTML = '';
    for (const c of chips) {
      const row = document.createElement('div'); row.className = 'parts-row'; row.dataset.name = c.name;
      const kind = c.ftype ? c.ftype.replace('simple_', '') : 'part';
      row.innerHTML = '<span class="kind">' + kind + '</span>' + c.name;
      row.addEventListener('click', () => renderPartDetail(c.name));
      container.appendChild(row);
    }
  }

  async function renderPartDetail(name) {
    // Highlight selected row
    document.querySelectorAll('#parts-list-items .parts-row').forEach(r => r.classList.toggle('active', r.dataset.name === name));
    const empty = document.getElementById('parts-detail-empty');
    const body = document.getElementById('parts-detail-body');
    empty.style.display = 'none'; body.style.display = 'block';
    const cj = await fetchCircuit(); if (!cj) return;
    const src = cj.find(x => x.type === 'source_component' && x.name === name); if (!src) return;
    const pc = cj.find(x => x.type === 'pcb_component' && x.source_component_id === src.source_component_id); if (!pc) { body.textContent = 'no pcb_component for ' + name; return; }
    const pads = cj.filter(x => x.type === 'pcb_smtpad' && x.pcb_component_id === pc.pcb_component_id);
    const pHoles = cj.filter(x => x.type === 'pcb_plated_hole' && x.pcb_component_id === pc.pcb_component_id);
    const dHoles = cj.filter(x => x.type === 'pcb_hole' && x.pcb_component_id === pc.pcb_component_id);
    const cad = cj.find(x => x.type === 'cad_component' && x.pcb_component_id === pc.pcb_component_id);

    const padCx = pads.length ? pads.reduce((s, p) => s + p.x, 0) / pads.length : null;
    const padCy = pads.length ? pads.reduce((s, p) => s + p.y, 0) / pads.length : null;
    const curRot = pc.rotation || 0;

    // Convert world positions back to LOCAL (unrotated) for the analysis.
    const toLocal = (wx, wy) => {
      // rotate by -curRot CCW then subtract pcb_component.center
      const d = rot(wx - pc.center.x, wy - pc.center.y, -curRot);
      return { x: d.x, y: d.y };
    };
    const localPads = pads.map(p => toLocal(p.x, p.y));
    const localPHoles = pHoles.map(h => toLocal(h.x, h.y));
    const localDHoles = dHoles.map(h => toLocal(h.x, h.y));
    const localCad = cad ? toLocal(cad.position.x, cad.position.y) : null;

    // For each hole, distance to cad centroid (should be constant at every rotation).
    const holeDistances = [...localPHoles, ...localDHoles].map(h =>
      localCad ? Math.sqrt((h.x - localCad.x) ** 2 + (h.y - localCad.y) ** 2) : null);

    // Mouth direction (for directional connectors like USB-C): project cad_z
    const cadZ = cad ? cad.rotation.z : 0;
    const mouthRad = cadZ * Math.PI / 180;
    const mouth = { x: -Math.sin(mouthRad), y: Math.cos(mouthRad) };
    const mouthDirName = Math.abs(mouth.x) > 0.5 ? (mouth.x > 0 ? 'east' : 'west') : (mouth.y > 0 ? 'north' : 'south');

    // Rotation grid: for each of 4 pcbRotations, predict where pads+holes+body land.
    const rotGrid = [0, 90, 180, 270].map(θ => {
      const rotPads = localPads.map(p => rot(p.x, p.y, θ));
      const rotPHoles = localPHoles.map(h => rot(h.x, h.y, θ));
      const rotCad = localCad ? rot(localCad.x, localCad.y, θ) : null;
      const padBboxY = rotPads.length ? [Math.min(...rotPads.map(p => p.y)), Math.max(...rotPads.map(p => p.y))] : [0, 0];
      const padBboxX = rotPads.length ? [Math.min(...rotPads.map(p => p.x)), Math.max(...rotPads.map(p => p.x))] : [0, 0];
      return { θ, rotPads, rotPHoles, rotCad, padBboxX, padBboxY };
    });

    // Detect invariant violation: body-to-hole distance should be rotation-invariant.
    // If all 4 rotations give same distances (within 0.01mm) the invariant holds.
    const invariantOK = holeDistances.every(d => d !== null);

    const kv = (k, v) => `<div class="k">${k}</div><div>${v}</div>`;
    // Build mini-SVG for the CURRENT component showing pads (teal),
    // plated holes (yellow), drill holes (gray), body centroid (red
    // cross), and chip origin (black cross). One SVG per pcbRotation
    // so the user can see visually whether the invariant holds.
    const allPts = [...localPads, ...localPHoles, ...localDHoles, ...(localCad ? [localCad] : [])];
    const extent = Math.max(6,
      ...allPts.flatMap(p => [Math.abs(p.x), Math.abs(p.y)])) + 1.5;
    const SIZE = 180, SCALE = SIZE / (extent * 2);
    const wToSvg = (x, y) => ({ sx: SIZE/2 + x * SCALE, sy: SIZE/2 - y * SCALE });

    const drawRot = (θ) => {
      const rPads = localPads.map(p => rot(p.x, p.y, θ));
      const rPH = localPHoles.map(p => rot(p.x, p.y, θ));
      const rDH = localDHoles.map(p => rot(p.x, p.y, θ));
      const rCad = localCad ? rot(localCad.x, localCad.y, θ) : null;
      const padW = 0.3 * SCALE, padH = 1.3 * SCALE;
      const parts = [
        `<rect x="0" y="0" width="${SIZE}" height="${SIZE}" fill="rgba(255,255,255,0.02)" stroke="var(--border)" stroke-width="1"/>`,
        // Crosshair at chip origin
        `<line x1="${SIZE/2 - 6}" y1="${SIZE/2}" x2="${SIZE/2 + 6}" y2="${SIZE/2}" stroke="rgba(255,255,255,0.25)" stroke-width="1"/>`,
        `<line x1="${SIZE/2}" y1="${SIZE/2 - 6}" x2="${SIZE/2}" y2="${SIZE/2 + 6}" stroke="rgba(255,255,255,0.25)" stroke-width="1"/>`,
      ];
      for (const p of rPads) {
        const { sx, sy } = wToSvg(p.x, p.y);
        parts.push(`<rect x="${sx - padW/2}" y="${sy - padH/2}" width="${padW}" height="${padH}" fill="#4fd1c7" opacity="0.85"/>`);
      }
      for (const h of rPH) {
        const { sx, sy } = wToSvg(h.x, h.y);
        parts.push(`<circle cx="${sx}" cy="${sy}" r="${0.6 * SCALE}" fill="#eab308" stroke="#713f12" stroke-width="0.5" opacity="0.85"/>`);
      }
      for (const h of rDH) {
        const { sx, sy } = wToSvg(h.x, h.y);
        parts.push(`<circle cx="${sx}" cy="${sy}" r="${0.38 * SCALE}" fill="#64748b" opacity="0.85"/>`);
      }
      if (rCad) {
        const { sx, sy } = wToSvg(rCad.x, rCad.y);
        parts.push(`<line x1="${sx - 8}" y1="${sy - 8}" x2="${sx + 8}" y2="${sy + 8}" stroke="#f87171" stroke-width="1.5"/>`);
        parts.push(`<line x1="${sx + 8}" y1="${sy - 8}" x2="${sx - 8}" y2="${sy + 8}" stroke="#f87171" stroke-width="1.5"/>`);
        parts.push(`<circle cx="${sx}" cy="${sy}" r="3" fill="none" stroke="#f87171" stroke-width="1.5"/>`);
      }
      return `<svg width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}" style="display:block;margin:0 auto 4px;">${parts.join('')}</svg>`;
    };

    body.innerHTML = `
      <div class="parts-detail-header">${src.name}</div>
      <div class="parts-detail-sub">${src.manufacturer_part_number || src.ftype || 'part'}${src.supplier_part_numbers && src.supplier_part_numbers.jlcpcb ? ' · JLCPCB ' + src.supplier_part_numbers.jlcpcb[0] : ''}</div>
      <div class="parts-kv">
        ${kv('Footprint', pads.length + ' SMT pads · ' + pHoles.length + ' plated holes · ' + dHoles.length + ' drill holes')}
        ${kv('pcb_component.center', `(${pc.center.x.toFixed(2)}, ${pc.center.y.toFixed(2)}) · rotation ${curRot}°`)}
        ${kv('Pad centroid (world)', padCx !== null ? `(${padCx.toFixed(2)}, ${padCy.toFixed(2)})` : '—')}
        ${kv('cad body (world)', cad ? `(${cad.position.x.toFixed(2)}, ${cad.position.y.toFixed(2)}) · rot.z ${cadZ}°` : '—')}
        ${kv('Mouth direction', `(${mouth.x.toFixed(2)}, ${mouth.y.toFixed(2)}) ≈ ${mouthDirName}`)}
        ${kv('Body ↔ hole distances', holeDistances.length ? holeDistances.map(d => d.toFixed(2) + ' mm').join(', ') : '—')}
        ${kv('Invariant', invariantOK
          ? '<span class="parts-invariant-ok">✓ distances rotation-invariant (computed in local frame before rotation)</span>'
          : '<span class="parts-invariant-fail">✗ could not compute — missing cad_component or holes</span>')}
      </div>
      <div style="font-size:12px;font-weight:600;margin-bottom:4px;">Root component — layout at each pcbRotation:</div>
      <div style="font-size:10px;color:var(--text-dim);margin-bottom:10px;">
        <span style="color:#4fd1c7;">▓</span> SMT pad &nbsp;
        <span style="color:#eab308;">●</span> Plated hole &nbsp;
        <span style="color:#64748b;">●</span> Drill hole &nbsp;
        <span style="color:#f87171;">✕</span> cad body centroid &nbsp;
        <span style="color:rgba(255,255,255,0.5);">+</span> chip origin
      </div>
      <div class="parts-rot-grid">
        ${rotGrid.map(g => `
          <div class="parts-rot-cell">
            <div class="title">pcbRotation = ${g.θ}°${g.θ === curRot ? '  ← current' : ''}</div>
            ${drawRot(g.θ)}
            <div style="font-size:10px;color:var(--text-dim);font-family:ui-monospace,monospace;">
              pads x: ${g.padBboxX[0].toFixed(2)} to ${g.padBboxX[1].toFixed(2)}<br/>
              pads y: ${g.padBboxY[0].toFixed(2)} to ${g.padBboxY[1].toFixed(2)}<br/>
              ${g.rotCad ? `body: (${g.rotCad.x.toFixed(2)}, ${g.rotCad.y.toFixed(2)})` : ''}
            </div>
          </div>
        `).join('')}
      </div>
    `;
  }

  // Redo list whenever the Parts tab is shown (in case circuit.json changed).
  document.addEventListener('click', (e) => {
    const t = e.target;
    if (!t || !t.classList || !t.classList.contains('tab-primary')) return;
    if (t.dataset && t.dataset.panel === 'parts') { _circuitJsonCache = null; renderPartsList(); }
  });
  // Initial: populate once the first GLB loads (so componentMap is ready-ish).
  setTimeout(renderPartsList, 2000);
})();

// ─── Cable-plug fixture: a visual ground-truth for USB-C mouth dir ───
// Renders a rectangular box the size of a USB-C plug (8.3 × 2.6 × 35 mm)
// extending from the receptacle's mouth in the mouth direction. If
// the box intersects the PCB substrate or extends the wrong way, the
// receptacle is rotated wrong — no math required to tell.
window._cableFixtures = new Map();  // refdes -> mesh
async function toggleCableFixture(refdes, meta, on) {
  const scene = viewer && viewer.getScene && viewer.getScene();
  if (!scene) return;
  if (!on) {
    const existing = window._cableFixtures.get(refdes);
    if (existing) { try { existing.dispose(); } catch {} window._cableFixtures.delete(refdes); }
    return;
  }
  // Fetch the cad_component for this refdes from circuit.json so we
  // know mouth direction (rotation.z) and body center.
  try {
    const r = await fetch('circuit.json');
    const cj = r.ok ? await r.json() : null;
    if (!cj) { console.warn('[cable-fixture] circuit.json unavailable'); return; }
    const src = cj.find(x => x.type === 'source_component' && x.name === refdes);
    if (!src) return;
    const pc = cj.find(x => x.type === 'pcb_component' && x.source_component_id === src.source_component_id);
    const cad = cj.find(x => x.type === 'cad_component' && x.pcb_component_id === pc.pcb_component_id);
    if (!cad) return;
    const rot = (cad.rotation && cad.rotation.z ? cad.rotation.z : 0) * Math.PI / 180;
    const mouthX = -Math.sin(rot), mouthY = Math.cos(rot);
    // USB-C plug dimensions (std Type-C plug shell): 8.3 × 2.55 mm,
    // insertion depth ~6.5 mm, external overmould ~30 mm before strain relief.
    const box = BABYLON.MeshBuilder.CreateBox('cable-fixture-' + refdes,
      { width: 8.3, height: 2.55, depth: 35 }, scene);
    // Center the box 17.5 mm out from the mouth (box depth / 2 + plug insert)
    const out = 4 + 17.5;  // 4mm insert into receptacle + 17.5 mm half-depth
    box.position.x = cad.position.x + mouthX * out;
    box.position.y = cad.position.y + mouthY * out;
    box.position.z = cad.position.z + 1.0;  // just above board surface
    // Align box depth axis to mouth direction.
    box.rotation.y = Math.atan2(mouthX, mouthY);
    const mat = new BABYLON.StandardMaterial('cable-fixture-mat-' + refdes, scene);
    mat.diffuseColor = new BABYLON.Color3(0.25, 0.28, 0.32);
    mat.emissiveColor = new BABYLON.Color3(0.1, 0.1, 0.1);
    mat.alpha = 0.85;
    box.material = mat;
    window._cableFixtures.set(refdes, box);
  } catch (e) {
    console.warn('[cable-fixture]', e && e.message || e);
  }
}

// ─── Click-to-select + right-click context menu on 3D components ───
// Left click on a component → highlight + store as window._selectedComponent.
//   Press spacebar to toggle visibility.
// Right click on a component → context menu with Hide/Show + Scroll to in HUD.
const _setupCompContextMenu = () => {
  const canvas = document.querySelector('#viewer-wrap canvas');
  if (!canvas) { setTimeout(_setupCompContextMenu, 500); return; }
  if (canvas.dataset.ctxMenuInit === '1') return;
  canvas.dataset.ctxMenuInit = '1';

  const findComponentAtPoint = () => {
    const scene = viewer && viewer.getScene && viewer.getScene();
    if (!scene) return null;
    const pick = scene.pick(scene.pointerX, scene.pointerY, (m) => m && m.isEnabled && m.isEnabled() && m.name);
    if (!pick || !pick.pickedMesh) return null;
    for (const [name, meta] of componentMap) {
      if (name === 'Board') continue;
      if (meta.meshes.includes(pick.pickedMesh)) return { name, meta, mesh: pick.pickedMesh };
    }
    return null;
  };
  window.findComponentAtPoint = findComponentAtPoint;

  // Click-to-select highlight = same teal Babylon HighlightLayer glow
  // every other selection in the app uses. Earlier this function used
  // mesh.renderOutline (per-mesh yellow outline) because the vendored
  // bundle didn't expose HighlightLayer; that's the "ancient hilite
  // technique" that survives no longer. We now find the component by
  // refdes, look it up in componentMap, and route through the same
  // path as window.highlightComponents — so a left-click ON a chip
  // produces the gorgeous teal glow halo.
  const _selectedRefdes = { value: null };
  const applySelectionHighlight = (meta) => {
    if (!meta) {
      _selectedRefdes.value = null;
      try { highlightComponents([]); } catch {}
      return;
    }
    // Find the refdes matching this meta object
    let refdes = null;
    for (const [name, m] of componentMap) if (m === meta) { refdes = name; break; }
    if (!refdes) return;
    _selectedRefdes.value = refdes;
    try { highlightComponents([refdes]); } catch (e) {
      console.warn('[click-select] highlight failed', refdes, e);
    }
  };
  window._applyCompSelectionHighlight = applySelectionHighlight;

  canvas.addEventListener('pointerdown', (e) => {
    if (e.button !== 0) return;  // left click only
    // Skip when Measure or Inspect tools are active (they own the canvas)
    if (window.activeTool && window.activeTool !== 'select') return;
    const hit = findComponentAtPoint();
    if (!hit) { window._selectedComponent = null; applySelectionHighlight(null); return; }
    window._selectedComponent = hit.name;
    applySelectionHighlight(hit.meta);
  });

  // Context menu on right-click
  const menu = document.createElement('div');
  menu.id = 'comp-context-menu';
  menu.style.cssText = 'position:fixed;display:none;min-width:180px;background:var(--surface);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px 0;z-index:99998;font-size:12px;user-select:none;';
  document.body.appendChild(menu);
  const hideMenu = () => { menu.style.display = 'none'; };
  window.addEventListener('click', hideMenu);
  window.addEventListener('resize', hideMenu);

  canvas.addEventListener('contextmenu', (e) => {
    e.preventDefault();
    const hit = findComponentAtPoint();
    if (!hit) return;
    window._selectedComponent = hit.name;
    applySelectionHighlight(hit.meta);
    const isVisible = componentIsVisible(hit.name);
    const mkItem = (label, fn) => {
      const it = document.createElement('div');
      it.textContent = label;
      it.style.cssText = 'padding:6px 14px;cursor:pointer;color:var(--text);';
      it.addEventListener('mouseenter', () => it.style.background = 'var(--accent-bg, rgba(120,160,255,0.12))');
      it.addEventListener('mouseleave', () => it.style.background = '');
      it.addEventListener('click', () => { hideMenu(); fn(); });
      return it;
    };
    menu.innerHTML = '';
    const header = document.createElement('div');
    header.textContent = hit.name + '  — ' + (hit.meta.kind || 'misc');
    header.style.cssText = 'padding:6px 14px;color:var(--text-dim);font-size:11px;text-transform:uppercase;letter-spacing:0.04em;border-bottom:1px solid var(--border);margin-bottom:3px;';
    menu.appendChild(header);
    menu.appendChild(mkItem(isVisible ? 'Hide ' + hit.name + '  (space)' : 'Show ' + hit.name + '  (space)', () => {
      setComponentVisibility(hit.name, !isVisible, false);
    }));
    menu.appendChild(mkItem('Find in Components HUD', () => {
      // Try to open/scroll to the component in the HUD.
      const hudToggleBtn = document.getElementById('tb-comp');
      if (hudToggleBtn && !document.querySelector('#panel-comp.active')) hudToggleBtn.click();
      setTimeout(() => {
        const hud = document.getElementById('panel-comp') || document;
        const row = hud.querySelector('[data-comp-row="' + hit.name.replace(/[^\w-]/g,'') + '"]') ||
                    Array.from(hud.querySelectorAll('.co-row-name, .co-row')).find(el => (el.textContent||'').trim() === hit.name);
        if (row && row.scrollIntoView) row.scrollIntoView({ behavior: 'smooth', block: 'center' });
        if (row) { row.style.outline = '2px solid var(--accent)'; setTimeout(() => row.style.outline = '', 1500); }
      }, 50);
    }));
    menu.appendChild(mkItem('Copy name to clipboard', () => {
      navigator.clipboard && navigator.clipboard.writeText(hit.name).catch(()=>{});
    }));
    // Cable-plug fixture for J-refdes connectors: ground-truth check
    // for "is the mouth really pointing off the edge?". Renders a
    // USB-C plug box tangent to the connector's mouth; if it crosses
    // the PCB substrate, the receptacle is rotated wrong.
    if (/^J[\d_]/i.test(hit.name)) {
      const fixtureOn = !!(window._cableFixtures && window._cableFixtures.has(hit.name));
      menu.appendChild(mkItem(fixtureOn ? 'Hide cable-plug fixture' : 'Show cable-plug fixture (visual mouth-direction test)', () => {
        toggleCableFixture(hit.name, hit.meta, !fixtureOn);
      }));
    }
    menu.style.left = Math.min(e.clientX, window.innerWidth - 220) + 'px';
    menu.style.top = Math.min(e.clientY, window.innerHeight - 200) + 'px';
    menu.style.display = 'block';
  });
};
_setupCompContextMenu();

// Footer: shows project name + a click-to-reveal in VS Code.
//
// No fallbacks. The only acceptable source for the project name is the
// server's /state response's `project_display_name` (server-side read of
// tscircuit.config.json `displayName`, then package.json name). If the
// server doesn't return one, don't render the footer at all — a wrong
// name (folder basename, slug, "adom-tsci") is worse than nothing.
(async () => {
  try {
    const r = await bridgeFetch('state'); const s = r.ok ? await r.json() : {};
    const dir = s.project_dir || '';
    const display = (s.project_display_name && s.project_display_name.trim()) || null;
    if (!display) {
      console.warn('[footer] /state.project_display_name missing — skipping footer render');
      return;
    }
    const footer = document.createElement('div');
    footer.id = 'tsci-footer';
    footer.style.cssText = 'position:fixed;left:0;right:0;bottom:0;height:22px;display:flex;align-items:center;padding:0 12px;font-size:11px;color:var(--text-dim);background:var(--surface);border-top:1px solid var(--border);z-index:1000;pointer-events:auto;user-select:none;';
    footer.innerHTML =
      '<span style="opacity:0.6;margin-right:6px;">project</span>' +
      '<a href="#" id="tsci-foot-project" title="Reveal ' + dir + ' in the VS Code Explorer (opens the sidebar if collapsed)" ' +
         'style="color:var(--accent);text-decoration:none;cursor:pointer;">' + display + '</a>' +
      '<span style="opacity:0.4;margin:0 8px;">·</span>' +
      '<span style="opacity:0.55;" title="' + dir + '">' + dir.replace(/^\/home\/[^/]+/, '~') + '</span>' +
      '<span style="flex:1"></span>' +
      '<span style="opacity:0.45;">adom-tsci</span>';
    document.body.appendChild(footer);
    // Mirror Colby's padding-top:54px trick for the fixed header (0.5.1)
    // — add padding-bottom on body so the fixed footer doesn't occlude
    // the bottom 22px of each panel. Panels are flex-in-flow (not
    // absolute) so setting .style.bottom on them is a no-op; body
    // padding is the right knob. box-sizing:border-box on body keeps
    // height:100vh stable.
    document.body.style.paddingBottom = '22px';
    document.getElementById('tsci-foot-project').addEventListener('click', async (e) => {
      e.preventDefault();
      try {
        const rr = await fetch('/api/reveal-folder', { method: 'POST' });
        const jr = rr.ok ? await rr.json() : null;
        const f = document.getElementById('tsci-footer');
        if (f) {
          const note = document.createElement('span');
          note.textContent = (jr && jr.ok) ? '✓ revealed in VS Code' : '✗ reveal failed';
          note.style.cssText = 'margin-left:10px;opacity:0.7;';
          f.appendChild(note);
          setTimeout(() => note.remove(), 3000);
        }
      } catch (_) {}
    });
  } catch (_) { /* footer is optional */ }
})();
</script>
</body>
</html>