172 lines
5.1 KiB
React
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 & 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>
|
|
);
|
|
}
|