import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { UltraHDRLoader } from 'three/addons/loaders/UltraHDRLoader.js'; import { GUI } from 'three/addons/libs/lil-gui.module.min.js'; const scene = new THREE.Scene(); const clock = new THREE.Clock(); // Required to calculate delta time const sizes = { width: window.innerWidth, height: window.innerHeight, } let loadData = null; let mixer; fetch('model-index.json') .then(response => response.json()) .then(data => { loadData = data; startApp(); }); //Add grid const size = 50; const divisions = 200; const centerlinecolor = 0xcccccc; const linecolor = 0xe0e0e0; const gridHelper = new THREE.GridHelper(size, divisions, centerlinecolor, linecolor); scene.add(gridHelper); //RENDERER const renderer = new THREE.WebGLRenderer({antialias:true}); renderer.setSize( sizes.width, sizes.height ); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.5; renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFShadowMap; document.body.appendChild( renderer.domElement ); //CAMERA const DEFAULT_CAMERA_POS = new THREE.Vector3(-0.4, 0.5, 1.5); // Camera default position const DEFAULT_TARGET = new THREE.Vector3(0, 0, 0); // Camera default target const camera = new THREE.PerspectiveCamera( 65, sizes.width / sizes.height, 0.001, 1000 ); // Apply default position initially camera.position.copy(DEFAULT_CAMERA_POS); camera.lookAt(DEFAULT_TARGET); const controls = new OrbitControls( camera, renderer.domElement ); controls.enableDamping = true; controls.dampingFactor = 0.05; // Friction controls.screenSpacePanning = false; // Vertical panning along screen controls.target.copy(DEFAULT_TARGET); controls.update(); //RESIZING function handleResize(){ sizes.width = window.innerWidth; sizes.height = window.innerHeight; camera.aspect = sizes.width/sizes.height; camera.updateProjectionMatrix(); renderer.setSize( sizes.width, sizes.height ); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); } window.addEventListener("resize", handleResize); //MODEL LOADER const loader = new GLTFLoader(); let currentModel = null; // UPDATE NAME FUNCTION function updateNameText(name) { const recordtitleElement = document.getElementById('recordnumbertext'); const titleElement = document.getElementById('nametext'); //Record Number const recnumber = name.slice(0,11); recordtitleElement.textContent = recnumber; //Scientific Name const trimmedstart = name.slice(11); titleElement.textContent = trimmedstart; titleElement.style.fontStyle = "italic"; //italicize scientific name } const gui = new GUI({ width: 425 }); //New UI if (window.innerWidth <= 600) { gui.close(); // Starts collapsed on mobile } //Loading elements const loaderContainer = document.getElementById('loader-container'); const loadingText = document.getElementById('loading-text'); let latestModelPath = null; //Load Function function loadModel(modelPath) { if (currentModel) { scene.remove(currentModel); currentModel.traverse((child) => { if (child.isMesh) { child.geometry.dispose(); if (Array.isArray(child.material)) { child.material.forEach(mat => mat.dispose()); } else { child.material.dispose(); } } }); currentModel = null; } loaderContainer.style.display = 'flex'; console.log(`Loaded: ${modelPath}`); setGuiEnabled(gui, false); loader.load(modelPath, (glb) => { currentModel = glb.scene; scene.add(glb.scene); // Check if the file contains animations if (glb.animations && glb.animations.length > 0) { // Create the AnimationMixer and bind it to the model mixer = new THREE.AnimationMixer(currentModel); // Get the first animation clip and create a playable action const animationClip = glb.animations[0]; const action = mixer.clipAction(animationClip); // Play the animation action.play(); } loaderContainer.style.display = 'none'; // Hide Loading Throbber setGuiEnabled(gui, true); }); } function startApp() { // 1. Create an object to hold the currently selected values const modelsData = loadData; const params = { name: modelsData[0].name, variant: Object.keys(modelsData[0].variants)[0] }; // 2. Extract just the names for the first dropdown const modelNames = modelsData.map(item => item.name); // 3. Add the Controllers const nameController = gui.add(params, 'name', modelNames).name('Model Name'); // Initialize the variants dropdown with the first model's options let variantOptions = Object.keys(modelsData[0].variants); let variantController = gui.add(params, 'variant', variantOptions).name('Variant'); let gridcontroller = gui.add(gridHelper, 'visible').name('Toggle Grid'); // 4. Handle dynamic updates nameController.onChange((selectedName) => { const selectedModelData = modelsData.find(item => item.name === selectedName); // Find the data for the newly selected model const newVariants = Object.keys(selectedModelData.variants); params.variant = newVariants[0]; // Update the param state to the first available variant of the new model // Destroy the old variant dropdown and gridhelper and create a new ones with updated options variantController.destroy(); gridcontroller.destroy(); variantController = gui.add(params, 'variant', newVariants).name('Variant'); gridcontroller = gui.add(gridHelper, 'visible').name('Toggle Grid'); // Re-attach the onChange listener to the new dropdown attachVariantListener(variantController, selectedModelData); //Load Model loadModel(selectedModelData.variants[params.variant]); // Reset camera position and target resetcam(); //Call function to update name text updateNameText(selectedName); }); // Helper function to handle variant changes function attachVariantListener(controller, modelData) { controller.onChange((selectedVariant) => { const modelPath = modelData.variants[selectedVariant]; // Load Model loadModel(modelPath); }); } // Attach listener to the very first variant dropdown attachVariantListener(variantController, modelsData[0]); const initialModelPath = modelsData[0].variants[params.variant]; // Load default model right away loadModel(initialModelPath); updateNameText(modelsData[0].name); // Reset camera transforms function resetcam(){ camera.position.copy(DEFAULT_CAMERA_POS); if (controls) { controls.target.copy(DEFAULT_TARGET); controls.update(); } else { camera.lookAt(DEFAULT_TARGET); } } } gui.domElement.style.right = '0px'; //ENVIRONMENT const env_loader = new UltraHDRLoader(); const env_texture = await env_loader.loadAsync( 'assets/studio_small_08_4k.jpg' ); env_texture.mapping = THREE.EquirectangularReflectionMapping; scene.background = new THREE.Color(0xe6e6e6); scene.environment = env_texture; //UPDATE PER FRAME renderer.setAnimationLoop( animate ); function animate() { const delta = clock.getDelta(); if (mixer) { mixer.update(delta); } controls.update(); renderer.render( scene, camera ); } //Toggle GUI function setGuiEnabled(guiInstance, isEnabled) { guiInstance.controllersRecursive().forEach(controller => { isEnabled ? controller.enable() : controller.disable(); }); // Visually dim the GUI when disabled guiInstance.domElement.style.opacity = isEnabled ? '1' : '0.5'; guiInstance.domElement.style.pointerEvents = isEnabled ? 'auto' : 'none'; } //Show Info div after 5 seconds after initial load setTimeout(() => { const infoElement = document.getElementById('info'); const mabuligheaderElement = document.getElementById('mabuligheader'); infoElement.style.display = 'block'; mabuligheaderElement.style.display = 'block'; infoElement.animate([ { opacity: 0 }, { opacity: 1 } ], { duration: 500, // Duration in milliseconds (500ms = 0.5s) easing: 'ease-in-out', fill: 'forwards' // Keeps the final state (opacity: 1) when the animation ends }); mabuligheaderElement.animate([ { opacity: 0 }, { opacity: 1 } ], { duration: 500, easing: 'ease-in-out', fill: 'forwards' }); }, 750);