123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>step2glb preview</title>
<style>
:root {
  --bg: #0d1117; --surface: #161b22; --surface2: #1c2128;
  --border: #30363d; --overlay: #21262d; --text: #e6edf3; --text-dim: #8b949e;
  --accent: #00b8b0; --red: #f85149; --yellow: #d29922; --green: #3fb950;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; width: 100%; background: var(--bg); color: var(--text);
  font-family: 'Satoshi', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  font-size: 13px; overflow: hidden; }
.app {
  position: fixed; inset: 0;
  display: grid; grid-template-rows: auto minmax(0, 1fr) auto;
  overflow: hidden;
}
header { background: var(--surface); border-bottom: 1px solid var(--border);
  padding: 10px 18px; display: flex; align-items: center; gap: 18px; min-height: 44px; flex-wrap: wrap; }
header h1 { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 15px;
  display: inline-flex; align-items: center; gap: 10px; }
header h1 .logo { display: inline-flex; width: 26px; height: 26px; background: rgba(0,184,176,0.1);
  border: 1px solid rgba(0,184,176,0.35); border-radius: 7px; align-items: center; justify-content: center; color: var(--text); }
header h1 .logo svg { width: 16px; height: 16px; }
header .meta { color: var(--text-dim); font-size: 11.5px; font-family: 'JetBrains Mono', monospace;
  display: inline-flex; gap: 8px; align-items: baseline; padding: 3px 9px;
  background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; }
header .meta .label { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); }
header .meta .val { color: var(--text); }

/* §3b/§3c: vertical gradient — brightened top per 2026-04-24 field feedback
   so black chips / black solder mask / black plastic read clearly against the
   background. Do NOT darken the top back toward --overlay (#21262d) — that
   regresses the black-chip-visibility problem. */
#canvas-wrap {
  position: relative; min-height: 0; min-width: 0; overflow: hidden;
  background:
    radial-gradient(ellipse 60% 40% at 50% 100%, rgba(0, 184, 177, 0.04), transparent 70%),
    linear-gradient(180deg, #3e4a5c 0%, var(--bg) 80%);
}
#canvas { width: 100%; height: 100%; min-height: 0; min-width: 0; max-width: 100%; max-height: 100%;
  display: block; outline: none; background: transparent; }

#hud { position: absolute; top: 12px; left: 12px; display: flex; gap: 6px; flex-wrap: wrap; z-index: 10; }
.pill { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
  background: rgba(22,27,34,0.92); border: 1px solid var(--border); border-radius: 999px;
  font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--text-dim); letter-spacing: 0.02em; }
.pill .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--text-dim); }
.pill.ok .dot { background: var(--green); } .pill.ok .lbl { color: var(--green); }
.pill.warn .dot { background: var(--yellow); } .pill.warn .lbl { color: var(--yellow); }
.pill.bad .dot { background: var(--red); } .pill.bad .lbl { color: var(--red); }
.pill .val { color: var(--text); }

/* §7: ViewCube-style panel, top-right under the drawer toggle. */
#viewcube {
  position: absolute; top: 12px; right: 120px; z-index: 10;
  display: grid; grid-template-columns: repeat(4, auto); gap: 4px;
  background: rgba(22,27,34,0.92); border: 1px solid var(--border); border-radius: 8px;
  padding: 6px;
}
#viewcube button {
  font-family: 'JetBrains Mono', monospace; font-size: 10.5px;
  background: var(--surface2); color: var(--text-dim);
  border: 1px solid var(--border); border-radius: 5px;
  padding: 4px 8px; min-width: 42px; cursor: pointer;
  transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
}
#viewcube button:hover { color: var(--text); border-color: var(--accent); }
#viewcube button.active { color: var(--accent); border-color: var(--accent); background: rgba(0,184,176,0.1); }

#drawer-toggle { position: absolute; top: 12px; right: 12px; z-index: 11;
  background: rgba(22,27,34,0.92); border: 1px solid var(--border); border-radius: 8px;
  padding: 6px 12px; font-size: 12px; color: var(--text); cursor: pointer; }
#drawer-toggle:hover { border-color: var(--accent); background: rgba(0,184,176,0.1); }
#drawer { position: absolute; top: 0; right: 0; bottom: 0; width: 380px; background: rgba(13,17,23,0.97);
  border-left: 1px solid var(--border); padding: 54px 16px 16px; overflow-y: auto;
  font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-dim); line-height: 1.55;
  transform: translateX(100%); transition: transform 220ms ease; z-index: 9; }
#drawer.open { transform: translateX(0); }
#drawer h2 { font-family: 'Familjen Grotesk', sans-serif; font-size: 11px; color: var(--accent);
  letter-spacing: 1.5px; text-transform: uppercase; margin: 0 0 8px; font-weight: 600; }
#drawer .section { margin-bottom: 18px; }
#drawer .row { display: flex; justify-content: space-between; gap: 14px; padding: 1px 0; }
#drawer .row .val { color: var(--text); }
.mat-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid rgba(48,54,61,0.4); }
.mat-row:last-child { border-bottom: none; }
.swatch { width: 22px; height: 22px; border-radius: 4px; border: 1px solid var(--border); flex-shrink: 0; }
.mat-row .mat-name { font-size: 10.5px; color: var(--text); }
.mat-row .mat-details { font-size: 10px; color: var(--text-dim); }

footer { background: var(--surface); border-top: 1px solid var(--border); padding: 8px 16px;
  display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--text-dim); min-height: 44px; flex-wrap: wrap; }
footer .btn {
  background: var(--surface2); border: 1px solid var(--border); border-radius: 6px;
  color: var(--text-dim); padding: 5px 10px; font-size: 11.5px; font-family: 'Satoshi', sans-serif;
  cursor: pointer; transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
}
footer .btn:hover { color: var(--text); border-color: var(--accent); }
footer .btn.active { color: var(--accent); border-color: var(--accent); background: rgba(0,184,176,0.1); }
footer .spacer { flex: 1; }
footer select.bg-preset {
  background: var(--surface2); color: var(--text-dim); border: 1px solid var(--border);
  border-radius: 6px; padding: 5px 8px; font-size: 11px;
}
footer .kbd-hint { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--text-dim); }
.kbd { font-family: 'JetBrains Mono', monospace; background: var(--surface2); border: 1px solid var(--border);
  border-radius: 3px; padding: 1px 6px; font-size: 11px; color: var(--text); }
