/* * Scene3D * --------------------------------------------------------------------------- * Was: ~11.6k lines of gltfjsx-generated components (1,372 meshes), each * with hardcoded castShadow/receiveShadow. That JSX shipped ~11.6k lines of JS, * forced React to reconcile 1,372 mesh fibers on mount (a long main-thread block), * and made every mesh cast + receive shadows. * * Now: the GLTF scene is rendered with a single . Animation refs and * shadow/LOD policy are applied by traversing the loaded graph once. * * REF WIRING — verified against the actual GLB node tree (not the gltfjsx JSX, * which flattens it): * tyre mesh (LCT300007_WheelStock_XX_RB1c_Tire_1k_*) * └─ Object_NN (wheel hub wrapper) * └─ LCT300007_WheelStock_XX ← wheelRef target (matches the old wheel * └─ RootNode group's transform exactly; Y is the axle) * └─ *.fbx * └─ Sketchfab_model ← truckRef target (a scene root; its * transform == the old truckRef group) * So: wheelRef = tyre.parent.parent, and truckRef = the tyre's ancestor whose * parent is the scene root. These point at the SAME nodes the original JSX did, * so TruckAnimation/wheelAnimation behave identically. * * The original generated file is preserved at Scene3D.gltfjsx.bak.txt. * * NOTE: still loads the single 31 MB GLB. Splitting + Draco/WebP compression is a * binary-asset step (see PR notes) and is what fixes DOWNLOAD weight; the changes * here reduce RUNTIME (JS bundle, mount cost, draw calls, shadow fill). */ import React, { useLayoutEffect } from 'react' import { useGLTF } from '@react-three/drei' const GLB = '/models/3d_scene_final.glb' const DRACO_PATH = '/draco/' // self-hosted decoder (public/draco/) // Tyre meshes in the exact order the rig expects: [FR, FL, RL, RR]. Order is // load-bearing — animateWheels() flips spin direction by index parity. const TYRE_ORDER = [ /WheelStock_FR_RB1c_Tire_1k_0$/i, // FR (suffix _0, NOT .001) /WheelStock_FR_RB1c_Tire_1k_0\.001$/i, // FL (the .001 duplicate) /WheelStock_RL_RB1c_Tire/i, // RL /WheelStock_RR_RB1c_Tire/i, // RR ] // Shadow policy: keep shadows ONLY on the truck (caster) and ground (receiver). const TRUCK_NAME_RX = /^LCT300007/i const GROUND_NAME_RX = /road|floor|slab|driveway|apron|grass|ground|pad|curb/i const GROUND_MAT_RX = /asphalt|concrete|lane|apron|curb|pavement|tarmac|grass/i // LOD buckets (matched against mesh name OR material name — both are reliable on // the graph). Hidden meshes are skipped at draw time. const FOLIAGE_NAME_RX = /tree|foliage|atlas|bush|hedge|shrub/i const FOLIAGE_MAT_RX = /tree|leaf|leaves|bark|foliage|shrub|grass/i const CLUTTER_NAME_RX = /carton|cube|crate|package_box|barrel|pallet|bench/i const CLUTTER_MAT_RX = /cardboard|pallet/i const STREETLIGHT_NAME_RX = /street_light|streetlight|lamp/i const STREETLIGHT_MAT_RX = /street_light/i const BG_TREE_NAME_RX = /atlas|background_tree/i const BG_TREE_MAT_RX = /background_tree_atlas/i const matName = (o) => (Array.isArray(o.material) ? o.material[0]?.name : o.material?.name) || '' export function Model({ truckRef, wheelRefs, tier = 'desktop', /* dashboardRefs (unused) */ ...props }) { // String arg = self-hosted Draco decoder path: the GLB is Draco-compressed // geometry + WebP textures (31 MB → 3.7 MB). Self-hosting (vs the gstatic CDN) // keeps this static-export site free of an external runtime dependency. const { scene } = useGLTF(GLB, DRACO_PATH) // useLayoutEffect: wire refs + prune before first paint so TruckAnimation / // CameraRig (which run in useFrame) see a fully-configured graph on frame 1. useLayoutEffect(() => { // 1) Wire animation refs by traversal (drei's `nodes` dict sanitises names // like ".001" → reading the live graph avoids that mismatch) ----------- const tyres = [null, null, null, null] let anyTyre = null scene.traverse((o) => { if (!o.isMesh) return const n = o.name || '' if (!/WheelStock_(FR|RL|RR)_RB1c_Tire/i.test(n)) return anyTyre = o TYRE_ORDER.forEach((rx, i) => { if (!tyres[i] && rx.test(n)) tyres[i] = o }) }) tyres.forEach((tyre, i) => { const wheelEmpty = tyre?.parent?.parent ?? null // tyre → Object_NN → WheelStock_XX if (wheelEmpty && wheelRefs?.[i]) wheelRefs[i].current = wheelEmpty }) // Truck root = the tyre's ancestor that is a direct child of the scene root // (== Sketchfab_model, the node the old JSX truckRef pointed at). if (truckRef) { let t = anyTyre while (t && t.parent && t.parent !== scene) t = t.parent truckRef.current = t ?? null if (!t && process.env.NODE_ENV !== 'production') { console.warn('[Scene3D] Could not resolve truck root — truck will not animate.') } } // 2) Shadow pruning + per-tier LOD visibility (single traversal) ---------- scene.traverse((o) => { if (!o.isMesh) return const n = o.name || '' const m = matName(o) const isTruck = TRUCK_NAME_RX.test(n) const isGround = GROUND_NAME_RX.test(n) || GROUND_MAT_RX.test(m) // GLTFLoader defaults cast/receive to false. Enable only where it matters. o.castShadow = isTruck o.receiveShadow = isTruck || isGround if (isTruck) o.frustumCulled = false // truck is the subject — never cull it let hidden = false if (tier === 'mobile') { hidden = FOLIAGE_NAME_RX.test(n) || FOLIAGE_MAT_RX.test(m) || CLUTTER_NAME_RX.test(n) || CLUTTER_MAT_RX.test(m) || STREETLIGHT_NAME_RX.test(n) || STREETLIGHT_MAT_RX.test(m) } else if (tier === 'tablet') { hidden = BG_TREE_NAME_RX.test(n) || BG_TREE_MAT_RX.test(m) } o.visible = !hidden }) }, [scene, truckRef, wheelRefs, tier]) return } useGLTF.preload(GLB, DRACO_PATH)