Files
Digitaltwin/web/src/three/Scene.jsx
T

172 lines
5.1 KiB
React

/**
* The 3D hero view of the line.
*
* No external assets are loaded anywhere in this scene - no HDRI environment, no
* CDN font, no texture. Everything is primitives and analytic lights, because the
* demo has to survive a room with no working wifi.
*/
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, ContactShadows } from '@react-three/drei';
import { useStore } from '../store.js';
import { Station } from './Station.jsx';
import { Belt, QueueBoxes, FlowBoxes } from './Conveyor.jsx';
import { stateMeta } from '../lib/format.js';
/** Station centres, evenly spaced along the line. */
const SPACING = 4.3;
const stationX = (i, n) => (i - (n - 1) / 2) * SPACING;
function Floor() {
return (
<>
<mesh position={[0, -0.02, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[60, 26]} />
<meshStandardMaterial color="#161513" roughness={0.95} metalness={0} />
</mesh>
{/* aisle markings, purely to give the eye a ground plane to read depth from */}
{[-3.2, 3.2].map((z) => (
<mesh key={z} position={[0, 0, z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[26, 0.06]} />
<meshBasicMaterial color="#3a3830" />
</mesh>
))}
</>
);
}
function LineContent() {
const frame = useStore((s) => s.frame);
const meta = useStore((s) => s.meta);
const selected = useStore((s) => s.selected);
const select = useStore((s) => s.select);
if (!frame || !meta) return null;
const n = frame.stations.length;
return (
<group>
{frame.stations.map((st, i) => (
<Station
key={st.id}
station={st}
spec={meta.stations.find((x) => x.id === st.id)}
x={stationX(i, n)}
selected={selected === st.id}
onSelect={select}
/>
))}
{frame.buffers.map((level, i) => {
const x = (stationX(i, n) + stationX(i + 1, n)) / 2;
const width = SPACING - 2.15;
const upstream = frame.stations[i];
return (
<group key={i}>
<Belt x={x} width={width} />
<QueueBoxes
x={x}
width={width}
count={level}
full={level >= frame.bufferCapacity}
/>
<FlowBoxes
x={x}
width={width}
active={upstream.state === 'running'}
rate={frame.controls.lineSpeedPct / 100}
/>
</group>
);
})}
</group>
);
}
export function Scene() {
const frame = useStore((s) => s.frame);
const connected = useStore((s) => s.connected);
const worstState = frame
? (frame.stations.find((s) => s.state === 'fault' || s.state === 'down')
|| frame.stations.find((s) => s.state === 'blocked'))
: null;
return (
<div className="scene">
<Canvas
shadows
dpr={[1, 1.75]}
camera={{ position: [11, 8.5, 13], fov: 40 }}
gl={{ antialias: true }}
>
<color attach="background" args={['#121211']} />
<fog attach="fog" args={['#121211', 26, 52]} />
{/* Analytic lights only - an HDRI would be a network fetch. */}
<hemisphereLight args={['#8fa4b8', '#2a2620', 0.55]} />
<directionalLight
position={[9, 14, 8]}
intensity={1.7}
castShadow
shadow-mapSize={[1024, 1024]}
shadow-camera-left={-16}
shadow-camera-right={16}
shadow-camera-top={12}
shadow-camera-bottom={-12}
/>
<directionalLight position={[-10, 6, -8]} intensity={0.45} color="#7fa8d0" />
<Suspense fallback={null}>
<Floor />
<LineContent />
<ContactShadows
position={[0, 0.01, 0]}
opacity={0.5}
scale={40}
blur={2.4}
far={5}
resolution={512}
color="#000000"
/>
</Suspense>
<OrbitControls
target={[0, 1.1, 0]}
enablePan
minDistance={7}
maxDistance={34}
minPolarAngle={0.15}
maxPolarAngle={Math.PI / 2.15}
/>
</Canvas>
<div className="scene-overlay">
<span className="ttl">LINE-1 · Assembly &amp; Cure</span>
<span className="sub">
{connected ? 'Live twin' : 'Disconnected'}
{worstState ? ` · ${worstState.id} ${stateMeta(worstState.state).label.toLowerCase()}` : ' · all stations nominal'}
</span>
</div>
{/* Legend pairs every status colour with its word. */}
<div className="scene-legend">
{['running', 'microstop', 'blocked', 'fault'].map((s) => {
const sm = stateMeta(s);
return (
<span className="legend-item" key={s}>
<span className="dot" style={{
width: 7, height: 7, borderRadius: '50%', background: sm.color, display: 'inline-block',
}} />
{sm.label}
</span>
);
})}
</div>
<div className="scene-hint">Drag to orbit · scroll to zoom · click a machine to inspect</div>
</div>
);
}