</style>
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<script src="https://cdn.babylonjs.com/loaders/babylonjs.loaders.min.js"></script>
</head>
<body>
<div class="app">
  <header>
    <h1>
      <span class="logo" aria-hidden="true">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 7v10l9 4 9-4V7"/><path d="M12 11v10"/>
        </svg>
      </span>
      step2glb
    </h1>
    <span class="meta"><span class="label">source</span><span class="val" id="meta-source">…</span></span>
    <span class="meta"><span class="label">glb</span><span class="val" id="meta-glb">…</span></span>
  </header>
  <div id="canvas-wrap">
    <div id="hud">
      <span class="pill" id="pill-meshes"><span class="dot"></span><span class="lbl">meshes</span><span class="val" id="p-meshes">—</span></span>
      <span class="pill" id="pill-materials"><span class="dot"></span><span class="lbl">materials</span><span class="val" id="p-materials">—</span></span>
      <span class="pill" id="pill-size"><span class="dot"></span><span class="lbl">size</span><span class="val" id="p-size">—</span></span>
      <span class="pill" id="pill-duration"><span class="dot"></span><span class="lbl">conv</span><span class="val" id="p-duration">—</span></span>
    </div>

    <!-- §7 ViewCube. Matches the Z-up preset angles the canonical viewer uses. -->
    <div id="viewcube" aria-label="View presets — click a face to tween the camera there">
      <button data-view="top"    title="Top view">Top</button>
      <button data-view="front"  title="Front view">Front</button>
      <button data-view="right"  title="Right view">Right</button>
      <button data-view="iso"    title="Isometric view">Iso</button>
      <button data-view="bottom" title="Bottom view">Btm</button>
      <button data-view="back"   title="Back view">Back</button>
      <button data-view="left"   title="Left view">Left</button>
      <button data-view="fit"    title="Fit to model">Fit</button>
    </div>

    <button id="drawer-toggle" onclick="toggleDrawer()">≡ Details</button>
    <div id="drawer">
      <div class="section">
        <h2>Conversion</h2>
        <div class="row"><span>source</span><span class="val" id="d-source">—</span></div>
        <div class="row"><span>glb path</span><span class="val" id="d-glb">—</span></div>
        <div class="row"><span>size</span><span class="val" id="d-size">—</span></div>
        <div class="row"><span>duration</span><span class="val" id="d-duration">—</span></div>
        <div class="row"><span>meshes</span><span class="val" id="d-meshes">—</span></div>
        <div class="row"><span>nodes</span><span class="val" id="d-nodes">—</span></div>
      </div>
      <div class="section">
        <h2>Materials (from STEP colors)</h2>
        <div id="materials-list">…</div>
      </div>
      <div class="section">
        <h2>Keyboard</h2>
        <div class="row"><span>Left-drag</span><span class="val">Orbit (teal sphere shows pivot)</span></div>
        <div class="row"><span>Right-drag</span><span class="val">Pan</span></div>
        <div class="row"><span>Scroll</span><span class="val">Zoom to cursor</span></div>
        <div class="row"><span>Shift+Alt+Click</span><span class="val">Move rotation pivot</span></div>
        <div class="row"><span>A</span><span class="val">Toggle world axes</span></div>
        <div class="row"><span>Shift+A</span><span class="val">Toggle mesh-local origins</span></div>
        <div class="row"><span>T / F / R / I</span><span class="val">Top / Front / Right / Iso</span></div>
        <div class="row"><span>B / L</span><span class="val">Back / Left</span></div>
      </div>
    </div>
    <canvas id="canvas" touch-action="none"></canvas>
  </div>
  <footer>
    <button class="btn active" id="btn-bottom-light"
            title="Boost hemispheric ground color so you can see under chips (§4b)."
            onclick="toggleBottomLight()">Bottom light</button>
    <button class="btn active" id="btn-ground"
            title="Show the medium-gray ground plane. 65% transparent so you can see the chip's bottom surface through it; receives a soft shadow from the chip so depth reads correctly."
            onclick="toggleGround()">Show ground</button>
    <button class="btn active" id="btn-axes"
            title="Always show the world origin (R=X, G=Y, B=Z). Useful when debugging alignment of chips, footprints, and molecules whose local origins may differ. Shortcut: A"
            onclick="toggleAxes('world')">Show axes</button>
    <button class="btn" id="btn-mesh-axes"
            title="Show each loaded mesh's local origin — reveals Y-up / centroid / pad-1 origin mismatches on load. Shortcut: Shift+A"
            onclick="toggleAxes('mesh-local')">Show mesh origins</button>
    <label for="bg-preset" class="kbd-hint">Bg:</label>
    <select id="bg-preset" class="bg-preset" onchange="setBgPreset(this.value)">
      <option value="studio">Studio gradient</option>
      <option value="dark">Dark flat</option>
      <option value="light">Light flat</option>
    </select>
    <div class="spacer"></div>
    <span class="kbd-hint">Drag (teal sphere = pivot) · <span class="kbd">Shift+Alt+Click</span> moves pivot · <span class="kbd">A</span> axes · <span class="kbd">Shift+A</span> mesh origins</span>
  </footer>
</div>

<script>
// ── console forwarding — per §9c ───────────────────────────────────
(function () {
  const orig = { log: console.log, warn: console.warn, error: console.error };
  ['log','warn','error'].forEach(level => {
    console[level] = function () {
      try {
        fetch('console', { method: 'POST', headers: {'Content-Type':'application/json'},
          body: JSON.stringify({ level, ts: Date.now(), message: Array.from(arguments).map(String).join(' ') }) }).catch(()=>{});
        if (window.parent && window.parent !== window) {
          window.parent.postMessage({ source: 'step2glb', type: 'console', level, message: Array.from(arguments).map(String).join(' ') }, '*');
        }
      } catch (_) {}
      orig[level].apply(console, arguments);
    };
  });
  window.addEventListener('error', e => console.error('window.onerror:', e.message, e.filename, e.lineno));
})();

const canvas = document.getElementById('canvas');
const engine = new BABYLON.Engine(canvas, true, { preserveDrawingBuffer: true, stencil: true });
const scene = new BABYLON.Scene(engine);

// §3: transparent clearColor so the CSS gradient shows through.
scene.clearColor = new BABYLON.Color4(0, 0, 0, 0);
scene.useRightHandedSystem = true;

// §10: the canonical viewer's transform — GLB spec is Y-up, Adom is Z-up.
// Handled at load-time below by parenting the GLB under a Z-up-swapped root.

