227 lines
8.1 KiB
React
227 lines
8.1 KiB
React
/**
|
|
* One machine, built from primitives.
|
|
*
|
|
* Geometry is boxes and cylinders on purpose. Sourcing or modelling CAD-accurate
|
|
* machines is a day of work that adds nothing to the argument being made, and it
|
|
* is the classic way a two-day demo becomes a four-day demo.
|
|
*
|
|
* What the geometry DOES carry is state, three ways: a status-coloured status bar
|
|
* on the machine, a pulse when it is stopped on a fault, and - the detail that
|
|
* lands with maintenance engineers - a physical shake on CNC-02 that scales with
|
|
* measured bearing vibration.
|
|
*/
|
|
|
|
import { useMemo, useRef } from 'react';
|
|
import { useFrame } from '@react-three/fiber';
|
|
import { Html } from '@react-three/drei';
|
|
import { stateMeta } from '../lib/format.js';
|
|
|
|
function Body({ w, h, d, y, color, roughness = 0.6, metalness = 0.35 }) {
|
|
return (
|
|
<mesh position={[0, y, 0]} castShadow receiveShadow>
|
|
<boxGeometry args={[w, h, d]} />
|
|
<meshStandardMaterial color={color} roughness={roughness} metalness={metalness} />
|
|
</mesh>
|
|
);
|
|
}
|
|
|
|
/** Per-kind machine shape. */
|
|
function Machine({ kind, station, spinRef }) {
|
|
const shell = '#4a4941';
|
|
const dark = '#2f2e28';
|
|
|
|
switch (kind) {
|
|
case 'conveyor':
|
|
return (
|
|
<group>
|
|
<Body w={1.9} h={0.5} d={1.3} y={0.25} color={shell} />
|
|
{[-0.5, 0, 0.5].map((x) => (
|
|
<mesh key={x} position={[x, 0.56, 0]} rotation={[0, 0, Math.PI / 2]}>
|
|
<cylinderGeometry args={[0.09, 0.09, 1.15, 14]} />
|
|
<meshStandardMaterial color={dark} roughness={0.5} metalness={0.6} />
|
|
</mesh>
|
|
))}
|
|
</group>
|
|
);
|
|
|
|
case 'cnc':
|
|
return (
|
|
<group>
|
|
<Body w={2.0} h={1.5} d={1.5} y={0.75} color={shell} />
|
|
{/* enclosure window */}
|
|
<mesh position={[0, 0.95, 0.77]}>
|
|
<boxGeometry args={[1.3, 0.6, 0.03]} />
|
|
<meshStandardMaterial color="#1b2a33" roughness={0.2} metalness={0.5} />
|
|
</mesh>
|
|
{/* spindle, rotates while cutting */}
|
|
<group ref={spinRef} position={[0, 1.62, 0]}>
|
|
<mesh castShadow>
|
|
<cylinderGeometry args={[0.17, 0.17, 0.42, 18]} />
|
|
<meshStandardMaterial color="#6d6a5e" roughness={0.35} metalness={0.8} />
|
|
</mesh>
|
|
<mesh position={[0, -0.3, 0]}>
|
|
<cylinderGeometry args={[0.05, 0.05, 0.24, 12]} />
|
|
<meshStandardMaterial color="#9a978c" roughness={0.25} metalness={0.9} />
|
|
</mesh>
|
|
</group>
|
|
</group>
|
|
);
|
|
|
|
case 'oven': {
|
|
// Zone glow follows burner duty, so a saturated burner visibly runs hot.
|
|
const duty = (station.signals.burnerDuty || 0) / 100;
|
|
return (
|
|
<group>
|
|
<Body w={2.6} h={1.25} d={1.5} y={0.62} color="#514f45" />
|
|
{/* tunnel mouth */}
|
|
<mesh position={[0, 0.62, 0.76]}>
|
|
<boxGeometry args={[2.1, 0.42, 0.04]} />
|
|
<meshStandardMaterial color="#120e0b" roughness={0.9} />
|
|
</mesh>
|
|
{/* three zone strips on the roof */}
|
|
{[-0.75, 0, 0.75].map((x, i) => (
|
|
<mesh key={x} position={[x, 1.27, 0]}>
|
|
<boxGeometry args={[0.62, 0.06, 1.2]} />
|
|
<meshStandardMaterial
|
|
color="#8a3a10"
|
|
emissive="#ff6a1a"
|
|
emissiveIntensity={0.15 + duty * 1.5}
|
|
roughness={0.6}
|
|
/>
|
|
</mesh>
|
|
))}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
case 'inspection':
|
|
return (
|
|
<group>
|
|
<Body w={1.7} h={0.45} d={1.3} y={0.22} color={shell} />
|
|
{/* gantry */}
|
|
{[-0.6, 0.6].map((x) => (
|
|
<mesh key={x} position={[x, 1.05, 0]}>
|
|
<boxGeometry args={[0.11, 1.25, 0.11]} />
|
|
<meshStandardMaterial color={dark} roughness={0.5} metalness={0.6} />
|
|
</mesh>
|
|
))}
|
|
<mesh position={[0, 1.7, 0]}>
|
|
<boxGeometry args={[1.35, 0.12, 0.3]} />
|
|
<meshStandardMaterial color={dark} roughness={0.5} metalness={0.6} />
|
|
</mesh>
|
|
{/* camera + light cone */}
|
|
<mesh position={[0, 1.5, 0]}>
|
|
<cylinderGeometry args={[0.11, 0.11, 0.24, 14]} />
|
|
<meshStandardMaterial color="#23221d" roughness={0.4} metalness={0.7} />
|
|
</mesh>
|
|
<mesh position={[0, 1.05, 0]}>
|
|
<coneGeometry args={[0.34, 0.72, 20, 1, true]} />
|
|
<meshStandardMaterial
|
|
color="#7ec8ff"
|
|
emissive="#4aa8ff"
|
|
emissiveIntensity={station.online ? 0.5 : 0}
|
|
transparent
|
|
opacity={station.online ? 0.16 : 0.03}
|
|
side={2}
|
|
/>
|
|
</mesh>
|
|
</group>
|
|
);
|
|
|
|
case 'packer':
|
|
return (
|
|
<group>
|
|
<Body w={1.9} h={1.15} d={1.4} y={0.57} color={shell} />
|
|
{/* film roll */}
|
|
<mesh position={[0.55, 1.35, 0]} rotation={[Math.PI / 2, 0, 0]} castShadow>
|
|
<cylinderGeometry args={[0.28, 0.28, 0.85, 20]} />
|
|
<meshStandardMaterial color="#b9b2a2" roughness={0.85} />
|
|
</mesh>
|
|
<mesh position={[-0.5, 1.22, 0]}>
|
|
<boxGeometry args={[0.75, 0.14, 1.0]} />
|
|
<meshStandardMaterial color={dark} roughness={0.55} metalness={0.5} />
|
|
</mesh>
|
|
</group>
|
|
);
|
|
|
|
default:
|
|
return <Body w={1.8} h={1} d={1.3} y={0.5} color={shell} />;
|
|
}
|
|
}
|
|
|
|
export function Station({ station, spec, x, selected, onSelect }) {
|
|
const group = useRef(null);
|
|
const spinRef = useRef(null);
|
|
const barRef = useRef(null);
|
|
|
|
const sm = stateMeta(station.state);
|
|
const stopped = station.state === 'fault' || station.state === 'down';
|
|
|
|
// Shake amplitude from measured vibration, above the healthy baseline.
|
|
const shake = useMemo(() => {
|
|
if (station.kind !== 'cnc') return 0;
|
|
const v = station.signals.vibration || 0;
|
|
return Math.max(0, v - 2.0) * 0.011;
|
|
}, [station.kind, station.signals.vibration]);
|
|
|
|
const running = station.state === 'running';
|
|
|
|
useFrame((state, dt) => {
|
|
const t = state.clock.elapsedTime;
|
|
|
|
if (group.current) {
|
|
group.current.position.x = x + (shake ? Math.sin(t * 47) * shake : 0);
|
|
group.current.position.z = shake ? Math.cos(t * 61) * shake : 0;
|
|
}
|
|
if (spinRef.current && running) {
|
|
spinRef.current.rotation.y += dt * 14;
|
|
}
|
|
// A stopped station pulses, so it draws the eye from across the room.
|
|
if (barRef.current) {
|
|
const mat = barRef.current.material;
|
|
mat.emissiveIntensity = stopped ? 0.7 + Math.sin(t * 5) * 0.6 : 0.65;
|
|
}
|
|
});
|
|
|
|
return (
|
|
<group ref={group} position={[x, 0, 0]}>
|
|
<group onClick={(e) => { e.stopPropagation(); onSelect(station.id); }}>
|
|
<Machine kind={station.kind} station={station} spinRef={spinRef} />
|
|
|
|
{/* status bar: the machine's own state light */}
|
|
<mesh ref={barRef} position={[0, 0.06, 0.8]}>
|
|
<boxGeometry args={[1.5, 0.09, 0.09]} />
|
|
<meshStandardMaterial color={sm.color} emissive={sm.color} emissiveIntensity={0.65} />
|
|
</mesh>
|
|
|
|
{/* selection ring */}
|
|
{selected && (
|
|
<mesh position={[0, 0.02, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
|
<ringGeometry args={[1.35, 1.5, 48]} />
|
|
<meshBasicMaterial color="#ffffff" transparent opacity={0.5} side={2} />
|
|
</mesh>
|
|
)}
|
|
</group>
|
|
|
|
{/* Labels are DOM, not 3D text: drei's <Text> fetches a font from a CDN,
|
|
which would fail in an offline demo room. */}
|
|
<Html position={[0, 2.32, 0]} center distanceFactor={12} zIndexRange={[10, 0]} pointerEvents="none">
|
|
<div style={{
|
|
textAlign: 'center', whiteSpace: 'nowrap', userSelect: 'none',
|
|
fontFamily: 'system-ui, sans-serif',
|
|
}}>
|
|
<div style={{
|
|
fontSize: 13, fontWeight: 700, letterSpacing: '0.03em',
|
|
color: selected ? '#ffffff' : '#d8d6cc',
|
|
textShadow: '0 1px 4px rgba(0,0,0,0.9)',
|
|
}}>{station.id}</div>
|
|
<div style={{
|
|
fontSize: 11, fontWeight: 600, color: sm.color,
|
|
textShadow: '0 1px 4px rgba(0,0,0,0.9)',
|
|
}}>{station.online ? sm.label : 'No data'}</div>
|
|
</div>
|
|
</Html>
|
|
</group>
|
|
);
|
|
}
|