#3892 GLTF/GLB Viewer App

#3892 GLTF/GLB Viewer App

#3892 Create a Windows app to select, load, display and interact with a GLTF

I will create a comprehensive Single Page Application using Three.js that functions as a 3D Model Viewer.

Since I cannot generate a compiled .exe file directly in this environment, I have built this as a high-performance web application. It runs locally in your browser (which is the engine for modern Windows apps via Electron/WebView2 anyway) and allows you to load files directly from your hard drive without uploading them to a server.

Features included:

  1. Local File Loading: Uses the File API to load .gltf or .glb files from your computer instantly.

  2. Auto-Scaling: Automatically centers and zooms the camera to fit the model, regardless of its original size.

  3. Orbit Controls: Mouse/Touch interaction to rotate, zoom, and pan.

  4. Lighting & Display: Studio lighting setup with options to toggle wireframe and auto-rotation.

  5. Animation Support: If the model has animations, the first one plays automatically.

<!DOCTYPE html>

<html lang="en">

<head>

    <meta charset="UTF-8">

    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <title>GLTF/GLB Viewer</title>

    <script src="https://cdn.tailwindcss.com"></script>

    <style>

        body { margin: 0; overflow: hidden; background-color: #1a1a1a; font-family: 'Segoe UI', sans-serif; }

        canvas { display: block; width: 100vw; height: 100vh; outline: none; }

        

        /* Custom Scrollbar for the info panel */

        ::-webkit-scrollbar { width: 6px; }

        ::-webkit-scrollbar-track { background: #2d2d2d; }

        ::-webkit-scrollbar-thumb { background: #555; borderRadius: 3px; }

        ::-webkit-scrollbar-thumb:hover { background: #777; }


        .glass-panel {

            background: rgba(30, 30, 30, 0.85);

            backdrop-filter: blur(10px);

            border: 1px solid rgba(255, 255, 255, 0.1);

        }


        #loading-overlay {

            transition: opacity 0.3s ease;

        }

    </style>

    <!-- Import Map for Three.js -->

    <script type="importmap">

        {

            "imports": {

                "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",

                "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"

            }

        }

    </script>

</head>

<body class="text-white">


    <!-- UI Overlay -->

    <div id="ui-container" class="absolute inset-0 pointer-events-none flex flex-col justify-between p-4">

        

        <!-- Header / Stats -->

        <div class="flex justify-between items-start">

            <div class="glass-panel p-4 rounded-lg shadow-xl pointer-events-auto max-w-sm">

                <h1 class="text-xl font-bold bg-gradient-to-r from-blue-400 to-cyan-300 bg-clip-text text-transparent mb-2">

                    GLTF Viewer

                </h1>

                <div class="space-y-3">

                    <div>

                        <label class="block text-xs text-gray-400 mb-1 uppercase tracking-wider">Load Model</label>

                        <label class="flex items-center justify-center w-full h-10 px-4 transition bg-blue-600 border border-blue-600 rounded-md shadow-sm hover:bg-blue-700 focus:outline-none cursor-pointer">

                            <span class="text-sm font-medium leading-normal">Select .glb / .gltf</span>

                            <input type="file" id="file-input" accept=".gltf, .glb" class="hidden" />

                        </label>

                    </div>

                    

                    <div id="model-stats" class="hidden text-xs text-gray-300 space-y-1 border-t border-gray-700 pt-2">

                        <div class="flex justify-between"><span>Vertices:</span> <span id="stat-verts">0</span></div>

                        <div class="flex justify-between"><span>Triangles:</span> <span id="stat-tris">0</span></div>

                        <div class="flex justify-between"><span>Animations:</span> <span id="stat-anims">0</span></div>

                    </div>

                </div>

            </div>


            <!-- Controls -->

            <div class="glass-panel p-4 rounded-lg shadow-xl pointer-events-auto space-y-3">

                <div class="flex items-center justify-between gap-4">

                    <span class="text-sm text-gray-300">Auto Rotate</span>

                    <button id="btn-rotate" class="w-10 h-6 rounded-full bg-gray-600 relative transition-colors duration-200">

                        <div class="w-4 h-4 bg-white rounded-full absolute top-1 left-1 transition-transform duration-200"></div>

                    </button>

                </div>

                <div class="flex items-center justify-between gap-4">

                    <span class="text-sm text-gray-300">Wireframe</span>

                    <button id="btn-wireframe" class="w-10 h-6 rounded-full bg-gray-600 relative transition-colors duration-200">

                        <div class="w-4 h-4 bg-white rounded-full absolute top-1 left-1 transition-transform duration-200"></div>

                    </button>

                </div>

                <div class="flex items-center justify-between gap-4">

                    <span class="text-sm text-gray-300">Grid</span>

                    <button id="btn-grid" class="w-10 h-6 rounded-full bg-blue-600 relative transition-colors duration-200">

                        <div class="w-4 h-4 bg-white rounded-full absolute top-1 left-1 transform translate-x-4 transition-transform duration-200"></div>

                    </button>

                </div>

                <div class="pt-2 border-t border-gray-700">

                    <button id="btn-reset" class="w-full py-1 text-xs text-center text-gray-400 hover:text-white hover:bg-white/10 rounded transition">

                        Reset Camera

                    </button>

                </div>

            </div>

        </div>


        <!-- Footer / Instructions -->

        <div class="text-center pointer-events-none opacity-50">

            <p class="text-xs text-gray-400">Left Click: Rotate • Right Click: Pan • Scroll: Zoom</p>

        </div>

    </div>


    <!-- Loading Overlay -->

    <div id="loading-overlay" class="absolute inset-0 z-50 flex flex-col items-center justify-center bg-[#1a1a1a] hidden">

        <div class="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mb-4"></div>

        <p class="text-blue-400 font-medium">Loading Model...</p>

    </div>


    <!-- Error Toast -->

    <div id="error-toast" class="absolute bottom-10 left-1/2 transform -translate-x-1/2 bg-red-600 text-white px-6 py-3 rounded-lg shadow-lg translate-y-20 transition-transform duration-300 pointer-events-none">

        <span id="error-msg">Error loading file</span>

    </div>


    <script type="module">

        import * as THREE from 'three';

        import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

        import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';


        // --- State ---

        const state = {

            autoRotate: false,

            wireframe: false,

            showGrid: true,

            currentModel: null,

            mixer: null,

            clock: new THREE.Clock()

        };


        // --- Initialization ---

        const container = document.body;

        

        // Renderer

        const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });

        renderer.setPixelRatio(window.devicePixelRatio);

        renderer.setSize(window.innerWidth, window.innerHeight);

        renderer.toneMapping = THREE.ACESFilmicToneMapping;

        renderer.toneMappingExposure = 1;

        renderer.shadowMap.enabled = true;

        renderer.shadowMap.type = THREE.PCFSoftShadowMap;

        container.appendChild(renderer.domElement);


        // Scene

        const scene = new THREE.Scene();

        scene.background = new THREE.Color(0x1a1a1a);


        // Camera

        const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);

        camera.position.set(5, 2, 5);


        // Environment / Lighting

        const pmremGenerator = new THREE.PMREMGenerator(renderer);

        scene.environment = pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture;


        const ambientLight = new THREE.AmbientLight(0xffffff, 0.2);

        scene.add(ambientLight);


        const dirLight = new THREE.DirectionalLight(0xffffff, 1);

        dirLight.position.set(5, 10, 7);

        dirLight.castShadow = true;

        dirLight.shadow.mapSize.width = 1024;

        dirLight.shadow.mapSize.height = 1024;

        scene.add(dirLight);


        // Grid

        const gridHelper = new THREE.GridHelper(20, 20, 0x444444, 0x2a2a2a);

        scene.add(gridHelper);


        // Controls

        const controls = new OrbitControls(camera, renderer.domElement);

        controls.enableDamping = true;

        controls.dampingFactor = 0.05;

        controls.screenSpacePanning = false;

        controls.minDistance = 0.1;

        controls.maxDistance = 500;


        // --- Functions ---


        function fitCameraToSelection(camera, controls, selection, fitOffset = 1.2) {

            const box = new THREE.Box3();

            

            box.setFromObject(selection);

            

            const size = box.getSize(new THREE.Vector3());

            const center = box.getCenter(new THREE.Vector3());

            

            const maxSize = Math.max(size.x, size.y, size.z);

            const fitHeightDistance = maxSize / (2 * Math.atan(Math.PI * camera.fov / 360));

            const fitWidthDistance = fitHeightDistance / camera.aspect;

            const distance = fitOffset * Math.max(fitHeightDistance, fitWidthDistance);

            

            const direction = controls.target.clone()

                .sub(camera.position)

                .normalize()

                .multiplyScalar(distance);


            controls.maxDistance = distance * 10;

            controls.target.copy(center);

            

            camera.near = distance / 100;

            camera.far = distance * 100;

            camera.updateProjectionMatrix();


            camera.position.copy(controls.target).sub(direction);

            

            controls.update();

        }


        function updateStats(object, animations) {

            let verts = 0;

            let tris = 0;


            object.traverse((o) => {

                if (o.isMesh) {

                    verts += o.geometry.attributes.position.count;

                    tris += o.geometry.index ? o.geometry.index.count / 3 : o.geometry.attributes.position.count / 3;

                }

            });


            document.getElementById('stat-verts').textContent = verts.toLocaleString();

            document.getElementById('stat-tris').textContent = Math.floor(tris).toLocaleString();

            document.getElementById('stat-anims').textContent = animations.length;

            document.getElementById('model-stats').classList.remove('hidden');

        }


        function showError(msg) {

            const el = document.getElementById('error-toast');

            document.getElementById('error-msg').textContent = msg;

            el.style.transform = "translate(-50%, 0)";

            setTimeout(() => {

                el.style.transform = "translate(-50%, 5rem)";

            }, 3000);

        }


        function toggleLoader(show) {

            const el = document.getElementById('loading-overlay');

            if (show) el.classList.remove('hidden');

            else el.classList.add('hidden');

        }


        // --- File Handling ---


        const fileInput = document.getElementById('file-input');

        const loader = new GLTFLoader();


        fileInput.addEventListener('change', (e) => {

            const file = e.target.files[0];

            if (!file) return;


            // Remove old model

            if (state.currentModel) {

                scene.remove(state.currentModel);

                state.mixer = null;

            }


            toggleLoader(true);


            const url = URL.createObjectURL(file);


            loader.load(

                url,

                (gltf) => {

                    const model = gltf.scene;

                    state.currentModel = model;


                    // Shadow & Wireframe Setup

                    model.traverse((child) => {

                        if (child.isMesh) {

                            child.castShadow = true;

                            child.receiveShadow = true;

                            if (state.wireframe) {

                                child.material.wireframe = true;

                            }

                        }

                    });


                    scene.add(model);


                    // Animation

                    if (gltf.animations && gltf.animations.length) {

                        state.mixer = new THREE.AnimationMixer(model);

                        const action = state.mixer.clipAction(gltf.animations[0]);

                        action.play();

                    }


                    // Positioning

                    fitCameraToSelection(camera, controls, model);

                    updateStats(model, gltf.animations);

                    

                    toggleLoader(false);

                    URL.revokeObjectURL(url); // Clean up memory

                },

                (xhr) => {

                    // Progress (optional)

                },

                (error) => {

                    console.error(error);

                    toggleLoader(false);

                    showError("Could not parse GLTF file.");

                }

            );

        });


        // --- UI Events ---


        // Toggle Helper

        function setupToggle(id, stateKey, onChange) {

            const btn = document.getElementById(id);

            const knob = btn.firstElementChild;

            

            btn.addEventListener('click', () => {

                state[stateKey] = !state[stateKey];

                

                // Visual Update

                if (state[stateKey]) {

                    btn.classList.replace('bg-gray-600', 'bg-blue-600');

                    knob.classList.add('translate-x-4');

                } else {

                    btn.classList.replace('bg-blue-600', 'bg-gray-600');

                    knob.classList.remove('translate-x-4');

                }

                

                if (onChange) onChange(state[stateKey]);

            });

        }


        setupToggle('btn-rotate', 'autoRotate', (val) => {

            controls.autoRotate = val;

        });


        setupToggle('btn-wireframe', 'wireframe', (val) => {

            if (!state.currentModel) return;

            state.currentModel.traverse((child) => {

                if (child.isMesh) {

                    // We clone material to avoid affecting shared materials permanently if we wanted, 

                    // but for simple viewer toggling existing mat is fine

                    child.material.wireframe = val;

                }

            });

        });


        setupToggle('btn-grid', 'showGrid', (val) => {

            gridHelper.visible = val;

        });


        document.getElementById('btn-reset').addEventListener('click', () => {

            if (state.currentModel) {

                fitCameraToSelection(camera, controls, state.currentModel);

            } else {

                controls.reset();

            }

        });


        // --- Animation Loop ---


        window.addEventListener('resize', onWindowResize, false);


        function onWindowResize() {

            camera.aspect = window.innerWidth / window.innerHeight;

            camera.updateProjectionMatrix();

            renderer.setSize(window.innerWidth, window.innerHeight);

        }


        function animate() {

            requestAnimationFrame(animate);


            const delta = state.clock.getDelta();


            if (state.mixer) {

                state.mixer.update(delta);

            }


            controls.update();

            renderer.render(scene, camera);

        }


        // Start

        animate();


    </script>

