import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { TransformControls } from "three/examples/jsm/controls/TransformControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"; import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js"; import "./style.css"; function getRequiredEl(id: string): T { const el = document.getElementById(id); if (!el) throw new Error(`Missing required element #${id}`); return el as T; } const app = getRequiredEl("app"); const canvas = getRequiredEl("scene"); // Top bar const rotateBtn = document.getElementById("rotateBtn") as HTMLButtonElement | null; const wireframeBtn = document.getElementById("wireframeBtn") as HTMLButtonElement | null; const movePanelBtn = getRequiredEl("movePanelBtn"); const cameraPanelBtn = getRequiredEl("cameraPanelBtn"); // Anchored UI const anchorBtn = getRequiredEl("anchorBtn"); const wheelsBtn = getRequiredEl("wheelsBtn"); const aeroAnchorBtn = getRequiredEl("aeroAnchorBtn"); const suspensionAnchorBtn = getRequiredEl("suspensionAnchorBtn"); const wheelsMenu = getRequiredEl("wheelsMenu"); const wheelsBrakeBtn = getRequiredEl("wheelsBrakeBtn"); const wheelsTreadingBtn = getRequiredEl("wheelsTreadingBtn"); const wheelsRimsBtn = getRequiredEl("wheelsRimsBtn"); const explorePopover = getRequiredEl("explorePopover"); const aeroPopover = getRequiredEl("aeroPopover"); const suspensionPopover = getRequiredEl("suspensionPopover"); const engineVideo = getRequiredEl("engineVideo"); const aeroVideo = getRequiredEl("aeroVideo"); const suspensionVideo = getRequiredEl("suspensionVideo"); const engineMenu = getRequiredEl("engineMenu"); const engineToggleCoverBtn = getRequiredEl("engineToggleCoverBtn"); const engineLearnBtn = getRequiredEl("engineLearnBtn"); const f1EngineAnchorBtn = getRequiredEl("f1EngineAnchorBtn"); const f1EngineMenu = getRequiredEl("f1EngineMenu"); const f1EngineToggleCoverBtn = getRequiredEl("f1EngineToggleCoverBtn"); const f1EngineLearnBtn = getRequiredEl("f1EngineLearnBtn"); // Toast + Loading const toast = getRequiredEl("toast"); const loading = getRequiredEl("loading"); const loadingDetail = getRequiredEl("loadingDetail"); const loadingBgVideo = document.querySelector("video.loading-bg-video") as HTMLVideoElement | null; // Move panel const modelSelect = getRequiredEl("modelSelect"); const xInput = getRequiredEl("xInput"); const yInput = getRequiredEl("yInput"); const zInput = getRequiredEl("zInput"); const rxInput = getRequiredEl("rxInput"); const ryInput = getRequiredEl("ryInput"); const rzInput = getRequiredEl("rzInput"); const sUniformInput = getRequiredEl("sUniformInput"); const sxInput = getRequiredEl("sxInput"); const syInput = getRequiredEl("syInput"); const szInput = getRequiredEl("szInput"); const applyMoveBtn = getRequiredEl("applyMoveBtn"); const resetTransformBtn = getRequiredEl("resetTransformBtn"); const modeTranslateBtn = getRequiredEl("modeTranslateBtn"); const modeRotateBtn = getRequiredEl("modeRotateBtn"); const modeScaleBtn = getRequiredEl("modeScaleBtn"); // Camera panel const camX = getRequiredEl("camX"); const camY = getRequiredEl("camY"); const camZ = getRequiredEl("camZ"); const tgtX = getRequiredEl("tgtX"); const tgtY = getRequiredEl("tgtY"); const tgtZ = getRequiredEl("tgtZ"); const cameraSetFromViewBtn = getRequiredEl("cameraSetFromViewBtn"); const cameraApplyBtn = getRequiredEl("cameraApplyBtn"); const cameraResetBtn = getRequiredEl("cameraResetBtn"); let toastTimer: number | null = null; function showToast(msg: string) { toast.textContent = msg; toast.classList.add("show"); if (toastTimer) window.clearTimeout(toastTimer); toastTimer = window.setTimeout(() => toast.classList.remove("show"), 1800); } let sceneReady = false; let loadingAnimDone = false; function hideLoadingOverlay() { loading.classList.add("hidden"); // Stop the loading animation once we enter the app. if (loadingBgVideo) { loadingBgVideo.pause(); } } function maybeHideLoadingOverlay() { if (!sceneReady || !loadingAnimDone) return; hideLoadingOverlay(); } // If the loading animation finishes before the scene is ready, keep the last frame displayed. if (loadingBgVideo) { loadingBgVideo.addEventListener( "ended", () => { // Some browsers show black on the exact end frame; nudge slightly back. try { if (Number.isFinite(loadingBgVideo.duration) && loadingBgVideo.duration > 0) { loadingBgVideo.currentTime = Math.max(0, loadingBgVideo.duration - 0.05); } } catch { // ignore } loadingAnimDone = true; maybeHideLoadingOverlay(); }, { once: true } ); loadingBgVideo.addEventListener( "error", () => { // Don't block app entry if the animation can't play. loadingAnimDone = true; maybeHideLoadingOverlay(); }, { once: true } ); } else { loadingAnimDone = true; } function primeVideo(video: HTMLVideoElement) { const dataSrc = video.dataset.src; if (!dataSrc) return; // Only set once; after that the browser cache handles re-opens. if (video.getAttribute("src")) return; // Encourage inline playback on iOS Safari. video.setAttribute("playsinline", ""); video.setAttribute("webkit-playsinline", ""); // If the user is interested, buffer aggressively. video.preload = "auto"; video.setAttribute("src", dataSrc); // Trigger the fetch as soon as we know the user is interested. try { video.load(); } catch { // ignore } } function playVideoFullscreen(video: HTMLVideoElement, onExit: () => void) { primeVideo(video); // Start playback first (some browsers require play() before fullscreen works reliably). void video.play().catch(() => {}); const anyVid = video as unknown as { webkitEnterFullscreen?: () => void; webkitRequestFullscreen?: () => Promise | void; requestFullscreen?: () => Promise; }; // iOS Safari uses a special fullscreen video API. if (typeof anyVid.webkitEnterFullscreen === "function") { video.addEventListener( "webkitendfullscreen", () => { onExit(); }, { once: true } as AddEventListenerOptions ); try { anyVid.webkitEnterFullscreen(); } catch { // If fullscreen fails, still keep it playable in the popover. } return; } const onFsChange = () => { const docAny = document as unknown as { webkitFullscreenElement?: Element | null }; const fsEl = document.fullscreenElement ?? docAny.webkitFullscreenElement ?? null; // When the video is no longer the fullscreen element, close the window. if (fsEl !== video) { document.removeEventListener("fullscreenchange", onFsChange); document.removeEventListener("webkitfullscreenchange", onFsChange); onExit(); } }; document.addEventListener("fullscreenchange", onFsChange); document.addEventListener("webkitfullscreenchange", onFsChange as EventListener); const request = (video.requestFullscreen?.bind(video) as (() => Promise) | undefined) || (anyVid.webkitRequestFullscreen?.bind(video) as (() => Promise | void) | undefined); if (request) { Promise.resolve(request()).catch(() => { // If fullscreen fails, remove listeners and keep popover open. document.removeEventListener("fullscreenchange", onFsChange); document.removeEventListener("webkitfullscreenchange", onFsChange as EventListener); }); } } function setLoading(msg: string | null) { if (!msg) return; // hiding is gated by `maybeHideLoadingOverlay()` loading.classList.remove("hidden"); loadingDetail.textContent = msg; } // Renderer / Scene const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); renderer.setSize(app.clientWidth, app.clientHeight, false); renderer.setClearColor(0x0b0b0b, 1); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.1; const scene = new THREE.Scene(); const pmrem = new THREE.PMREMGenerator(renderer); scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; pmrem.dispose(); const camera = new THREE.PerspectiveCamera(50, app.clientWidth / app.clientHeight, 0.1, 100000); camera.position.set(0, 1.2, 3); // OrbitControls const controls = new OrbitControls(camera, canvas); controls.enableDamping = true; // Lower = smoother / more eased motion. controls.dampingFactor = 0.035; controls.enablePan = true; controls.rotateSpeed = 0.45; controls.zoomSpeed = 0.7; controls.panSpeed = 0.6; controls.screenSpacePanning = true; controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.DOLLY }; controls.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_PAN }; // Enforce navigation bounds on interaction-driven camera changes (if enabled). // The actual clamping function is declared later. controls.addEventListener("change", () => clampCameraToNavBounds()); // Transform controls for moving objects along X/Y/Z const tControls = new TransformControls(camera, renderer.domElement); tControls.setMode("translate"); tControls.setSpace("world"); tControls.enabled = false; scene.add(tControls); tControls.addEventListener("dragging-changed", (ev) => { const dragging = (ev as unknown as { value?: boolean }).value === true; controls.enabled = !dragging; }); const modelRoot = new THREE.Group(); scene.add(modelRoot); // Models let showroomBox: THREE.Object3D | null = null; let carModel: THREE.Object3D | null = null; let f1CarModel: THREE.Object3D | null = null; // Navigation bounds (camera + target clamped inside this box) let navBounds: THREE.Box3 | null = null; let navBoundsHelper: THREE.Box3Helper | null = null; let isClampingNav = false; // UI anchors that you can move with TransformControls (still “UI interactions”, but not extra visible models) let engineUiAnchor: THREE.Object3D | null = null; let wheelsUiAnchor: THREE.Object3D | null = null; let aeroUiAnchor: THREE.Object3D | null = null; let suspensionUiAnchor: THREE.Object3D | null = null; let f1EngineUiAnchor: THREE.Object3D | null = null; type ModelId = "car" | "f1Engine" | "united" | "engineUI" | "wheelsUI" | "aeroUI" | "suspensionUI"; type TransformSnapshot = { pos: { x: number; y: number; z: number }; rotDeg: { x: number; y: number; z: number }; scale: { x: number; y: number; z: number }; }; const TRANSFORM_STORE_KEY = "threejs-landing.transforms.v1"; const initialTransforms = new Map(); type CameraSnapshot = { pos: { x: number; y: number; z: number }; target: { x: number; y: number; z: number }; }; const CAMERA_STORE_KEY = "threejs-landing.camera.v1"; let initialCamera: CameraSnapshot | null = null; function getStoreIdFromSelectValue(v: string): ModelId | null { // Ensure each selectable item saves/restores into its own slot in localStorage. // This prevents transforms overwriting each other across reloads. if (v === "car") return "car"; if (v === "f1Engine") return "f1Engine"; if (v === "united") return "united"; if (v === "engineUI") return "engineUI"; if (v === "wheelsUI") return "wheelsUI"; if (v === "aeroUI") return "aeroUI"; if (v === "suspensionUI") return "suspensionUI"; return null; } function findNavBoundsObject(root: THREE.Object3D): THREE.Object3D | null { let found: THREE.Object3D | null = null; root.traverse((child) => { if (found) return; const name = (child.name || "").trim().toLowerCase(); if (!name) return; if (name === "navbounds" || name === "__nav_bounds" || name.includes("navbounds")) found = child; }); return found; } function computeDefaultNavBoundsFromBackground(bg: THREE.Object3D): THREE.Box3 { const box = new THREE.Box3().setFromObject(bg); const size = box.getSize(new THREE.Vector3()); // Shrink slightly so you can't clip through walls/edges while panning. const pad = size.multiplyScalar(0.06); box.expandByVector(pad.multiplyScalar(-1)); return box; } const NAV_BOUNDS_EXPAND = 1500; // increase/decrease to taste (units expanded per side) function setNavBoundsFromBackground(bg: THREE.Object3D) { // Prefer a dedicated "NavBounds" object inside the GLB. const navObj = findNavBoundsObject(bg); const box = navObj ? new THREE.Box3().setFromObject(navObj) : computeDefaultNavBoundsFromBackground(bg); // Expand the navigation volume on all axes (in all directions). box.expandByScalar(NAV_BOUNDS_EXPAND); navBounds = box; // Hide the nav bounds object if the author included a visible box mesh. if (navObj) navObj.visible = false; // Update helper if (navBoundsHelper) scene.remove(navBoundsHelper); navBoundsHelper = new THREE.Box3Helper(navBounds, 0x86a8ff); navBoundsHelper.visible = false; // toggle with "N" scene.add(navBoundsHelper); // Set reasonable distance limits based on bounds size (prevents huge zoom jumps). const diag = navBounds.getSize(new THREE.Vector3()).length() || 1; controls.minDistance = Math.max(0.05, diag * 0.01); controls.maxDistance = diag * 1.5; } function clampCameraToNavBounds() { if (!navBounds || isClampingNav) return; isClampingNav = true; try { controls.target.clamp(navBounds.min, navBounds.max); camera.position.clamp(navBounds.min, navBounds.max); controls.update(); } finally { isClampingNav = false; } } function clamp(v: number, min: number, max: number) { return Math.max(min, Math.min(max, v)); } function degToRad(deg: number) { return (deg * Math.PI) / 180; } function radToDeg(rad: number) { return (rad * 180) / Math.PI; } function safeNum(v: string, fallback: number) { const n = Number(v); return Number.isFinite(n) ? n : fallback; } function enableTRS(obj: THREE.Object3D) { obj.updateWorldMatrix(true, false); obj.matrix.decompose(obj.position, obj.quaternion, obj.scale); obj.matrixAutoUpdate = true; } function snapshotTransform(obj: THREE.Object3D): TransformSnapshot { const e = new THREE.Euler().setFromQuaternion(obj.quaternion, "XYZ"); return { pos: { x: obj.position.x, y: obj.position.y, z: obj.position.z }, rotDeg: { x: radToDeg(e.x), y: radToDeg(e.y), z: radToDeg(e.z) }, scale: { x: obj.scale.x, y: obj.scale.y, z: obj.scale.z } }; } function applySnapshot(obj: THREE.Object3D, snap: TransformSnapshot) { enableTRS(obj); obj.position.set(snap.pos.x, snap.pos.y, snap.pos.z); obj.quaternion.setFromEuler( new THREE.Euler(degToRad(snap.rotDeg.x), degToRad(snap.rotDeg.y), degToRad(snap.rotDeg.z), "XYZ") ); obj.scale.set(snap.scale.x, snap.scale.y, snap.scale.z); obj.updateMatrixWorld(true); } function readSavedTransforms(): Partial> { try { const raw = window.localStorage.getItem(TRANSFORM_STORE_KEY); if (!raw) return {}; const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== "object") return {}; return parsed as Partial>; } catch { return {}; } } function writeSavedTransforms(data: Partial>) { try { window.localStorage.setItem(TRANSFORM_STORE_KEY, JSON.stringify(data)); } catch { // ignore } } function saveTransformFor(id: ModelId, obj: THREE.Object3D) { const all = readSavedTransforms(); all[id] = snapshotTransform(obj); writeSavedTransforms(all); } function applySavedTransformIfAny(id: ModelId, obj: THREE.Object3D) { const all = readSavedTransforms(); const snap = all[id]; if (!snap) return; applySnapshot(obj, snap); } function snapshotCamera(): CameraSnapshot { return { pos: { x: camera.position.x, y: camera.position.y, z: camera.position.z }, target: { x: controls.target.x, y: controls.target.y, z: controls.target.z } }; } function applyCameraSnapshot(s: CameraSnapshot) { camera.position.set(s.pos.x, s.pos.y, s.pos.z); controls.target.set(s.target.x, s.target.y, s.target.z); controls.update(); } function readSavedCamera(): CameraSnapshot | null { try { const raw = window.localStorage.getItem(CAMERA_STORE_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as CameraSnapshot; if (!parsed?.pos || !parsed?.target) return null; return parsed; } catch { return null; } } function writeSavedCamera(s: CameraSnapshot) { try { window.localStorage.setItem(CAMERA_STORE_KEY, JSON.stringify(s)); } catch { // ignore } } function syncCameraInputsFromView() { camX.value = camera.position.x.toFixed(3); camY.value = camera.position.y.toFixed(3); camZ.value = camera.position.z.toFixed(3); tgtX.value = controls.target.x.toFixed(3); tgtY.value = controls.target.y.toFixed(3); tgtZ.value = controls.target.z.toFixed(3); } function applyCameraInputsToView() { const s: CameraSnapshot = { pos: { x: safeNum(camX.value, camera.position.x), y: safeNum(camY.value, camera.position.y), z: safeNum(camZ.value, camera.position.z) }, target: { x: safeNum(tgtX.value, controls.target.x), y: safeNum(tgtY.value, controls.target.y), z: safeNum(tgtZ.value, controls.target.z) } }; applyCameraSnapshot(s); writeSavedCamera(s); } // Persist camera changes made by OrbitControls (only when camera panel is on). let cameraModeOn = false; controls.addEventListener("end", () => { if (!cameraModeOn) return; writeSavedCamera(snapshotCamera()); syncCameraInputsFromView(); }); // UI anchors / buttons positioning const tmpV = new THREE.Vector3(); // (tmpBox removed; use local Box3 instances where needed to satisfy noUnusedLocals) function createUiAnchorAtWorld(parent: THREE.Object3D, worldPos: THREE.Vector3, name: string) { const anchor = new THREE.Object3D(); anchor.name = name; const local = worldPos.clone(); parent.worldToLocal(local); anchor.position.copy(local); parent.add(anchor); anchor.updateMatrixWorld(true); return anchor; } function findEngineAnchor(root: THREE.Object3D): THREE.Object3D | null { const nameCandidates: THREE.Object3D[] = []; root.traverse((child) => { const name = (child.name || "").toLowerCase(); if (!name) return; if (name.includes("engine") || name.includes("motor") || name.includes("v6") || name.includes("v8") || name.includes("hybrid")) { nameCandidates.push(child); } }); const scoreByVolume = (obj: THREE.Object3D) => { const box = new THREE.Box3().setFromObject(obj); const size = box.getSize(new THREE.Vector3()); const score = size.x * size.y * size.z; return Number.isFinite(score) ? score : -Infinity; }; if (nameCandidates.length > 0) { let best: THREE.Object3D | null = null; let bestScore = -Infinity; for (const c of nameCandidates) { const score = scoreByVolume(c); if (score > bestScore) { bestScore = score; best = c; } } if (best) return best; } // Fallback: choose a mesh near the rear/top center of the car (works for many exports). const carBox = new THREE.Box3().setFromObject(root); const carSize = carBox.getSize(new THREE.Vector3()); if (carSize.length() <= 0) return null; const center = carBox.getCenter(new THREE.Vector3()); const zMin = carBox.min.z; const zMax = carBox.max.z; const yMin = carBox.min.y; const yMax = carBox.max.y; const rearZStart = zMin + (zMax - zMin) * 0.55; const yStart = yMin + (yMax - yMin) * 0.25; const xBand = Math.max(0.001, carSize.x * 0.25); const meshCandidates: THREE.Mesh[] = []; root.traverse((child) => { if (!(child as THREE.Mesh).isMesh) return; const mesh = child as THREE.Mesh; const b = new THREE.Box3().setFromObject(mesh); const c = b.getCenter(new THREE.Vector3()); if (c.z < rearZStart) return; if (c.y < yStart) return; if (Math.abs(c.x - center.x) > xBand) return; meshCandidates.push(mesh); }); if (meshCandidates.length > 0) { let best: THREE.Object3D | null = null; let bestScore = -Infinity; for (const m of meshCandidates) { const score = scoreByVolume(m); if (score > bestScore) { bestScore = score; best = m; } } return best; } return null; } function findFrontRightWheelAnchor(root: THREE.Object3D): THREE.Object3D | null { const wheels: THREE.Object3D[] = []; root.traverse((child) => { const name = (child.name || "").toLowerCase(); if (name.includes("wheel") || name.includes("tire") || name.includes("rim")) wheels.push(child); }); if (wheels.length === 0) return null; // In this scene we treat "front" as smaller Z. const wp = new THREE.Vector3(); let minZ = Infinity; let front: THREE.Object3D[] = []; for (const w of wheels) { w.getWorldPosition(wp); if (wp.z < minZ - 1e-6) { minZ = wp.z; front = [w]; } else if (Math.abs(wp.z - minZ) <= 1e-6) { front.push(w); } } let best: THREE.Object3D | null = null; let maxX = -Infinity; for (const w of front) { w.getWorldPosition(wp); if (wp.x > maxX) { maxX = wp.x; best = w; } } return best; } function findAeroAnchor(root: THREE.Object3D): THREE.Object3D | null { const nameCandidates: THREE.Object3D[] = []; root.traverse((child) => { const name = (child.name || "").toLowerCase(); if (!name) return; if ( name.includes("aero") || name.includes("wing") || name.includes("spoiler") || name.includes("diffuser") || name.includes("splitter") ) { nameCandidates.push(child); } }); if (nameCandidates.length === 0) return null; // Prefer the largest candidate by bounds volume. let best: THREE.Object3D | null = null; let bestScore = -Infinity; for (const c of nameCandidates) { const box = new THREE.Box3().setFromObject(c); const size = box.getSize(new THREE.Vector3()); const score = size.x * size.y * size.z; if (Number.isFinite(score) && score > bestScore) { bestScore = score; best = c; } } return best; } function findSuspensionAnchor(root: THREE.Object3D): THREE.Object3D | null { const nameCandidates: THREE.Object3D[] = []; root.traverse((child) => { const name = (child.name || "").toLowerCase(); if (!name) return; if ( name.includes("suspension") || name.includes("spring") || name.includes("damper") || name.includes("shock") || name.includes("strut") || name.includes("coilover") || name.includes("controlarm") || name.includes("wishbone") ) { nameCandidates.push(child); } }); if (nameCandidates.length === 0) return null; // Prefer the largest candidate by bounds volume (usually the whole suspension assembly). let best: THREE.Object3D | null = null; let bestScore = -Infinity; for (const c of nameCandidates) { const box = new THREE.Box3().setFromObject(c); const size = box.getSize(new THREE.Vector3()); const score = size.x * size.y * size.z; if (Number.isFinite(score) && score > bestScore) { bestScore = score; best = c; } } return best; } function seedUiAnchors(car: THREE.Object3D) { // Remove previous anchors. if (engineUiAnchor?.parent) engineUiAnchor.parent.remove(engineUiAnchor); if (wheelsUiAnchor?.parent) wheelsUiAnchor.parent.remove(wheelsUiAnchor); if (aeroUiAnchor?.parent) aeroUiAnchor.parent.remove(aeroUiAnchor); if (suspensionUiAnchor?.parent) suspensionUiAnchor.parent.remove(suspensionUiAnchor); engineUiAnchor = null; wheelsUiAnchor = null; aeroUiAnchor = null; suspensionUiAnchor = null; // Seed from real parts if we can find them; fallback to bounding box. const engObj = findEngineAnchor(car); const wheelObj = findFrontRightWheelAnchor(car); const aeroObj = findAeroAnchor(car); const suspObj = findSuspensionAnchor(car); const carBox = new THREE.Box3().setFromObject(car); const center = carBox.getCenter(new THREE.Vector3()); const size = carBox.getSize(new THREE.Vector3()); // These are just reasonable defaults; you can move them in the Move panel. const engineWorld = engObj ? new THREE.Box3().setFromObject(engObj).getCenter(new THREE.Vector3()) : center.clone().add(new THREE.Vector3(0, size.y * 0.15, 0)); const wheelsWorld = wheelObj ? new THREE.Box3().setFromObject(wheelObj).getCenter(new THREE.Vector3()) : center.clone().add(new THREE.Vector3(size.x * 0.18, size.y * -0.05, 0)); const aeroWorld = aeroObj ? new THREE.Box3().setFromObject(aeroObj).getCenter(new THREE.Vector3()) : center.clone().add(new THREE.Vector3(0, size.y * 0.22, size.z * 0.22)); const suspensionWorld = suspObj ? new THREE.Box3().setFromObject(suspObj).getCenter(new THREE.Vector3()) : center.clone().add(new THREE.Vector3(size.x * 0.12, size.y * -0.12, size.z * -0.08)); engineUiAnchor = createUiAnchorAtWorld(car, engineWorld, "__engine_ui_anchor"); wheelsUiAnchor = createUiAnchorAtWorld(car, wheelsWorld, "__wheels_ui_anchor"); aeroUiAnchor = createUiAnchorAtWorld(car, aeroWorld, "__aero_ui_anchor"); suspensionUiAnchor = createUiAnchorAtWorld(car, suspensionWorld, "__suspension_ui_anchor"); initialTransforms.set("engineUI", snapshotTransform(engineUiAnchor)); initialTransforms.set("wheelsUI", snapshotTransform(wheelsUiAnchor)); initialTransforms.set("aeroUI", snapshotTransform(aeroUiAnchor)); initialTransforms.set("suspensionUI", snapshotTransform(suspensionUiAnchor)); applySavedTransformIfAny("engineUI", engineUiAnchor); applySavedTransformIfAny("wheelsUI", wheelsUiAnchor); applySavedTransformIfAny("aeroUI", aeroUiAnchor); applySavedTransformIfAny("suspensionUI", suspensionUiAnchor); } function seedF1EngineAnchor(f1Car: THREE.Object3D) { if (f1EngineUiAnchor?.parent) f1EngineUiAnchor.parent.remove(f1EngineUiAnchor); f1EngineUiAnchor = null; const engObj = findEngineAnchor(f1Car); const carBox = new THREE.Box3().setFromObject(f1Car); const center = carBox.getCenter(new THREE.Vector3()); const size = carBox.getSize(new THREE.Vector3()); const engineWorld = engObj ? new THREE.Box3().setFromObject(engObj).getCenter(new THREE.Vector3()) : center.clone().add(new THREE.Vector3(0, size.y * 0.15, 0)); f1EngineUiAnchor = createUiAnchorAtWorld(f1Car, engineWorld, "__f1_engine_ui_anchor"); } function updateAnchorButtons() { if (!showroomBox || !engineUiAnchor || !wheelsUiAnchor || !aeroUiAnchor || !suspensionUiAnchor) { anchorBtn.style.display = "none"; f1EngineAnchorBtn.style.display = "none"; wheelsBtn.style.display = "none"; aeroAnchorBtn.style.display = "none"; suspensionAnchorBtn.style.display = "none"; return; } const eWorld = engineUiAnchor.getWorldPosition(tmpV); const eNdc = eWorld.clone().project(camera); const ex = (eNdc.x * 0.5 + 0.5) * app.clientWidth; const ey = (-eNdc.y * 0.5 + 0.5) * app.clientHeight; anchorBtn.style.left = `${ex}px`; anchorBtn.style.top = `${ey}px`; anchorBtn.style.display = eNdc.z > 1 ? "none" : "block"; if (isExploreOpen) positionExplorePopover(); if (isEngineMenuOpen) positionEngineMenu(); if (f1EngineUiAnchor && f1CarModel) { const feWorld = f1EngineUiAnchor.getWorldPosition(tmpV); const feNdc = feWorld.clone().project(camera); const fx = (feNdc.x * 0.5 + 0.5) * app.clientWidth; const fy = (-feNdc.y * 0.5 + 0.5) * app.clientHeight; f1EngineAnchorBtn.style.left = `${fx}px`; f1EngineAnchorBtn.style.top = `${fy}px`; f1EngineAnchorBtn.style.display = feNdc.z > 1 ? "none" : "block"; if (isF1EngineMenuOpen) positionF1EngineMenu(); } else { f1EngineAnchorBtn.style.display = "none"; } const wWorld = wheelsUiAnchor.getWorldPosition(tmpV); const wNdc = wWorld.clone().project(camera); const wx = (wNdc.x * 0.5 + 0.5) * app.clientWidth; const wy = (-wNdc.y * 0.5 + 0.5) * app.clientHeight; wheelsBtn.style.left = `${wx}px`; wheelsBtn.style.top = `${wy}px`; wheelsBtn.style.display = wNdc.z > 1 ? "none" : "block"; if (isWheelsMenuOpen) positionWheelsMenu(); const aWorld = aeroUiAnchor.getWorldPosition(tmpV); const aNdc = aWorld.clone().project(camera); const ax = (aNdc.x * 0.5 + 0.5) * app.clientWidth; const ay = (-aNdc.y * 0.5 + 0.5) * app.clientHeight; aeroAnchorBtn.style.left = `${ax}px`; aeroAnchorBtn.style.top = `${ay}px`; aeroAnchorBtn.style.display = aNdc.z > 1 ? "none" : "block"; if (isAeroOpen) positionAeroPopover(); const sWorld = suspensionUiAnchor.getWorldPosition(tmpV); const sNdc = sWorld.clone().project(camera); const sx = (sNdc.x * 0.5 + 0.5) * app.clientWidth; const sy = (-sNdc.y * 0.5 + 0.5) * app.clientHeight; suspensionAnchorBtn.style.left = `${sx}px`; suspensionAnchorBtn.style.top = `${sy}px`; suspensionAnchorBtn.style.display = sNdc.z > 1 ? "none" : "block"; if (isSuspensionOpen) positionSuspensionPopover(); } // Popover / menus let isExploreOpen = false; function setExploreOpen(next: boolean) { isExploreOpen = next; explorePopover.classList.toggle("open", next); explorePopover.setAttribute("aria-hidden", next ? "false" : "true"); // Keep `aria-expanded` reflecting the engine menu state (not the learn popover). positionExplorePopover(); if (!next) { engineVideo.pause(); engineVideo.currentTime = 0; } } function positionExplorePopover() { if (!isExploreOpen) return; const appRect = app.getBoundingClientRect(); const btnRect = anchorBtn.getBoundingClientRect(); let left = btnRect.right - appRect.left + 12; let top = btnRect.top - appRect.top + btnRect.height / 2; explorePopover.style.left = `${left}px`; explorePopover.style.top = `${top}px`; explorePopover.style.transform = "translateY(-50%)"; const popRect = explorePopover.getBoundingClientRect(); const pad = 12; const maxLeft = appRect.width - popRect.width - pad; const minLeft = pad; left = clamp(left, minLeft, maxLeft); const desiredTopLeft = top - popRect.height / 2; const clampedTopLeft = clamp(desiredTopLeft, pad, appRect.height - popRect.height - pad); top = clampedTopLeft + popRect.height / 2; explorePopover.style.left = `${left}px`; explorePopover.style.top = `${top}px`; } let isEngineMenuOpen = false; function setEngineMenuOpen(next: boolean) { isEngineMenuOpen = next; engineMenu.classList.toggle("open", next); engineMenu.setAttribute("aria-hidden", next ? "false" : "true"); anchorBtn.setAttribute("aria-expanded", next ? "true" : "false"); if (!next) setExploreOpen(false); } function positionEngineMenu() { if (!isEngineMenuOpen) return; const appRect = app.getBoundingClientRect(); const btnRect = anchorBtn.getBoundingClientRect(); const left = btnRect.left - appRect.left + btnRect.width / 2; const top = btnRect.bottom - appRect.top + 10; engineMenu.style.left = `${left}px`; engineMenu.style.top = `${top}px`; engineMenu.style.transform = "translate(-50%, 0)"; } let lamboEngineOpen = false; function setLamboToggleLabel(open: boolean) { engineToggleCoverBtn.textContent = open ? "Close" : "Open"; } function setF1ToggleLabel(v: "cover" | "noCover") { f1EngineToggleCoverBtn.textContent = v === "cover" ? "Open" : "Close"; } anchorBtn.addEventListener("click", () => { setEngineMenuOpen(!isEngineMenuOpen); positionEngineMenu(); // Prefetch video once the user expresses interest. primeVideo(engineVideo); }); engineLearnBtn.addEventListener("click", () => { setExploreOpen(true); positionExplorePopover(); // On click, go straight into fullscreen playback; when fullscreen exits, close the window. playVideoFullscreen(engineVideo, () => setExploreOpen(false)); }); engineLearnBtn.addEventListener("pointerenter", () => primeVideo(engineVideo)); let isF1EngineMenuOpen = false; function setF1EngineMenuOpen(next: boolean) { isF1EngineMenuOpen = next; f1EngineMenu.classList.toggle("open", next); f1EngineMenu.setAttribute("aria-hidden", next ? "false" : "true"); f1EngineAnchorBtn.setAttribute("aria-expanded", next ? "true" : "false"); if (!next) setExploreOpen(false); } function positionF1EngineMenu() { if (!isF1EngineMenuOpen) return; const appRect = app.getBoundingClientRect(); const btnRect = f1EngineAnchorBtn.getBoundingClientRect(); const left = btnRect.left - appRect.left + btnRect.width / 2; const top = btnRect.bottom - appRect.top + 10; f1EngineMenu.style.left = `${left}px`; f1EngineMenu.style.top = `${top}px`; f1EngineMenu.style.transform = "translate(-50%, 0)"; } f1EngineAnchorBtn.addEventListener("click", () => { setF1EngineMenuOpen(!isF1EngineMenuOpen); positionF1EngineMenu(); primeVideo(engineVideo); }); f1EngineLearnBtn.addEventListener("click", () => { setExploreOpen(true); positionExplorePopover(); playVideoFullscreen(engineVideo, () => setExploreOpen(false)); }); f1EngineLearnBtn.addEventListener("pointerenter", () => primeVideo(engineVideo)); let isAeroOpen = false; function setAeroOpen(next: boolean) { isAeroOpen = next; aeroPopover.classList.toggle("open", next); aeroPopover.setAttribute("aria-hidden", next ? "false" : "true"); aeroAnchorBtn.setAttribute("aria-expanded", next ? "true" : "false"); positionAeroPopover(); if (!next) { aeroVideo.pause(); aeroVideo.currentTime = 0; } } function positionAeroPopover() { if (!isAeroOpen) return; const appRect = app.getBoundingClientRect(); const btnRect = aeroAnchorBtn.getBoundingClientRect(); let left = btnRect.right - appRect.left + 12; let top = btnRect.top - appRect.top + btnRect.height / 2; aeroPopover.style.left = `${left}px`; aeroPopover.style.top = `${top}px`; aeroPopover.style.transform = "translateY(-50%)"; const popRect = aeroPopover.getBoundingClientRect(); const pad = 12; const maxLeft = appRect.width - popRect.width - pad; const minLeft = pad; left = clamp(left, minLeft, maxLeft); const desiredTopLeft = top - popRect.height / 2; const clampedTopLeft = clamp(desiredTopLeft, pad, appRect.height - popRect.height - pad); top = clampedTopLeft + popRect.height / 2; aeroPopover.style.left = `${left}px`; aeroPopover.style.top = `${top}px`; } aeroAnchorBtn.addEventListener("click", () => { setAeroOpen(true); playVideoFullscreen(aeroVideo, () => setAeroOpen(false)); }); // Start fetching on hover to make playback feel instant on click. aeroAnchorBtn.addEventListener("pointerenter", () => primeVideo(aeroVideo)); let isSuspensionOpen = false; function setSuspensionOpen(next: boolean) { isSuspensionOpen = next; suspensionPopover.classList.toggle("open", next); suspensionPopover.setAttribute("aria-hidden", next ? "false" : "true"); suspensionAnchorBtn.setAttribute("aria-expanded", next ? "true" : "false"); positionSuspensionPopover(); if (!next) { suspensionVideo.pause(); suspensionVideo.currentTime = 0; } } function positionSuspensionPopover() { if (!isSuspensionOpen) return; const appRect = app.getBoundingClientRect(); const btnRect = suspensionAnchorBtn.getBoundingClientRect(); let left = btnRect.right - appRect.left + 12; let top = btnRect.top - appRect.top + btnRect.height / 2; suspensionPopover.style.left = `${left}px`; suspensionPopover.style.top = `${top}px`; suspensionPopover.style.transform = "translateY(-50%)"; const popRect = suspensionPopover.getBoundingClientRect(); const pad = 12; const maxLeft = appRect.width - popRect.width - pad; const minLeft = pad; left = clamp(left, minLeft, maxLeft); const desiredTopLeft = top - popRect.height / 2; const clampedTopLeft = clamp(desiredTopLeft, pad, appRect.height - popRect.height - pad); top = clampedTopLeft + popRect.height / 2; suspensionPopover.style.left = `${left}px`; suspensionPopover.style.top = `${top}px`; } suspensionAnchorBtn.addEventListener("click", () => { setSuspensionOpen(true); playVideoFullscreen(suspensionVideo, () => setSuspensionOpen(false)); }); // Start fetching on hover to make playback feel instant on click. suspensionAnchorBtn.addEventListener("pointerenter", () => primeVideo(suspensionVideo)); let isWheelsMenuOpen = false; function setWheelsMenuOpen(next: boolean) { isWheelsMenuOpen = next; wheelsMenu.classList.toggle("open", next); wheelsMenu.setAttribute("aria-hidden", next ? "false" : "true"); wheelsBtn.setAttribute("aria-expanded", next ? "true" : "false"); positionWheelsMenu(); } function positionWheelsMenu() { if (!isWheelsMenuOpen) return; const appRect = app.getBoundingClientRect(); const btnRect = wheelsBtn.getBoundingClientRect(); const radius = 86; const angleOffset = -Math.PI / 2; const centerX = btnRect.left + btnRect.width / 2; const centerY = btnRect.top + btnRect.height / 2; const buttons = [wheelsBrakeBtn, wheelsTreadingBtn, wheelsRimsBtn]; buttons.forEach((button, i) => { const angle = angleOffset + (i * Math.PI) / (buttons.length - 1); const x = centerX + radius * Math.cos(angle); const y = centerY + radius * Math.sin(angle); button.style.left = `${x - appRect.left}px`; button.style.top = `${y - appRect.top}px`; button.style.transform = "translate(-50%, -50%)"; }); } wheelsBtn.addEventListener("click", () => setWheelsMenuOpen(!isWheelsMenuOpen)); wheelsBrakeBtn.addEventListener("click", () => showToast("Brake pads clicked")); wheelsTreadingBtn.addEventListener("click", () => showToast("Treading clicked")); wheelsRimsBtn.addEventListener("click", () => showToast("Rims clicked")); document.addEventListener("pointerdown", (e) => { const target = e.target as Node | null; if (!target) return; if (isExploreOpen) { if (!engineLearnBtn.contains(target) && !explorePopover.contains(target)) setExploreOpen(false); } if (isEngineMenuOpen) { if (!anchorBtn.contains(target) && !engineMenu.contains(target)) setEngineMenuOpen(false); } if (isF1EngineMenuOpen) { if (!f1EngineAnchorBtn.contains(target) && !f1EngineMenu.contains(target)) setF1EngineMenuOpen(false); } if (isAeroOpen) { if (!aeroAnchorBtn.contains(target) && !aeroPopover.contains(target)) setAeroOpen(false); } if (isSuspensionOpen) { if (!suspensionAnchorBtn.contains(target) && !suspensionPopover.contains(target)) setSuspensionOpen(false); } if (isWheelsMenuOpen) { if (!wheelsBtn.contains(target) && !wheelsMenu.contains(target)) setWheelsMenuOpen(false); } }); // Topbar: rotate / wireframe let autoRotate = false; function updateRotateBtnLabel() { if (!rotateBtn) return; rotateBtn.textContent = autoRotate ? "Stop rotation" : "Rotate"; } updateRotateBtnLabel(); rotateBtn?.addEventListener("click", () => { autoRotate = !autoRotate; updateRotateBtnLabel(); showToast(autoRotate ? "Rotation enabled" : "Rotation stopped"); }); let wireframeOn = false; const meshState = new WeakMap(); function setWireframeForObject(root: THREE.Object3D, enabled: boolean) { root.traverse((child) => { if (!(child as THREE.Mesh).isMesh) return; const mesh = child as THREE.Mesh; if (enabled) { if (!meshState.has(mesh)) meshState.set(mesh, { material: mesh.material }); mesh.material = new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true }); } else { const prev = meshState.get(mesh); if (prev) { mesh.material = prev.material; meshState.delete(mesh); } } if (Array.isArray(mesh.material)) { for (const m of mesh.material) m.needsUpdate = true; } else { mesh.material.needsUpdate = true; } }); } wireframeBtn?.addEventListener("click", () => { wireframeOn = !wireframeOn; if (showroomBox) setWireframeForObject(showroomBox, wireframeOn); if (carModel) setWireframeForObject(carModel, wireframeOn); if (f1CarModel) setWireframeForObject(f1CarModel, wireframeOn); showToast(wireframeOn ? "Wireframe ON" : "Wireframe OFF"); }); // Move panel + TransformControls let selectedMoveModel: THREE.Object3D | null = null; let moveModeOn = false; function setPanelOpen(btn: HTMLButtonElement, panel: HTMLElement, open: boolean) { btn.setAttribute("aria-expanded", open ? "true" : "false"); btn.setAttribute("aria-pressed", open ? "true" : "false"); panel.classList.toggle("collapsed", !open); panel.setAttribute("aria-hidden", open ? "false" : "true"); } function syncTransformInputsFromObject(obj: THREE.Object3D) { xInput.value = obj.position.x.toFixed(3); yInput.value = obj.position.y.toFixed(3); zInput.value = obj.position.z.toFixed(3); const e = new THREE.Euler().setFromQuaternion(obj.quaternion, "XYZ"); rxInput.value = `${Math.round(radToDeg(e.x))}`; ryInput.value = `${Math.round(radToDeg(e.y))}`; rzInput.value = `${Math.round(radToDeg(e.z))}`; sxInput.value = obj.scale.x.toFixed(4); syInput.value = obj.scale.y.toFixed(4); szInput.value = obj.scale.z.toFixed(4); const avg = (obj.scale.x + obj.scale.y + obj.scale.z) / 3; sUniformInput.value = avg.toFixed(4); } function applyTransformInputsToObject(obj: THREE.Object3D) { enableTRS(obj); obj.position.set( safeNum(xInput.value, obj.position.x), safeNum(yInput.value, obj.position.y), safeNum(zInput.value, obj.position.z) ); const ex = degToRad(safeNum(rxInput.value, 0)); const ey = degToRad(safeNum(ryInput.value, 0)); const ez = degToRad(safeNum(rzInput.value, 0)); obj.quaternion.setFromEuler(new THREE.Euler(ex, ey, ez, "XYZ")); const uni = safeNum(sUniformInput.value, NaN); const minScale = 0.001; if (Number.isFinite(uni) && uni > 0) { const s = Math.max(minScale, uni); obj.scale.set(s, s, s); } else { obj.scale.set( Math.max(minScale, safeNum(sxInput.value, obj.scale.x)), Math.max(minScale, safeNum(syInput.value, obj.scale.y)), Math.max(minScale, safeNum(szInput.value, obj.scale.z)) ); } obj.updateMatrixWorld(true); } function setMoveMode(next: boolean) { moveModeOn = next; tControls.enabled = next; setPanelOpen(movePanelBtn, getRequiredEl("movePanel"), next); if (!next) { tControls.detach(); selectedMoveModel = null; } else { tControls.setMode("translate"); selectMoveModel(); } showToast(next ? "Move mode ON" : "Move mode OFF"); } function setCameraMode(next: boolean) { cameraModeOn = next; setPanelOpen(cameraPanelBtn, getRequiredEl("cameraPanel"), next); if (next) { syncCameraInputsFromView(); writeSavedCamera(snapshotCamera()); showToast("Camera editing ON (saved)."); } else { showToast("Camera editing OFF."); } } movePanelBtn.addEventListener("click", () => setMoveMode(!moveModeOn)); cameraPanelBtn.addEventListener("click", () => setCameraMode(!cameraModeOn)); function selectMoveModel() { if (!tControls.enabled) return; tControls.detach(); selectedMoveModel = null; // Keep existing dropdown values: // - car -> Lambo // - f1Engine -> F1 car // - united -> background (if present) // - engineUI/wheelsUI -> movable UI anchors if (modelSelect.value === "engineUI") selectedMoveModel = engineUiAnchor; else if (modelSelect.value === "wheelsUI") selectedMoveModel = wheelsUiAnchor; else if (modelSelect.value === "aeroUI") selectedMoveModel = aeroUiAnchor; else if (modelSelect.value === "suspensionUI") selectedMoveModel = suspensionUiAnchor; else if (modelSelect.value === "car") selectedMoveModel = carModel; else if (modelSelect.value === "f1Engine") selectedMoveModel = f1CarModel; else selectedMoveModel = showroomBox; if (selectedMoveModel) { enableTRS(selectedMoveModel); tControls.attach(selectedMoveModel); syncTransformInputsFromObject(selectedMoveModel); } } modelSelect.addEventListener("change", selectMoveModel); applyMoveBtn.addEventListener("click", () => { if (!selectedMoveModel) return; applyTransformInputsToObject(selectedMoveModel); const storeId = getStoreIdFromSelectValue(modelSelect.value); if (storeId) { // Make the newly-applied transform the new "default" for Reset. // This keeps only the current snapshot (no history) and avoids drift across reloads. initialTransforms.set(storeId, snapshotTransform(selectedMoveModel)); saveTransformFor(storeId, selectedMoveModel); } showToast(`Updated ${modelSelect.value}.`); }); tControls.addEventListener("objectChange", () => { if (!selectedMoveModel) return; syncTransformInputsFromObject(selectedMoveModel); const storeId = getStoreIdFromSelectValue(modelSelect.value); if (storeId) saveTransformFor(storeId, selectedMoveModel); }); resetTransformBtn.addEventListener("click", () => { if (!selectedMoveModel) return; const storeId = getStoreIdFromSelectValue(modelSelect.value); const snap = storeId ? initialTransforms.get(storeId) : undefined; if (snap) { applySnapshot(selectedMoveModel, snap); syncTransformInputsFromObject(selectedMoveModel); if (storeId) saveTransformFor(storeId, selectedMoveModel); showToast(`Reset ${modelSelect.value} (saved).`); return; } showToast("Nothing to reset yet."); }); function setTransformMode(mode: "translate" | "rotate" | "scale") { tControls.setMode(mode); showToast(`Gizmo: ${mode}`); } modeTranslateBtn.addEventListener("click", () => setTransformMode("translate")); modeRotateBtn.addEventListener("click", () => setTransformMode("rotate")); modeScaleBtn.addEventListener("click", () => setTransformMode("scale")); function syncUniformScaleFromAxes() { const sx = safeNum(sxInput.value, NaN); const sy = safeNum(syInput.value, NaN); const sz = safeNum(szInput.value, NaN); if (!Number.isFinite(sx) || !Number.isFinite(sy) || !Number.isFinite(sz)) return; sUniformInput.value = ((sx + sy + sz) / 3).toFixed(4); } sxInput.addEventListener("input", syncUniformScaleFromAxes); syInput.addEventListener("input", syncUniformScaleFromAxes); szInput.addEventListener("input", syncUniformScaleFromAxes); sUniformInput.addEventListener("input", () => { const uni = safeNum(sUniformInput.value, NaN); if (!Number.isFinite(uni)) return; const s = Math.max(0.001, uni); sxInput.value = s.toFixed(4); syInput.value = s.toFixed(4); szInput.value = s.toFixed(4); }); // Camera panel interactions cameraSetFromViewBtn.addEventListener("click", () => { syncCameraInputsFromView(); if (cameraModeOn) writeSavedCamera(snapshotCamera()); showToast("Camera captured from view."); }); cameraApplyBtn.addEventListener("click", () => { applyCameraInputsToView(); showToast("Camera applied (saved)."); }); cameraResetBtn.addEventListener("click", () => { if (!initialCamera) { try { window.localStorage.removeItem(CAMERA_STORE_KEY); } catch { // ignore } showToast("Camera reset (cleared saved)."); return; } applyCameraSnapshot(initialCamera); writeSavedCamera(initialCamera); syncCameraInputsFromView(); showToast("Camera reset (saved)."); }); function maybeLiveApplyCamera() { if (!cameraModeOn) return; applyCameraInputsToView(); } camX.addEventListener("input", maybeLiveApplyCamera); camY.addEventListener("input", maybeLiveApplyCamera); camZ.addEventListener("input", maybeLiveApplyCamera); tgtX.addEventListener("input", maybeLiveApplyCamera); tgtY.addEventListener("input", maybeLiveApplyCamera); tgtZ.addEventListener("input", maybeLiveApplyCamera); // Load only FINAL BG ONLY TRIAL 17.glb const baseUrl = import.meta.env.BASE_URL || "/"; const BG_GLB_URL = `${baseUrl}FINAL BG ONLY TRIAL 17.glb`; const CAR_GLB_URL = `${baseUrl}2 LAMBOS FINAL.glb`; const CAR_ENGINE_OPEN_GLB_URL = `${baseUrl}2 LAMBOS FINAL - ENGINE OPENv2.glb`; const F1_CAR_GLB_URL = `${baseUrl}F1CAR FINAL FILE.glb`; const F1_CAR_NO_COVER_GLB_URL = `${baseUrl}F1CAR FINAL FILE-no engine cover.glb`; // F1 swap state (controlled by Engine -> Open/Close) let f1NoCoverModel: THREE.Object3D | null = null; let f1Variant: "cover" | "noCover" = "cover"; let initialF1CoverModel: THREE.Object3D | null = null; // Lambo swap state (also controlled by Engine -> Open/Close) let lamboEngineOpenModel: THREE.Object3D | null = null; let initialLamboClosedModel: THREE.Object3D | null = null; function swapModelInRoot(current: THREE.Object3D, next: THREE.Object3D) { const snap = snapshotTransform(current); if (current.parent) current.parent.remove(current); applySnapshot(next, snap); modelRoot.add(next); setWireframeForObject(next, wireframeOn); // Keep Move panel selection working if user was editing the swapped model. if (tControls.enabled && selectedMoveModel === current) { selectedMoveModel = next; tControls.detach(); enableTRS(selectedMoveModel); tControls.attach(selectedMoveModel); syncTransformInputsFromObject(selectedMoveModel); } } function toggleLamboEngine() { if (!carModel || !lamboEngineOpenModel || !initialLamboClosedModel) return; const opening = !lamboEngineOpen; const current = carModel; const next = opening ? lamboEngineOpenModel : initialLamboClosedModel; swapModelInRoot(current, next); carModel = next; lamboEngineOpen = opening; setLamboToggleLabel(lamboEngineOpen); // Re-seed UI anchors so the Engine/Wheels/Aero/Suspension UI stays attached to the visible Lambo. seedUiAnchors(next); showToast(lamboEngineOpen ? "Lambo engine opened" : "Lambo engine closed"); } function toggleF1EngineCover() { if (!f1CarModel || !f1NoCoverModel || !initialF1CoverModel) return; const opening = f1Variant === "cover"; const current = f1CarModel; const next = opening ? f1NoCoverModel : initialF1CoverModel; swapModelInRoot(current, next); f1CarModel = next; f1Variant = opening ? "noCover" : "cover"; setF1ToggleLabel(f1Variant); // Keep the F1 engine button attached to the visible F1 model. seedF1EngineAnchor(next); showToast(f1Variant === "noCover" ? "F1 cover removed" : "F1 cover installed"); } async function loadGlb(url: string, name: string): Promise { setLoading(`Fetching ${name} model… (this file can be large)`); const loader = new GLTFLoader(); const draco = new DRACOLoader(); draco.setDecoderPath(`${baseUrl}draco/`); loader.setDRACOLoader(draco); try { const head = await fetch(encodeURI(url), { method: "HEAD" }); const len = head.headers.get("content-length"); if (len) { const mb = (Number(len) / (1024 * 1024)).toFixed(1); setLoading(`Downloading ${name} model… ~${mb} MB`); } } catch { // ignore } return await new Promise((resolve, reject) => { loader.load( encodeURI(url), (gltf) => { // Remove embedded lights (user asked to clear lights); lights panel will add controllable ones. gltf.scene.traverse((child) => { const anyObj = child as unknown as { isLight?: boolean }; if (anyObj.isLight && child.parent) child.parent.remove(child); }); resolve(gltf.scene); }, (evt) => { if (evt.total > 0) { const pct = Math.round((evt.loaded / evt.total) * 100); setLoading(`Downloading ${name} model… ${pct}%`); } else { setLoading(`Downloading ${name} model…`); } }, (err) => reject(err) ); }); } function frameCameraToObject(obj: THREE.Object3D) { const box = new THREE.Box3().setFromObject(obj); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); if (!Number.isFinite(size.x) || size.length() <= 0) return; const maxDim = Math.max(size.x, size.y, size.z); const fov = (camera.fov * Math.PI) / 180; const dist = (maxDim / (2 * Math.tan(fov / 2))) * 1.25; camera.position.copy(center.clone().add(new THREE.Vector3(0, maxDim * 0.15, dist))); camera.near = Math.max(0.01, dist / 200); camera.far = dist * 200; camera.updateProjectionMatrix(); controls.target.copy(center); controls.update(); } function placeCarInBackground(bg: THREE.Object3D, car: THREE.Object3D) { const bgBox = new THREE.Box3().setFromObject(bg); const bgSize = bgBox.getSize(new THREE.Vector3()); const bgCenter = bgBox.getCenter(new THREE.Vector3()); const bgFloorY = bgBox.min.y; const carBox = new THREE.Box3().setFromObject(car); const carSize = carBox.getSize(new THREE.Vector3()); if (carSize.length() <= 0) return; // Scale the car to a reasonable size relative to the background bounds. const targetWidth = bgSize.x * 0.22; const scale = targetWidth / (carSize.x || 1); car.scale.setScalar(scale); car.updateWorldMatrix(true, true); // Recompute after scaling const scaledCarBox = new THREE.Box3().setFromObject(car); const scaledCarSize = scaledCarBox.getSize(new THREE.Vector3()); const scaledCarCenter = scaledCarBox.getCenter(new THREE.Vector3()); // Place centered in X/Z, sitting on the BG "floor". const targetY = bgFloorY + scaledCarSize.y / 2; const targetPos = new THREE.Vector3(bgCenter.x, targetY, bgCenter.z); const offset = targetPos.sub(scaledCarCenter); car.position.add(offset); car.updateMatrixWorld(true); } function placeF1CarInBackground(bg: THREE.Object3D, car: THREE.Object3D) { const bgBox = new THREE.Box3().setFromObject(bg); const bgSize = bgBox.getSize(new THREE.Vector3()); const bgCenter = bgBox.getCenter(new THREE.Vector3()); const bgFloorY = bgBox.min.y; const carBox = new THREE.Box3().setFromObject(car); const carSize = carBox.getSize(new THREE.Vector3()); if (carSize.length() <= 0) return; // Slightly smaller than the Lambo so both can fit. const targetWidth = bgSize.x * 0.18; const scale = targetWidth / (carSize.x || 1); car.scale.setScalar(scale); car.updateWorldMatrix(true, true); const scaledCarBox = new THREE.Box3().setFromObject(car); const scaledCarSize = scaledCarBox.getSize(new THREE.Vector3()); const scaledCarCenter = scaledCarBox.getCenter(new THREE.Vector3()); // Place beside the Lambo (positive X), sitting on the BG floor. const targetY = bgFloorY + scaledCarSize.y / 2; const targetPos = new THREE.Vector3(bgCenter.x + bgSize.x * 0.18, targetY, bgCenter.z); const offset = targetPos.sub(scaledCarCenter); car.position.add(offset); car.updateMatrixWorld(true); } void (async () => { try { const [bgObj, carObj, carEngineOpenObj, f1CarObj, f1NoCoverObj] = await Promise.all([ loadGlb(BG_GLB_URL, "background"), loadGlb(CAR_GLB_URL, "car"), loadGlb(CAR_ENGINE_OPEN_GLB_URL, "car (engine open)"), loadGlb(F1_CAR_GLB_URL, "f1car"), loadGlb(F1_CAR_NO_COVER_GLB_URL, "f1car (no cover)") ]); modelRoot.clear(); modelRoot.add(bgObj); modelRoot.add(carObj); modelRoot.add(f1CarObj); showroomBox = bgObj; carModel = carObj; lamboEngineOpenModel = carEngineOpenObj; initialLamboClosedModel = carObj; f1CarModel = f1CarObj; f1NoCoverModel = f1NoCoverObj; initialF1CoverModel = f1CarObj; f1Variant = "cover"; lamboEngineOpen = false; setLamboToggleLabel(lamboEngineOpen); setF1ToggleLabel(f1Variant); placeCarInBackground(bgObj, carObj); placeF1CarInBackground(bgObj, f1CarObj); // Keep the alternate variant perfectly aligned with the cover variant. applySnapshot(f1NoCoverObj, snapshotTransform(f1CarObj)); // Keep the engine-open Lambo perfectly aligned with the closed Lambo. applySnapshot(carEngineOpenObj, snapshotTransform(carObj)); // Navigation bounds are derived from the background (or a `NavBounds` object inside it). setNavBoundsFromBackground(bgObj); // Baseline transforms for Reset (and restore any saved ones) initialTransforms.set("united", snapshotTransform(bgObj)); applySavedTransformIfAny("united", bgObj); initialTransforms.set("car", snapshotTransform(carObj)); applySavedTransformIfAny("car", carObj); // Mirror any saved transform to the alternate Lambo variant too. applySnapshot(carEngineOpenObj, snapshotTransform(carObj)); initialTransforms.set("f1Engine", snapshotTransform(f1CarObj)); applySavedTransformIfAny("f1Engine", f1CarObj); // Mirror any saved transform to the alternate variant too. applySnapshot(f1NoCoverObj, snapshotTransform(f1CarObj)); // UI anchors should attach to the car (engine/wheels UI). seedUiAnchors(carObj); seedF1EngineAnchor(f1CarObj); frameCameraToObject(modelRoot); initialCamera = snapshotCamera(); const savedCam = readSavedCamera(); if (savedCam) applyCameraSnapshot(savedCam); syncCameraInputsFromView(); // Ensure the initial view is inside bounds even if a saved camera is outside. clampCameraToNavBounds(); // Gate the transition: only hide the loading animation after both // (1) the scene is ready and (2) the loading video finished playing. sceneReady = true; setLoading("Ready…"); maybeHideLoadingOverlay(); showToast("Background loaded."); } catch (e) { console.error(e); setLoading("Couldn’t load background. Make sure the GLB is in /public and run `npm run dev`."); } })(); engineToggleCoverBtn.addEventListener("click", () => { toggleLamboEngine(); positionEngineMenu(); }); f1EngineToggleCoverBtn.addEventListener("click", () => { toggleF1EngineCover(); positionF1EngineMenu(); }); function onResize() { camera.aspect = app.clientWidth / app.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(app.clientWidth, app.clientHeight, false); if (isExploreOpen) positionExplorePopover(); if (isWheelsMenuOpen) positionWheelsMenu(); } window.addEventListener("resize", onResize); // Toggle navigation bounds helper with "N" window.addEventListener("keydown", (e) => { if (e.key.toLowerCase() !== "n") return; if (!navBoundsHelper) return; navBoundsHelper.visible = !navBoundsHelper.visible; showToast(navBoundsHelper.visible ? "Nav bounds ON" : "Nav bounds OFF"); }); function animate() { controls.update(); updateAnchorButtons(); if (isEngineMenuOpen) positionEngineMenu(); if (isF1EngineMenuOpen) positionF1EngineMenu(); if (autoRotate) modelRoot.rotation.y += 0.004; renderer.render(scene, camera); requestAnimationFrame(animate); } animate();