diff --git a/eslint.config.mjs b/eslint.config.mjs
index d74721c..fb43e27 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -14,6 +14,9 @@ const eslintConfig = defineConfig([
"next-env.d.ts",
// Vendored third-party JS shipped to /public is not ours to lint.
"public/**",
+ // Ported 3D experience (incl. the ~11.6k-line gltfjsx-generated model) — kept
+ // as faithful .jsx/.js from the standalone app; not linted to ours rules.
+ "src/modules/how-it-works-3d/**",
]),
]);
diff --git a/package-lock.json b/package-lock.json
index 0cc86a6..fe319e5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,11 +16,13 @@
"gsap": "^3.15.0",
"leaflet": "^1.9.4",
"lenis": "^1.3.23",
+ "maath": "^0.10.8",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-leaflet": "^5.0.0",
- "three": "^0.171.0"
+ "three": "^0.184.0",
+ "zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -31,7 +33,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
- "@types/three": "^0.171.0",
+ "@types/three": "^0.184.0",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"jest": "^30.4.2",
@@ -726,6 +728,12 @@
"node": ">=18"
}
},
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "license": "Apache-2.0"
+ },
"node_modules/@emailjs/browser": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@emailjs/browser/-/browser-4.4.1.tgz",
@@ -3085,17 +3093,17 @@
"license": "MIT"
},
"node_modules/@types/three": {
- "version": "0.171.0",
- "resolved": "https://registry.npmjs.org/@types/three/-/three-0.171.0.tgz",
- "integrity": "sha512-oLuT1SAsT+CUg/wxUTFHo0K3NtJLnx9sJhZWQJp/0uXqFpzSk1hRHmvWvpaAWSfvx2db0lVKZ5/wV0I0isD2mQ==",
+ "version": "0.184.1",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
+ "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
"license": "MIT",
"dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
- "@types/webxr": "*",
- "@webgpu/types": "*",
+ "@types/webxr": ">=0.5.17",
"fflate": "~0.8.2",
- "meshoptimizer": "~0.18.1"
+ "meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/tough-cookie": {
@@ -3761,12 +3769,6 @@
"react": ">= 16.8.0"
}
},
- "node_modules/@webgpu/types": {
- "version": "0.1.70",
- "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.70.tgz",
- "integrity": "sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA==",
- "license": "BSD-3-Clause"
- },
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -8754,9 +8756,9 @@
}
},
"node_modules/meshoptimizer": {
- "version": "0.18.1",
- "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
- "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
+ "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"license": "MIT"
},
"node_modules/micromatch": {
@@ -10762,9 +10764,9 @@
}
},
"node_modules/three": {
- "version": "0.171.0",
- "resolved": "https://registry.npmjs.org/three/-/three-0.171.0.tgz",
- "integrity": "sha512-Y/lAXPaKZPcEdkKjh0JOAHVv8OOnv/NDJqm0wjfCzyQmfKxV7zvkwsnBgPBKTzJHToSOhRGQAGbPJObT59B/PQ==",
+ "version": "0.184.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
+ "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
"license": "MIT"
},
"node_modules/three-mesh-bvh": {
diff --git a/package.json b/package.json
index 4dde4b8..3a6814c 100644
--- a/package.json
+++ b/package.json
@@ -21,11 +21,13 @@
"gsap": "^3.15.0",
"leaflet": "^1.9.4",
"lenis": "^1.3.23",
+ "maath": "^0.10.8",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-leaflet": "^5.0.0",
- "three": "^0.171.0"
+ "three": "^0.184.0",
+ "zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -36,7 +38,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
- "@types/three": "^0.171.0",
+ "@types/three": "^0.184.0",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"jest": "^30.4.2",
diff --git a/public/models/3d_scene_final.glb b/public/models/3d_scene_final.glb
new file mode 100644
index 0000000..c7eb88f
Binary files /dev/null and b/public/models/3d_scene_final.glb differ
diff --git a/src/animations/SmoothScroll.tsx b/src/animations/SmoothScroll.tsx
index a3cfae5..7eed2b7 100644
--- a/src/animations/SmoothScroll.tsx
+++ b/src/animations/SmoothScroll.tsx
@@ -27,7 +27,10 @@ import Lenis from "lenis";
* Re-evaluates on every route change: the effect cleanup destroys the previous
* instance and re-inits on the next route.
*/
-const DISABLED_ROUTES: string[] = [];
+// /how-it-works runs its own tuned Lenis inside the embedded 3D experience
+// (src/modules/how-it-works-3d); the global instance is gated off there so two
+// Lenis instances don't fight over the same document scroll.
+const DISABLED_ROUTES: string[] = ["/how-it-works"];
export default function SmoothScroll() {
const pathname = usePathname();
diff --git a/src/app/how-it-works/page.tsx b/src/app/how-it-works/page.tsx
index 1c72f96..b159c78 100644
--- a/src/app/how-it-works/page.tsx
+++ b/src/app/how-it-works/page.tsx
@@ -1,8 +1,6 @@
import React from "react";
import HowItWorksHero from "../../components/sections/HowItWorksHero";
-import Miles3 from "../../components/sections/Miles3";
-import WhyChooseDoormile from "../../components/sections/WhyChooseDoormile";
-import TheDoormileWay from "../../components/sections/TheDoormileWay";
+import Experience3DLoader from "@/modules/how-it-works-3d/Experience3DLoader";
export const metadata = {
title: "How It Works – Doormile",
@@ -16,9 +14,10 @@ export default function HowItWorksPage() {
-
-
-
+ {/* The first/mid/last-mile story is now told by the scroll-driven 3D
+ experience, which replaces the former Miles3 / WhyChooseDoormile /
+ TheDoormileWay content sections on this page. */}
+
diff --git a/src/modules/how-it-works-3d/Experience3D.jsx b/src/modules/how-it-works-3d/Experience3D.jsx
new file mode 100644
index 0000000..fbef78e
--- /dev/null
+++ b/src/modules/how-it-works-3d/Experience3D.jsx
@@ -0,0 +1,158 @@
+"use client";
+
+import React, { useRef, useEffect, useState } from 'react'
+import Experience from './components/Experience'
+import ScrollRig from './components/ScrollRig'
+import Navbar from './components/ui/Navbar'
+import FirstMile from './components/sections/FirstMile'
+import MidMile from './components/sections/MidMile'
+import LastMile from './components/sections/LastMile'
+import Analytics from './components/sections/Analytics'
+import { useSceneStore } from './store/useSceneStore'
+import './styles/experience.css'
+
+import Lenis from 'lenis'
+import gsap from 'gsap'
+import { ScrollTrigger } from 'gsap/ScrollTrigger'
+
+gsap.registerPlugin(ScrollTrigger)
+
+/**
+ * Experience3D
+ * ---------------------------------------------------------------------------
+ * The full scroll-driven 3D logistics story, ported from the standalone Vite
+ * app's App.jsx and embedded as the body of the How It Works page (below the
+ * existing Elementor hero, above the global Footer).
+ *
+ * Two integration changes vs. the standalone app:
+ * 1. Self-managed fixed pin. The site has a fixed header and an ancestor with
+ * `overflow:hidden`, both of which break CSS `position: sticky`. So this is
+ * a tall `position:relative` section (`.dm-hiw-3d`, its height supplied by
+ * the 900vh ScrollRig spacer) with an absolutely-positioned `.dm-hiw-3d-stage`
+ * toggled absolute(top) → fixed → absolute(bottom) via the ScrollTrigger pin
+ * state — the same approach the site's other 3D sections use (StrategySection).
+ * 2. The global Lenis is disabled on `/how-it-works` (SmoothScroll.tsx) so the
+ * experience runs its own tuned Lenis here without a second instance fighting
+ * it. The internal "Scroll to start" Hero overlay is dropped because the page
+ * keeps the Elementor HowItWorksHero above this section.
+ */
+export default function Experience3D() {
+ const scrollProgress = useSceneStore((state) => state.scrollProgress)
+ const setLenis = useSceneStore((state) => state.setLenis)
+
+ const containerRef = useRef(null)
+ const [pinState, setPinState] = useState('before')
+ // Defer mounting the WebGL Canvas until the section nears the viewport. This
+ // mirrors the site's other 3D sections (StrategySection's `mountScene`): besides
+ // saving the heavy 32MB scene until needed, it keeps the Canvas out of React
+ // StrictMode's initial synchronous double-mount, which otherwise creates and
+ // immediately loses the WebGL context in dev ("THREE.WebGLRenderer: Context Lost"),
+ // leaving a blank canvas. Once mounted it stays mounted.
+ const [mountScene, setMountScene] = useState(false)
+
+ useEffect(() => {
+ const el = containerRef.current
+ if (!el) return
+ const io = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((e) => e.isIntersecting)) {
+ setMountScene(true)
+ io.disconnect()
+ }
+ },
+ { rootMargin: '200% 0px' }, // mount well before it scrolls into view
+ )
+ io.observe(el)
+ return () => io.disconnect()
+ }, [])
+
+ // Own Lenis instance (global Lenis is gated off for this route).
+ useEffect(() => {
+ const lenis = new Lenis({
+ duration: 1.2,
+ lerp: 0.08,
+ syncTouch: true,
+ })
+
+ setLenis(lenis)
+ lenis.on('scroll', ScrollTrigger.update)
+
+ let rafId
+ function raf(time) {
+ lenis.raf(time)
+ rafId = requestAnimationFrame(raf)
+ }
+ rafId = requestAnimationFrame(raf)
+
+ gsap.ticker.lagSmoothing(0)
+ ScrollTrigger.refresh()
+
+ return () => {
+ cancelAnimationFrame(rafId)
+ lenis.destroy()
+ setLenis(null)
+ }
+ }, [setLenis])
+
+ // 3D references shared between R3F and the GSAP scroll system.
+ const truckRef = useRef(null)
+
+ const wheelRefs = React.useMemo(() => [
+ { current: null }, // FR
+ { current: null }, // FL
+ { current: null }, // RL
+ { current: null }, // RR
+ ], [])
+
+ const dashboardRefs = React.useMemo(() => ({
+ bars: [
+ { current: null }, { current: null }, { current: null },
+ { current: null }, { current: null }, { current: null }
+ ],
+ floorBars: [
+ { current: null }, { current: null }, { current: null },
+ { current: null }, { current: null }
+ ],
+ pieQuarters: [
+ { current: null }, { current: null }, { current: null }, { current: null }
+ ]
+ }), [])
+
+ return (
+
+ {/* Pinned stage: canvas + HTML overlays. Stays fixed across the scroll. */}
+
+
= 0.92 ? 0.85 : 1.0,
+ transition: 'opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1)',
+ }}
+ >
+ {mountScene && (
+
+ )}
+
+
+ {/* In-experience section navigation */}
+
+
+ {/* Story stage text panels (revealed at their scroll ranges) */}
+
+
= 0.02 && scrollProgress < 0.14} />
+ = 0.38 && scrollProgress < 0.50} />
+ = 0.80 && scrollProgress < 0.92} />
+ = 0.94} />
+
+
+
+ {/* GSAP scroll system: 900vh in-flow spacer that gives the section its
+ height, drives scroll progress, and reports pin state. */}
+
+
+ )
+}
diff --git a/src/modules/how-it-works-3d/Experience3DLoader.tsx b/src/modules/how-it-works-3d/Experience3DLoader.tsx
new file mode 100644
index 0000000..f5b35ff
--- /dev/null
+++ b/src/modules/how-it-works-3d/Experience3DLoader.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import dynamic from "next/dynamic";
+
+/**
+ * Client-only loader for the 3D How It Works experience.
+ *
+ * `ssr: false` is required: the experience uses React Three Fiber, a Zustand
+ * store, Lenis, and `window`/`AudioContext` — all client-only. The 100vh
+ * placeholder reserves space so the page doesn't jump while the (large) GLB
+ * scene and WebGL bundle load.
+ */
+const Experience3D = dynamic(() => import("./Experience3D"), {
+ ssr: false,
+ loading: () =>
,
+});
+
+export default function Experience3DLoader() {
+ return ;
+}
diff --git a/src/modules/how-it-works-3d/animations/cameraTimeline.js b/src/modules/how-it-works-3d/animations/cameraTimeline.js
new file mode 100644
index 0000000..733361c
--- /dev/null
+++ b/src/modules/how-it-works-3d/animations/cameraTimeline.js
@@ -0,0 +1,21 @@
+import gsap from 'gsap'
+
+// Optional GSAP timeline utility to animate custom camera effects (like micro-shake)
+export const playCameraTransition = (camera, target, duration = 1.0) => {
+ if (!camera) return
+
+ const tl = gsap.timeline()
+
+ // Add a subtle drift to camera position to make it feel organic and premium
+ tl.to(camera.position, {
+ x: '+=0.3',
+ y: '+=0.1',
+ z: '-=0.2',
+ duration: duration,
+ yoyo: true,
+ repeat: 1,
+ ease: 'power1.inOut',
+ })
+
+ return tl
+}
diff --git a/src/modules/how-it-works-3d/animations/dashboardAnimation.js b/src/modules/how-it-works-3d/animations/dashboardAnimation.js
new file mode 100644
index 0000000..91a64b0
--- /dev/null
+++ b/src/modules/how-it-works-3d/animations/dashboardAnimation.js
@@ -0,0 +1,24 @@
+import { clamp } from '../utils/helpers'
+
+export const animateDashboard = (bars, pieQuarters, progress) => {
+ // progress is 0 at scrollProgress = 0.75, and 1 at scrollProgress = 1.0
+ // Scale bar charts on their Y axis with a staggered effect
+ bars.forEach((barRef, index) => {
+ if (barRef.current) {
+ const delay = index * 0.08
+ const scaleY = clamp((progress - delay) / 0.5, 0, 1)
+
+ // Interpolate scale Y
+ barRef.current.scale.y = scaleY
+ }
+ })
+
+ // Rotate pie chart quarters around their local Y axis
+ pieQuarters.forEach((quarterRef, index) => {
+ if (quarterRef.current) {
+ // Rotate based on progress (offset each slice slightly for dynamic feeling)
+ const rotationSpeed = 2 + index * 0.5
+ quarterRef.current.rotation.y = -0.709 + progress * Math.PI * 2 * rotationSpeed
+ }
+ })
+}
diff --git a/src/modules/how-it-works-3d/animations/truckTimeline.js b/src/modules/how-it-works-3d/animations/truckTimeline.js
new file mode 100644
index 0000000..5a14f60
--- /dev/null
+++ b/src/modules/how-it-works-3d/animations/truckTimeline.js
@@ -0,0 +1,18 @@
+import gsap from 'gsap'
+
+// Play a subtle engine idle vibration when the truck is active
+export const playTruckEngineVibration = (truckGroup, isActive = true) => {
+ if (!truckGroup) return null
+
+ if (isActive) {
+ return gsap.to(truckGroup.position, {
+ y: '+=0.015',
+ duration: 0.08,
+ yoyo: true,
+ repeat: -1,
+ ease: 'sine.inOut',
+ })
+ }
+
+ return null
+}
diff --git a/src/modules/how-it-works-3d/animations/wheelAnimation.js b/src/modules/how-it-works-3d/animations/wheelAnimation.js
new file mode 100644
index 0000000..4c798da
--- /dev/null
+++ b/src/modules/how-it-works-3d/animations/wheelAnimation.js
@@ -0,0 +1,14 @@
+export const animateWheels = (wheelRefs, rotation) => {
+ if (!wheelRefs || wheelRefs.length === 0) return
+
+ wheelRefs.forEach((wheelRef, index) => {
+ if (wheelRef.current) {
+ // Y-axis is the axle for these wheel meshes.
+ // Odd indices (1, 3) are left side wheels; even indices (0, 2) are right side wheels.
+ // Since left-side wheel groups are rotated 180 degrees in GLTF to face outward,
+ // we invert the spin direction for one side so they all roll forward together.
+ const direction = (index % 2 === 0) ? 1 : -1
+ wheelRef.current.rotation.y = rotation * direction
+ }
+ })
+}
diff --git a/src/modules/how-it-works-3d/components/CameraRig.jsx b/src/modules/how-it-works-3d/components/CameraRig.jsx
new file mode 100644
index 0000000..e0eb3b8
--- /dev/null
+++ b/src/modules/how-it-works-3d/components/CameraRig.jsx
@@ -0,0 +1,38 @@
+import React, { useRef } from 'react'
+import { useFrame } from '@react-three/fiber'
+import * as THREE from 'three'
+import { useSceneStore } from '../store/useSceneStore'
+import { useCameraAnimation } from '../hooks/useCameraAnimation'
+import { easing } from 'maath'
+
+export default function CameraRig() {
+ const scrollProgress = useSceneStore((state) => state.scrollProgress)
+ const { position: targetPosition, target: lookAtTarget } = useCameraAnimation(scrollProgress)
+
+ // Track the current focus point of the camera in a ref so we can interpolate it smoothly
+ const currentLookAt = useRef(new THREE.Vector3(19.7, 4.4, -31.08))
+
+ useFrame((state, delta) => {
+ const { camera } = state
+
+ // Smoothly damp the camera position towards the target position
+ easing.damp3(camera.position, targetPosition, 0.35, delta)
+
+ // Smoothly damp the camera focus target (lookAt)
+ easing.damp3(currentLookAt.current, lookAtTarget, 0.25, delta)
+
+ // Apply lookAt orientation using the interpolated target vector
+ camera.lookAt(currentLookAt.current)
+
+ // Responsive aspect ratio adjustments: increase FOV on portrait screens to zoom out and keep truck & buildings in frame
+ const aspect = state.size.width / state.size.height
+ if (aspect < 1.0) {
+ camera.fov = Math.min(75, 45 / Math.sqrt(aspect))
+ } else {
+ camera.fov = 45
+ }
+ camera.updateProjectionMatrix()
+ })
+
+ return null
+}
diff --git a/src/modules/how-it-works-3d/components/Experience.jsx b/src/modules/how-it-works-3d/components/Experience.jsx
new file mode 100644
index 0000000..4afd992
--- /dev/null
+++ b/src/modules/how-it-works-3d/components/Experience.jsx
@@ -0,0 +1,150 @@
+import React, { useRef, useEffect } from 'react'
+import { Canvas, useFrame } from '@react-three/fiber'
+import { Environment, SoftShadows } from '@react-three/drei'
+import * as THREE from 'three'
+import { EffectComposer, Bloom, Vignette } from '@react-three/postprocessing'
+import { Model as SceneModel } from '../models/Scene3D'
+import CameraRig from './CameraRig'
+import TruckAnimation from './TruckAnimation'
+import StreetLights from './StreetLights'
+import { useSceneStore } from '../store/useSceneStore'
+
+const dayBgColor = new THREE.Color('#f5f5f7')
+const nightBgColor = new THREE.Color('#010103') // Pitch black sky with a tiny touch of midnight slate
+
+const dayAmbientColor = new THREE.Color('#ffffff')
+const nightAmbientColor = new THREE.Color('#000000') // Pitch black ambient
+
+const dayDirColor = new THREE.Color('#ffffff')
+const nightDirColor = new THREE.Color('#000000') // Pitch black sun/moon directional light
+
+const tempColor = new THREE.Color()
+
+// Dynamic lighting rig that centers the shadow frustum on the moving truck
+const SceneLighting = React.memo(function SceneLighting({ truckRef }) {
+ const dirLightRef = useRef()
+ const ambientLightRef = useRef()
+ const targetRef = useRef()
+
+ useEffect(() => {
+ if (dirLightRef.current && targetRef.current) {
+ dirLightRef.current.target = targetRef.current
+ }
+ }, [])
+
+ useFrame((state) => {
+ // 1. Center shadows on the truck
+ if (dirLightRef.current && targetRef.current && truckRef.current) {
+ const truckPos = new THREE.Vector3()
+ truckRef.current.getWorldPosition(truckPos)
+
+ targetRef.current.position.copy(truckPos)
+ targetRef.current.updateMatrixWorld()
+
+ dirLightRef.current.position.set(truckPos.x + 10, truckPos.y + 20, truckPos.z + 10)
+ }
+
+ // 2. Day-to-Night transition calculations (disabled: keeping day view throughout the scroll)
+ const nightFactor = 0
+
+ // 3. Mutate scene background color & environment intensity
+ if (state.scene) {
+ state.scene.background = tempColor.lerpColors(dayBgColor, nightBgColor, nightFactor)
+ state.scene.environmentIntensity = 1.0 - nightFactor * 1.0 // Fades completely to 0.0
+ }
+
+ // 4. Update lights properties
+ if (ambientLightRef.current) {
+ ambientLightRef.current.intensity = 0.45 - nightFactor * 0.45 // Fades completely to 0.0
+ ambientLightRef.current.color.lerpColors(dayAmbientColor, nightAmbientColor, nightFactor)
+ }
+
+ if (dirLightRef.current) {
+ dirLightRef.current.intensity = 1.5 - nightFactor * 1.5 // Fades completely to 0.0
+ dirLightRef.current.color.lerpColors(dayDirColor, nightDirColor, nightFactor)
+ }
+ })
+
+ return (
+
+
+
+
+
+ )
+})
+
+export default React.memo(function Experience({ dashboardRefs, wheelRefs, truckRef }) {
+ return (
+
+
+
+
+ {/* Soft shadows */}
+
+
+ {/* Dynamic ambient and shadow-tracking directional lights */}
+
+
+ {/* Focused street lights along the road */}
+
+
+ {/* Environment preset */}
+
+
+ {/* Main 3D logistics scene model */}
+
+
+ {/* Delivery truck model animation controller */}
+
+
+ {/* Dynamic camera rig with damping and target interpolation */}
+
+
+ {/* Post-processing — Bloom + Vignette only.
+ The original Vite code added SSAO with a NormalPass, but on this heavy
+ scene (32MB GLB, ~500 meshes, SoftShadows) the extra full-scene normal
+ render exhausts the WebGL context and it is lost (blank canvas). The
+ site's other R3F canvases (e.g. StrategyCanvas) use a Bloom-only
+ composer for the same reason; Bloom + the screen-space Vignette keep the
+ cinematic look without the SSAO normal pass. */}
+
+
+
+
+
+
+ )
+})
+
diff --git a/src/modules/how-it-works-3d/components/ScrollRig.jsx b/src/modules/how-it-works-3d/components/ScrollRig.jsx
new file mode 100644
index 0000000..25a39cc
--- /dev/null
+++ b/src/modules/how-it-works-3d/components/ScrollRig.jsx
@@ -0,0 +1,103 @@
+import React, { useEffect, useRef } from 'react'
+import gsap from 'gsap'
+import { ScrollTrigger } from 'gsap/ScrollTrigger'
+import { useSceneStore } from '../store/useSceneStore'
+import { animateDashboard } from '../animations/dashboardAnimation'
+import { playRevealChime } from '../utils/audioHelper'
+
+gsap.registerPlugin(ScrollTrigger)
+
+export default function ScrollRig({ dashboardRefs, onPinState }) {
+ const setScrollProgress = useSceneStore((state) => state.setScrollProgress)
+ const setActiveSection = useSceneStore((state) => state.setActiveSection)
+ const lenis = useSceneStore((state) => state.lenis)
+ const containerRef = useRef(null)
+ const activeSectionRef = useRef(0)
+ const pinStateRef = useRef('before')
+
+ useEffect(() => {
+ const element = containerRef.current
+ if (!element) return
+
+ // Create the ScrollTrigger to track the scrolling progress of the 900vh height container
+ const trigger = ScrollTrigger.create({
+ trigger: element,
+ start: 'top top',
+ end: 'bottom bottom',
+ scrub: 2.5, // Even slower, weightier scroll follow for premium feel
+ invalidateOnRefresh: true,
+ onUpdate: (self) => {
+ const progress = self.progress
+ setScrollProgress(progress)
+
+ // Report pin state so the parent toggles the stage between
+ // absolute(top) → fixed → absolute(bottom). Mirrors StrategySection.
+ const ns = progress <= 0.0002 ? 'before' : progress >= 0.9998 ? 'after' : 'pinned'
+ if (ns !== pinStateRef.current) {
+ pinStateRef.current = ns
+ onPinState?.(ns)
+ }
+
+ // Determine the active stage section
+ // Section 0 (First Mile): 0% to 12%
+ // Section 1 (Mid Mile): 12% to 50%
+ // Section 2 (Last Mile): 50% to 76%
+ // Section 3 (Analytics): 76% to 100%
+ let section = 0
+ if (progress >= 0.92) {
+ section = 3
+ } else if (progress >= 0.50) {
+ section = 2
+ } else if (progress >= 0.12) {
+ section = 1
+ }
+
+ if (section !== activeSectionRef.current) {
+ playRevealChime()
+ activeSectionRef.current = section
+ }
+
+ setActiveSection(section)
+
+ // Trigger dashboard animations inside R3F when entering the analytics stage (progress >= 0.92)
+ if (dashboardRefs) {
+ if (progress >= 0.92) {
+ const dashboardProgress = (progress - 0.92) / 0.08
+ animateDashboard(
+ dashboardRefs.bars || [],
+ dashboardRefs.pieQuarters || [],
+ dashboardProgress
+ )
+ } else {
+ // Keep reset when out of analytics section
+ animateDashboard(
+ dashboardRefs.bars || [],
+ dashboardRefs.pieQuarters || [],
+ 0
+ )
+ }
+ }
+ },
+ })
+ return () => {
+ trigger.kill()
+ }
+ }, [setScrollProgress, setActiveSection, dashboardRefs, lenis, onPinState])
+
+ return (
+
+ )
+}
diff --git a/src/modules/how-it-works-3d/components/StreetLights.jsx b/src/modules/how-it-works-3d/components/StreetLights.jsx
new file mode 100644
index 0000000..4da3b14
--- /dev/null
+++ b/src/modules/how-it-works-3d/components/StreetLights.jsx
@@ -0,0 +1,93 @@
+import React, { useRef, useEffect } from 'react'
+import { useFrame } from '@react-three/fiber'
+import * as THREE from 'three'
+import { useSceneStore } from '../store/useSceneStore'
+
+// The exact calculated world coordinates of the 10 street light heads in the scene
+const streetLightsData = [
+ { pos: [0, 4.2, -4.56], target: [0, 0, -4.56] },
+ { pos: [9.113, 4.2, 0.944], target: [9.113, 0, 0.944] },
+ { pos: [-10.158, 4.2, -9.874], target: [-10.158, 0, -9.874] },
+ { pos: [3.513, 4.2, 9.195], target: [3.513, 0, 9.195] },
+ { pos: [3.96, 4.2, -21.17], target: [3.96, 0, -21.17] },
+ { pos: [12.25, 4.2, -16.7], target: [12.25, 0, -16.7] },
+ { pos: [3.052, 4.2, -12.335], target: [3.052, 0, -12.335] },
+ { pos: [-2.03, 4.2, -16.89], target: [-2.03, 0, -16.89] },
+ { pos: [-27.151, 3.98, -9], target: [-27.151, 0, -9] }
+]
+
+const bulbOffColor = new THREE.Color('#333333')
+const bulbOnColor = new THREE.Color('#ffdf6d')
+const emissiveOffColor = new THREE.Color('#000000')
+const emissiveOnColor = new THREE.Color('#ffdf6d')
+
+function SingleStreetLight({ pos, targetPos }) {
+ const lightRef = useRef()
+ const targetRef = useRef()
+ const bulbRef = useRef()
+
+ useEffect(() => {
+ if (lightRef.current && targetRef.current) {
+ lightRef.current.target = targetRef.current
+ lightRef.current.target.updateMatrixWorld()
+ }
+ }, [])
+
+ useFrame(() => {
+ // Day-to-Night factor (disabled: streetlights stay off)
+ const nightFactor = 0
+
+ // Smoothly scale spotlights intensity
+ if (lightRef.current) {
+ lightRef.current.intensity = nightFactor * 12.0
+ }
+
+ // Interpolate light bulb material colors to simulate glowing filament
+ if (bulbRef.current) {
+ bulbRef.current.material.color.lerpColors(bulbOffColor, bulbOnColor, nightFactor)
+ bulbRef.current.material.emissive.lerpColors(emissiveOffColor, emissiveOnColor, nightFactor)
+ }
+ })
+
+ return (
+
+ {/* Spotlight casting cone of light downward */}
+
+ {/* Glowing bulb mesh placed exactly at the light coordinates */}
+
+
+
+
+
+
+ )
+}
+
+export default React.memo(function StreetLights() {
+ return (
+
+ {streetLightsData.map((light, index) => (
+
+ ))}
+
+ )
+})
diff --git a/src/modules/how-it-works-3d/components/TruckAnimation.jsx b/src/modules/how-it-works-3d/components/TruckAnimation.jsx
new file mode 100644
index 0000000..39e26dc
--- /dev/null
+++ b/src/modules/how-it-works-3d/components/TruckAnimation.jsx
@@ -0,0 +1,159 @@
+import React, { useEffect, useRef } from 'react'
+import { useFrame } from '@react-three/fiber'
+import * as THREE from 'three'
+import { useSceneStore } from '../store/useSceneStore'
+import { useTruckMovement } from '../hooks/useTruckMovement'
+import { animateWheels } from '../animations/wheelAnimation'
+import { easing } from 'maath'
+import { truckPath } from '../curves/truckPath'
+
+export default function TruckAnimation({ truckRef, wheelRefs }) {
+ const scrollProgress = useSceneStore((state) => state.scrollProgress)
+ const activeSection = useSceneStore((state) => state.activeSection)
+ const setTruckProgress = useSceneStore((state) => state.setTruckProgress)
+
+ const { truckProgress } = useTruckMovement(scrollProgress)
+
+ const initialized = useRef(false)
+
+ // Sync truck progress to the global store
+ useEffect(() => {
+ setTruckProgress(truckProgress)
+ }, [truckProgress, setTruckProgress])
+
+ // Float trackers for 1D progress and direction detection
+ const dampedProgressRef = useRef(0)
+ const lastScrollProgressRef = useRef(0)
+ const isReversingRef = useRef(false)
+
+ // Tracker for smooth 180-degree yaw rotation (prevents glitches by pivoting Y rotation angle directly)
+ const extraRotationRef = useRef(0)
+
+ // Track wheel rotation accumulation
+ const accumulatedRotationRef = useRef(0)
+ const lastDampedProgressRef = useRef(0)
+
+
+
+ useFrame((state, delta) => {
+ if (!truckRef.current) return
+
+ // Detect scroll direction changes from the actual page scroll progress
+ const deltaScroll = scrollProgress - lastScrollProgressRef.current
+ if (deltaScroll < -0.0001) {
+ isReversingRef.current = true
+ } else if (deltaScroll > 0.0001) {
+ isReversingRef.current = false
+ }
+ lastScrollProgressRef.current = scrollProgress
+
+ // Ensure correct parent-child structure and orientation for the truck (runs reactively on re-renders)
+ const innerGroup = truckRef.current.children[0]
+ if (innerGroup && truckRef.current.children.length > 1) {
+ const siblings = [...truckRef.current.children].slice(1)
+ siblings.forEach((sibling) => {
+ innerGroup.attach(sibling)
+ })
+
+ innerGroup.rotation.set(0, -Math.PI / 2, 0)
+
+ // Disable frustum culling on all child meshes so the truck/shadow is always visible
+ truckRef.current.traverse((child) => {
+ if (child.isMesh) {
+ child.frustumCulled = false
+ child.castShadow = true
+ child.receiveShadow = true
+ }
+ })
+ }
+
+ // Run one-time state initialization for progress trackers
+ if (!initialized.current) {
+ dampedProgressRef.current = truckProgress
+ lastDampedProgressRef.current = truckProgress
+ lastScrollProgressRef.current = scrollProgress
+ isReversingRef.current = false
+ extraRotationRef.current = 0
+
+ const position = truckPath.getPoint(dampedProgressRef.current)
+ let lookAtTargetVector
+ if (dampedProgressRef.current >= 0.99) {
+ const tangent = truckPath.getTangent(1.0)
+ const endPoint = truckPath.getPoint(1.0)
+ lookAtTargetVector = new THREE.Vector3().copy(endPoint).addScaledVector(tangent, 1.0)
+ } else {
+ const ahead = Math.min(dampedProgressRef.current + 0.01, 1.0)
+ lookAtTargetVector = truckPath.getPoint(ahead)
+ }
+
+ truckRef.current.position.copy(position)
+ if (truckRef.current.position.distanceToSquared(lookAtTargetVector) > 0.0001) {
+ truckRef.current.lookAt(lookAtTargetVector)
+ }
+
+ initialized.current = true
+ }
+
+ // Smoothly damp the 1D progress scalar along the curve path
+ easing.damp(dampedProgressRef, 'current', truckProgress, 0.30, delta)
+
+ // Evaluate the 3D position and orientation directly on the spline curve
+ const position = truckPath.getPoint(dampedProgressRef.current)
+
+ let lookAtTargetVector
+ if (dampedProgressRef.current >= 0.99) {
+ const tangent = truckPath.getTangent(1.0)
+ const endPoint = truckPath.getPoint(1.0)
+ lookAtTargetVector = new THREE.Vector3().copy(endPoint).addScaledVector(tangent, 1.0)
+ } else {
+ const ahead = Math.min(dampedProgressRef.current + 0.01, 1.0)
+ lookAtTargetVector = truckPath.getPoint(ahead)
+ }
+
+ // Update position and base forward rotation directly (ensures 100% spline compliance, zero corner cutting)
+ truckRef.current.position.copy(position)
+ if (truckRef.current.position.distanceToSquared(lookAtTargetVector) > 0.0001) {
+ truckRef.current.lookAt(lookAtTargetVector)
+ }
+
+ // Determine target extra rotation:
+ // - 0 radians when moving forward
+ // - Math.PI radians (180 degrees) when reversing
+ // We disable U-turns at the extreme start and end of the path to keep the truck stable at warehouse/delivery spots
+ let targetExtraRotation = 0
+ if (dampedProgressRef.current > 0.05 && dampedProgressRef.current < 0.95) {
+ if (isReversingRef.current) {
+ targetExtraRotation = Math.PI
+ }
+ }
+
+ // Smoothly damp the extra rotation angle directly (prevents pitch/roll glitches or 3D target collapse)
+ easing.damp(extraRotationRef, 'current', targetExtraRotation, 0.20, delta)
+
+ // Apply the yaw pivot around the local vertical axis
+ truckRef.current.rotateY(extraRotationRef.current)
+
+ // Calculate progress delta for wheels and audio
+ const deltaDamped = Math.abs(dampedProgressRef.current - lastDampedProgressRef.current)
+ lastDampedProgressRef.current = dampedProgressRef.current
+
+ // Accumulate wheel rotation based on absolute movement delta so they always roll forward locally
+ const isMoving = dampedProgressRef.current > 0.001 && dampedProgressRef.current < 0.999
+ if (isMoving) {
+ accumulatedRotationRef.current += deltaDamped * 250 // spinFactor
+ }
+
+ // Spin wheels
+ animateWheels(wheelRefs, accumulatedRotationRef.current)
+
+
+
+ // Add engine vibration to the inner group to prevent coordinate pollution on the root group
+ if (truckRef.current.children && truckRef.current.children[0]) {
+ const innerGroup = truckRef.current.children[0]
+ innerGroup.position.y = Math.sin(state.clock.getElapsedTime() * 45) * 0.003
+ }
+ })
+
+ return null
+}
diff --git a/src/modules/how-it-works-3d/components/sections/Analytics.jsx b/src/modules/how-it-works-3d/components/sections/Analytics.jsx
new file mode 100644
index 0000000..9b8a2e0
--- /dev/null
+++ b/src/modules/how-it-works-3d/components/sections/Analytics.jsx
@@ -0,0 +1,46 @@
+import React from 'react'
+import RevealCard from '../ui/RevealCard'
+
+export default function Analytics({ active }) {
+ return (
+
+ Workflow
+ Doormile Insights
+ 3-Mile Logistics Ecosystem
+
+
+
+
+
+
First Mile
+
Incoming shipments are securely loaded, checked, and consolidated at initial fulfillment hubs.
+
+
+
+
+
+
+
Mid Mile
+
Consolidated goods travel between primary distribution nodes via optimized express transit corridors.
+
+
+
+
+
+ 03
+
+
+
Last Mile
+
Local delivery units organize doorstep routes to transport packages to final customers.
+
+
+
+
+ )
+}
diff --git a/src/modules/how-it-works-3d/components/sections/FirstMile.jsx b/src/modules/how-it-works-3d/components/sections/FirstMile.jsx
new file mode 100644
index 0000000..59d2bb4
--- /dev/null
+++ b/src/modules/how-it-works-3d/components/sections/FirstMile.jsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import { sections } from '../../constants/sectionConfig'
+import RevealCard from '../ui/RevealCard'
+
+export default function FirstMile({ active }) {
+ const config = sections[0]
+ return (
+
+ Stage 01
+ {config.title}
+ {config.subtitle}
+ {config.description}
+
+
+ 14,250
+ Parcels Processed
+
+
+ 99.98%
+ Sorting Accuracy
+
+
+
+ )
+}
diff --git a/src/modules/how-it-works-3d/components/sections/LastMile.jsx b/src/modules/how-it-works-3d/components/sections/LastMile.jsx
new file mode 100644
index 0000000..48e3e84
--- /dev/null
+++ b/src/modules/how-it-works-3d/components/sections/LastMile.jsx
@@ -0,0 +1,52 @@
+import React from 'react'
+import { sections } from '../../constants/sectionConfig'
+import { useSceneStore } from '../../store/useSceneStore'
+import RevealCard from '../ui/RevealCard'
+import { progressToScrollY } from '../../utils/helpers'
+
+export default function LastMile({ active }) {
+ const config = sections[2]
+ const lenis = useSceneStore((state) => state.lenis)
+
+ const handleClose = () => {
+ // Smoothly scroll to 97% progress, which is inside the Analytics Dashboard section.
+ // Relative to the experience spacer (the section sits below the page hero).
+ lenis?.scrollTo(progressToScrollY(0.97), { duration: 1.5 })
+ }
+
+ return (
+
+ Stage 03
+ {config.title}
+ {config.subtitle}
+ {config.description}
+
+
+ 12.5 min
+ Avg. Delivery window
+
+
+ 99.4%
+ On-Time Rate
+
+
+
+ View Analytics
+
+
+
+
+
+
+ )
+}
diff --git a/src/modules/how-it-works-3d/components/sections/MidMile.jsx b/src/modules/how-it-works-3d/components/sections/MidMile.jsx
new file mode 100644
index 0000000..52301f0
--- /dev/null
+++ b/src/modules/how-it-works-3d/components/sections/MidMile.jsx
@@ -0,0 +1,52 @@
+import React from 'react'
+import { sections } from '../../constants/sectionConfig'
+import { useSceneStore } from '../../store/useSceneStore'
+import RevealCard from '../ui/RevealCard'
+import { progressToScrollY } from '../../utils/helpers'
+
+export default function MidMile({ active }) {
+ const config = sections[1]
+ const lenis = useSceneStore((state) => state.lenis)
+
+ const handleClose = () => {
+ // Smoothly scroll to 57.5% progress, which is just after the truck resumes moving (at 57%).
+ // Relative to the experience spacer (the section sits below the page hero).
+ lenis?.scrollTo(progressToScrollY(0.575), { duration: 1.5 })
+ }
+
+ return (
+
+ Stage 02
+ {config.title}
+ {config.subtitle}
+ {config.description}
+
+
+ 4.2 hr
+ Avg. Transit Time
+
+
+ 220 kw
+ Solar Output (Self-powered)
+
+
+
+ Continue Journey
+
+
+
+
+
+
+ )
+}
diff --git a/src/modules/how-it-works-3d/components/ui/Hero.jsx b/src/modules/how-it-works-3d/components/ui/Hero.jsx
new file mode 100644
index 0000000..3c11cb2
--- /dev/null
+++ b/src/modules/how-it-works-3d/components/ui/Hero.jsx
@@ -0,0 +1,22 @@
+import React from 'react'
+import { useSceneStore } from '../../store/useSceneStore'
+
+export default function Hero() {
+ const lenis = useSceneStore((state) => state.lenis)
+ const handleScrollToStart = () => {
+ // Scroll down to the first active transition point
+ lenis?.scrollTo(window.innerHeight * 0.5, { duration: 1.5 })
+ }
+
+ return (
+
+ {/* Dynamic mouse scrolling indicator */}
+
+
+ )
+}
diff --git a/src/modules/how-it-works-3d/components/ui/Navbar.jsx b/src/modules/how-it-works-3d/components/ui/Navbar.jsx
new file mode 100644
index 0000000..9b53dea
--- /dev/null
+++ b/src/modules/how-it-works-3d/components/ui/Navbar.jsx
@@ -0,0 +1,38 @@
+import React from 'react'
+import { useSceneStore } from '../../store/useSceneStore'
+import { progressToScrollY } from '../../utils/helpers'
+
+export default function Navbar() {
+ const activeSection = useSceneStore((state) => state.activeSection)
+ const lenis = useSceneStore((state) => state.lenis)
+
+ const handleNavClick = (index) => {
+ // Map index (0, 1, 2, 3) to the stable parking progress percentages (0.0, 0.38, 0.76, 0.97).
+ const sectionFractions = [0, 0.38, 0.76, 0.97]
+ const targetProgress = sectionFractions[index]
+ // Relative to the experience spacer (the section sits below the page hero).
+ lenis?.scrollTo(progressToScrollY(targetProgress), { duration: 1.5 })
+ }
+
+ const navItems = [
+ { label: 'First Mile', index: 0 },
+ { label: 'Mid Mile', index: 1 },
+ { label: 'Last Mile', index: 2 },
+ { label: 'Analytics', index: 3 },
+ ]
+
+ return (
+
+ {navItems.map((item) => (
+ handleNavClick(item.index)}
+ className={`side-nav-item ${activeSection === item.index ? 'active' : ''}`}
+ >
+ {item.label}
+
+
+ ))}
+
+ )
+}
diff --git a/src/modules/how-it-works-3d/components/ui/RevealCard.jsx b/src/modules/how-it-works-3d/components/ui/RevealCard.jsx
new file mode 100644
index 0000000..ade6544
--- /dev/null
+++ b/src/modules/how-it-works-3d/components/ui/RevealCard.jsx
@@ -0,0 +1,89 @@
+import React, { useEffect, useRef } from 'react'
+import gsap from 'gsap'
+
+export default function RevealCard({ children, active, id, className = "" }) {
+ const cardRef = useRef(null)
+
+ useEffect(() => {
+ const card = cardRef.current
+ if (!card) return
+
+ // Find all target children inside the card to create a staggered entrance
+ const animTargets = card.querySelectorAll(
+ '.section-badge, .section-title, .section-subtitle, .section-description, .section-metrics, .section-close-btn, .workflow-step'
+ )
+
+ const isAnalytics = id === 'analytics-section'
+
+ if (active) {
+ // Clean up any ongoing animations first
+ gsap.killTweensOf([card, animTargets])
+
+ // Animate card container in
+ gsap.to(card, {
+ xPercent: isAnalytics ? -50 : 0,
+ yPercent: isAnalytics ? -50 : 0,
+ y: 0,
+ scale: 1,
+ opacity: 1,
+ duration: 0.85,
+ ease: 'power4.out',
+ })
+
+ // Stagger child elements reveal
+ gsap.fromTo(
+ animTargets,
+ {
+ y: 15,
+ opacity: 0
+ },
+ {
+ y: 0,
+ opacity: 1,
+ duration: 0.6,
+ stagger: 0.08,
+ ease: 'power3.out',
+ delay: 0.1, // brief delay to let card body expand first
+ }
+ )
+ } else {
+ // Kill active tweens
+ gsap.killTweensOf([card, animTargets])
+
+ // Animate card container out
+ gsap.to(card, {
+ xPercent: isAnalytics ? -50 : 0,
+ yPercent: isAnalytics ? -50 : 0,
+ y: isAnalytics ? 18 : 20,
+ scale: 0.96,
+ opacity: 0,
+ duration: 0.5,
+ ease: 'power3.inOut',
+ })
+
+ // Smoothly hide child elements
+ gsap.to(animTargets, {
+ y: 10,
+ opacity: 0,
+ duration: 0.35,
+ ease: 'power2.in',
+ })
+ }
+ }, [active, id])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/modules/how-it-works-3d/constants/cameraPositions.js b/src/modules/how-it-works-3d/constants/cameraPositions.js
new file mode 100644
index 0000000..d028963
--- /dev/null
+++ b/src/modules/how-it-works-3d/constants/cameraPositions.js
@@ -0,0 +1,35 @@
+import * as THREE from 'three'
+
+// Premium Apple-inspired cinematic keyframes looking directly at the front of each building
+export const cameraPositions = [
+ {
+ // Stage 01: First Mile Warehouse (Front-on view of loading bays, lowered target to center truck)
+ progress: 0.0,
+ position: new THREE.Vector3(19.727, 7.5, -14.0),
+ target: new THREE.Vector3(19.727, 2.0, -31.02),
+ },
+ {
+ // Transition 01: Highway Cruise (Looking down at the highway joining road)
+ progress: 0.25,
+ position: new THREE.Vector3(0.0, 12.0, -12.0),
+ target: new THREE.Vector3(6.447, 2.0, -19.06),
+ },
+ {
+ // Stage 02: Mid Mile Hub (Front-on view of loading bays, lowered target to center truck)
+ progress: 0.5,
+ position: new THREE.Vector3(-19.146, 6.5, 10.0),
+ target: new THREE.Vector3(-19.146, 1.5, -6.00),
+ },
+ {
+ // Stage 03: Last Mile Delivery Center (Front-on view of local hub, lowered target to center truck)
+ progress: 0.75,
+ position: new THREE.Vector3(19.263, 5.5, 27.0),
+ target: new THREE.Vector3(19.263, 1.2, 4.0),
+ },
+ {
+ // Stage 04: Centralized Dashboard (Front-on view of the analytics monitor screen)
+ progress: 1.0,
+ position: new THREE.Vector3(-13.5, 5.0, 31.0),
+ target: new THREE.Vector3(-7.7, 3.8, 25.4),
+ },
+]
diff --git a/src/modules/how-it-works-3d/constants/colors.js b/src/modules/how-it-works-3d/constants/colors.js
new file mode 100644
index 0000000..db090bf
--- /dev/null
+++ b/src/modules/how-it-works-3d/constants/colors.js
@@ -0,0 +1,12 @@
+export const colors = {
+ primary: '#0071e3', // Apple Blue
+ secondary: '#86868b', // Apple Gray
+ background: '#ffffff', // White
+ backgroundDark: '#f5f5f7', // Off-white/Light gray
+ text: '#1d1d1f', // Premium dark gray (text)
+ textMuted: '#86868b', // Subtitle text
+ border: '#d2d2d7', // Thin borders
+ success: '#34c759', // Apple Green
+ accentBlue: '#2997ff', // Bright active blue
+ cardBg: 'rgba(255, 255, 255, 0.8)',
+}
diff --git a/src/modules/how-it-works-3d/constants/sectionConfig.js b/src/modules/how-it-works-3d/constants/sectionConfig.js
new file mode 100644
index 0000000..0b9ead5
--- /dev/null
+++ b/src/modules/how-it-works-3d/constants/sectionConfig.js
@@ -0,0 +1,34 @@
+export const sections = [
+ {
+ id: 'first-mile',
+ title: 'First Mile Warehouse',
+ subtitle: 'Consolidation & Prep',
+ description: 'Incoming shipments are securely loaded, checked, and queued for transfer in our high-capacity fulfillment centers.',
+ progressStart: 0.0,
+ progressEnd: 0.25,
+ },
+ {
+ id: 'mid-mile',
+ title: 'Mid Mile Hub',
+ subtitle: 'Sorting & Direct Dispatch',
+ description: 'Consolidated goods travel between primary distribution nodes. Heavy logistics lanes sorting thousands of parcels per hour.',
+ progressStart: 0.25,
+ progressEnd: 0.5,
+ },
+ {
+ id: 'last-mile',
+ title: 'Last Mile Delivery',
+ subtitle: 'Doorstep Courier Services',
+ description: 'Local delivery units take over, planning optimal paths to transport packages directly to customer doorsteps.',
+ progressStart: 0.5,
+ progressEnd: 0.75,
+ },
+ {
+ id: 'analytics',
+ title: 'Fulfillment Analytics',
+ subtitle: 'Real-Time Operational Insights',
+ description: 'A fully centralized dashboard monitoring transit times, fleet coordinates, carbon footprint, and delivery success rates.',
+ progressStart: 0.75,
+ progressEnd: 1.0,
+ },
+]
diff --git a/src/modules/how-it-works-3d/curves/truckPath.js b/src/modules/how-it-works-3d/curves/truckPath.js
new file mode 100644
index 0000000..3a50526
--- /dev/null
+++ b/src/modules/how-it-works-3d/curves/truckPath.js
@@ -0,0 +1,27 @@
+import * as THREE from 'three'
+
+// Exact coordinates extracted from the white road lane markers and building platform heights
+export const truckPoints = [
+ new THREE.Vector3(15.5, 0.45, -26.5), // Start on road lane in front of First Mile warehouse
+ new THREE.Vector3(13.399, 0.324, -24.742), // Road lane start in front of warehouse
+ new THREE.Vector3(11.211, 0.178, -22.973), // Road lane marker
+ new THREE.Vector3(8.823, 0.111, -20.949), // Road lane marker
+ new THREE.Vector3(6.447, 0.059, -19.06), // Road lane marker
+ new THREE.Vector3(3.786, 0.072, -17.002), // Joining main road
+ new THREE.Vector3(0.732, 0.124, -14.955), // Road lane marker
+ new THREE.Vector3(-2.156, 0.124, -12.903), // Road lane marker
+ new THREE.Vector3(-4.417, 0.124, -10.929), // Road lane marker
+ new THREE.Vector3(-5.896, 0.124, -8.052), // Road lane marker
+ new THREE.Vector3(-5.985, 0.124, -5.497), // Stopped on road in front of Mid Mile hub
+ new THREE.Vector3(-4.362, 0.124, -3.25), // Road lane marker
+ new THREE.Vector3(-1.448, 0.124, -1.234), // Road lane marker
+ new THREE.Vector3(2.539, 0.124, 0.986), // Road lane marker
+ new THREE.Vector3(6.686, 0.124, 3.379), // Road lane marker
+ new THREE.Vector3(8.213, 0.124, 6.14), // Road lane marker
+ new THREE.Vector3(7.976, 0.124, 9.176), // Road lane marker
+ new THREE.Vector3(6.424, 0.124, 12.428), // Road lane marker
+ new THREE.Vector3(3.883, 0.124, 15.769), // Road lane marker
+ new THREE.Vector3(1.241, 0.124, 19.056) // Stopped in front of Last Mile hub
+]
+
+export const truckPath = new THREE.CatmullRomCurve3(truckPoints)
diff --git a/src/modules/how-it-works-3d/hooks/useCameraAnimation.js b/src/modules/how-it-works-3d/hooks/useCameraAnimation.js
new file mode 100644
index 0000000..bf6ea1e
--- /dev/null
+++ b/src/modules/how-it-works-3d/hooks/useCameraAnimation.js
@@ -0,0 +1,164 @@
+import { useMemo } from 'react'
+import * as THREE from 'three'
+import { truckPath } from '../curves/truckPath'
+import { clamp } from '../utils/helpers'
+
+export const useCameraAnimation = (scrollProgress) => {
+ const cameraState = useMemo(() => {
+ // 1. Calculate the truck position corresponding to the current scroll progress
+ // Use the exact same piecewise mapping to keep camera follow 100% synchronized
+ let truckProgress = 0
+ if (scrollProgress < 0.14) {
+ truckProgress = 0.0
+ } else if (scrollProgress >= 0.14 && scrollProgress < 0.38) {
+ truckProgress = 0.5 * (scrollProgress - 0.14) / 0.24
+ } else if (scrollProgress >= 0.38 && scrollProgress < 0.50) {
+ truckProgress = 0.5
+ } else if (scrollProgress >= 0.50 && scrollProgress < 0.76) {
+ truckProgress = 0.5 + 0.5 * (scrollProgress - 0.50) / 0.26
+ } else {
+ truckProgress = 1.0
+ }
+ const truckPos = truckPath.getPoint(truckProgress)
+
+ const firstMileViewWhole = {
+ position: new THREE.Vector3(38.0, 15.0, -10.0),
+ target: new THREE.Vector3(24.377, 4.0, -39.303)
+ }
+ const firstMileViewFront = {
+ position: new THREE.Vector3(7.0, 3.0, -19.0),
+ target: new THREE.Vector3(15.5, 1.5, -26.5)
+ }
+ const midMileView = {
+ position: new THREE.Vector3(-7.0, 7.5, 8.0),
+ target: new THREE.Vector3(-19.146, 2.5, -9.0)
+ }
+ const lastMileViewClose = {
+ position: new THREE.Vector3(-3.5, 4.0, 15.0),
+ target: new THREE.Vector3(8.0, 2.0, 20.0)
+ }
+ const lastMileViewZoomedOut = {
+ position: new THREE.Vector3(-10.4, 5.2, 12.0),
+ target: new THREE.Vector3(8.0, 2.0, 20.0)
+ }
+ const analyticsView = {
+ position: new THREE.Vector3(-13.5, 5.0, 31.0),
+ target: new THREE.Vector3(-7.7, 3.5, 25.4)
+ }
+
+ // 3. Calculate local coordinate axes of the truck based on the spline tangent
+ const forward = truckPath.getTangent(truckProgress).normalize()
+ const up = new THREE.Vector3(0, 1, 0)
+ const right = new THREE.Vector3().crossVectors(forward, up).normalize()
+
+ // Cruise 1: Front-left follow perspective (facing the oncoming truck, zoomed out follow)
+ const cruise1Pos = truckPos.clone()
+ .addScaledVector(forward, 7.2)
+ .addScaledVector(up, 3.2)
+ .addScaledVector(right, -3.0)
+ const cruise1Target = truckPos.clone()
+
+ // Cruise 2: Front-right follow perspective (facing the oncoming truck, zoomed out follow)
+ const cruise2Pos = truckPos.clone()
+ .addScaledVector(forward, 7.2)
+ .addScaledVector(up, 3.2)
+ .addScaledVector(right, 3.0)
+ const cruise2Target = truckPos.clone()
+
+ const position = new THREE.Vector3()
+ const target = new THREE.Vector3()
+
+ // 4. Smoothly blend positions and targets depending on active scroll boundaries
+ if (scrollProgress < 0.04) {
+ // Step 1: Zoomed out overview of the whole building
+ position.copy(firstMileViewWhole.position)
+ target.copy(firstMileViewWhole.target)
+ }
+ else if (scrollProgress >= 0.04 && scrollProgress < 0.14) {
+ // Step 2: Camera moves to the front close-up view of the building
+ const alpha = (scrollProgress - 0.04) / 0.10
+ const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
+ position.lerpVectors(firstMileViewWhole.position, firstMileViewFront.position, smoothAlpha)
+ target.lerpVectors(firstMileViewWhole.target, firstMileViewFront.target, smoothAlpha)
+ }
+ else if (scrollProgress >= 0.14 && scrollProgress < 0.18) {
+ // Step 3: Truck starts moving, camera blends to close follow tracking
+ const alpha = (scrollProgress - 0.14) / 0.04
+ const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
+ position.lerpVectors(firstMileViewFront.position, cruise1Pos, smoothAlpha)
+ target.lerpVectors(firstMileViewFront.target, cruise1Target, smoothAlpha)
+ }
+ else if (scrollProgress >= 0.18 && scrollProgress < 0.34) {
+ // Cruise 1: Close follow tracking
+ position.copy(cruise1Pos)
+ target.copy(cruise1Target)
+ }
+ else if (scrollProgress >= 0.34 && scrollProgress < 0.38) {
+ // Blend: Cruise 1 Follow -> Mid Mile Building
+ const alpha = (scrollProgress - 0.34) / 0.04
+ const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
+ position.lerpVectors(cruise1Pos, midMileView.position, smoothAlpha)
+ target.lerpVectors(cruise1Target, midMileView.target, smoothAlpha)
+ }
+ else if (scrollProgress >= 0.38 && scrollProgress < 0.50) {
+ // Mid Mile Building focus
+ position.copy(midMileView.position)
+ target.copy(midMileView.target)
+ }
+ else if (scrollProgress >= 0.50 && scrollProgress < 0.54) {
+ // Blend: Mid Mile Building -> Cruise 2 Follow
+ const alpha = (scrollProgress - 0.50) / 0.04
+ const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
+ position.lerpVectors(midMileView.position, cruise2Pos, smoothAlpha)
+ target.lerpVectors(midMileView.target, cruise2Target, smoothAlpha)
+ }
+ else if (scrollProgress >= 0.54 && scrollProgress < 0.72) {
+ // Cruise 2: Close follow tracking
+ position.copy(cruise2Pos)
+ target.copy(cruise2Target)
+ }
+ else if (scrollProgress >= 0.72 && scrollProgress < 0.76) {
+ // Blend: Cruise 2 Follow -> Last Mile Building Close-up
+ const alpha = (scrollProgress - 0.72) / 0.04
+ const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
+ position.lerpVectors(cruise2Pos, lastMileViewClose.position, smoothAlpha)
+ target.lerpVectors(cruise2Target, lastMileViewClose.target, smoothAlpha)
+ }
+ else if (scrollProgress >= 0.76 && scrollProgress < 0.92) {
+ // Last Mile Building Stop Sequence:
+ // - 0.76 to 0.80: Parked close-up view of the truck and building
+ // - 0.80 to 0.84: Zoom out transition back along the camera viewing axis
+ // - 0.84 to 0.92: Zoomed-out overview of the final delivery stage (card stays frozen here)
+ if (scrollProgress < 0.80) {
+ position.copy(lastMileViewClose.position)
+ target.copy(lastMileViewClose.target)
+ } else if (scrollProgress >= 0.80 && scrollProgress < 0.84) {
+ const alpha = (scrollProgress - 0.80) / 0.04
+ const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
+ position.lerpVectors(lastMileViewClose.position, lastMileViewZoomedOut.position, smoothAlpha)
+ target.lerpVectors(lastMileViewClose.target, lastMileViewZoomedOut.target, smoothAlpha)
+ } else {
+ position.copy(lastMileViewZoomedOut.position)
+ target.copy(lastMileViewZoomedOut.target)
+ }
+ }
+ else if (scrollProgress >= 0.92 && scrollProgress < 0.96) {
+ // Blend: Last Mile Building Zoomed-Out -> Analytics Dashboard screen
+ const alpha = (scrollProgress - 0.92) / 0.04
+ const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
+ position.lerpVectors(lastMileViewZoomedOut.position, analyticsView.position, smoothAlpha)
+ target.lerpVectors(lastMileViewZoomedOut.target, analyticsView.target, smoothAlpha)
+ }
+ else {
+ // Analytics Dashboard screen focus
+ position.copy(analyticsView.position)
+ target.copy(analyticsView.target)
+ }
+
+ return { position, target }
+ }, [scrollProgress])
+
+ return cameraState
+}
+
+export default useCameraAnimation
diff --git a/src/modules/how-it-works-3d/hooks/useScrollProgress.js b/src/modules/how-it-works-3d/hooks/useScrollProgress.js
new file mode 100644
index 0000000..418a2e1
--- /dev/null
+++ b/src/modules/how-it-works-3d/hooks/useScrollProgress.js
@@ -0,0 +1,16 @@
+import { useSceneStore } from '../store/useSceneStore'
+
+export const useScrollProgress = () => {
+ const scrollProgress = useSceneStore((state) => state.scrollProgress)
+ const activeSection = useSceneStore((state) => state.activeSection)
+ const setScrollProgress = useSceneStore((state) => state.setScrollProgress)
+ const setActiveSection = useSceneStore((state) => state.setActiveSection)
+
+ return {
+ scrollProgress,
+ activeSection,
+ setScrollProgress,
+ setActiveSection,
+ }
+}
+export default useScrollProgress
diff --git a/src/modules/how-it-works-3d/hooks/useTruckMovement.js b/src/modules/how-it-works-3d/hooks/useTruckMovement.js
new file mode 100644
index 0000000..08fbb5d
--- /dev/null
+++ b/src/modules/how-it-works-3d/hooks/useTruckMovement.js
@@ -0,0 +1,52 @@
+import { useMemo } from 'react'
+import * as THREE from 'three'
+import { truckPath } from '../curves/truckPath'
+import { clamp } from '../utils/helpers'
+
+export const useTruckMovement = (scrollProgress) => {
+ // Piecewise mapping of scroll progress to make the truck stop at Mid Mile:
+ // - 0% to 25%: Parked at First Mile (progress = 0)
+ // - 25% to 45%: Driving from First Mile to Mid Mile (progress 0 -> 0.5)
+ // - 45% to 55%: Parked at Mid Mile (progress = 0.5)
+ // - 55% to 75%: Driving from Mid Mile to Last Mile (progress 0.5 -> 1.0)
+ // - 75% to 100%: Parked at Last Mile (progress = 1.0)
+ const truckProgress = useMemo(() => {
+ if (scrollProgress < 0.14) {
+ return 0.0
+ }
+ if (scrollProgress >= 0.14 && scrollProgress < 0.38) {
+ return 0.5 * (scrollProgress - 0.14) / 0.24
+ }
+ if (scrollProgress >= 0.38 && scrollProgress < 0.50) {
+ return 0.5
+ }
+ if (scrollProgress >= 0.50 && scrollProgress < 0.76) {
+ return 0.5 + 0.5 * (scrollProgress - 0.50) / 0.26
+ }
+ return 1.0
+ }, [scrollProgress])
+
+ // Get current position on the curve
+ const position = useMemo(() => {
+ return truckPath.getPoint(truckProgress)
+ }, [truckProgress])
+
+ // Get lookAt target (a point slightly ahead on the curve, using tangent at the end to prevent matrix collapse)
+ const lookAtTarget = useMemo(() => {
+ if (truckProgress >= 0.99) {
+ const tangent = truckPath.getTangent(1.0)
+ const endPoint = truckPath.getPoint(1.0)
+ return new THREE.Vector3().copy(endPoint).addScaledVector(tangent, 1.0)
+ }
+ const ahead = Math.min(truckProgress + 0.01, 1.0)
+ return truckPath.getPoint(ahead)
+ }, [truckProgress])
+
+ return {
+ truckProgress,
+ position,
+ lookAtTarget,
+ }
+}
+
+export default useTruckMovement
diff --git a/src/modules/how-it-works-3d/models/Scene3D.jsx b/src/modules/how-it-works-3d/models/Scene3D.jsx
new file mode 100644
index 0000000..d119eb1
--- /dev/null
+++ b/src/modules/how-it-works-3d/models/Scene3D.jsx
@@ -0,0 +1,11596 @@
+/*
+Auto-generated by: https://github.com/pmndrs/gltfjsx
+*/
+
+import React, { useRef } from 'react'
+import { useGLTF } from '@react-three/drei'
+
+export function Model(props) {
+ const { nodes, materials } = useGLTF('/models/3d_scene_final.glb')
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+useGLTF.preload('/models/3d_scene_final.glb')
+
+
+
+
diff --git a/src/modules/how-it-works-3d/store/useSceneStore.js b/src/modules/how-it-works-3d/store/useSceneStore.js
new file mode 100644
index 0000000..0acf5b4
--- /dev/null
+++ b/src/modules/how-it-works-3d/store/useSceneStore.js
@@ -0,0 +1,14 @@
+import { create } from 'zustand'
+
+export const useSceneStore = create((set) => ({
+ scrollProgress: 0,
+ activeSection: 0, // 0: First Mile, 1: Mid Mile, 2: Last Mile, 3: Analytics
+ truckProgress: 0,
+ cameraTarget: [19.727, 4.397, -31.08], // Initial target, e.g. First Mile warehouse
+ lenis: null,
+ setScrollProgress: (progress) => set({ scrollProgress: progress }),
+ setActiveSection: (section) => set({ activeSection: section }),
+ setTruckProgress: (progress) => set({ truckProgress: progress }),
+ setCameraTarget: (target) => set({ cameraTarget: target }),
+ setLenis: (lenis) => set({ lenis }),
+}))
diff --git a/src/modules/how-it-works-3d/styles/experience.css b/src/modules/how-it-works-3d/styles/experience.css
new file mode 100644
index 0000000..c1e8a81
--- /dev/null
+++ b/src/modules/how-it-works-3d/styles/experience.css
@@ -0,0 +1,439 @@
+/* ============================================================================
+ How It Works — 3D experience styles.
+
+ Ported from the standalone Vite app's index.css and FULLY SCOPED under
+ `.dm-hiw-3d` so nothing bleeds into the surrounding Next.js site. The original
+ had global `:root` / `body` / `::-webkit-scrollbar` rules and generic class
+ names (.navbar, .btn-primary, .section-title, .loader-overlay) that would
+ collide with the site's Elementor CSS — every selector below is therefore
+ prefixed with `.dm-hiw-3d`.
+
+ Pinning mirrors the site's existing scroll-driven 3D sections (see
+ StrategySection): a tall `position:relative` section + an absolutely
+ positioned stage toggled absolute(top) → fixed → absolute(bottom) from
+ ScrollTrigger pin state. CSS `position: sticky` / GSAP pin are unreliable
+ here because the site has a fixed header and an ancestor `overflow:hidden`.
+ ============================================================================ */
+
+@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap');
+
+/* ---- Section shell + self-managed fixed pin ---- */
+.dm-hiw-3d {
+ position: relative;
+ width: 100%;
+ font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+ line-height: 1.47;
+ font-weight: 400;
+ color: #1d1d1f;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.dm-hiw-3d-stage {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100vh;
+ overflow: hidden;
+ will-change: transform;
+ transform: translateZ(0);
+ backface-visibility: hidden;
+}
+.dm-hiw-3d.is-pinned .dm-hiw-3d-stage {
+ position: fixed;
+ top: 0;
+ left: 0;
+}
+.dm-hiw-3d.is-after .dm-hiw-3d-stage {
+ position: absolute;
+ top: auto;
+ bottom: 0;
+}
+
+.dm-hiw-3d .canvas-wrapper {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ pointer-events: auto;
+}
+
+/* Floating Vertical Side Navigation */
+.dm-hiw-3d .side-navigation {
+ position: absolute;
+ right: 28px;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ background: transparent;
+ padding: 18px 10px;
+}
+
+.dm-hiw-3d .side-nav-item {
+ background: none;
+ border: none;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+ padding: 4px 6px;
+ transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
+ outline: none;
+}
+
+.dm-hiw-3d .side-nav-label {
+ font-family: inherit;
+ font-size: 10px;
+ font-weight: 600;
+ color: #86868b;
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ opacity: 0;
+ transform: translateX(8px) scale(0.9);
+ transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
+ pointer-events: none;
+}
+
+.dm-hiw-3d .side-nav-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background-color: rgba(0, 0, 0, 0.15);
+ transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
+}
+
+.dm-hiw-3d .side-nav-item:hover .side-nav-label {
+ opacity: 1;
+ transform: translateX(0) scale(1);
+}
+.dm-hiw-3d .side-nav-item:hover .side-nav-dot {
+ background-color: #1d1d1f;
+ transform: scale(1.15);
+}
+.dm-hiw-3d .side-nav-item.active .side-nav-label {
+ color: #0071e3;
+}
+.dm-hiw-3d .side-nav-item.active .side-nav-dot {
+ background-color: #0071e3;
+ transform: scale(1.3);
+ box-shadow: 0 0 8px rgba(0, 113, 227, 0.3);
+}
+
+.dm-hiw-3d .section-close-btn {
+ margin-top: 20px;
+ background-color: #0071e3;
+ color: #ffffff;
+ border: none;
+ font-family: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ padding: 8px 16px;
+ border-radius: 18px;
+ cursor: pointer;
+ transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
+ box-shadow: 0 4px 12px rgba(0, 113, 227, 0.15);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: auto;
+}
+.dm-hiw-3d .section-close-btn:hover {
+ background-color: #0077ed;
+ box-shadow: 0 6px 16px rgba(0, 113, 227, 0.3);
+ transform: translateY(-1px);
+}
+.dm-hiw-3d .section-close-btn:active {
+ transform: translateY(1px);
+}
+
+/* ---- Story stage text panels ---- */
+.dm-hiw-3d .sections-overlay-container {
+ position: absolute;
+ inset: 0;
+ z-index: 8;
+ pointer-events: none; /* Let clicks pass to 3D canvas */
+ display: flex;
+ align-items: center;
+}
+
+.dm-hiw-3d #first-mile-section,
+.dm-hiw-3d #last-mile-section {
+ left: 6%;
+}
+.dm-hiw-3d #mid-mile-section {
+ right: 6%;
+}
+.dm-hiw-3d #analytics-section {
+ left: 50%;
+ right: auto;
+ top: 50%;
+ transform: translate(-50%, -50%) translateY(18px) scale(0.97);
+ max-width: 500px;
+ width: 90%;
+ background: rgba(20, 21, 26, 0.88); /* Deep slate blackboard theme */
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ box-shadow: 0 30px 70px rgba(0, 0, 0, 0.5);
+ color: #ffffff;
+}
+.dm-hiw-3d #analytics-section.active {
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+}
+.dm-hiw-3d #analytics-section .section-title { color: #ffffff; }
+.dm-hiw-3d #analytics-section .section-subtitle { color: #a1a1a6; }
+.dm-hiw-3d #analytics-section .step-title { color: #ffffff; }
+.dm-hiw-3d #analytics-section .step-description { color: #a1a1a6; }
+.dm-hiw-3d #analytics-section .step-line {
+ background: linear-gradient(to bottom, #0071e3 40%, rgba(255, 255, 255, 0.1) 100%);
+}
+
+.dm-hiw-3d .section-panel {
+ position: absolute;
+ max-width: 380px;
+ padding: 30px;
+ background: rgba(255, 255, 255, 0.76);
+ backdrop-filter: blur(0px);
+ -webkit-backdrop-filter: blur(0px);
+ border: 1px solid rgba(0, 0, 0, 0.06);
+ border-radius: 24px;
+ opacity: 0;
+ transform: translateY(18px) scale(0.97);
+ visibility: hidden;
+ transition: backdrop-filter 0.9s cubic-bezier(0.16, 1, 0.3, 1),
+ -webkit-backdrop-filter 0.9s cubic-bezier(0.16, 1, 0.3, 1),
+ visibility 0.9s;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.03);
+ pointer-events: none;
+}
+.dm-hiw-3d .section-panel.active {
+ visibility: visible;
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+ pointer-events: auto;
+}
+
+.dm-hiw-3d .section-badge {
+ font-size: 11px;
+ text-transform: uppercase;
+ font-weight: 600;
+ letter-spacing: 1px;
+ color: #0071e3;
+ margin-bottom: 8px;
+}
+.dm-hiw-3d .section-title {
+ font-size: 26px;
+ font-weight: 600;
+ letter-spacing: -0.6px;
+ color: #1d1d1f;
+ margin: 0 0 4px 0;
+}
+.dm-hiw-3d .section-subtitle {
+ font-size: 15px;
+ font-weight: 500;
+ color: #86868b;
+ margin: 0 0 14px 0;
+}
+.dm-hiw-3d .section-description {
+ font-size: 13px;
+ line-height: 1.5;
+ color: #515154;
+ margin-bottom: 20px;
+}
+.dm-hiw-3d .section-metrics {
+ display: flex;
+ gap: 20px;
+ border-top: 1px solid rgba(0, 0, 0, 0.05);
+ padding-top: 16px;
+}
+.dm-hiw-3d .metric-item {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+.dm-hiw-3d .metric-value {
+ font-size: 20px;
+ font-weight: 600;
+ color: #1d1d1f;
+ letter-spacing: -0.3px;
+}
+.dm-hiw-3d .metric-label {
+ font-size: 10px;
+ font-weight: 500;
+ color: #86868b;
+}
+.dm-hiw-3d .font-green .metric-value { color: #34c759; }
+
+/* ---- Animations (keyframes left global; uniquely named) ---- */
+@keyframes dmHiwScrollWheel {
+ 0% { top: 6px; opacity: 1; height: 6px; }
+ 50% { top: 14px; opacity: 0.3; height: 4px; }
+ 100% { top: 6px; opacity: 1; height: 6px; }
+}
+@keyframes dmHiwPulseGreen {
+ 0%, 100% { opacity: 0.6; }
+ 50% { opacity: 1; }
+}
+@keyframes dmHiwMoveArrow {
+ 0%, 100% { transform: translateX(0); }
+ 50% { transform: translateX(8px); }
+}
+
+/* ---- Workflow steps styling inside the Analytics overlay ---- */
+.dm-hiw-3d .workflow-steps {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ margin-top: 18px;
+}
+.dm-hiw-3d .workflow-step { display: flex; gap: 14px; }
+.dm-hiw-3d .step-number-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 24px;
+}
+.dm-hiw-3d .step-number {
+ font-size: 11px;
+ font-weight: 700;
+ color: #0071e3;
+ background: rgba(0, 113, 227, 0.1);
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid rgba(0, 113, 227, 0.15);
+}
+.dm-hiw-3d .step-line {
+ width: 1px;
+ flex-grow: 1;
+ background: linear-gradient(to bottom, #0071e3 40%, rgba(0, 0, 0, 0.05) 100%);
+ margin-top: 6px;
+ min-height: 24px;
+}
+.dm-hiw-3d .step-content { flex-grow: 1; }
+.dm-hiw-3d .step-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: #1d1d1f;
+ margin: 0 0 2px 0;
+}
+.dm-hiw-3d .step-description {
+ font-size: 11.5px;
+ line-height: 1.45;
+ color: #6e6e73;
+ margin: 0;
+}
+
+/* ---- Responsive ---- */
+@media (max-width: 1024px) {
+ .dm-hiw-3d .sections-overlay-container {
+ padding-left: 0;
+ padding-right: 0;
+ align-items: flex-end;
+ padding-bottom: 50px;
+ }
+ .dm-hiw-3d .section-panel {
+ max-width: 100%;
+ width: calc(100vw - 40px);
+ padding: 20px;
+ border-radius: 18px;
+ }
+ .dm-hiw-3d #first-mile-section,
+ .dm-hiw-3d #mid-mile-section,
+ .dm-hiw-3d #last-mile-section,
+ .dm-hiw-3d #analytics-section {
+ left: 50% !important;
+ right: auto !important;
+ top: auto !important;
+ bottom: 60px !important;
+ transform: translateX(-50%) translateY(18px) scale(0.97) !important;
+ max-width: 380px;
+ width: calc(100vw - 120px) !important;
+ }
+ .dm-hiw-3d #first-mile-section.active,
+ .dm-hiw-3d #mid-mile-section.active,
+ .dm-hiw-3d #last-mile-section.active,
+ .dm-hiw-3d #analytics-section.active {
+ transform: translateX(-50%) translateY(0) scale(1) !important;
+ }
+ .dm-hiw-3d #analytics-section { background: rgba(20, 21, 26, 0.92); }
+ .dm-hiw-3d .side-navigation {
+ bottom: 12px;
+ top: auto;
+ right: auto;
+ left: 50%;
+ transform: translateX(-50%);
+ flex-direction: row;
+ gap: 18px;
+ background: rgba(255, 255, 255, 0.85);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border: 1px solid rgba(0, 0, 0, 0.08);
+ border-radius: 20px;
+ padding: 8px 16px;
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
+ }
+ .dm-hiw-3d .side-nav-item { justify-content: center; padding: 0; }
+ .dm-hiw-3d .side-nav-label { display: none; }
+}
+
+@media (max-width: 400px) {
+ .dm-hiw-3d .section-panel {
+ padding: 16px !important;
+ width: calc(100vw - 80px) !important;
+ bottom: 40px !important;
+ }
+ .dm-hiw-3d .section-badge {
+ font-size: 9px !important;
+ margin-bottom: 4px !important;
+ }
+ .dm-hiw-3d .section-title {
+ font-size: 20px !important;
+ letter-spacing: -0.4px !important;
+ }
+ .dm-hiw-3d .section-subtitle {
+ font-size: 13px !important;
+ margin-bottom: 8px !important;
+ }
+ .dm-hiw-3d .section-description {
+ font-size: 11px !important;
+ line-height: 1.4 !important;
+ margin-bottom: 12px !important;
+ }
+ .dm-hiw-3d .section-metrics {
+ padding-top: 10px !important;
+ gap: 12px !important;
+ }
+ .dm-hiw-3d .metric-value { font-size: 15px !important; }
+ .dm-hiw-3d .metric-label { font-size: 8.5px !important; }
+ .dm-hiw-3d .side-navigation {
+ bottom: 8px !important;
+ gap: 12px !important;
+ padding: 6px 12px !important;
+ }
+ .dm-hiw-3d .side-nav-dot { width: 6px !important; height: 6px !important; }
+ .dm-hiw-3d #first-mile-section,
+ .dm-hiw-3d #mid-mile-section,
+ .dm-hiw-3d #last-mile-section,
+ .dm-hiw-3d #analytics-section {
+ left: 50% !important;
+ right: auto !important;
+ bottom: 40px !important;
+ width: calc(100vw - 80px) !important;
+ transform: translateX(-50%) translateY(18px) scale(0.97) !important;
+ }
+ .dm-hiw-3d #first-mile-section.active,
+ .dm-hiw-3d #mid-mile-section.active,
+ .dm-hiw-3d #last-mile-section.active,
+ .dm-hiw-3d #analytics-section.active {
+ transform: translateX(-50%) translateY(0) scale(1) !important;
+ }
+}
diff --git a/src/modules/how-it-works-3d/utils/audioHelper.js b/src/modules/how-it-works-3d/utils/audioHelper.js
new file mode 100644
index 0000000..25a2acf
--- /dev/null
+++ b/src/modules/how-it-works-3d/utils/audioHelper.js
@@ -0,0 +1,89 @@
+let audioContext = null;
+let isUnlocked = false;
+
+// Initialize and unlock audio context
+export const initAudio = () => {
+ if (isUnlocked) return;
+
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
+ if (!AudioContextClass) return;
+
+ if (!audioContext) {
+ audioContext = new AudioContextClass();
+ }
+
+ // Resume context if suspended (browser autoplay policy)
+ if (audioContext.state === 'suspended') {
+ audioContext.resume().then(() => {
+ isUnlocked = true;
+ cleanupListeners();
+ }).catch(() => {});
+ } else {
+ isUnlocked = true;
+ cleanupListeners();
+ }
+};
+
+const cleanupListeners = () => {
+ window.removeEventListener('click', initAudio);
+ window.removeEventListener('keydown', initAudio);
+ window.removeEventListener('touchstart', initAudio);
+ window.removeEventListener('wheel', initAudio);
+};
+
+// Add listeners for early activation
+if (typeof window !== 'undefined') {
+ window.addEventListener('click', initAudio, { passive: true });
+ window.addEventListener('keydown', initAudio, { passive: true });
+ window.addEventListener('touchstart', initAudio, { passive: true });
+ window.addEventListener('wheel', initAudio, { passive: true });
+}
+
+// Play a high-tech UI chime sound for card reveal
+export const playRevealChime = () => {
+ try {
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
+ if (!AudioContextClass) return;
+
+ if (!audioContext) {
+ audioContext = new AudioContextClass();
+ }
+
+ if (audioContext.state === 'suspended') {
+ audioContext.resume().catch(() => {});
+ }
+
+ const now = audioContext.currentTime;
+
+ // Master Volume node with exponential decay
+ const masterGain = audioContext.createGain();
+ masterGain.gain.setValueAtTime(0, now);
+ masterGain.gain.linearRampToValueAtTime(0.15, now + 0.04); // subtle fade-in to avoid clicking
+ masterGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.4); // smooth tail decay
+
+ // Warm base oscillator (triangle wave)
+ const baseOsc = audioContext.createOscillator();
+ baseOsc.type = 'triangle';
+ baseOsc.frequency.setValueAtTime(329.63, now); // E4 pitch
+ baseOsc.frequency.exponentialRampToValueAtTime(523.25, now + 0.25); // Slide up to C5
+
+ // High harmonic chime oscillator (sine wave)
+ const chimeOsc = audioContext.createOscillator();
+ chimeOsc.type = 'sine';
+ chimeOsc.frequency.setValueAtTime(659.25, now); // E5 pitch
+ chimeOsc.frequency.exponentialRampToValueAtTime(1046.50, now + 0.25); // Slide up to C6
+
+ // Connect nodes
+ baseOsc.connect(masterGain);
+ chimeOsc.connect(masterGain);
+ masterGain.connect(audioContext.destination);
+
+ // Play oscillators
+ baseOsc.start(now);
+ baseOsc.stop(now + 0.4);
+ chimeOsc.start(now);
+ chimeOsc.stop(now + 0.4);
+ } catch (error) {
+ console.warn('Playback of reveal chime failed:', error);
+ }
+};
diff --git a/src/modules/how-it-works-3d/utils/easing.js b/src/modules/how-it-works-3d/utils/easing.js
new file mode 100644
index 0000000..dc38e26
--- /dev/null
+++ b/src/modules/how-it-works-3d/utils/easing.js
@@ -0,0 +1,15 @@
+export const easeInOutCubic = (t) => {
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
+}
+
+export const easeOutQuad = (t) => {
+ return t * (2 - t)
+}
+
+export const easeInQuad = (t) => {
+ return t * t
+}
+
+export const easeOutCubic = (t) => {
+ return 1 - Math.pow(1 - t, 3)
+}
diff --git a/src/modules/how-it-works-3d/utils/helpers.js b/src/modules/how-it-works-3d/utils/helpers.js
new file mode 100644
index 0000000..93903db
--- /dev/null
+++ b/src/modules/how-it-works-3d/utils/helpers.js
@@ -0,0 +1,41 @@
+import * as THREE from 'three'
+
+// Linear interpolation
+export const lerp = (start, end, amt) => {
+ return (1 - amt) * start + amt * end
+}
+
+// Map a number from [inMin, inMax] to [outMin, outMax]
+export const mapRange = (value, inMin, inMax, outMin, outMax) => {
+ const result = ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
+ return isNaN(result) ? outMin : result
+}
+
+// Clamp a number between min and max
+export const clamp = (value, min, max) => {
+ return Math.min(Math.max(value, min), max)
+}
+
+// Vector3 interpolation helper
+export const lerpVectors = (v1, v2, alpha, outVector = new THREE.Vector3()) => {
+ outVector.x = lerp(v1.x, v2.x, alpha)
+ outVector.y = lerp(v1.y, v2.y, alpha)
+ outVector.z = lerp(v1.z, v2.z, alpha)
+ return outVector
+}
+
+// Convert a normalized scroll progress (0..1) within the experience into an
+// absolute document scrollY, relative to the 900vh ScrollRig spacer. Because the
+// experience now sits below the page hero (not at document top), jump-to-section
+// targets must be measured from the spacer's offset rather than the document top.
+// The spacer (#scroll-trigger-trigger) maps progress over the scroll span
+// [spacerTop, spacerTop + (spacerHeight - viewportHeight)] — matching the
+// ScrollTrigger start:'top top' / end:'bottom bottom' on the same element.
+export const progressToScrollY = (progress) => {
+ if (typeof document === 'undefined') return 0
+ const rig = document.getElementById('scroll-trigger-trigger')
+ if (!rig) return 0
+ const top = rig.getBoundingClientRect().top + window.scrollY
+ const scrollable = rig.offsetHeight - window.innerHeight
+ return top + clamp(progress, 0, 1) * scrollable
+}