// ── Lighting (§4a + §4c fallback) ─────────────────────────────────
// TODO: once gallia/viewer ships the HDRI at a publicly-fetchable URL
// (or step2glb uploads `environmentSpecular.env` as a wiki asset), wire
// `scene.environmentTexture = BABYLON.CubeTexture.CreateFromPrefilteredData(...)`
// with an onError hook that detaches the texture on load failure. Attempting
// the load with an invalid URL leaves a broken-texture placeholder
// rendered as a red-orange cube, which is worse than no env texture at all.
// Stay hemispheric-only until that asset exists.
const envFallback = true;
const hemi = new BABYLON.HemisphericLight('hemi', new BABYLON.Vector3(0, 0, 1), scene);
hemi.intensity   = 0.9;
hemi.diffuse     = BABYLON.Color3.FromHexString('#e6edf3');
hemi.groundColor = new BABYLON.Color3(0.2, 0.18, 0.15);

// §4c fallback studio rig — key + fill + rim (teal-tinted).
const keyLight = new BABYLON.DirectionalLight('key', new BABYLON.Vector3(-0.4, -0.5, -0.8).normalize(), scene);
keyLight.intensity = 0.9; keyLight.diffuse = BABYLON.Color3.FromHexString('#ffffff');
const fillLight = new BABYLON.DirectionalLight('fill', new BABYLON.Vector3(0.5, 0.4, -0.2).normalize(), scene);
fillLight.intensity = 0.35; fillLight.diffuse = BABYLON.Color3.FromHexString('#e6edf3');
const rimLight = new BABYLON.DirectionalLight('rim', new BABYLON.Vector3(0, 1, -0.3).normalize(), scene);
rimLight.intensity = 0.25; rimLight.diffuse = BABYLON.Color3.FromHexString('#00b8b0');

// §Ground — matches the canonical viewer's `_ground` pattern
// (gallia/viewer/fp-to-3d.js:472–488) + real shadows via ShadowGenerator.
// Medium blue-gray diffuse so black chips don't vanish into the ground,
// α=0.35 (~65% transparent) so the user can see the chip's bottom surface
// through it (critical for "is the chip actually seated or hovering?"),
// specular matte, small emissive so the ground stays visible when the
// directional key light's far side is dim, backface-culling off so it's
// visible from below too. receiveShadows=true pairs with the
// ShadowGenerator fed by the key directional light (keyLight, created
// earlier). Chip meshes (GLB-loaded) are added to the shadow render list
// after load so they cast onto the ground.
let shadowGround = null;
let shadowGen = null;
function buildShadowGround() {
  if (shadowGround) return;
  shadowGround = BABYLON.MeshBuilder.CreatePlane('shadowGround',
    { size: 1, sideOrientation: BABYLON.Mesh.DOUBLESIDE }, scene);
  shadowGround.position.z = -0.01;   // just below z=0 to avoid z-fight with pads/seat planes
  shadowGround.isPickable = false;
  shadowGround.renderingGroupId = 0;
  shadowGround.receiveShadows = true;
  // Canonical uses StandardMaterial for shadow reception — Babylon's shadow
  // system is mature on StandardMaterial and this is the one documented
  // exception to the §5a PBR-default rule (alongside the laser-etch overlay
  // in §5c). PBR shadow reception works but is fussier.
  const mat = new BABYLON.StandardMaterial('shadowGroundMat', scene);
  mat.diffuseColor  = new BABYLON.Color3(0.45, 0.47, 0.50);
  mat.specularColor = new BABYLON.Color3(0.05, 0.05, 0.05);
  // Keep emissive low so cast shadows can actually darken the ground —
  // too much emissive washes shadows out. Canonical fp-to-3d uses 0.12
  // across, but fp-to-3d has a more varied light rig; for step2glb a
  // darker ground lets the shadow read more clearly.
  mat.emissiveColor = new BABYLON.Color3(0.04, 0.045, 0.05);
  mat.alpha = 0.35;                  // ~65% transparent — see chip bottom through it
  mat.backFaceCulling = false;
  shadowGround.material = mat;

  // Key light casts a shadow onto the ground. Previously created as an
  // unconfigured DirectionalLight — repoint it through a ShadowGenerator.
  if (!shadowGen) {
    shadowGen = new BABYLON.ShadowGenerator(2048, keyLight);
    shadowGen.useBlurExponentialShadowMap = true;   // soft edges
    shadowGen.blurScale = 2;
    shadowGen.blurBoxOffset = 2;
    shadowGen.setDarkness(0.25);                     // 0 = jet black (strong), 1 = no shadow. Low = crisp.
    // Give the key light a sensible shadow frustum.
    keyLight.shadowMinZ = 0.1;
    keyLight.shadowMaxZ = 200;
    // Re-aim the key light so its shadow casts directly below the model
    // rather than off to one side — the iso view sees the full contact
    // shadow instead of a long diagonal one falling off the ground.
    keyLight.direction = new BABYLON.Vector3(-0.2, -0.2, -0.95).normalize();
  }
}
function resizeShadowGround() {
  if (!shadowGround) return;
  // Plane size = ~4× scene diameter so shadows have room to fade and the
  // ground extends well past any inspection zoom.
  const s = Math.max(sceneExtentMm * 4, 50);
  shadowGround.scaling = new BABYLON.Vector3(s, s, 1);
}
buildShadowGround();

function addChipShadowCasters(beforeIds) {
  if (!shadowGen) return;
  const list = shadowGen.getShadowMap().renderList;
  scene.meshes.forEach(m => {
    if (beforeIds && beforeIds.has(m.uniqueId)) return;
    if (!m.getTotalVertices || m.getTotalVertices() === 0) return;
    if (list.indexOf(m) < 0) list.push(m);
  });
}

let bottomLightOn = true;
function setBottomLight(enabled) {
  bottomLightOn = enabled;
  if (enabled) {
    hemi.groundColor = new BABYLON.Color3(0.7, 0.65, 0.55);
    hemi.intensity   = Math.max(hemi.intensity, 1.2);
  } else {
    hemi.groundColor = new BABYLON.Color3(0.2, 0.18, 0.15);
    hemi.intensity   = envFallback ? 0.9 : 0.6;
  }
  document.getElementById('btn-bottom-light').classList.toggle('active', enabled);
}
window.toggleBottomLight = () => setBottomLight(!bottomLightOn);
setBottomLight(true);

