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>