app
Adom 3D Viewer
Public Made by Adomby adom
The Babylon 9.5 engine behind every component page's 3D tab on wiki.adom.inc. Versioned ESM bundle with GLB loading, view cube, layers toolbar, ground shadows, and Z-up CAD framing.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { SceneLoader } from '@babylonjs/core/Loading/sceneLoader';
import '@babylonjs/loaders/glTF';
import { Vector3 } from '@babylonjs/core/Maths/math.vector';
import { Color3 } from '@babylonjs/core/Maths/math.color';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
import type { Mesh } from '@babylonjs/core/Meshes/mesh';
import { AdomStatics } from './lib/adom-babylon/AdomStatics';
import { SceneBuilder } from './lib/adom-babylon/3DViewer/SceneBuilder';
import * as AxisHelpers from './lib/adom-babylon/3DViewer/AxisHelpers';
import { ResizableLoadingScreen } from './lib/adom-babylon/ui/ResizableLoadingScreen';
import type { ViewCubeUICallbacks } from './lib/adom-babylon/3DViewer/ViewCubeBuilder';
import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh';
import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
import { type Scene, type Engine, type Camera, type ShadowGenerator, PBRMetallicRoughnessMaterial } from '@babylonjs/core';
export let zUp: boolean = true;
export let modelUrl: string | undefined = undefined;
export let showViewCube: boolean = true;
export let showGround: boolean = false;
export let environmentUrl: string | undefined = undefined;
/** Initial camera projection. Applied once when the viewer is ready. Use setProjectionMode() to change at runtime. */
export let initialViewMode: 'perspective' | 'orthographic' = 'perspective';
let container: HTMLDivElement;
let canvas: HTMLCanvasElement;
let resizeObserver: ResizeObserver | null = null;
let loadedRootNodes: TransformNode[] = [];
/** User-added roots (e.g. native Babylon meshes) included in framing. */
let contentRoots: TransformNode[] = [];
let groundMesh: Mesh | null = null;
let isOrtho: boolean = false;
// Tooltip / dropdown state driven by ViewCube callbacks
let tooltipText: string | null = null;
let tooltipX = 0;
let tooltipY = 0;
let dropdownOpen = false;
let dropdownX = 0;
let dropdownY = 0;
let dropdownOpenedAt = 0;
// Mesh hover tooltips (#35): glTF extras.tooltip on the mesh/node wins,
// meaningful mesh names are the fallback. Junk auto-generated names show
// nothing so generic models don't sprout labels on every surface.
let meshTipText: string | null = null;
let meshTipX = 0;
let meshTipY = 0;
let meshTipFlip = false;
let lastMeshPick = 0;
const LAYER_LABELS: Record<string, string> = {
fr4_board: 'FR4 board',
pad_top: 'Pads (top)',
pad_bottom: 'Pads (bottom)',
solder_top: 'Solder (top)',
solder_bottom: 'Solder (bottom)',
paste_top: 'Paste (top)',
paste_bottom: 'Paste (bottom)',
silk: 'Silkscreen',
silk_top: 'Silkscreen (top)',
silk_bottom: 'Silkscreen (bottom)',
barrel: 'Plated barrel',
pin1_marker: 'Pin 1',
};
const JUNK_NAME = /^(__|mesh|node|object|primitive|instance|clone)[\w.]*$|_primitive\d+$/i;
function tooltipForMesh(mesh: AbstractMesh): string | null {
// extras.tooltip may sit on the picked mesh or an ancestor node
// (Babylon splits multi-material prims into child meshes).
let n: any = mesh;
for (let depth = 0; n && depth < 6; depth++) {
const tip = n.metadata?.gltf?.extras?.tooltip;
if (typeof tip === 'string' && tip.trim()) return tip;
if (n.name === '__root__') break;
n = n.parent;
}
let name = mesh.name;
if (JUNK_NAME.test(name) && mesh.parent) name = mesh.parent.name;
if (!name || JUNK_NAME.test(name)) return null;
// Only humanly meaningful names: at least two letters, or a refdes
// (J10, C42). Exporter artifacts like "=>[0:1:1:40]" show nothing.
if (!/[a-z]{2,}/i.test(name) && !/^[a-z]{1,3}\d{1,4}$/i.test(name)) return null;
return LAYER_LABELS[name.toLowerCase()] || name.replace(/_+/g, ' ');
}
function belongsToContent(mesh: AbstractMesh): boolean {
const roots = new Set<any>([...loadedRootNodes, ...contentRoots]);
let cur: any = mesh;
while (cur) {
if (roots.has(cur)) return true;
cur = cur.parent;
}
return false;
}
function onPointerMoveTooltip(e: PointerEvent): void {
const scene = AdomStatics.scene;
if (!scene || !container) return;
// No tooltip while dragging/orbiting or while placing an orbit pivot.
if (e.buttons !== 0 || pivotChordHeld) {
meshTipText = null;
return;
}
const now = performance.now();
if (now - lastMeshPick < 50) return;
lastMeshPick = now;
const pick = scene.pick(scene.pointerX, scene.pointerY, (m) => m.isPickable && m.isVisible);
if (pick?.hit && pick.pickedMesh && belongsToContent(pick.pickedMesh)) {
const tip = tooltipForMesh(pick.pickedMesh);
if (tip) {
const rect = container.getBoundingClientRect();
meshTipText = tip;
meshTipX = e.clientX - rect.left;
meshTipY = e.clientY - rect.top;
meshTipFlip = meshTipX > rect.width - 220;
return;
}
}
meshTipText = null;
}
function onPointerLeaveCanvas(): void {
meshTipText = null;
}
// Layers toolbar (#48): GLBs whose meshes/groups follow the naming
// convention (docs/CONTENT-CONVENTIONS.md) get a slide-out panel with
// per-layer visibility toggles. Solder layers start hidden; everything
// else starts visible. Detection happens after each loadModel.
const LAYER_ORDER = [
'fr4_board', 'pad_top', 'pad_bottom', 'solder_top', 'solder_bottom',
'paste_top', 'paste_bottom', 'silk', 'silk_top', 'silk_bottom',
'barrel', 'pin1_marker',
];
const LAYERS_DEFAULT_OFF = new Set(['solder_top', 'solder_bottom', 'paste_top', 'paste_bottom']);
let layers: { name: string; label: string; visible: boolean; meshes: AbstractMesh[] }[] = [];
let layersPanelOpen = false;
function layerNameFor(node: any): string | null {
const n = (node.name || '').toLowerCase();
for (const key of LAYER_ORDER) {
if (n === key || n.startsWith(key + '_') || n.startsWith(key + '.')) return key;
}
return null;
}
function detectLayers(): void {
const found = new Map<string, AbstractMesh[]>();
for (const root of loadedRootNodes) {
const visit = (node: any) => {
const layer = layerNameFor(node);
if (layer) {
const meshes: AbstractMesh[] = [];
if (node.getTotalVertices?.() > 0) meshes.push(node);
if (node.getChildMeshes) meshes.push(...node.getChildMeshes(false).filter((m: AbstractMesh) => m.getTotalVertices && m.getTotalVertices() > 0));
if (meshes.length) {
found.set(layer, [...(found.get(layer) || []), ...meshes]);
return; // children are covered by this layer
}
}
(node.getChildren?.() || []).forEach(visit);
};
visit(root);
}
layers = LAYER_ORDER.filter(k => found.has(k)).map(name => {
const visible = !LAYERS_DEFAULT_OFF.has(name);
const meshes = found.get(name)!;
meshes.forEach(m => { m.isVisible = visible; });
return { name, label: LAYER_LABELS[name] || name.replace(/_+/g, ' '), visible, meshes };
});
if (layers.length < 2) layers = [];
if (layers.length === 0) layersPanelOpen = false;
}
export function getLayers(): { name: string; label: string; visible: boolean }[] {
return layers.map(l => ({ name: l.name, label: l.label, visible: l.visible }));
}
export function setLayerVisible(name: string, visible: boolean): boolean {
const layer = layers.find(l => l.name === name);
if (!layer) return false;
layer.visible = visible;
layer.meshes.forEach(m => { m.isVisible = visible; });
layers = layers; // trigger svelte reactivity
AdomStatics.refreshShadows();
return true;
}
function toggleLayerRow(name: string): void {
const layer = layers.find(l => l.name === name);
if (layer) setLayerVisible(name, !layer.visible);
}
// Orbit-center discoverability (#41): live indicator while shift+alt is
// held, plus a one-shot first-visit hint after the first model loads.
let pivotChordHeld = false;
let pivotHintVisible = false;
let pivotHintTimer: ReturnType<typeof setTimeout> | null = null;
function safeStorageGet(key: string): string | null {
try { return window.localStorage.getItem(key); } catch (e) { return null; }
}
function safeStorageSet(key: string, value: string): void {
try { window.localStorage.setItem(key, value); } catch (e) { /* sandboxed */ }
}
function updatePivotChord(e: KeyboardEvent): void {
const held = e.shiftKey && e.altKey;
if (held !== pivotChordHeld) {
pivotChordHeld = held;
if (held) meshTipText = null;
}
}
function maybeShowPivotHint(): void {
if (safeStorageGet('adom_viewer_pivot_hint_seen') === '1') return;
safeStorageSet('adom_viewer_pivot_hint_seen', '1');
pivotHintVisible = true;
if (pivotHintTimer) clearTimeout(pivotHintTimer);
pivotHintTimer = setTimeout(() => { pivotHintVisible = false; }, 8000);
}
function onCanvasPointerDown(e: PointerEvent): void {
if (e.shiftKey && e.altKey) {
pivotHintVisible = false;
}
}
const viewCubeCallbacks: ViewCubeUICallbacks = {
onTooltip(text, x, y) {
tooltipText = text;
tooltipX = x;
tooltipY = y;
},
onHomeClick() {
goHome();
},
onFitClick() {
frameModel();
},
onViewModeClick(x, y) {
tooltipText = null;
dropdownX = x;
dropdownY = y;
dropdownOpen = !dropdownOpen;
if (dropdownOpen) dropdownOpenedAt = Date.now();
}
};
function selectProjection(ortho: boolean) {
setProjectionMode(ortho);
dropdownOpen = false;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && dropdownOpen) {
dropdownOpen = false;
}
updatePivotChord(e);
}
function onKeyup(e: KeyboardEvent) {
updatePivotChord(e);
}
function onWindowBlur() {
pivotChordHeld = false;
}
function onWindowClick(e: MouseEvent) {
// Skip clicks that happen within 300ms of opening (the same
// pointerdown that opened the dropdown also fires a click)
if (dropdownOpen && Date.now() - dropdownOpenedAt > 300) {
const target = e.target as HTMLElement;
if (!target.closest('.vcube-dropdown')) {
dropdownOpen = false;
}
}
}
async function initViewer() {
if (!canvas || !container) return;
await AdomStatics.init(canvas, zUp);
const engine = AdomStatics.engine!;
const scene = AdomStatics.scene!;
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
const loadingScreen = new ResizableLoadingScreen(canvas, 'Loading...', '#bfbfbf');
engine.loadingScreen = loadingScreen;
engine.displayLoadingUI();
await SceneBuilder.init(engine, { zUp, showViewCube, environmentUrl, viewCubeCallbacks });
scene.executeWhenReady(async () => {
engine.runRenderLoop(() => scene.render());
const cam = AdomStatics.camera;
if (cam && initialViewMode === 'orthographic' && !cam.isOrthographic()) {
cam.toggleCameraMode();
isOrtho = true;
} else if (cam) {
isOrtho = cam.isOrthographic();
}
if (modelUrl) {
await loadModel(modelUrl);
}
engine.hideLoadingUI();
canvas.addEventListener('dblclick', onDoubleClick);
canvas.addEventListener('pointermove', onPointerMoveTooltip);
canvas.addEventListener('pointerleave', onPointerLeaveCanvas);
canvas.addEventListener('pointerdown', onCanvasPointerDown);
});
resizeObserver = new ResizeObserver(() => {
const eng = AdomStatics.engine;
const sc = AdomStatics.scene;
if (canvas && eng) {
const cont = canvas.parentElement;
if (cont) {
canvas.width = cont.clientWidth;
canvas.height = cont.clientHeight;
eng.resize();
sc?.render();
}
}
});
resizeObserver.observe(container);
}
export async function loadModel(url: string): Promise<void> {
const scene = AdomStatics.scene;
if (!scene) return;
const shadowGenerator = AdomStatics.shadowGenerator;
const lastSlash = url.lastIndexOf('/');
const rootUrl = lastSlash >= 0 ? url.substring(0, lastSlash + 1) : '';
const sceneFilename = lastSlash >= 0 ? url.substring(lastSlash + 1) : url;
// Detect file type for Babylon.js plugin selection.
// URLs without a file extension (e.g. /api/export/glb?name=...)
// need an explicit pluginExtension hint so Babylon.js uses the right loader.
let pluginExt: string | undefined;
const cleanPath = url.split('?')[0].split('#')[0];
if (!cleanPath.match(/\.(glb|gltf|obj|stl|ply|splat)$/i)) {
// Default to GLB for extensionless URLs (most common in our ecosystem)
pluginExt = '.glb';
}
const result = await SceneLoader.ImportMeshAsync('', rootUrl, sceneFilename, scene, undefined, pluginExt);
maybeShowPivotHint();
// EXT_mesh_gpu_instancing (gltf-transform optimize's default) loads as
// thin instances and currently mis-renders here (one giant mis-scaled
// instance). Until the handedness/zUp transform chain supports it,
// detect and warn loudly so authors know to rebuild the GLB.
const instanced = result.meshes.filter((m: any) => (m.thinInstanceCount || 0) > 0);
if (instanced.length > 0) {
console.warn(
'[adom-3d-viewer] This GLB uses EXT_mesh_gpu_instancing (' + instanced.length +
' instanced mesh' + (instanced.length === 1 ? '' : 'es') + '), which mis-renders in this ' +
'viewer as giant mis-scaled geometry. Rebuild the GLB without GPU instancing, e.g. ' +
'gltf-transform optimize --instance false. Meshes: ' +
instanced.slice(0, 5).map((m: any) => m.name).join(', ') + (instanced.length > 5 ? ', ...' : '')
);
}
const root = result.meshes[0];
if (root) {
loadedRootNodes.push(root);
result.meshes.forEach((m: AbstractMesh) => {
m.receiveShadows = true;
if (shadowGenerator && m.getTotalVertices && m.getTotalVertices() > 0) {
shadowGenerator.addShadowCaster(m);
}
});
AdomStatics.refreshShadows();
detectLayers();
mitigateCoplanarFighting(result.meshes);
frameModel();
}
}
/**
* PCB exports stack the board as nested shells (substrate, mask, copper,
* silk) separated by tens of microns, which z-fight at normal zoom.
* Detect AABB-nested meshes and give inner shells' materials a polygon
* offset (zOffsetUnits), separating them by whole depth-buffer quanta at
* any camera distance. A few quanta is visually nothing for genuinely
* separated geometry, so this is safe to apply generically.
*/
function mitigateCoplanarFighting(meshes: AbstractMesh[]): void {
const items = meshes
.filter(m => m.getTotalVertices && m.getTotalVertices() > 0)
.map(m => {
m.computeWorldMatrix(true);
const bb = m.getBoundingInfo().boundingBox;
const min = bb.minimumWorld, max = bb.maximumWorld;
// Sort by XY footprint, not volume: a thin full-board shell
// has tiny volume but is the CONTAINER of the chunky meshes
// sitting on it (pads/pins), and containment checks only run
// against earlier (larger) entries.
return { m, min, max, area: (max.x - min.x) * (max.y - min.y) };
})
.sort((a, b) => b.area - a.area);
const tier = new Map<AbstractMesh, number>();
const pokers = new Set<AbstractMesh>();
for (let i = 1; i < items.length; i++) {
for (let j = 0; j < i; j++) {
const A = items[j], B = items[i];
const ex = (A.max.x - A.min.x) * 0.02 + 1e-9;
const ey = (A.max.y - A.min.y) * 0.02 + 1e-9;
const ezA = (A.max.z - A.min.z) * 0.25 + 1e-9;
if (B.min.x >= A.min.x - ex && B.max.x <= A.max.x + ex &&
B.min.y >= A.min.y - ey && B.max.y <= A.max.y + ey &&
B.min.z <= A.max.z && B.max.z >= A.min.z) {
if (B.max.z <= A.max.z + ezA) {
// True nested layer (inset shell): render in front of
// its container so stacked layers can't z-fight.
tier.set(B.m, Math.max(tier.get(B.m) || 0, (tier.get(A.m) || 0) + 1));
} else {
// Pokes far above its container (pins, pads mesh,
// component bodies): push BEHIND depth ties instead.
// Where it should be visible it is either grossly
// closer (pin shafts) or exposed through real mask
// openings that have no covering geometry at all, so
// losing exact ties to the shells is the physically
// correct outcome (mask covers pour).
pokers.add(B.m);
}
}
}
}
// Offset per material; a shared material takes its deepest layer
// tier, and layer status wins over poker status.
const matTier = new Map<any, number>();
for (const [m, t] of tier) {
if (m.material) matTier.set(m.material, Math.max(matTier.get(m.material) || 0, t));
}
for (const [mat, t] of matTier) {
mat.zOffsetUnits = -2 * t;
}
for (const m of pokers) {
if (m.material && !matTier.has(m.material)) m.material.zOffsetUnits = 2;
}
}
export function clearScene(): void {
loadedRootNodes.forEach((node) => node.dispose());
loadedRootNodes = [];
layers = [];
layersPanelOpen = false;
AdomStatics.refreshShadows();
AdomStatics.camera?.disableBounds();
}
/** Babylon scene — use to create/modify meshes (e.g. MeshBuilder.CreateSphere). */
export function getScene(): Scene | null {
return AdomStatics.scene;
}
/** Babylon engine (resize, etc.). */
export function getEngine(): Engine | null {
return AdomStatics.engine;
}
/** Active camera (orbit/pan/zoom). */
export function getCamera(): Camera | null {
return AdomStatics.camera ?? null;
}
/** Shadow generator — call addShadowCaster(mesh) for custom meshes that should cast shadows. */
export function getShadowGenerator(): ShadowGenerator | null {
return AdomStatics.shadowGenerator;
}
/**
* Register a root (e.g. parent of spheres) so it is included in frameModel() and optional shadow setup.
* Does not dispose the node on clearScene(); remove with removeContentRoot or dispose yourself.
*/
export function addContentRoot(node: TransformNode, options?: { castShadows?: boolean; receiveShadows?: boolean }): void {
if (contentRoots.includes(node)) return;
contentRoots.push(node);
const sg = AdomStatics.shadowGenerator;
const cast = options?.castShadows !== false;
const receive = options?.receiveShadows !== false;
const visit = (m: AbstractMesh) => {
if (receive) m.receiveShadows = true;
if (cast && sg && m.getTotalVertices?.() && m.getTotalVertices() > 0) sg.addShadowCaster(m);
};
if ((node as AbstractMesh).getTotalVertices?.() && (node as AbstractMesh).getTotalVertices()! > 0) {
visit(node as AbstractMesh);
}
if ('getChildMeshes' in node) {
(node as AbstractMesh).getChildMeshes(false).forEach(visit);
}
AdomStatics.refreshShadows();
}
/** Unregister a content root from framing (does not dispose the node). */
export function removeContentRoot(node: TransformNode): void {
const i = contentRoots.indexOf(node);
if (i >= 0) contentRoots.splice(i, 1);
AdomStatics.refreshShadows();
}
/** Show or hide the ground plane at runtime. Re-runs frameModel to create/remove it. */
export function setGroundVisible(visible: boolean): void {
showGround = visible;
if (loadedRootNodes.length > 0 || contentRoots.length > 0) {
frameModel();
} else if (!visible && groundMesh) {
groundMesh.dispose();
groundMesh = null;
}
}
/** Toggle the bottom-fill HemisphericLight. Off by default — flip on
* when inspecting BGA balls / QFN pads / through-hole leg bottoms.
* Intensity 0.6 when on (matches the top hemisphere's perceived
* brightness once spotlight contribution is added). */
export function setBottomLight(on: boolean): void {
const bl = AdomStatics.bottomLight;
if (bl) bl.intensity = on ? 0.6 : 0;
}
/** Returns the current bottom-light state (true == on). */
export function getBottomLightState(): boolean {
const bl = AdomStatics.bottomLight;
return !!(bl && bl.intensity > 0);
}
/** Jump camera to the home view (front-top-right, like the view cube corner) and re-frame the model. No animation. */
export function goHome(): void {
const cam = AdomStatics.camera;
if (!cam) return;
cam.rotateToCubeTopFrontRight(0);
frameModel();
}
/**
* 3d-viewer-design §8 axis-helper API.
* target: 'world' | 'mesh-local' — corner triad is always on (no toggle)
* enabled: undefined → toggle current state; bool → set explicitly
* Returns the new state.
*/
export function toggleAxes(target: 'world' | 'mesh-local', enabled?: boolean): boolean {
const scene = AdomStatics.scene;
if (!scene) return false;
if (target === 'world') {
const next = enabled !== undefined ? enabled : !AxisHelpers.isWorldAxesVisible(scene);
AxisHelpers.setWorldAxesVisible(scene, next);
return next;
}
if (target === 'mesh-local') {
const next = enabled !== undefined ? enabled : !AxisHelpers.isMeshLocalAxesVisible(scene);
AxisHelpers.setMeshLocalAxesVisible(scene, next);
// Auto-attach a local-axes helper to every loaded root that
// doesn't already have one (safe for §8b lazy-init).
for (const root of loadedRootNodes) {
AxisHelpers.addMeshLocalAxes(scene, root);
}
return next;
}
return false;
}
export function getAxesState(): { world: boolean; meshLocal: boolean } {
const scene = AdomStatics.scene;
if (!scene) return { world: false, meshLocal: false };
return {
world: AxisHelpers.isWorldAxesVisible(scene),
meshLocal: AxisHelpers.isMeshLocalAxesVisible(scene),
};
}
/** Set camera projection to perspective or orthographic. */
export function setProjectionMode(ortho: boolean): void {
const cam = AdomStatics.camera;
if (!cam) return;
const currentlyOrtho = cam.isOrthographic();
if (currentlyOrtho !== ortho) {
cam.toggleCameraMode();
isOrtho = cam.isOrthographic();
}
}
/** Current projection: `'orthographic'` or `'perspective'`. */
export function getProjectionMode(): 'perspective' | 'orthographic' {
return AdomStatics.camera?.isOrthographic() ? 'orthographic' : 'perspective';
}
/**
* Double-click handler: pick the mesh, compute its bounds,
* calculate optimal camera distance, and animate to it via moveCamTo.
*/
function onDoubleClick(): void {
const scene = AdomStatics.scene;
const camera = AdomStatics.camera;
if (!scene || !camera) return;
const pickInfo = scene.pick(scene.pointerX, scene.pointerY);
if (!pickInfo?.hit || !pickInfo.pickedMesh) return;
const mesh = pickInfo.pickedMesh;
// Only zoom to meshes that belong to a known model or content root
const allRoots = new Set<TransformNode>([...loadedRootNodes, ...contentRoots]);
const loadedSet = new Set<TransformNode>(loadedRootNodes);
let root: TransformNode | null = null;
let current: TransformNode | null = mesh;
while (current) {
if (allRoots.has(current)) { root = current; break; }
current = (current.parent as TransformNode | null) ?? null;
}
if (!root) return;
// For GLB models: zoom to the __root__ node (visual meshes).
// For content roots (native shapes): zoom to the individual clicked mesh.
let targetNode: TransformNode;
if (loadedSet.has(root)) {
const rootChild = root.getChildren().find(c => c.name === '__root__');
targetNode = (rootChild as TransformNode) ?? root;
} else {
targetNode = mesh;
}
// Collect visible meshes under the target
const meshes: AbstractMesh[] = [];
if ('getChildMeshes' in targetNode) {
meshes.push(...(targetNode as AbstractMesh).getChildMeshes(false).filter(
m => m.isVisible && m.getTotalVertices && m.getTotalVertices() > 0
));
}
if ((targetNode as AbstractMesh).getTotalVertices?.() > 0) {
meshes.push(targetNode as AbstractMesh);
}
if (meshes.length === 0) return;
// Compute world bounds
let worldMin = new Vector3(Infinity, Infinity, Infinity);
let worldMax = new Vector3(-Infinity, -Infinity, -Infinity);
for (const m of meshes) {
m.computeWorldMatrix(true);
const bb = m.getBoundingInfo().boundingBox;
worldMin = Vector3.Minimize(worldMin, bb.minimumWorld);
worldMax = Vector3.Maximize(worldMax, bb.maximumWorld);
}
if (!isFinite(worldMin.x)) return;
const center = Vector3.Center(worldMin, worldMax);
const sizeVec = worldMax.subtract(worldMin);
const diag = sizeVec.length();
if (diag === 0) return;
// Calculate optimal camera distance to fill ~80% of the viewport
const fov = camera.fov || 0.8;
const aspect = (AdomStatics.engine?.getAspectRatio(camera) ?? 1);
const hFov = 2 * Math.atan(Math.tan(fov / 2) * aspect);
const effectiveFov = Math.min(fov, hFov);
const radius = (diag / 2) / Math.tan(effectiveFov / 2) / 0.8;
// Animate to the target using moveCamTo (keeps current alpha/beta)
const pos = camera.position.clone();
camera.moveCamTo(
pos.x, pos.y, pos.z,
center.x, center.y, center.z,
radius,
camera.alpha,
camera.beta,
60, 50
);
}
/**
* Frame the camera so the given roots (or all loaded/content roots) fill the viewport.
* Adapts clip planes, zoom limits, interaction speeds, light frustum, and ground to the model's scale.
* @param fillFraction Multiplier on zoomOn radius (1 = fills viewport, >1 = more zoomed out). Default 1.
* @param rootsToFrame When provided, frame only these roots. Otherwise frame all.
*/
export function frameModel(fillFraction: number = 1, rootsToFrame?: TransformNode[]): void {
const scene = AdomStatics.scene;
const camera = AdomStatics.camera;
const roots = rootsToFrame ?? [...loadedRootNodes, ...contentRoots];
if (!scene || !camera || roots.length === 0) return;
const meshes: AbstractMesh[] = [];
for (const root of roots) {
if ('getChildMeshes' in root) {
meshes.push(...(root as AbstractMesh).getChildMeshes(false));
}
if ((root as AbstractMesh).getTotalVertices?.() > 0) {
meshes.push(root as AbstractMesh);
}
}
const visible = meshes.filter(m =>
m.isVisible && m.getTotalVertices && m.getTotalVertices() > 0
);
if (visible.length === 0) return;
let worldMin = new Vector3(Infinity, Infinity, Infinity);
let worldMax = new Vector3(-Infinity, -Infinity, -Infinity);
for (const m of visible) {
m.computeWorldMatrix(true);
const bi = m.getBoundingInfo();
worldMin = Vector3.Minimize(worldMin, bi.boundingBox.minimumWorld);
worldMax = Vector3.Maximize(worldMax, bi.boundingBox.maximumWorld);
}
if (!isFinite(worldMin.x)) return;
const extent = worldMax.subtract(worldMin);
const modelSize = extent.length();
if (modelSize === 0) return;
camera.lowerRadiusLimit = 0;
camera.upperRadiusLimit = Infinity;
camera.zoomOn(visible, true);
camera.radius *= fillFraction;
const radius = camera.radius;
const halfSize = modelSize / 2;
const center = Vector3.Center(worldMin, worldMax);
camera.minZ = Math.max(halfSize * 0.01, 0.01);
// No skybox floor here anymore: the background is a screen-space
// layer with no depth, so the far plane only has to cover the model
// and max zoom-out. Keeping it model-scaled preserves depth precision
// (the old 7500 floor with a meter-scale near plane was #45).
camera.maxZ = radius * 20;
camera.panningSensibility = 5000 / halfSize;
const pad = 0.2;
camera.setTargetBounds(
worldMin.x - extent.x * pad, worldMax.x + extent.x * pad,
worldMin.y - extent.y * pad, worldMax.y + extent.y * pad,
worldMin.z - extent.z * pad, worldMax.z + extent.z * pad
);
camera.upperRadiusLimit = radius / 0.3;
camera.lowerRadiusLimit = halfSize * 0.01;
// 3d-viewer-design §8a — world-origin axis helper sized to 15% of
// the current scene radius (modelSize/2). Recomputed on every
// frameModel so it scales with the user's framing.
AxisHelpers.refreshAxisScale(scene, halfSize);
// Reposition SpotLight proportional to model size, angled from front-left.
const spotLight = AdomStatics.spotLight;
if (spotLight) {
const spotHeight = modelSize * 3;
const lateralOffset = modelSize * 1.2;
if (zUp) {
spotLight.position = new Vector3(
center.x + lateralOffset,
center.y + lateralOffset ,
center.z + spotHeight
);
} else {
spotLight.position = new Vector3(
center.x + lateralOffset,
center.y + spotHeight,
center.z - lateralOffset * 0.5
);
}
spotLight.direction = center.subtract(spotLight.position).normalize();
// Scale intensity with the square of distance (inverse-square falloff).
// Hydrogen reference: 2M intensity at height 625 for ~200-unit models.
const hydrogenRef = 625;
const scale = (spotHeight / hydrogenRef);
spotLight.intensity = 2_000_000 * scale * scale;
spotLight.shadowMinZ = spotHeight * 0.15;
spotLight.shadowMaxZ = spotHeight * 3.2;
}
if (showGround) {
const groundSize = modelSize * 4;
if (groundMesh) groundMesh.dispose();
groundMesh = MeshBuilder.CreateGround('shadowGround', { width: groundSize, height: groundSize }, scene);
groundMesh.receiveShadows = true;
const mat = new PBRMetallicRoughnessMaterial('groundMat', scene);
mat.baseColor = new Color3(0.15, 0.15, 0.15);
mat.metallic = 0.0;
mat.roughness = 0.8;
mat.backFaceCulling = false;
// ~20% opacity: the plane still reads as a ground reference from
// above, but undersides (pads, castellations, thermal pads, pin-1
// marks) stay visible when orbiting below the model.
mat.alpha = 0.2;
// Radial alpha fade (solid center, transparent rim): at grazing
// angles the square plane's hard edge otherwise draws a visible
// horizon line across the background gradient.
const fadeTex = new DynamicTexture('groundFadeTex', { width: 256, height: 256 }, scene, false);
const fadeCtx = fadeTex.getContext();
fadeCtx.clearRect(0, 0, 256, 256);
const fade = fadeCtx.createRadialGradient(128, 128, 0, 128, 128, 128);
fade.addColorStop(0, 'rgba(255,255,255,1)');
fade.addColorStop(0.5, 'rgba(255,255,255,1)');
fade.addColorStop(1, 'rgba(255,255,255,0)');
fadeCtx.fillStyle = fade;
fadeCtx.fillRect(0, 0, 256, 256);
fadeTex.update();
fadeTex.hasAlpha = true;
mat.baseTexture = fadeTex;
mat.transparencyMode = 2; // ALPHABLEND: final alpha = texture.a * mat.alpha
groundMesh.material = mat;
groundMesh.isPickable = false;
if (zUp) {
groundMesh.rotation.x = Math.PI / 2;
groundMesh.position = new Vector3(center.x, center.y, worldMin.z);
} else {
groundMesh.position = new Vector3(center.x, worldMin.y, center.z);
}
} else if (groundMesh) {
groundMesh.dispose();
groundMesh = null;
}
// Register the model as shadow casters HERE (not only in loadModel).
// loadModel can run before SceneBuilder.init has created the shadow
// generator (the standalone init() returns synchronously while the
// component initializes async), so its caster loop is skipped and the
// model casts nothing. frameModel always runs after init is complete
// and on every fit/home, so it's the reliable place. The renderList
// index guard keeps it idempotent across re-frames.
const sg = AdomStatics.shadowGenerator;
if (sg) {
const sm = sg.getShadowMap();
for (const m of visible) {
m.receiveShadows = true;
if (sm && sm.renderList && sm.renderList.indexOf(m) === -1) {
sm.renderList.push(m);
}
}
}
AdomStatics.refreshShadows();
}
/** Show the Babylon Inspector. Lazy-imported so it lands in its own
* module scope (avoids "object is not extensible" frozen-namespace
* errors that happen with static import in our ESM build).
*
* Also suppresses Babylon Inspector v9's "first-time-user" teaching
* popovers — they pop up next to every toolbar button (Extensions,
* Translate, Rotate, Scale, etc.) on every page load with no
* persistent dismissal state. Adom users (and other expert users)
* have seen them many times and don't need re-onboarding each time
* they open the inspector. We zero them out via a global CSS rule
* the first time showDebugLayer fires; the rule covers Fluent UI's
* TeachingPopoverSurface class, which is what the inspector uses. */
function suppressInspectorTeachingPopovers(): void {
if (document.getElementById('__adom_kill_teaching_popovers__')) return;
const style = document.createElement('style');
style.id = '__adom_kill_teaching_popovers__';
style.textContent =
// Fluent UI v9 TeachingPopover surface — the actual onboarding bubble.
'.fui-TeachingPopoverSurface{display:none !important;}\n' +
// Defensive: also hide any teaching-popover trigger badge that may
// glow / pulse to draw attention to the toolbar button itself.
'.fui-TeachingPopover__indicator{display:none !important;}\n';
document.head.appendChild(style);
}
export async function showDebugLayer(): Promise<void> {
const scene = AdomStatics.scene;
if (!scene) return;
await import('@babylonjs/inspector');
suppressInspectorTeachingPopovers();
await scene.debugLayer.show({ embedMode: true, overlay: true });
}
/** Hide the Inspector. */
export function hideDebugLayer(): void {
AdomStatics.scene?.debugLayer.hide();
}
/** Toggle the Inspector visibility. */
export async function toggleDebugLayer(): Promise<void> {
const scene = AdomStatics.scene;
if (!scene) return;
if (scene.debugLayer.isVisible()) {
hideDebugLayer();
} else {
await showDebugLayer();
}
}
onMount(() => {
initViewer();
});
onDestroy(() => {
canvas?.removeEventListener('dblclick', onDoubleClick);
canvas?.removeEventListener('pointermove', onPointerMoveTooltip);
canvas?.removeEventListener('pointerleave', onPointerLeaveCanvas);
canvas?.removeEventListener('pointerdown', onCanvasPointerDown);
if (pivotHintTimer) clearTimeout(pivotHintTimer);
resizeObserver?.disconnect();
AdomStatics.engine?.stopRenderLoop();
AdomStatics.dispose();
});
</script>
<svelte:window on:keydown={onKeydown} on:keyup={onKeyup} on:blur={onWindowBlur} on:click={onWindowClick} />
<div class="viewer-container" class:pivot-mode={pivotChordHeld} bind:this={container}>
<canvas bind:this={canvas}></canvas>
{#if meshTipText && !tooltipText && !dropdownOpen}
<div
class="mesh-tooltip"
class:flip={meshTipFlip}
style="left: {meshTipX + 14}px; top: {meshTipY + 16}px;"
>
{meshTipText}
</div>
{/if}
{#if pivotChordHeld || pivotHintVisible}
<div class="pivot-chip">
{pivotChordHeld ? 'Click a point to set the orbit center' : 'Tip: Shift+Alt+Click sets the orbit center'}
</div>
{/if}
{#if layers.length > 0}
<div class="layers-wrap" class:open={layersPanelOpen}>
<button
class="layers-toggle"
type="button"
title="Toggle layer visibility"
on:click={() => layersPanelOpen = !layersPanelOpen}
>
Layers
</button>
{#if layersPanelOpen}
<div class="layers-panel">
{#each layers as layer (layer.name)}
<label class="layers-row">
<input
type="checkbox"
checked={layer.visible}
on:change={() => toggleLayerRow(layer.name)}
/>
<span>{layer.label}</span>
</label>
{/each}
</div>
{/if}
</div>
{/if}
{#if tooltipText && !dropdownOpen}
<div
class="vcube-tooltip"
style="left: {tooltipX - 12}px; top: {tooltipY + 12}px;"
>
{tooltipText}
</div>
{/if}
{#if dropdownOpen}
<div
class="vcube-dropdown"
style="left: {dropdownX - 12}px; top: {dropdownY + 12}px;"
>
<button
class="vcube-dropdown-item"
class:active={!isOrtho}
on:click|stopPropagation={() => selectProjection(false)}
>
<span class="vcube-check">{!isOrtho ? '✓' : ''}</span>
Perspective
</button>
<button
class="vcube-dropdown-item"
class:active={isOrtho}
on:click|stopPropagation={() => selectProjection(true)}
>
<span class="vcube-check">{isOrtho ? '✓' : ''}</span>
Orthographic
</button>
</div>
{/if}
</div>
<style>
.viewer-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.viewer-container canvas {
display: block;
width: 100%;
height: 100%;
outline: none;
}
.viewer-container.pivot-mode canvas {
cursor: crosshair;
}
/* Mesh hover tooltip — same look as the ViewCube tooltip, offset to the
lower-right of the cursor (flips left near the right edge). */
.mesh-tooltip {
position: absolute;
pointer-events: none;
background: rgba(30, 34, 38, 0.92);
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
line-height: 1.35;
padding: 5px 10px;
border-radius: 4px;
white-space: pre-line;
max-width: 260px;
z-index: 20;
}
.mesh-tooltip.flip {
transform: translateX(calc(-100% - 28px));
}
/* Layers slide-out (#48) — left edge, vertically centered so it clears
the embedding page's info bar (top) and the scene toggles (bottom). */
.layers-wrap {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
z-index: 20;
display: flex;
align-items: flex-start;
gap: 8px;
}
.layers-toggle {
background: rgba(30, 34, 38, 0.92);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #d0d0d0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
line-height: 1;
padding: 7px 11px;
border-radius: 14px;
cursor: pointer;
writing-mode: vertical-rl;
letter-spacing: 0.06em;
}
.layers-toggle:hover { color: #ffffff; border-color: rgba(255, 255, 255, 0.3); }
.layers-wrap.open .layers-toggle { color: #00b8b0; border-color: rgba(0, 184, 176, 0.5); }
.layers-panel {
background: rgba(40, 44, 48, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
padding: 6px 0;
min-width: 150px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
animation: layers-slide 0.15s ease-out;
}
@keyframes layers-slide {
from { opacity: 0; transform: translateX(-8px); }
to { opacity: 1; transform: translateX(0); }
}
.layers-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
color: #d0d0d0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
cursor: pointer;
user-select: none;
}
.layers-row:hover { background: rgba(255, 255, 255, 0.08); }
.layers-row input { accent-color: #00b8b0; cursor: pointer; }
/* Orbit-center chip — live indicator while shift+alt is held, and the
one-shot first-visit hint. */
.pivot-chip {
position: absolute;
left: 50%;
bottom: 14px;
transform: translateX(-50%);
background: rgba(30, 34, 38, 0.92);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
line-height: 1;
padding: 7px 12px;
border-radius: 14px;
white-space: nowrap;
z-index: 20;
pointer-events: none;
}
/* ViewCube tooltip */
.vcube-tooltip {
position: absolute;
pointer-events: none;
transform: translateX(-100%);
background: rgba(30, 34, 38, 0.92);
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
line-height: 1;
padding: 5px 10px;
border-radius: 4px;
white-space: nowrap;
z-index: 20;
}
/* ViewCube dropdown */
.vcube-dropdown {
position: absolute;
transform: translateX(-100%);
background: rgba(40, 44, 48, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
padding: 4px 0;
z-index: 30;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
min-width: 140px;
}
.vcube-dropdown-item {
display: flex;
align-items: center;
width: 100%;
padding: 6px 12px;
border: none;
background: none;
color: #d0d0d0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
cursor: pointer;
text-align: left;
gap: 6px;
}
.vcube-dropdown-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.vcube-dropdown-item.active {
color: #00b8b0;
}
.vcube-check {
display: inline-block;
width: 14px;
font-size: 13px;
}
</style>