let groundVisible = true;
function setGroundVisible(on) {
  groundVisible = !!on;
  if (shadowGround) shadowGround.setEnabled(groundVisible);
  const b = document.getElementById('btn-ground');
  if (b) b.classList.toggle('active', groundVisible);
}
window.toggleGround = () => setGroundVisible(!groundVisible);

// ── Camera (§6) — zoomToMouseLocation, soft limits, stripped behaviors ─
const camera = new BABYLON.ArcRotateCamera('cam', -Math.PI/4, Math.PI/3, 40,
  BABYLON.Vector3.Zero(), scene);
camera.upVector = new BABYLON.Vector3(0, 0, 1);  // Z-up
camera.attachControl(canvas, true);
// §6d: FOV 35–40° — tighter than Babylon's ~45° default. Gives a more
// orthographic-feeling inspection camera and reduces chip-body
// fisheye distortion when zoomed close.
camera.fov = 0.6;  // ~34.4°

// §6b — strip every behavior before limits apply
while (camera.behaviors && camera.behaviors.length > 0) camera.removeBehavior(camera.behaviors[0]);
camera.useFramingBehavior = false;
camera.zoomToMouseLocation = true;   // §6a — the non-negotiable

// All distances in mm. GLBs are scaled 1000× on load (meters → mm) so these
// numbers match the canonical viewer's defaults without per-app tweaking.
camera.lowerRadiusLimit = 0.1;
camera.upperRadiusLimit = 200;
camera.lowerBetaLimit   = 0.01;
camera.upperBetaLimit   = Math.PI - 0.01;
// Wheel / pan / pinch precision are recomputed per-load in fitCameraToScene
// using the canonical adaptive formula (wheelPrecision = 100/R,
// panningSensibility = 5000/R). These placeholders are only active until
// the first GLB lands. Hard-coded values tuned for one scene size feel
// 30% too slow on a small chip or 300% too fast on a large assembly;
// the adaptive formula keeps the feel consistent across any model.
camera.wheelPrecision     = 10;
camera.pinchPrecision     = 50;
camera.panningSensibility = 500;
camera.minZ = 0.01;
camera.maxZ = 1000;

// ── §6c / v1.1 — persistent teal pivot sphere ──────────────────────
// Single PBR-unlit sphere. Shown during every left-drag rotate so the user
// forms the mental link "teal sphere = rotation center"; that training is
// what makes Shift+Alt+Click discoverable. The SAME sphere doubles as the
// recenter-flash — consistent visual language, not two different markers.
let pivotSphere = null;
let pivotHideTimer = null;
let sceneExtentMm = 50;  // updated by fitCameraToScene()

function ensurePivotSphere() {
  if (pivotSphere && !pivotSphere.isDisposed()) return pivotSphere;
  const diameter = Math.max(sceneExtentMm * 0.01, 0.2);
  pivotSphere = BABYLON.MeshBuilder.CreateSphere('pivot', { diameter }, scene);
  pivotSphere.isPickable = false;
  // Depth-tested (renderingGroupId 0), not xray. If the pivot is inside a
  // solid chip body the sphere will be occluded — that's the correct
  // signal to the human: "your rotation center is buried in the model."
  // The user then Shift+Alt+Clicks a surface point to move the pivot
  // somewhere visible.
  pivotSphere.renderingGroupId = 0;
  // §5a PBR-default. `unlit` makes it a pure emissive marker without adding
  // a StandardMaterial — self-lit, ignores HDRI, correct class per the skill.
  const mat = new BABYLON.PBRMaterial('pivotMat', scene);
  mat.unlit = true;
  mat.emissiveColor = BABYLON.Color3.FromHexString('#00b8b0');
  mat.albedoColor   = BABYLON.Color3.FromHexString('#00b8b0');
  mat.alpha = 0.7;
  mat.transparencyMode = BABYLON.PBRMaterial.PBRMATERIAL_ALPHABLEND;
  pivotSphere.material = mat;
  pivotSphere.setEnabled(false);
  // Track cam.target each frame so the sphere moves with the pivot even mid-tween.
  scene.onBeforeRenderObservable.add(() => {
    if (pivotSphere && pivotSphere.isEnabled()) pivotSphere.position.copyFrom(camera.target);
  });
  return pivotSphere;
}
function showPivot() {
  ensurePivotSphere();
  if (pivotHideTimer) { clearTimeout(pivotHideTimer); pivotHideTimer = null; }
  pivotSphere.position.copyFrom(camera.target);
  pivotSphere.setEnabled(true);
}
function hidePivot(delayMs) {
  if (pivotHideTimer) clearTimeout(pivotHideTimer);
  pivotHideTimer = setTimeout(() => {
    if (pivotSphere) pivotSphere.setEnabled(false);
    pivotHideTimer = null;
  }, delayMs == null ? 400 : delayMs);
}

// Show pivot during EVERY left-drag rotate — the key bit of §6c v1.1.
canvas.addEventListener('pointerdown', (e) => {
  if (e.button === 0 && !e.shiftKey && !e.altKey) showPivot();
});
canvas.addEventListener('pointerup',   () => hidePivot(400));
canvas.addEventListener('pointerleave',() => hidePivot(400));

// ── §6c Shift+Alt+Click orbit recenter — reuses the same pivot sphere ─
function attachOrbitCenterRecenter() {
  canvas.addEventListener('pointerdown', (e) => {
    if (e.button !== 0 || !e.shiftKey || !e.altKey) return;
    e.preventDefault();
    const pick = scene.pick(scene.pointerX, scene.pointerY);
    let newTarget = null;
    if (pick && pick.hit && pick.pickedPoint) {
      newTarget = pick.pickedPoint.clone();
    } else {
      const ray = scene.createPickingRay(scene.pointerX, scene.pointerY, null, camera);
      for (const planeZ of [camera.target.z, 0]) {
        if (Math.abs(ray.direction.z) < 1e-6) continue;
        const t = (planeZ - ray.origin.z) / ray.direction.z;
        if (t > 0 && t < 1e6) { newTarget = ray.origin.add(ray.direction.scale(t)); break; }
      }
    }
    if (!newTarget) return;

    const ease = new BABYLON.CubicEase();
    ease.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT);
    BABYLON.Animation.CreateAndStartAnimation(
      'orbitRecenter', camera, 'target',
      60, 12,
      camera.target.clone(), newTarget,
      BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT,
      ease
    );
    // Same sphere as drag-rotate; linger 600 ms so the user sees where the
    // pivot landed. §6c v1.1: "one sphere, one meaning."
    showPivot();
    hidePivot(600);
  });
}
attachOrbitCenterRecenter();

