Files
doormile_react/src/modules/how-it-works-3d/models/Scene3D.jsx

133 lines
5.9 KiB
JavaScript

/*
* Scene3D
* ---------------------------------------------------------------------------
* Was: ~11.6k lines of gltfjsx-generated <mesh> 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 <primitive>. 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 <primitive> 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 <primitive object={scene} {...props} dispose={null} />
}
useGLTF.preload(GLB, DRACO_PATH)