app
Adom Step2GLB - STEP to GLB converter
Public Made by Adomby adom
Color-preserving STEP (.step/.stp) to GLB converter. Thin Rust CLI shelling to a shared OCCT XCAF service. Formerly 'step2glb'.
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>