// ── §8a world-origin axis helper (default ON) ──────────────────────
// The gizmo is DEPTH-TESTED, not always-on-top. If the origin is inside a
// solid chip body, the body occludes the gizmo — that's a feature, not a
// bug: it's how the human tells whether the origin sits on the surface,
// below the seat plane, or buried inside the package. Contrast with
// "xray" / "draw-on-top" mode (renderingGroupId 2 in Babylon) which is
// the CAD term for ignoring the depth buffer. X/Y/Z labels are drawn on
// the screen-space corner triad (§8c) instead of here so they never
// clutter the scene.
const axesState = { world: true, meshLocal: false };
let worldAxes = null;
function buildWorldAxes() {
  if (worldAxes) { worldAxes.dispose(); worldAxes = null; }
  const len = sceneExtentMm * 0.15;
  // Third arg = renderingGroupId. 0 = default depth-tested group (the chip
  // occludes the gizmo when the origin is inside the body). Do NOT use
  // renderingGroupId 2 here — see module-level comment above.
  worldAxes = new BABYLON.AxesViewer(scene, len, 0);
  setWorldAxes(axesState.world);
}
function setWorldAxes(visible) {
  axesState.world = !!visible;
  if (!worldAxes) return;
  [worldAxes.xAxis, worldAxes.yAxis, worldAxes.zAxis].forEach(m => m.setEnabled(visible));
  const b = document.getElementById('btn-axes');
  if (b) b.classList.toggle('active', visible);
}

// ── §8b mesh-local axis helpers (default OFF) ──────────────────────
const meshLocalAxes = [];  // { mesh, axes, labels }
function addMeshLocalAxes(rootNode) {
  // Works for both Mesh (has its own bbox) and TransformNode (needs child-aggregate bbox).
  let len = sceneExtentMm * 0.12;
  if (rootNode.getBoundingInfo) {
    const r = rootNode.getBoundingInfo().boundingSphere.radius || 0;
    if (r > 0) len = r * 0.8;
  } else if (rootNode.getHierarchyBoundingVectors) {
    const { min, max } = rootNode.getHierarchyBoundingVectors(true);
    const diag = max.subtract(min).length();
    if (diag > 0) len = diag * 0.4;
  }
  // Depth-tested (renderingGroupId 0) so the mesh geometry can occlude the
  // local-origin gizmo. Same rationale as §8a's module-level note.
  const axes = new BABYLON.AxesViewer(scene, len, 0);
  [axes.xAxis, axes.yAxis, axes.zAxis].forEach(m => { m.parent = rootNode; m.setEnabled(axesState.meshLocal); });
  meshLocalAxes.push({ node: rootNode, axes });
}
function setMeshLocalAxes(visible) {
  axesState.meshLocal = !!visible;
  meshLocalAxes.forEach(({ axes }) =>
    [axes.xAxis, axes.yAxis, axes.zAxis].forEach(m => m.setEnabled(visible)));
  const b = document.getElementById('btn-mesh-axes');
  if (b) b.classList.toggle('active', visible);
}

// ── §8c screen-space corner triad (always on) ──────────────────────
// Second camera on the main scene with a pinned bottom-left viewport.
// Mirrors the main camera's alpha/beta so the orientation always matches.
// Uses layerMask to isolate the triad meshes from the main view — the triad
// renders only in the corner viewport, scene meshes render only in the main.
const CORNER_LAYER = 0x20000000;
let cornerCam = null;
function buildCornerTriad() {
  cornerCam = new BABYLON.ArcRotateCamera('cornerCam', camera.alpha, camera.beta, 3,
    BABYLON.Vector3.Zero(), scene);
  cornerCam.upVector = new BABYLON.Vector3(0, 0, 1);
  cornerCam.minZ = 0.01; cornerCam.maxZ = 100;
  cornerCam.layerMask = CORNER_LAYER;
  cornerCam.viewport = new BABYLON.Viewport(0.005, 0.01, 0.09, 0.16);  // bottom-left ~9%×16%
  // Don't let user input drive this camera.
  cornerCam.inputs.clear();

  // Main camera rendering layer — everything currently in the scene goes here.
  const MAIN_LAYER = 0x0FFFFFFF;
  camera.layerMask = MAIN_LAYER;
  scene.meshes.forEach(m => { if (m.layerMask === 0x0FFFFFFF) m.layerMask = MAIN_LAYER; });
  // Future meshes default to 0x0FFFFFFF which the main cam sees; only the
  // triad lines get CORNER_LAYER below.

  const tAxes = new BABYLON.AxesViewer(scene, 1);
  [tAxes.xAxis, tAxes.yAxis, tAxes.zAxis].forEach(n => {
    n.getChildMeshes(false).forEach(m => { m.layerMask = CORNER_LAYER; m.renderingGroupId = 3; });
  });

  // X/Y/Z labels at the triad tips. Moved here from the world-origin helper
  // per UX feedback: labels on the in-scene gizmo clutter the model; the
  // corner triad already lives in its own viewport where labels never
  // interfere with the chip. Always on top within that viewport so they're
  // always readable against whatever's rendered behind them.
  const mkCornerLabel = (text, pos) => {
    const plane = BABYLON.MeshBuilder.CreatePlane(`corner-lbl-${text}`, { size: 0.4 }, scene);
    plane.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL;
    plane.position.copyFrom(pos);
    plane.layerMask = CORNER_LAYER;
    plane.renderingGroupId = 3;
    plane.isPickable = false;
    const dt = new BABYLON.DynamicTexture(`dt-corner-${text}`, { width: 64, height: 64 }, scene, true);
    dt.hasAlpha = true;
    const ctx = dt.getContext();
    ctx.clearRect(0, 0, 64, 64);
    ctx.font = 'bold 52px Inter, sans-serif';
    ctx.fillStyle = '#e6edf3';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText(text, 32, 34);
    dt.update();
    const mat = new BABYLON.PBRMaterial(`corner-lbl-mat-${text}`, scene);
    mat.unlit = true;
    mat.albedoTexture = dt;
    mat.albedoTexture.hasAlpha = true;
    mat.transparencyMode = BABYLON.PBRMaterial.PBRMATERIAL_ALPHABLEND;
    plane.material = mat;
    return plane;
  };
  mkCornerLabel('X', new BABYLON.Vector3(1.15, 0, 0));
  mkCornerLabel('Y', new BABYLON.Vector3(0, 1.15, 0));
  mkCornerLabel('Z', new BABYLON.Vector3(0, 0, 1.15));

  // Render both cameras each frame.
  scene.activeCameras = [camera, cornerCam];

  // Mirror alpha/beta so the triad rotates in sync with the main camera.
  scene.onBeforeRenderObservable.add(() => {
    cornerCam.alpha = camera.alpha;
    cornerCam.beta  = camera.beta;
  });
}

