From 4fb2279c47087b781308834bf31f835512c293d4 Mon Sep 17 00:00:00 2001 From: YASLOGIST <265797509+YASLOGIST@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:16:19 +0000 Subject: [PATCH] Add scroll-scrubbed warehouse walkthrough Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../src/components/three/IndustrialScene.tsx | 46 +++++++++++--- LOGISDATA/src/lib/cinematic.ts | 63 +++++++++++++++++++ LOGISDATA/tests/unit/cinematic.test.ts | 44 +++++++++++++ 3 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 LOGISDATA/src/lib/cinematic.ts create mode 100644 LOGISDATA/tests/unit/cinematic.test.ts diff --git a/LOGISDATA/src/components/three/IndustrialScene.tsx b/LOGISDATA/src/components/three/IndustrialScene.tsx index 59b4642..043ef7b 100644 --- a/LOGISDATA/src/components/three/IndustrialScene.tsx +++ b/LOGISDATA/src/components/three/IndustrialScene.tsx @@ -5,6 +5,7 @@ import { useScroll } from "@react-three/drei"; import { memo, Suspense, useEffect, useMemo, useRef } from "react"; import * as THREE from "three"; import { SECTION_COUNT, pagePositionFromOffset } from "@/lib/sections"; +import { getCinematicFrame, type CinematicFrame } from "@/lib/cinematic"; import type { DeviceProfile } from "@/lib/device"; import type { Language, ThemeMode } from "@/lib/types"; import { AuditScanner } from "./AuditScanner"; @@ -53,28 +54,57 @@ const DEPTH_STEP = 2.2; const clamp01 = (value: number) => THREE.MathUtils.clamp(value, 0, 1); const smoothStep = (value: number) => value * value * (3 - 2 * value); -/** Photo-real warehouse plate behind the scroll-synchronized 3D audit models. */ -function WarehouseBackdrop() { - const texture = useLoader(THREE.TextureLoader, "/warehouse-interior.jpg"); +/** + * Photo-real warehouse plate behind the 3D audit models. Its crop is a + * reversible five-shot timeline driven by ScrollControls: no autoplay, no + * idle loop, and no extra video decoder competing with the WebGL scene. + */ +function WarehouseBackdrop({ reducedMotion }: { reducedMotion: boolean }) { + const sourceTexture = useLoader(THREE.TextureLoader, "/warehouse-interior.jpg"); + const texture = useMemo(() => sourceTexture.clone(), [sourceTexture]); + const scroll = useScroll(); + const lastFrame = useRef(null); - // Configure the cached GPU texture once; this is an owned renderer resource, - // not React state. The loader cache must retain the same texture reference. + // Own a cloned texture so its animated UV transform never mutates drei's + // shared loader cache (or another scene using the same photograph). /* eslint-disable react-hooks/immutability */ useEffect(() => { texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = 4; + texture.wrapS = THREE.ClampToEdgeWrapping; + texture.wrapT = THREE.ClampToEdgeWrapping; texture.needsUpdate = true; + return () => texture.dispose(); }, [texture]); /* eslint-enable react-hooks/immutability */ + useFrame(() => { + const frame = getCinematicFrame(scroll.offset, reducedMotion); + const previous = lastFrame.current; + if ( + previous && + Math.abs(previous.zoom - frame.zoom) < 0.0001 && + Math.abs(previous.x - frame.x) < 0.0001 && + Math.abs(previous.y - frame.y) < 0.0001 + ) return; + + // Repeat controls the crop zoom; offset moves that crop over the photo. + // Since both are functions of the current scroll offset, the view freezes + // naturally at rest and retraces precisely when the user scrolls upward. + const repeat = 1 / frame.zoom; + texture.repeat.set(repeat, repeat); + texture.offset.set(frame.x * (1 - repeat), frame.y * (1 - repeat)); + lastFrame.current = frame; + }); + return ( {device.tier !== "low" && ( - + )} {device.effects && } diff --git a/LOGISDATA/src/lib/cinematic.ts b/LOGISDATA/src/lib/cinematic.ts new file mode 100644 index 0000000..6f20934 --- /dev/null +++ b/LOGISDATA/src/lib/cinematic.ts @@ -0,0 +1,63 @@ +/** + * Scroll-scrubbed framing for the warehouse photo plate. + * + * This deliberately is not an autoplay loop: the background is a five-shot + * camera move whose playhead is the presentation's scroll position. When the + * operator pauses on a chapter, the image holds on that shot. + */ +export interface CinematicFrame { + /** Scale of the sampled image area (1 = the full photograph). */ + zoom: number; + /** Normalized crop origin, from the left/top (0) to the right/bottom (1). */ + x: number; + y: number; +} + +/** One warehouse establishing shot for each of the five presentation chapters. */ +export const WAREHOUSE_SHOTS: readonly CinematicFrame[] = [ + { zoom: 1.02, x: 0.5, y: 0.5 }, // overview: full warehouse aisle + { zoom: 1.12, x: 0.22, y: 0.5 }, // invoice audit: left-side pallet racks + { zoom: 1.1, x: 0.48, y: 0.42 }, // demand: central aisle and load flow + { zoom: 1.13, x: 0.76, y: 0.52 }, // routes: right-side operations lane + { zoom: 1.18, x: 0.48, y: 0.56 }, // warehouse: close view of rack inventory +] as const; + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} + +function smoothstep(value: number): number { + const t = clamp01(value); + return t * t * (3 - 2 * t); +} + +/** + * Interpolates the real warehouse backdrop along the five chapter shots. + * The same normalized progress drives both directions, so scrolling upward + * retraces the camera path exactly rather than starting a second animation. + */ +export function getCinematicFrame(progress: number, reducedMotion = false): CinematicFrame { + if (WAREHOUSE_SHOTS.length === 1) return WAREHOUSE_SHOTS[0]; + + const position = clamp01(progress) * (WAREHOUSE_SHOTS.length - 1); + const fromIndex = Math.min(WAREHOUSE_SHOTS.length - 1, Math.floor(position)); + const toIndex = Math.min(WAREHOUSE_SHOTS.length - 1, fromIndex + 1); + const amount = smoothstep(position - fromIndex); + const from = WAREHOUSE_SHOTS[fromIndex]; + const to = WAREHOUSE_SHOTS[toIndex]; + + const zoom = from.zoom + (to.zoom - from.zoom) * amount; + const x = from.x + (to.x - from.x) * amount; + const y = from.y + (to.y - from.y) * amount; + + if (!reducedMotion) return { zoom, x, y }; + + // Keep the scene-to-scene relationship, but minimize camera movement for + // visitors who have requested reduced motion. + return { + zoom: 1 + (zoom - 1) * 0.18, + x: 0.5 + (x - 0.5) * 0.18, + y: 0.5 + (y - 0.5) * 0.18, + }; +} diff --git a/LOGISDATA/tests/unit/cinematic.test.ts b/LOGISDATA/tests/unit/cinematic.test.ts new file mode 100644 index 0000000..bacb561 --- /dev/null +++ b/LOGISDATA/tests/unit/cinematic.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { getCinematicFrame, WAREHOUSE_SHOTS } from "@/lib/cinematic"; + +describe("warehouse cinematic scroll timeline", () => { + it("defines one camera shot for each of the five presentation chapters", () => { + expect(WAREHOUSE_SHOTS).toHaveLength(5); + expect(getCinematicFrame(0)).toEqual(WAREHOUSE_SHOTS[0]); + expect(getCinematicFrame(0.25)).toEqual(WAREHOUSE_SHOTS[1]); + expect(getCinematicFrame(0.5)).toEqual(WAREHOUSE_SHOTS[2]); + expect(getCinematicFrame(0.75)).toEqual(WAREHOUSE_SHOTS[3]); + expect(getCinematicFrame(1)).toEqual(WAREHOUSE_SHOTS[4]); + }); + + it("smoothly interpolates adjacent shots in both scroll directions", () => { + const forward = getCinematicFrame(0.375); + const backward = getCinematicFrame(0.375); + const halfwayBetweenShots = { + zoom: (WAREHOUSE_SHOTS[1].zoom + WAREHOUSE_SHOTS[2].zoom) / 2, + x: (WAREHOUSE_SHOTS[1].x + WAREHOUSE_SHOTS[2].x) / 2, + y: (WAREHOUSE_SHOTS[1].y + WAREHOUSE_SHOTS[2].y) / 2, + }; + + expect(forward).toEqual(backward); + expect(forward.zoom).toBeCloseTo(halfwayBetweenShots.zoom, 10); + expect(forward.x).toBeCloseTo(halfwayBetweenShots.x, 10); + expect(forward.y).toBeCloseTo(halfwayBetweenShots.y, 10); + }); + + it("clamps progress and safely handles non-finite input", () => { + expect(getCinematicFrame(-2)).toEqual(WAREHOUSE_SHOTS[0]); + expect(getCinematicFrame(4)).toEqual(WAREHOUSE_SHOTS[4]); + expect(getCinematicFrame(Number.NaN)).toEqual(WAREHOUSE_SHOTS[0]); + expect(getCinematicFrame(Number.POSITIVE_INFINITY)).toEqual(WAREHOUSE_SHOTS[0]); + }); + + it("minimizes pan and zoom when reduced motion is enabled", () => { + const normal = getCinematicFrame(0.75); + const reduced = getCinematicFrame(0.75, true); + + expect(Math.abs(reduced.zoom - 1)).toBeLessThan(Math.abs(normal.zoom - 1)); + expect(Math.abs(reduced.x - 0.5)).toBeLessThan(Math.abs(normal.x - 0.5)); + expect(Math.abs(reduced.y - 0.5)).toBeLessThan(Math.abs(normal.y - 0.5)); + }); +});