Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 38 additions & 8 deletions LOGISDATA/src/components/three/IndustrialScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<CinematicFrame | null>(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 (
<mesh position={[0, 0, -30]} frustumCulled={false} renderOrder={-10}>
<planeGeometry args={[56, 32]} />
<meshBasicMaterial
map={texture}
color="#a9bac2"
color="#c1cfd5"
transparent
opacity={0.46}
opacity={0.52}
depthWrite={false}
fog={false}
toneMapped={false}
Expand Down Expand Up @@ -185,7 +215,7 @@ function SceneRig({ language, theme, device }: IndustrialSceneProps) {
<directionalLight position={[4, 7, 5]} intensity={theme === "dark" ? 1.35 : 1.05} />
{device.tier !== "low" && (
<Suspense fallback={null}>
<WarehouseBackdrop />
<WarehouseBackdrop reducedMotion={device.reducedMotion} />
</Suspense>
)}
{device.effects && <DataStreamField device={device} theme={theme} />}
Expand Down
63 changes: 63 additions & 0 deletions LOGISDATA/src/lib/cinematic.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
44 changes: 44 additions & 0 deletions LOGISDATA/tests/unit/cinematic.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
Loading