// ── §7 ViewCube — tween, don't snap. Z-up preset angles. ────────────

// ── §7 ViewCube — tween, don't snap. Z-up preset angles. ────────────
//
// ADOM/CNC CONVENTION (non-negotiable across every Adom 3D viewer):
//   +X = east  (operator's right as they face the machine)
//   +Y = north (away from the operator, toward the back of the machine)
//   +Z = up    (out of the table surface, toward the ceiling)
//
// View presets below are tuned so TOP shows (+X right, +Y up, +Z toward
// viewer) — i.e. exactly the mental map a CNC operator has of the
// machine bed. Every lateral view assumes the operator is standing at -Y
// facing +Y; FRONT is what the operator sees when walking up to the
// machine.
//
// If you change any of these angles, first read the §NEW "Adom/CNC
// coordinate convention" section in the 3d-viewer-design skill — these
// numbers are coupled to that spec.
// Babylon's ArcRotateCamera + upVector=(0,0,1) places the camera at
//   pos = (r·sin β·cos α, −r·sin β·sin α, r·cos β)
// i.e. **alpha's sign is reversed vs standard spherical**. That sign
// flip is the reason an earlier iteration of this table showed +X on
// the wrong side at Front / Back — a "-π/2" I'd written down meaning
// "camera at -Y" was actually landing the camera at +Y. The table
// below is derived empirically (see the eval dump in the 0.4.1 commit).
const VIEW_PRESETS = {
  // Top — camera at +Z, screen-right = +X, screen-up = +Y.
  top:    { alpha:  Math.PI/2, beta: 0.01 },
  // Bottom — camera at -Z, mirrors Top (screen-up becomes -Y).
  bottom: { alpha:  Math.PI/2, beta: Math.PI - 0.01 },
  // Front — operator's natural view: camera at -Y, looking +Y.
  // screen-right = +X, screen-up = +Z.
  front:  { alpha:  Math.PI/2, beta: Math.PI/2 },
  // Back — camera at +Y, looking -Y. screen-right = -X, screen-up = +Z.
  back:   { alpha: -Math.PI/2, beta: Math.PI/2 },
  // Right — camera at +X, looking -X. screen-right = -Y, screen-up = +Z.
  right:  { alpha:  0,         beta: Math.PI/2 },
  // Left — camera at -X, looking +X. screen-right = +Y, screen-up = +Z.
  left:   { alpha: -Math.PI,   beta: Math.PI/2 },
  // Iso — camera in (+X, -Y, +Z) octant, i.e. operator's-right-and-in-front,
  // slightly above. This is the Adom/CNC canonical iso: the operator
  // stands at -Y, so the iso "home" view is from their own side of the
  // machine, not from behind it. (−π/4 here would land in the +Y/back
  // octant — that's the Blender/VRay default but wrong for CNC.)
  iso:    { alpha:  Math.PI/4, beta: Math.PI/3 },
};
function setView(preset) {
  if (preset === 'fit') { fitCameraToScene(); return; }
  const p = VIEW_PRESETS[preset];
  if (!p) return;
  const ease = new BABYLON.CubicEase();
  ease.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT);
  BABYLON.Animation.CreateAndStartAnimation(
    'viewAlpha', camera, 'alpha', 60, 18, camera.alpha, p.alpha,
    BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT, ease);
  BABYLON.Animation.CreateAndStartAnimation(
    'viewBeta',  camera, 'beta',  60, 18, camera.beta,  p.beta,
    BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT, ease);
  document.querySelectorAll('#viewcube button').forEach(b =>
    b.classList.toggle('active', b.dataset.view === preset));
}
document.querySelectorAll('#viewcube button').forEach(b =>
  b.addEventListener('click', () => setView(b.dataset.view)));
window.setView = setView;