</body>

</html>

How to use this Viewer

  1. File Selection: Click the blue "Select .glb / .gltf" button in the top left corner.

  2. Interaction:

    • Left Click + Drag: Rotate the model.

    • Right Click + Drag: Pan the camera.

    • Scroll: Zoom in and out.

  3. Controls: Use the toggles on the right to spin the model automatically, view the wireframe mesh structure, or hide the floor grid.

This application runs entirely in your browser memory. The files you select are processed locally and are not uploaded to any server.


Add keyboard arrow controls, plus click to reposition camera

I've updated the app to include keyboard controls and click-to-center functionality.

New Interactions:

  1. Arrow Keys: You can now use the Up, Down, Left, and Right keys to rotate the camera around the model with precision.

  2. Click to Center: Clicking anywhere on the 3D model will smoothly animate the camera to center that specific point.

Here are the updates:

<!DOCTYPE html>

<html lang="en">

<head>

    <meta charset="UTF-8">

    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <title>GLTF/GLB Viewer</title>

    <script src="https://cdn.tailwindcss.com"></script>

    <style>

        body { margin: 0; overflow: hidden; background-color: #1a1a1a; font-family: 'Segoe UI', sans-serif; }

        canvas { display: block; width: 100vw; height: 100vh; outline: none; }

        

        /* Custom Scrollbar for the info panel */

        ::-webkit-scrollbar { width: 6px; }

        ::-webkit-scrollbar-track { background: #2d2d2d; }

        ::-webkit-scrollbar-thumb { background: #555; borderRadius: 3px; }

        ::-webkit-scrollbar-thumb:hover { background: #777; }


        .glass-panel {

            background: rgba(30, 30, 30, 0.85);

            backdrop-filter: blur(10px);

            border: 1px solid rgba(255, 255, 255, 0.1);

        }


        #loading-overlay {

            transition: opacity 0.3s ease;

        }

    </style>

    <!-- Import Map for Three.js -->

    <script type="importmap">

        {

            "imports": {

                "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",

                "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"

            }

        }

    </script>

</head>

<body class="text-white">


    <!-- UI Overlay -->

    <div id="ui-container" class="absolute inset-0 pointer-events-none flex flex-col justify-between p-4">

        

        <!-- Header / Stats -->

        <div class="flex justify-between items-start">

            <div class="glass-panel p-4 rounded-lg shadow-xl pointer-events-auto max-w-sm">

                <h1 class="text-xl font-bold bg-gradient-to-r from-blue-400 to-cyan-300 bg-clip-text text-transparent mb-2">

                    GLTF Viewer

                </h1>

                <div class="space-y-3">

                    <div>

                        <label class="block text-xs text-gray-400 mb-1 uppercase tracking-wider">Load Model</label>

                        <label class="flex items-center justify-center w-full h-10 px-4 transition bg-blue-600 border border-blue-600 rounded-md shadow-sm hover:bg-blue-700 focus:outline-none cursor-pointer">

                            <span class="text-sm font-medium leading-normal">Select .glb / .gltf</span>

                            <input type="file" id="file-input" accept=".gltf, .glb" class="hidden" />

                        </label>

                    </div>

                    

                    <div id="model-stats" class="hidden text-xs text-gray-300 space-y-1 border-t border-gray-700 pt-2">

                        <div class="flex justify-between"><span>Vertices:</span> <span id="stat-verts">0</span></div>

                        <div class="flex justify-between"><span>Triangles:</span> <span id="stat-tris">0</span></div>

                        <div class="flex justify-between"><span>Animations:</span> <span id="stat-anims">0</span></div>

                    </div>

                </div>

            </div>


            <!-- Controls -->

            <div class="glass-panel p-4 rounded-lg shadow-xl pointer-events-auto space-y-3">

                <div class="flex items-center justify-between gap-4">

                    <span class="text-sm text-gray-300">Auto Rotate</span>

                    <button id="btn-rotate" class="w-10 h-6 rounded-full bg-gray-600 relative transition-colors duration-200">

                        <div class="w-4 h-4 bg-white rounded-full absolute top-1 left-1 transition-transform duration-200"></div>

                    </button>

                </div>

                <div class="flex items-center justify-between gap-4">

                    <span class="text-sm text-gray-300">Wireframe</span>

                    <button id="btn-wireframe" class="w-10 h-6 rounded-full bg-gray-600 relative transition-colors duration-200">

                        <div class="w-4 h-4 bg-white rounded-full absolute top-1 left-1 transition-transform duration-200"></div>

                    </button>

                </div>

                <div class="flex items-center justify-between gap-4">

                    <span class="text-sm text-gray-300">Grid</span>

                    <button id="btn-grid" class="w-10 h-6 rounded-full bg-blue-600 relative transition-colors duration-200">

                        <div class="w-4 h-4 bg-white rounded-full absolute top-1 left-1 transform translate-x-4 transition-transform duration-200"></div>

                    </button>

                </div>

                <div class="pt-2 border-t border-gray-700">

                    <button id="btn-reset" class="w-full py-1 text-xs text-center text-gray-400 hover:text-white hover:bg-white/10 rounded transition">

                        Reset Camera

                    </button>

                </div>

            </div>

        </div>


        <!-- Footer / Instructions -->

        <div class="text-center pointer-events-none opacity-50">

            <p class="text-xs text-gray-400">Left Click: Rotate • Right Click: Pan • Scroll: Zoom • Arrow Keys: Rotate • Click Object: Center</p>

        </div>

    </div>


    <!-- Loading Overlay -->

    <div id="loading-overlay" class="absolute inset-0 z-50 flex flex-col items-center justify-center bg-[#1a1a1a] hidden">

        <div class="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mb-4"></div>

        <p class="text-blue-400 font-medium">Loading Model...</p>

    </div>


    <!-- Error Toast -->

    <div id="error-toast" class="absolute bottom-10 left-1/2 transform -translate-x-1/2 bg-red-600 text-white px-6 py-3 rounded-lg shadow-lg translate-y-20 transition-transform duration-300 pointer-events-none">

        <span id="error-msg">Error loading file</span>

    </div>


    <script type="module">

        import * as THREE from 'three';

        import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

        import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';


        // --- State ---

        const state = {

            autoRotate: false,

            wireframe: false,

            showGrid: true,

            currentModel: null,

            mixer: null,

            clock: new THREE.Clock(),

            targetTransition: null

        };


        // --- Initialization ---

        const container = document.body;

        

        // Renderer

        const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });

        renderer.setPixelRatio(window.devicePixelRatio);

        renderer.setSize(window.innerWidth, window.innerHeight);

        renderer.toneMapping = THREE.ACESFilmicToneMapping;

        renderer.toneMappingExposure = 1;

        renderer.shadowMap.enabled = true;

        renderer.shadowMap.type = THREE.PCFSoftShadowMap;

        container.appendChild(renderer.domElement);


        // Scene

        const scene = new THREE.Scene();

        scene.background = new THREE.Color(0x1a1a1a);


        // Camera

        const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);

        camera.position.set(5, 2, 5);


        // Environment / Lighting

        const pmremGenerator = new THREE.PMREMGenerator(renderer);

        scene.environment = pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture;


        const ambientLight = new THREE.AmbientLight(0xffffff, 0.2);

        scene.add(ambientLight);


        const dirLight = new THREE.DirectionalLight(0xffffff, 1);

        dirLight.position.set(5, 10, 7);

        dirLight.castShadow = true;

        dirLight.shadow.mapSize.width = 1024;

        dirLight.shadow.mapSize.height = 1024;

        scene.add(dirLight);


        // Grid

        const gridHelper = new THREE.GridHelper(20, 20, 0x444444, 0x2a2a2a);

        scene.add(gridHelper);


        // Controls

        const controls = new OrbitControls(camera, renderer.domElement);

        controls.enableDamping = true;

        controls.dampingFactor = 0.05;

        controls.screenSpacePanning = false;

        controls.minDistance = 0.1;

        controls.maxDistance = 500;


        // --- Interaction Logic (Raycaster & Keys) ---

        const raycaster = new THREE.Raycaster();

        const mouse = new THREE.Vector2();

        const mouseClickStart = new THREE.Vector2();


        // 1. Click to Reposition

        container.addEventListener('pointerdown', (e) => {

            mouseClickStart.set(e.clientX, e.clientY);

        });


        container.addEventListener('pointerup', (e) => {

            // Check if it was a drag or a click (threshold 5px)

            if (mouseClickStart.distanceTo(new THREE.Vector2(e.clientX, e.clientY)) < 5) {

                // Calculate normalized device coordinates

                const rect = renderer.domElement.getBoundingClientRect();

                mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;

                mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;


                raycaster.setFromCamera(mouse, camera);

                const intersects = raycaster.intersectObjects(scene.children, true);


                if (intersects.length > 0) {

                    // Find the first mesh that isn't the grid or helper

                    const hit = intersects.find(i => i.object.type === 'Mesh' && i.object !== gridHelper);

                    if (hit) {

                        startTargetTransition(hit.point);

                    }

                }

            }

        });


        function startTargetTransition(newTarget) {

            state.targetTransition = {

                start: controls.target.clone(),

                end: newTarget,

                startTime: performance.now(),

                duration: 800 // ms

            };

        }


        // 2. Keyboard Arrows

        window.addEventListener('keydown', (e) => {

            const moveSpeed = 0.05;

            const offset = new THREE.Vector3().subVectors(camera.position, controls.target);

            const spherical = new THREE.Spherical().setFromVector3(offset);


            let changed = false;


            switch(e.key) {

                case 'ArrowLeft':

                    spherical.theta += moveSpeed;

                    changed = true;

                    break;

                case 'ArrowRight':

                    spherical.theta -= moveSpeed;

                    changed = true;

                    break;

                case 'ArrowUp':

                    spherical.phi -= moveSpeed;

                    changed = true;

                    break;

                case 'ArrowDown':

                    spherical.phi += moveSpeed;

                    changed = true;

                    break;

            }


            if (changed) {

                e.preventDefault(); // Prevent page scroll

                // Clamp phi to avoid gimbal lock

                spherical.phi = Math.max(0.05, Math.min(Math.PI - 0.05, spherical.phi));

                spherical.makeSafe();

                

                offset.setFromSpherical(spherical);

                camera.position.copy(controls.target).add(offset);

                camera.lookAt(controls.target);

            }

        });


        // --- Functions ---


        function fitCameraToSelection(camera, controls, selection, fitOffset = 1.2) {

            const box = new THREE.Box3();

            

            box.setFromObject(selection);

            

            const size = box.getSize(new THREE.Vector3());

            const center = box.getCenter(new THREE.Vector3());

            

            const maxSize = Math.max(size.x, size.y, size.z);

            const fitHeightDistance = maxSize / (2 * Math.atan(Math.PI * camera.fov / 360));

            const fitWidthDistance = fitHeightDistance / camera.aspect;

            const distance = fitOffset * Math.max(fitHeightDistance, fitWidthDistance);

            

            const direction = controls.target.clone()

                .sub(camera.position)

                .normalize()

                .multiplyScalar(distance);


            controls.maxDistance = distance * 10;

            controls.target.copy(center);

            

            camera.near = distance / 100;

            camera.far = distance * 100;

            camera.updateProjectionMatrix();


            camera.position.copy(controls.target).sub(direction);

            

            controls.update();

        }


        function updateStats(object, animations) {

            let verts = 0;

            let tris = 0;


            object.traverse((o) => {

                if (o.isMesh) {

                    verts += o.geometry.attributes.position.count;

                    tris += o.geometry.index ? o.geometry.index.count / 3 : o.geometry.attributes.position.count / 3;

                }

            });


            document.getElementById('stat-verts').textContent = verts.toLocaleString();

            document.getElementById('stat-tris').textContent = Math.floor(tris).toLocaleString();

            document.getElementById('stat-anims').textContent = animations.length;

            document.getElementById('model-stats').classList.remove('hidden');

        }


        function showError(msg) {

            const el = document.getElementById('error-toast');

            document.getElementById('error-msg').textContent = msg;

            el.style.transform = "translate(-50%, 0)";

            setTimeout(() => {

                el.style.transform = "translate(-50%, 5rem)";

            }, 3000);

        }


        function toggleLoader(show) {

            const el = document.getElementById('loading-overlay');

            if (show) el.classList.remove('hidden');

            else el.classList.add('hidden');

        }


        // --- File Handling ---


        const fileInput = document.getElementById('file-input');

        const loader = new GLTFLoader();


        fileInput.addEventListener('change', (e) => {

            const file = e.target.files[0];

            if (!file) return;


            // Remove old model

            if (state.currentModel) {

                scene.remove(state.currentModel);

                state.mixer = null;

            }


            toggleLoader(true);


            const url = URL.createObjectURL(file);


            loader.load(

                url,

                (gltf) => {

                    const model = gltf.scene;

                    state.currentModel = model;


                    // Shadow & Wireframe Setup

                    model.traverse((child) => {

                        if (child.isMesh) {

                            child.castShadow = true;

                            child.receiveShadow = true;

                            if (state.wireframe) {

                                child.material.wireframe = true;

                            }

                        }

                    });


                    scene.add(model);


                    // Animation

                    if (gltf.animations && gltf.animations.length) {

                        state.mixer = new THREE.AnimationMixer(model);

                        const action = state.mixer.clipAction(gltf.animations[0]);

                        action.play();

                    }


                    // Positioning

                    fitCameraToSelection(camera, controls, model);

                    updateStats(model, gltf.animations);

                    

                    toggleLoader(false);

                    URL.revokeObjectURL(url); // Clean up memory

                },

                (xhr) => {

                    // Progress (optional)

                },

                (error) => {

                    console.error(error);

                    toggleLoader(false);

                    showError("Could not parse GLTF file.");

                }

            );

        });


        // --- UI Events ---


        // Toggle Helper

        function setupToggle(id, stateKey, onChange) {

            const btn = document.getElementById(id);

            const knob = btn.firstElementChild;

            

            btn.addEventListener('click', () => {

                state[stateKey] = !state[stateKey];

                

                // Visual Update

                if (state[stateKey]) {

                    btn.classList.replace('bg-gray-600', 'bg-blue-600');

                    knob.classList.add('translate-x-4');

                } else {

                    btn.classList.replace('bg-blue-600', 'bg-gray-600');

                    knob.classList.remove('translate-x-4');

                }

                

                if (onChange) onChange(state[stateKey]);

            });

        }


        setupToggle('btn-rotate', 'autoRotate', (val) => {

            controls.autoRotate = val;

        });


        setupToggle('btn-wireframe', 'wireframe', (val) => {

            if (!state.currentModel) return;

            state.currentModel.traverse((child) => {

                if (child.isMesh) {

                    // We clone material to avoid affecting shared materials permanently if we wanted, 

                    // but for simple viewer toggling existing mat is fine

                    child.material.wireframe = val;

                }

            });

        });


        setupToggle('btn-grid', 'showGrid', (val) => {

            gridHelper.visible = val;

        });


        document.getElementById('btn-reset').addEventListener('click', () => {

            if (state.currentModel) {

                fitCameraToSelection(camera, controls, state.currentModel);

            } else {

                controls.reset();

            }

        });


        // --- Animation Loop ---


        window.addEventListener('resize', onWindowResize, false);


        function onWindowResize() {

            camera.aspect = window.innerWidth / window.innerHeight;

            camera.updateProjectionMatrix();

            renderer.setSize(window.innerWidth, window.innerHeight);

        }


        function animate() {

            requestAnimationFrame(animate);


            const delta = state.clock.getDelta();


            // Smooth Target Transition

            if (state.targetTransition) {

                const now = performance.now();

                let progress = (now - state.targetTransition.startTime) / state.targetTransition.duration;

                

                if (progress >= 1) {

                    // Transition Complete: Snap to end and clear state

                    controls.target.copy(state.targetTransition.end);

                    state.targetTransition = null;

                } else {

                    // Transition in Progress

                    const ease = 1 - Math.pow(1 - progress, 3);

                    controls.target.lerpVectors(state.targetTransition.start, state.targetTransition.end, ease);

                }

            }


            if (state.mixer) {

                state.mixer.update(delta);

            }


            controls.update();

            renderer.render(scene, camera);

        }


        // Start

        animate();


    </script>

</body>

</html>


From <https://gemini.google.com/app/>  Google Gemini 3.0 Pro (Thinking)


https://poly.cam/3d-models/gltf-models

Comments

Popular posts from this blog

#2892 Example of ChatGPT o1 Prompt

#3105 Federal and State Spending Idiocy