window.addEventListener('keydown', e => {
  if (e.target && ['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
  // §8e: A → world axes, Shift+A → mesh-local axes
  if ((e.key === 'a' || e.key === 'A') && !e.ctrlKey && !e.metaKey && !e.altKey) {
    e.preventDefault();
    if (e.shiftKey) toggleAxes('mesh-local');
    else            toggleAxes('world');
    return;
  }
  // Skip view-preset keys when any modifier is held.
  if (e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
  const m = { t:'top', f:'front', r:'right', i:'iso', b:'back', l:'left' };
  if (m[e.key]) setView(m[e.key]);
});

function toggleAxes(target, explicit) {
  if (target === 'world')
    setWorldAxes(explicit == null ? !axesState.world : !!explicit);
  else if (target === 'mesh-local')
    setMeshLocalAxes(explicit == null ? !axesState.meshLocal : !!explicit);
}
window.toggleAxes = toggleAxes;

// ── §5b Material fixups on any GLB load ────────────────────────────
// §5a mandates PBRMaterial as the default for every new material, and
// §5b says incoming GLB materials should be detected + converted on
// load. Scoped to JUST the freshly-loaded meshes (via the `beforeIds`
// set passed from loadGlb) — otherwise the iteration would also rewrite
// the AxesViewer's internal cylinders, the shadow ground, pivot sphere,
// and corner-triad labels, which we already built correctly as PBR +
// don't want touched.
function fixupMaterials(beforeIds) {
  scene.meshes.forEach(mesh => {
    if (beforeIds && beforeIds.has(mesh.uniqueId)) return;  // pre-existing scenery, leave alone
    const mat = mesh.material;
    if (!mat) return;
    const cls = mat.getClassName();
    if (cls === 'PBRMaterial') {
      if (!envFallback) mat.environmentBRDFTexture = scene.environmentBRDFTexture;
    } else if (cls === 'StandardMaterial') {
      // Copy key StandardMaterial fields into a fresh PBRMaterial. This
      // preserves color + textures and gives the mesh consistent HDRI
      // reflections once the env texture is wired up.
      const pbr = new BABYLON.PBRMaterial(mat.name + '_pbr', scene);
      pbr.albedoColor = (mat.diffuseColor || BABYLON.Color3.White()).clone();
      if (mat.diffuseTexture) pbr.albedoTexture = mat.diffuseTexture;
      if (mat.emissiveColor) pbr.emissiveColor = mat.emissiveColor.clone();
      if (mat.alpha != null) pbr.alpha = mat.alpha;
      // shininess 0–128 → roughness 1–0. Flat Standard materials become
      // matte PBR; shiny ones become glossy. Rough heuristic, per §5b.
      const shin = (mat.specularPower != null) ? mat.specularPower : 64;
      pbr.roughness = Math.max(0.05, Math.min(1.0, 1 - shin / 128));
      pbr.metallic = 0;
      if (!envFallback) pbr.environmentBRDFTexture = scene.environmentBRDFTexture;
      mesh.material = pbr;
    }
  });
}

// ── §6d / fit — frame model to ~70% of viewport ─────────────────────
function fitCameraToScene() {
  const all = scene.meshes.filter(m =>
    m.name !== '__root__' && !m.name.startsWith('pivot-flash') &&
    m.isVisible && m.getTotalVertices && m.getTotalVertices() > 0);
  if (!all.length) return;
  let min = new BABYLON.Vector3( Infinity,  Infinity,  Infinity);
  let max = new BABYLON.Vector3(-Infinity, -Infinity, -Infinity);
  all.forEach(m => {
    const bb = m.getBoundingInfo().boundingBox;
    min = BABYLON.Vector3.Minimize(min, bb.minimumWorld);
    max = BABYLON.Vector3.Maximize(max, bb.maximumWorld);
  });
  const center = BABYLON.Vector3.Center(min, max);
  const diag = max.subtract(min).length();
  camera.setTarget(center);
  // §6d: initial radius = bounding sphere radius × 1.3. Half the
  // diagonal is the bounding-sphere radius; × 2.6 there lands the
  // camera far enough at FOV ~34° to frame ~70% of the viewport.
  const boundingRadius = Math.max(diag / 2, 1);
  camera.radius = boundingRadius * 2.6;
  // §8a axis-helper length + §6c pivot-sphere size scale off this.
  sceneExtentMm = Math.max(diag, 1);
  // §6b adaptive precision — scale wheel/pan/pinch inversely with scene
  // size so a small chip (R=10mm) and a full assembly (R=200mm) both
  // feel identical at the mouse. Canonical viewer pattern from
  // gallia/viewer/viewer/js/adom-3d-viewer.min.js.
  camera.wheelPrecision     = 100  / boundingRadius;
  camera.panningSensibility = 5000 / boundingRadius;
  camera.pinchPrecision     = 500  / boundingRadius;
  // Rebuild world axes + shadow ground at the new extent if already built.
  if (worldAxes) buildWorldAxes();
  if (shadowGround) resizeShadowGround();
}

// ── GLB load with meters→mm scale + Y-up→Z-up ──────────────────────
async function loadGlb() {
  // Parent all newly loaded meshes under a wrapper that:
  //   (a) scales by 1000 (glTF spec is 1 unit = 1 meter; we want mm like the rest of the Adom stack)
  //   (b) rotates to Z-up (Y-up → Z-up is `rotation.x = -π/2`; canonical viewer's applyGlbZUpTransform)
  const wrapper = new BABYLON.TransformNode('glb-wrapper', scene);
  wrapper.scaling = new BABYLON.Vector3(1000, 1000, 1000);
  // Do NOT pre-rotate. Babylon's glTF loader handles the Y-up→right-handed
  // flip itself when scene.useRightHandedSystem is true; we then orient
  // via the camera's upVector = (0,0,1). A manual rotation.x = ±π/2 here
  // puts the chip on-edge. The canonical viewer's applyGlbZUpTransform is
  // data-dependent (reads axis metadata from the source); for KiCad-exported
  // STEP→GLB via OCCT the identity works because OCCT writes Z-up meshes
  // that Babylon's loader lands in our Z-up scene correctly.

  const beforeIds = new Set(scene.meshes.map(m => m.uniqueId));
  await BABYLON.SceneLoader.AppendAsync('', 'model.glb', scene);
  scene.meshes.forEach(m => {
    if (beforeIds.has(m.uniqueId)) return;
    if (!m.parent) m.parent = wrapper;
  });
  // Force-update world matrices so fit-camera sees the transformed bounds.
  scene.meshes.forEach(m => m.computeWorldMatrix && m.computeWorldMatrix(true));
  fixupMaterials(beforeIds);
  addChipShadowCasters(beforeIds);
  fitCameraToScene();
  // §8b — attach a local-axis helper to every NEW root node so the
  // user can flip on "Show mesh origins" and instantly see each mesh's
  // local coordinate frame. Uses the GLB wrapper as the root so the
  // axes pick up our meters→mm scale + any future rotation.
  addMeshLocalAxes(wrapper);
}

// ── HUD metadata ────────────────────────────────────────────────────
async function init() {
  const meta = await (await fetch('meta')).json();
  const c = meta.conversion || {};
  document.getElementById('meta-source').textContent = shortLabel(meta.source || '');
  document.getElementById('meta-glb').textContent = shortLabel(meta.glbPath || '');
  document.getElementById('d-source').textContent = meta.source || '—';
  document.getElementById('d-glb').textContent = meta.glbPath || '—';
  document.getElementById('d-size').textContent = fmtBytes(c.sizeBytes);
  document.getElementById('d-duration').textContent = c.durationMs != null ? c.durationMs + ' ms' : '—';
  document.getElementById('d-meshes').textContent = c.meshes ?? '—';
  document.getElementById('d-nodes').textContent = c.nodes ?? '—';
  document.getElementById('p-meshes').textContent = c.meshes ?? '—';
  document.getElementById('p-materials').textContent = (c.materials && c.materials.length) ?? 0;
  document.getElementById('p-size').textContent = fmtBytes(c.sizeBytes);
  document.getElementById('p-duration').textContent = c.durationMs != null ? c.durationMs + 'ms' : '—';

  classifyPill('pill-meshes', (c.meshes || 0) > 0);
  classifyPill('pill-materials', (c.materials && c.materials.length) > 0);
  classifyPill('pill-size', (c.sizeBytes || 0) > 1000);
  classifyPill('pill-duration', (c.durationMs || 0) < 30000);

  const matList = document.getElementById('materials-list');
  matList.innerHTML = '';
  for (const m of (c.materials || [])) {
    const r = document.createElement('div'); r.className = 'mat-row';
    const sw = document.createElement('div'); sw.className = 'swatch';
    const bc = m.baseColor || [0.5, 0.5, 0.5, 1];
    sw.style.background = `rgb(${(bc[0]*255|0)}, ${(bc[1]*255|0)}, ${(bc[2]*255|0)})`;
    const info = document.createElement('div'); info.style.flex = '1';
    info.innerHTML = `<div class="mat-name">${m.name || '(unnamed)'}</div>` +
      `<div class="mat-details">rgb(${bc.slice(0,3).map(v=>v.toFixed(3)).join(', ')})` +
      (m.metal != null ? ` · metal=${m.metal}` : '') + `</div>`;
    r.appendChild(sw); r.appendChild(info);
    matList.appendChild(r);
  }
  if ((c.materials || []).length === 0) {
    matList.innerHTML = '<div style="color:var(--yellow)">⚠ no materials — the STEP may lack color assignments</div>';
  }

  // §8c corner triad — built once, always on.
  buildCornerTriad();

  await loadGlb();
  // §8a world-origin helper — default ON, built AFTER fit so length
  // scales to the loaded model's bounding sphere (15% of scene extent).
  buildWorldAxes();

  setView('iso');
}

function fmtBytes(n) { if (!n) return '—'; if (n < 1024) return n+' B'; if (n<1048576) return (n/1024).toFixed(1)+' KB'; return (n/1048576).toFixed(2)+' MB'; }
function classifyPill(id, ok) { const el = document.getElementById(id); if (!el) return; el.classList.remove('ok','warn','bad'); el.classList.add(ok ? 'ok' : 'bad'); }
function shortLabel(s) { return s.length > 48 ? '…' + s.slice(-45) : s; }
function toggleDrawer() { document.getElementById('drawer').classList.toggle('open'); document.getElementById('drawer-toggle').classList.toggle('active'); }
window.toggleDrawer = toggleDrawer;

// ── §11 bg preset toolbar ──────────────────────────────────────────
const BG_PRESETS = {
  // §3b: top brightened from #21262d to #3e4a5c per the 2026-04-24 field-feedback pass;
  // anything darker regresses black-chip / black-solder-mask visibility.
  studio: 'radial-gradient(ellipse 60% 40% at 50% 100%, rgba(0,184,177,0.04), transparent 70%), linear-gradient(180deg, #3e4a5c 0%, #0d1117 80%)',
  dark:   '#0d1117',
  light:  '#e6edf3',
};
function setBgPreset(name) {
  const css = BG_PRESETS[name] || BG_PRESETS.studio;
  document.getElementById('canvas-wrap').style.background = css;
  try { localStorage.setItem('step2glb-bg', name); } catch (_) {}
}
window.setBgPreset = setBgPreset;
(function restoreBg() {
  try {
    const saved = localStorage.getItem('step2glb-bg');
    if (saved && BG_PRESETS[saved]) {
      document.getElementById('bg-preset').value = saved;
      setBgPreset(saved);
    }
  } catch (_) {}
})();

// ── §9a window.Adom3DViewer global ─────────────────────────────────
window.Adom3DViewer = {
  get scene() { return scene; },
  get camera() { return camera; },
  setCamera({ alpha, beta, radius, targetX, targetY, targetZ }) {
    if (alpha  != null) camera.alpha  = alpha;
    if (beta   != null) camera.beta   = beta;
    if (radius != null) camera.radius = radius;
    if (targetX != null || targetY != null || targetZ != null) {
      camera.setTarget(new BABYLON.Vector3(
        targetX != null ? targetX : camera.target.x,
        targetY != null ? targetY : camera.target.y,
        targetZ != null ? targetZ : camera.target.z,
      ));
    }
  },
  setBottomLight,
  setBgPreset,
  viewPreset: setView,
  toggleAxes,
  recenterOrbit({ x, y, z }) {
    const ease = new BABYLON.CubicEase();
    ease.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT);
    BABYLON.Animation.CreateAndStartAnimation(
      'orbitRecenterMsg', camera, 'target', 60, 12,
      camera.target.clone(), new BABYLON.Vector3(x, y, z),
      BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT, ease);
    flashPivotMarker(new BABYLON.Vector3(x, y, z));
  },
  dumpMeshes() {
    return scene.meshes.filter(m => m.getTotalVertices && m.getTotalVertices() > 0)
      .map(m => ({ name: m.name, vertices: m.getTotalVertices(), material: m.material && m.material.name }));
  },
  BABYLON,
};

// ── §9b postMessage protocol ───────────────────────────────────────
window.addEventListener('message', (e) => {
  const d = e && e.data; if (!d || typeof d !== 'object') return;
  switch (d.type) {
    case 'set_camera':       window.Adom3DViewer.setCamera(d); break;
    case 'set_bottom_light': setBottomLight(!!d.enabled); break;
    case 'recenter_orbit':   window.Adom3DViewer.recenterOrbit(d); break;
    case 'view_preset':      setView(d.preset); break;
    case 'toggle_axes':      toggleAxes(d.target, d.enabled); break;  // §8e / §10b
    case 'tweak_scene':
      if (d.envIntensity != null) scene.environmentIntensity = d.envIntensity;
      if (d.hemiIntensity != null) hemi.intensity = d.hemiIntensity;
      break;
    case 'set_bg_preset':    setBgPreset(d.preset); break;
  }
});

engine.runRenderLoop(() => scene.render());

// ── Resize handling — grid intrinsic-sizing bug fix, as in chipfit ─
(function () {
  const app = document.querySelector('.app');
  let pending = false;
  const sync = () => {
    const h = (window.visualViewport && window.visualViewport.height) || window.innerHeight;
    const w = (window.visualViewport && window.visualViewport.width)  || window.innerWidth;
    app.style.height = h + 'px'; app.style.maxHeight = h + 'px';
    app.style.width  = w + 'px'; app.style.maxWidth  = w + 'px';
  };
  const schedule = () => {
    if (pending) return; pending = true;
    requestAnimationFrame(() => { pending = false; sync(); engine.resize(); requestAnimationFrame(() => engine.resize()); });
  };
  sync();
  window.addEventListener('resize', schedule);
  if (window.visualViewport) {
    window.visualViewport.addEventListener('resize', schedule);
    window.visualViewport.addEventListener('scroll', schedule);
  }
  new ResizeObserver(schedule).observe(document.getElementById('canvas-wrap'));
  let lw = window.innerWidth, lh = window.innerHeight;
  setInterval(() => {
    if (window.innerWidth !== lw || window.innerHeight !== lh) { lw = window.innerWidth; lh = window.innerHeight; schedule(); }
  }, 1000);
})();

init();
</script>
</body>
</html>