feat: 3D digital twin major rework - real models, clean labels, improved geometry
- Real STL Models: 6 models loaded (55%), fallback for 5 (45%) - Clean Labels: 12+ → 5 default, step-based, technical toggle - Geometry: Gate lift, pusher plates, conveyor rollers, roll-cage wireframes - Demo Director: 12 steps, camera presets, auto-play logic (not integrated) - Tests: +16 new (43/43 green), Build ✅, Docker ✅, Domains ✅ Remaining: Auto demo UI integration, camera transitions, docs updates, Visual QA
This commit is contained in:
@@ -12,41 +12,76 @@ export default function Actuator3D({ gate, actuators, machineState }: Props) {
|
||||
const pusherC = pusherOffset(actuators.pusherC);
|
||||
const pusherD = pusherOffset(actuators.pusherD);
|
||||
const fault = machineState === 'FAULT' || machineState === 'EMERGENCY_STOP';
|
||||
|
||||
// Gate vertical lift: closed (Y = 0.2) → open (Y = 0.65)
|
||||
const gateY = gateOpen ? 0.65 : 0.2;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Stop-gate */}
|
||||
<mesh
|
||||
position={[TWIN_LAYOUT.gateX, TWIN_LAYOUT.beltY + 0.2, 0]}
|
||||
rotation={[0, 0, gateOpen ? -0.7 : 0]}
|
||||
>
|
||||
<boxGeometry args={[0.06, 0.55, 0.62]} />
|
||||
<meshStandardMaterial
|
||||
color={fault ? '#fb3d4e' : gateOpen ? '#4ade80' : '#f87171'}
|
||||
emissive={fault ? '#fb3d4e' : '#000000'}
|
||||
emissiveIntensity={fault ? 0.4 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Stop-gate — vertical lift mechanism */}
|
||||
<group position={[TWIN_LAYOUT.gateX, TWIN_LAYOUT.beltY, 0]}>
|
||||
{/* Gate support posts (left and right) */}
|
||||
<mesh position={[0, 0.35, -0.35]}>
|
||||
<boxGeometry args={[0.04, 0.7, 0.04]} />
|
||||
<meshStandardMaterial color="#475569" metalness={0.3} roughness={0.7} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.35, 0.35]}>
|
||||
<boxGeometry args={[0.04, 0.7, 0.04]} />
|
||||
<meshStandardMaterial color="#475569" metalness={0.3} roughness={0.7} />
|
||||
</mesh>
|
||||
|
||||
{/* Gate plate — moves up/down */}
|
||||
<mesh position={[0, gateY, 0]}>
|
||||
<boxGeometry args={[0.08, 0.05, 0.65]} />
|
||||
<meshStandardMaterial
|
||||
color={fault ? '#fb3d4e' : gateOpen ? '#4ade80' : '#f87171'}
|
||||
emissive={fault ? '#fb3d4e' : '#000000'}
|
||||
emissiveIntensity={fault ? 0.4 : gateOpen ? 0.15 : 0}
|
||||
metalness={0.2}
|
||||
roughness={0.6}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* Pusher C → roll-cage C */}
|
||||
<mesh position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY + 0.12, pusherC]}>
|
||||
<boxGeometry args={[0.35, 0.18, 0.18]} />
|
||||
<meshStandardMaterial
|
||||
color={actuators.pusherC === 'extended' ? '#f59e0b' : '#78350f'}
|
||||
emissive={actuators.pusherC === 'extended' ? '#f59e0b' : '#000000'}
|
||||
emissiveIntensity={actuators.pusherC === 'extended' ? 0.35 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Pusher C → roll-cage C (extended plate) */}
|
||||
<group position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY, 0]}>
|
||||
{/* Pusher base (stationary) */}
|
||||
<mesh position={[0, 0.12, 0.1]}>
|
||||
<boxGeometry args={[0.15, 0.18, 0.12]} />
|
||||
<meshStandardMaterial color="#78350f" roughness={0.8} />
|
||||
</mesh>
|
||||
|
||||
{/* Pusher plate (moves) */}
|
||||
<mesh position={[0, 0.12, pusherC]}>
|
||||
<boxGeometry args={[0.3, 0.15, 0.2]} />
|
||||
<meshStandardMaterial
|
||||
color={actuators.pusherC === 'extended' ? '#f59e0b' : '#92400e'}
|
||||
emissive={actuators.pusherC === 'extended' ? '#f59e0b' : '#000000'}
|
||||
emissiveIntensity={actuators.pusherC === 'extended' ? 0.35 : 0}
|
||||
roughness={0.65}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* Pusher D → roll-cage D */}
|
||||
<mesh position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY + 0.12, -pusherD]}>
|
||||
<boxGeometry args={[0.35, 0.18, 0.18]} />
|
||||
<meshStandardMaterial
|
||||
color={actuators.pusherD === 'extended' ? '#c084fc' : '#4c1d95'}
|
||||
emissive={actuators.pusherD === 'extended' ? '#c084fc' : '#000000'}
|
||||
emissiveIntensity={actuators.pusherD === 'extended' ? 0.35 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Pusher D → roll-cage D (extended plate) */}
|
||||
<group position={[TWIN_LAYOUT.gateX - 0.1, TWIN_LAYOUT.beltY, 0]}>
|
||||
{/* Pusher base (stationary) */}
|
||||
<mesh position={[0, 0.12, -0.1]}>
|
||||
<boxGeometry args={[0.15, 0.18, 0.12]} />
|
||||
<meshStandardMaterial color="#4c1d95" roughness={0.8} />
|
||||
</mesh>
|
||||
|
||||
{/* Pusher plate (moves) */}
|
||||
<mesh position={[0, 0.12, -pusherD]}>
|
||||
<boxGeometry args={[0.3, 0.15, 0.2]} />
|
||||
<meshStandardMaterial
|
||||
color={actuators.pusherD === 'extended' ? '#c084fc' : '#581c87'}
|
||||
emissive={actuators.pusherD === 'extended' ? '#c084fc' : '#000000'}
|
||||
emissiveIntensity={actuators.pusherD === 'extended' ? 0.35 : 0}
|
||||
roughness={0.65}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export default function Conveyor3D({ stopped }: Props) {
|
||||
const color = stopped ? '#7f1d1d' : '#1e3a54';
|
||||
const beltColor = stopped ? '#991b1b' : '#334155';
|
||||
const accColor = stopped ? '#7f1d1d' : '#0f766e';
|
||||
const sideGuardColor = '#475569';
|
||||
|
||||
return (
|
||||
<group>
|
||||
@@ -22,25 +23,56 @@ export default function Conveyor3D({ stopped }: Props) {
|
||||
<planeGeometry args={[8.4, 0.48]} />
|
||||
<meshStandardMaterial color={beltColor} roughness={0.9} />
|
||||
</mesh>
|
||||
|
||||
{/* Conveyor side guards (borders) */}
|
||||
<mesh position={[0, TWIN_LAYOUT.beltY + 0.05, 0.28]}>
|
||||
<boxGeometry args={[8.4, 0.1, 0.03]} />
|
||||
<meshStandardMaterial color={sideGuardColor} metalness={0.2} roughness={0.8} />
|
||||
</mesh>
|
||||
<mesh position={[0, TWIN_LAYOUT.beltY + 0.05, -0.28]}>
|
||||
<boxGeometry args={[8.4, 0.1, 0.03]} />
|
||||
<meshStandardMaterial color={sideGuardColor} metalness={0.2} roughness={0.8} />
|
||||
</mesh>
|
||||
|
||||
{/* Rollers (visual indication of conveyor mechanism) — spaced every 1.2m */}
|
||||
{[-3.6, -2.4, -1.2, 0, 1.2].map((x) => (
|
||||
<mesh key={x} position={[x, TWIN_LAYOUT.beltY - 0.08, 0]} rotation={[0, 0, Math.PI / 2]}>
|
||||
<cylinderGeometry args={[0.04, 0.04, 0.5, 8]} />
|
||||
<meshStandardMaterial color="#334155" metalness={0.4} roughness={0.6} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{/* Accumulator / buffer at end of feed conveyor — explicit raised pocket */}
|
||||
<group position={[TWIN_LAYOUT.accumulatorX, TWIN_LAYOUT.beltY, 0]}>
|
||||
{/* Accumulator floor (raised pocket) */}
|
||||
<mesh position={[0, -0.02, 0]}>
|
||||
<boxGeometry args={[1.05, 0.12, 0.82]} />
|
||||
<meshStandardMaterial color={accColor} roughness={0.65} emissive={accColor} emissiveIntensity={0.15} />
|
||||
</mesh>
|
||||
{/* Side walls of accumulator */}
|
||||
|
||||
{/* Accumulator walls — three sides (back + left + right) */}
|
||||
<mesh position={[0, 0.12, 0.38]}>
|
||||
<boxGeometry args={[1.05, 0.28, 0.06]} />
|
||||
<meshStandardMaterial color="#14b8a6" />
|
||||
<meshStandardMaterial color="#14b8a6" roughness={0.7} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.12, -0.38]}>
|
||||
<boxGeometry args={[1.05, 0.28, 0.06]} />
|
||||
<meshStandardMaterial color="#14b8a6" />
|
||||
<meshStandardMaterial color="#14b8a6" roughness={0.7} />
|
||||
</mesh>
|
||||
<mesh position={[-0.48, 0.12, 0]}>
|
||||
<boxGeometry args={[0.06, 0.28, 0.82]} />
|
||||
<meshStandardMaterial color="#14b8a6" />
|
||||
<meshStandardMaterial color="#14b8a6" roughness={0.7} />
|
||||
</mesh>
|
||||
|
||||
{/* Direction arrow (subtle indication of flow) */}
|
||||
<mesh position={[0.2, 0.05, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[0.3, 0.2]} />
|
||||
<meshStandardMaterial
|
||||
color="#5eead4"
|
||||
transparent
|
||||
opacity={0.4}
|
||||
side={2}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
@@ -1,12 +1,74 @@
|
||||
import { Suspense } from 'react';
|
||||
import type { SimulatedItem, SimulationState } from '../../domain/types';
|
||||
import { itemPosition3D, ROUTE_COLORS } from './itemMotion';
|
||||
import { DIMENSION_LIMITS } from '../../domain/classifier';
|
||||
import { getModelAsset } from '../../data/modelAssets';
|
||||
import STLModel from './STLModel';
|
||||
|
||||
interface Props {
|
||||
simulation: SimulationState;
|
||||
currentItem?: SimulatedItem;
|
||||
}
|
||||
|
||||
function FallbackPrimitive({
|
||||
primitive,
|
||||
sx,
|
||||
sy,
|
||||
sz,
|
||||
color,
|
||||
emissive,
|
||||
roughness,
|
||||
}: {
|
||||
primitive: 'box' | 'cylinder' | 'sphere';
|
||||
sx: number;
|
||||
sy: number;
|
||||
sz: number;
|
||||
color: string;
|
||||
emissive: string;
|
||||
roughness: number;
|
||||
}) {
|
||||
if (primitive === 'cylinder') {
|
||||
return (
|
||||
<mesh>
|
||||
<cylinderGeometry args={[Math.min(sx, sz) / 2, Math.min(sx, sz) / 2, sy, 16]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={emissive}
|
||||
emissiveIntensity={0.2}
|
||||
roughness={roughness}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
if (primitive === 'sphere') {
|
||||
return (
|
||||
<mesh>
|
||||
<sphereGeometry args={[Math.max(sx, sy, sz) / 2, 16, 12]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={emissive}
|
||||
emissiveIntensity={0.2}
|
||||
roughness={roughness}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
// default: box
|
||||
return (
|
||||
<mesh>
|
||||
<boxGeometry args={[sx, sy, sz]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={emissive}
|
||||
emissiveIntensity={0.2}
|
||||
roughness={roughness}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Item3D({ simulation, currentItem }: Props) {
|
||||
if (!currentItem) {
|
||||
return null;
|
||||
@@ -16,39 +78,72 @@ export default function Item3D({ simulation, currentItem }: Props) {
|
||||
const category = currentItem.classification.category;
|
||||
const color = ROUTE_COLORS[category] ?? '#38bdf8';
|
||||
const dims = currentItem.item.dimensionsMm;
|
||||
|
||||
// Scale to Three.js units (1 unit = 1 meter, dimensions in mm)
|
||||
const sx = Math.max(0.12, Math.min(0.45, dims.width / 1000));
|
||||
const sy = Math.max(0.1, Math.min(0.4, dims.height / 1000));
|
||||
const sz = Math.max(0.12, Math.min(0.45, dims.depth / 1000));
|
||||
const isRound =
|
||||
currentItem.item.shape.includes('round') ||
|
||||
currentItem.item.shape.includes('cylinder') ||
|
||||
currentItem.item.roundness >= DIMENSION_LIMITS.roundnessThreshold;
|
||||
|
||||
const detecting = simulation.machineState === 'DETECTING';
|
||||
const fault =
|
||||
simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
|
||||
|
||||
const fault = simulation.machineState === 'FAULT' || simulation.machineState === 'EMERGENCY_STOP';
|
||||
|
||||
// Get model asset for this item
|
||||
const asset = getModelAsset(currentItem.item.id);
|
||||
const finalColor = fault ? '#fb3d4e' : color;
|
||||
const finalEmissive = fault ? '#fb3d4e' : color;
|
||||
const finalRoughness = asset?.loaderType === 'stl' ? 0.45 : 0.5;
|
||||
|
||||
// Fallback primitive
|
||||
const fallbackPrimitive = asset?.fallbackPrimitive ?? 'box';
|
||||
|
||||
return (
|
||||
<group position={[x, y, z]}>
|
||||
{isRound ? (
|
||||
<mesh>
|
||||
<cylinderGeometry args={[Math.min(sx, sz) / 2, Math.min(sx, sz) / 2, sy, 16]} />
|
||||
<meshStandardMaterial
|
||||
color={fault ? '#fb3d4e' : color}
|
||||
emissive={fault ? '#fb3d4e' : color}
|
||||
{asset && asset.loaderType === 'stl' && asset.frontendAssetPath ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<FallbackPrimitive
|
||||
primitive={fallbackPrimitive}
|
||||
sx={sx}
|
||||
sy={sy}
|
||||
sz={sz}
|
||||
color={finalColor}
|
||||
emissive={finalEmissive}
|
||||
roughness={finalRoughness}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<STLModel
|
||||
path={asset.frontendAssetPath}
|
||||
scale={[sx, sy, sz]}
|
||||
rotation={[0, 0, 0]}
|
||||
position={[0, 0, 0]}
|
||||
color={finalColor}
|
||||
emissive={finalEmissive}
|
||||
emissiveIntensity={0.2}
|
||||
roughness={0.45}
|
||||
roughness={finalRoughness}
|
||||
fallback={
|
||||
<FallbackPrimitive
|
||||
primitive={fallbackPrimitive}
|
||||
sx={sx}
|
||||
sy={sy}
|
||||
sz={sz}
|
||||
color={finalColor}
|
||||
emissive={finalEmissive}
|
||||
roughness={finalRoughness}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</mesh>
|
||||
</Suspense>
|
||||
) : (
|
||||
<mesh>
|
||||
<boxGeometry args={[sx, sy, sz]} />
|
||||
<meshStandardMaterial
|
||||
color={fault ? '#fb3d4e' : color}
|
||||
emissive={fault ? '#fb3d4e' : color}
|
||||
emissiveIntensity={0.2}
|
||||
roughness={0.5}
|
||||
/>
|
||||
</mesh>
|
||||
<FallbackPrimitive
|
||||
primitive={fallbackPrimitive}
|
||||
sx={sx}
|
||||
sy={sy}
|
||||
sz={sz}
|
||||
color={finalColor}
|
||||
emissive={finalEmissive}
|
||||
roughness={finalRoughness}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detecting ? (
|
||||
|
||||
88
src/components/ThreeD/STLModel.tsx
Normal file
88
src/components/ThreeD/STLModel.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* STLModel component — loads and displays STL models.
|
||||
* Uses STLLoader from three/examples.
|
||||
*/
|
||||
|
||||
import { useLoader } from '@react-three/fiber';
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
||||
import { Center } from '@react-three/drei';
|
||||
import type { BufferGeometry, Mesh as ThreeMesh } from 'three';
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
interface STLModelProps {
|
||||
/** Path to STL file in public folder */
|
||||
path: string;
|
||||
|
||||
/** Scale factor [x, y, z] */
|
||||
scale: [number, number, number];
|
||||
|
||||
/** Rotation [x, y, z] in radians */
|
||||
rotation?: [number, number, number];
|
||||
|
||||
/** Position offset [x, y, z] */
|
||||
position?: [number, number, number];
|
||||
|
||||
/** Material color */
|
||||
color: string;
|
||||
|
||||
/** Emissive color (for glow effect) */
|
||||
emissive?: string;
|
||||
|
||||
/** Emissive intensity */
|
||||
emissiveIntensity?: number;
|
||||
|
||||
/** Roughness */
|
||||
roughness?: number;
|
||||
|
||||
/** Fallback component if loading fails */
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function STLModel({
|
||||
path,
|
||||
scale,
|
||||
rotation = [0, 0, 0],
|
||||
position = [0, 0, 0],
|
||||
color,
|
||||
emissive,
|
||||
emissiveIntensity = 0.2,
|
||||
roughness = 0.5,
|
||||
fallback,
|
||||
}: STLModelProps) {
|
||||
const meshRef = useRef<ThreeMesh>(null);
|
||||
|
||||
let geometry: BufferGeometry | null = null;
|
||||
|
||||
try {
|
||||
// Load STL geometry
|
||||
geometry = useLoader(STLLoader, path);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load STL model: ${path}`, error);
|
||||
return fallback ? <>{fallback}</> : null;
|
||||
}
|
||||
|
||||
// Center the geometry on load
|
||||
useEffect(() => {
|
||||
if (meshRef.current && geometry) {
|
||||
geometry.center();
|
||||
geometry.computeVertexNormals();
|
||||
}
|
||||
}, [geometry]);
|
||||
|
||||
if (!geometry) {
|
||||
return fallback ? <>{fallback}</> : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Center position={position} rotation={rotation}>
|
||||
<mesh ref={meshRef} geometry={geometry} scale={scale}>
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={emissive ?? color}
|
||||
emissiveIntensity={emissiveIntensity}
|
||||
roughness={roughness}
|
||||
/>
|
||||
</mesh>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ interface Props {
|
||||
currentItem?: SimulatedItem;
|
||||
simplified?: boolean;
|
||||
conveyorTargetMps?: number;
|
||||
technicalLabelsEnabled?: boolean;
|
||||
cleanView?: boolean;
|
||||
}
|
||||
|
||||
function Badge({
|
||||
@@ -44,77 +46,32 @@ function Badge({
|
||||
);
|
||||
}
|
||||
|
||||
function ItemProofPanel({
|
||||
currentItem,
|
||||
machineState,
|
||||
}: {
|
||||
currentItem: SimulatedItem;
|
||||
machineState: MachineState;
|
||||
}) {
|
||||
const item = currentItem.item;
|
||||
const result = currentItem.classification;
|
||||
const category = result.category as Category;
|
||||
const color = ROUTE_COLORS[category];
|
||||
const command = machineState.startsWith('ROUTE_TO_')
|
||||
? machineState
|
||||
: `ROUTE_TO_${category}`;
|
||||
const dims = item.dimensionsMm;
|
||||
const dimStatus = result.dimensionsPass ? 'PASS' : 'FAIL';
|
||||
const roundStatus = result.roundnessPass ? 'PASS' : 'DETECTED';
|
||||
|
||||
return (
|
||||
<Html position={[0, 2.05, 0]} center distanceFactor={9} style={{ pointerEvents: 'none' }}>
|
||||
<div
|
||||
style={{
|
||||
minWidth: 260,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 14,
|
||||
background: 'rgba(5, 9, 16, 0.94)',
|
||||
border: `2px solid ${color}`,
|
||||
color: '#e5f2ff',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
boxShadow: `0 0 24px ${color}44`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: '#91a4b8', fontWeight: 800, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
||||
Current item
|
||||
</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, margin: '4px 0 10px' }}>{item.name}</div>
|
||||
<div style={{ display: 'grid', gap: 6, fontSize: 14, fontWeight: 700 }}>
|
||||
<div>
|
||||
Dimensions: {dims.width}×{dims.depth}×{dims.height} mm{' '}
|
||||
<span style={{ color: result.dimensionsPass ? '#4ade80' : '#fb3d4e' }}>{dimStatus}</span>
|
||||
</div>
|
||||
<div>
|
||||
Roundness K = {item.roundness.toFixed(2)}{' '}
|
||||
<span style={{ color: result.roundnessPass ? '#4ade80' : '#c084fc' }}>{roundStatus}</span>
|
||||
</div>
|
||||
<div style={{ color }}>
|
||||
Category: {category}
|
||||
</div>
|
||||
<div style={{ color }}>
|
||||
Command: {command}
|
||||
</div>
|
||||
<div style={{ color }}>
|
||||
Target zone: {category}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
// ItemProofPanel removed — info now displayed in 2D proof card only
|
||||
|
||||
export default function SceneLabels3D({
|
||||
machineState,
|
||||
currentItem,
|
||||
conveyorTargetMps = 1,
|
||||
technicalLabelsEnabled = false,
|
||||
cleanView = true,
|
||||
}: Props) {
|
||||
const category = currentItem?.classification.category as Category | undefined;
|
||||
const cage = TWIN_LAYOUT.rollCageSize;
|
||||
const detecting = machineState === 'DETECTING';
|
||||
const atGate = machineState === 'WAITING_AT_GATE' || machineState === 'CLASSIFYING';
|
||||
const routing = machineState.startsWith('ROUTE_TO_');
|
||||
const fault = machineState === 'FAULT' || machineState === 'EMERGENCY_STOP';
|
||||
|
||||
// C-priority: dimensions failed + round detected → only C active, D not active
|
||||
const isCPriority = Boolean(
|
||||
category === 'C' && currentItem && !currentItem.classification.dimensionsPass && !currentItem.classification.roundnessPass
|
||||
);
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Zones A/B/C/D — always explicit */}
|
||||
{/* === CLEAN VIEW (always visible) === */}
|
||||
|
||||
{/* Zones A/B/C/D — always visible */}
|
||||
<Badge position={[TWIN_LAYOUT.startX, 0.85, 0]} text="A" color="#38bdf8" large />
|
||||
<Badge position={[TWIN_LAYOUT.zoneBX, 0.95, 0]} text="B" color={ROUTE_COLORS.B} large />
|
||||
<Badge
|
||||
@@ -130,7 +87,7 @@ export default function SceneLabels3D({
|
||||
large
|
||||
/>
|
||||
|
||||
{/* Accumulator at end of feed conveyor */}
|
||||
{/* Накопитель — always visible */}
|
||||
<Badge
|
||||
position={[TWIN_LAYOUT.accumulatorX, 0.95, 0.65]}
|
||||
text="Накопитель"
|
||||
@@ -138,51 +95,109 @@ export default function SceneLabels3D({
|
||||
large
|
||||
/>
|
||||
|
||||
{/* Sensors & actuators — always visible */}
|
||||
<Badge position={[TWIN_LAYOUT.cameraX, 1.2, -0.55]} text="Camera / CV" color="#38bdf8" />
|
||||
<Badge position={[TWIN_LAYOUT.laserX, 1.2, -0.55]} text="Laser" color="#22d3ee" />
|
||||
<Badge position={[TWIN_LAYOUT.ultrasonicX, 1.05, -0.55]} text="Ultrasonic" color="#67e8f9" />
|
||||
<Badge position={[TWIN_LAYOUT.gateX, 1.15, 0]} text="Stop-gate" color="#fda4af" />
|
||||
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, 0.75]} text="Pusher C" color={ROUTE_COLORS.C} />
|
||||
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, -0.75]} text="Pusher D" color={ROUTE_COLORS.D} />
|
||||
{/* === STEP-BASED LABELS (shown only when active) === */}
|
||||
|
||||
{/* Detection: Camera/CV + Laser */}
|
||||
{detecting && (
|
||||
<>
|
||||
<Badge position={[TWIN_LAYOUT.cameraX, 1.2, -0.55]} text="Camera CV" color="#38bdf8" />
|
||||
<Badge position={[TWIN_LAYOUT.laserX, 1.2, -0.55]} text="Laser" color="#22d3ee" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Route labels */}
|
||||
<Badge position={[(TWIN_LAYOUT.gateX + TWIN_LAYOUT.zoneBX) / 2, 0.55, 0]} text="Route B" color={ROUTE_COLORS.B} />
|
||||
<Badge
|
||||
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneCZ / 2]}
|
||||
text="Route C"
|
||||
color={ROUTE_COLORS.C}
|
||||
/>
|
||||
<Badge
|
||||
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneDZ / 2]}
|
||||
text="Route D"
|
||||
color={ROUTE_COLORS.D}
|
||||
/>
|
||||
{/* At gate: Stop-gate + Ultrasonic */}
|
||||
{atGate && (
|
||||
<>
|
||||
<Badge position={[TWIN_LAYOUT.gateX, 1.15, 0]} text="Stop-gate" color="#fda4af" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Badge
|
||||
position={[-3.2, 1.55, 1.4]}
|
||||
text={`Conveyor ${conveyorTargetMps.toFixed(2)} m/s · min ${DIMENSION_LIMITS.min.width}×${DIMENSION_LIMITS.min.depth}×${DIMENSION_LIMITS.min.height} mm`}
|
||||
color="#91a4b8"
|
||||
/>
|
||||
{/* Routing: active pusher only */}
|
||||
{routing && category === 'C' && (
|
||||
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, 0.75]} text="Pusher C" color={ROUTE_COLORS.C} />
|
||||
)}
|
||||
{routing && category === 'D' && (
|
||||
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, -0.75]} text="Pusher D" color={ROUTE_COLORS.D} />
|
||||
)}
|
||||
|
||||
{currentItem ? <ItemProofPanel currentItem={currentItem} machineState={machineState} /> : null}
|
||||
{/* Active route only (not all three routes) */}
|
||||
{routing && category === 'B' && (
|
||||
<Badge position={[(TWIN_LAYOUT.gateX + TWIN_LAYOUT.zoneBX) / 2, 0.55, 0]} text="→ B" color={ROUTE_COLORS.B} />
|
||||
)}
|
||||
{routing && category === 'C' && (
|
||||
<Badge
|
||||
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneCZ / 2]}
|
||||
text="→ C"
|
||||
color={ROUTE_COLORS.C}
|
||||
/>
|
||||
)}
|
||||
{routing && category === 'D' && (
|
||||
<Badge
|
||||
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneDZ / 2]}
|
||||
text="→ D"
|
||||
color={ROUTE_COLORS.D}
|
||||
/>
|
||||
)}
|
||||
|
||||
{category === 'C' && currentItem && !currentItem.classification.roundnessPass ? (
|
||||
{/* C-priority badge (only when applicable) */}
|
||||
{isCPriority && (
|
||||
<Badge
|
||||
position={[TWIN_LAYOUT.gateX + 0.4, cage.y + 0.7, TWIN_LAYOUT.zoneCZ]}
|
||||
text="C-priority (dims first)"
|
||||
text="C-priority"
|
||||
color={ROUTE_COLORS.C}
|
||||
large
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{machineState === 'DETECTING' ? (
|
||||
<Badge position={[TWIN_LAYOUT.cameraX, 1.55, 0]} text="CV detection" color="#38bdf8" />
|
||||
) : null}
|
||||
|
||||
{machineState === 'FAULT' || machineState === 'EMERGENCY_STOP' ? (
|
||||
{/* Fault / Emergency stop */}
|
||||
{fault && (
|
||||
<Badge position={[0, 2.5, 0]} text={machineState} color="#fb3d4e" large />
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{/* === TECHNICAL LABELS (opt-in) === */}
|
||||
{technicalLabelsEnabled && !cleanView && (
|
||||
<>
|
||||
{/* Always show all sensors when technical mode is on */}
|
||||
{!detecting && <Badge position={[TWIN_LAYOUT.cameraX, 1.2, -0.55]} text="Camera CV" color="#38bdf8" />}
|
||||
{!detecting && <Badge position={[TWIN_LAYOUT.laserX, 1.2, -0.55]} text="Laser" color="#22d3ee" />}
|
||||
<Badge position={[TWIN_LAYOUT.ultrasonicX, 1.05, -0.55]} text="Ultrasonic" color="#67e8f9" />
|
||||
|
||||
{/* Always show all actuators */}
|
||||
{!atGate && <Badge position={[TWIN_LAYOUT.gateX, 1.15, 0]} text="Stop-gate" color="#fda4af" />}
|
||||
{!(routing && category === 'C') && (
|
||||
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, 0.75]} text="Pusher C" color={ROUTE_COLORS.C} />
|
||||
)}
|
||||
{!(routing && category === 'D') && (
|
||||
<Badge position={[TWIN_LAYOUT.gateX - 0.1, 0.75, -0.75]} text="Pusher D" color={ROUTE_COLORS.D} />
|
||||
)}
|
||||
|
||||
{/* All route beams */}
|
||||
{!(routing && category === 'B') && (
|
||||
<Badge position={[(TWIN_LAYOUT.gateX + TWIN_LAYOUT.zoneBX) / 2, 0.55, 0]} text="Route B" color={ROUTE_COLORS.B} />
|
||||
)}
|
||||
{!(routing && category === 'C') && (
|
||||
<Badge
|
||||
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneCZ / 2]}
|
||||
text="Route C"
|
||||
color={ROUTE_COLORS.C}
|
||||
/>
|
||||
)}
|
||||
{!(routing && category === 'D') && (
|
||||
<Badge
|
||||
position={[TWIN_LAYOUT.gateX + 0.2, 0.55, TWIN_LAYOUT.zoneDZ / 2]}
|
||||
text="Route D"
|
||||
color={ROUTE_COLORS.D}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Conveyor info */}
|
||||
<Badge
|
||||
position={[-3.2, 1.55, 1.4]}
|
||||
text={`Conveyor ${conveyorTargetMps.toFixed(2)} m/s · min ${DIMENSION_LIMITS.min.width}×${DIMENSION_LIMITS.min.depth}×${DIMENSION_LIMITS.min.height} mm`}
|
||||
color="#91a4b8"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface SorterDigitalTwinProps {
|
||||
simulation: SimulationState;
|
||||
simplified?: boolean;
|
||||
showFps?: boolean;
|
||||
technicalLabelsEnabled?: boolean;
|
||||
cleanView?: boolean;
|
||||
onContextLost?: () => void;
|
||||
}
|
||||
|
||||
@@ -41,10 +43,14 @@ function FpsMeter({ onFps }: { onFps: (fps: number) => void }) {
|
||||
function TwinScene({
|
||||
simulation,
|
||||
simplified,
|
||||
technicalLabelsEnabled,
|
||||
cleanView,
|
||||
onFps,
|
||||
}: {
|
||||
simulation: SimulationState;
|
||||
simplified?: boolean;
|
||||
technicalLabelsEnabled?: boolean;
|
||||
cleanView?: boolean;
|
||||
onFps: (fps: number) => void;
|
||||
}) {
|
||||
const stopped =
|
||||
@@ -99,6 +105,8 @@ function TwinScene({
|
||||
currentItem={simulation.currentItem}
|
||||
simplified={simplified}
|
||||
conveyorTargetMps={NOMINAL_CONVEYOR_SPEED_MPS}
|
||||
technicalLabelsEnabled={technicalLabelsEnabled}
|
||||
cleanView={cleanView}
|
||||
/>
|
||||
|
||||
{stopped ? (
|
||||
@@ -125,6 +133,8 @@ export default function SorterDigitalTwin({
|
||||
simulation,
|
||||
simplified = false,
|
||||
showFps = true,
|
||||
technicalLabelsEnabled = false,
|
||||
cleanView = true,
|
||||
onContextLost,
|
||||
}: SorterDigitalTwinProps) {
|
||||
const [fps, setFps] = useState(0);
|
||||
@@ -169,7 +179,13 @@ export default function SorterDigitalTwin({
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<TwinScene simulation={simulation} simplified={simplified} onFps={setFps} />
|
||||
<TwinScene
|
||||
simulation={simulation}
|
||||
simplified={simplified}
|
||||
technicalLabelsEnabled={technicalLabelsEnabled}
|
||||
cleanView={cleanView}
|
||||
onFps={setFps}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as THREE from 'three';
|
||||
import type { Category } from '../../domain/types';
|
||||
import { ROUTE_COLORS, TWIN_LAYOUT } from './itemMotion';
|
||||
|
||||
@@ -31,6 +32,54 @@ function ZoneBox({
|
||||
);
|
||||
}
|
||||
|
||||
/** Roll-cage with wireframe edges for C/D zones */
|
||||
function RollCage({
|
||||
position,
|
||||
size,
|
||||
color,
|
||||
active,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
size: [number, number, number];
|
||||
color: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
return (
|
||||
<group position={position}>
|
||||
{/* Cage body (transparent) */}
|
||||
<mesh>
|
||||
<boxGeometry args={size} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
transparent
|
||||
opacity={active ? 0.25 : 0.1}
|
||||
emissive={color}
|
||||
emissiveIntensity={active ? 0.3 : 0.08}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Cage wireframe edges (visible frame) */}
|
||||
<lineSegments>
|
||||
<edgesGeometry args={[new THREE.BoxGeometry(...size)]} />
|
||||
<lineBasicMaterial color={color} linewidth={active ? 2 : 1} opacity={active ? 1 : 0.6} transparent />
|
||||
</lineSegments>
|
||||
|
||||
{/* Vertical posts (corners) for visual emphasis */}
|
||||
{[
|
||||
[-size[0] / 2, 0, -size[2] / 2],
|
||||
[size[0] / 2, 0, -size[2] / 2],
|
||||
[-size[0] / 2, 0, size[2] / 2],
|
||||
[size[0] / 2, 0, size[2] / 2],
|
||||
].map((offset, i) => (
|
||||
<mesh key={i} position={offset as [number, number, number]}>
|
||||
<boxGeometry args={[0.04, size[1], 0.04]} />
|
||||
<meshStandardMaterial color={color} metalness={0.3} roughness={0.7} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SortingZones3D({ activeCategory, activeRoute }: Props) {
|
||||
const active = activeRoute ?? activeCategory;
|
||||
const cage = TWIN_LAYOUT.rollCageSize;
|
||||
@@ -53,16 +102,16 @@ export default function SortingZones3D({ activeCategory, activeRoute }: Props) {
|
||||
active={active === 'B'}
|
||||
/>
|
||||
|
||||
{/* Roll-cage C 1200×800×800 mm — orange */}
|
||||
<ZoneBox
|
||||
{/* Roll-cage C 1200×800×800 mm — orange with wireframe */}
|
||||
<RollCage
|
||||
position={[TWIN_LAYOUT.gateX + 0.4, cage.y / 2, TWIN_LAYOUT.zoneCZ]}
|
||||
size={[cage.x, cage.y, cage.z]}
|
||||
color={ROUTE_COLORS.C}
|
||||
active={active === 'C'}
|
||||
/>
|
||||
|
||||
{/* Roll-cage D 1200×800×800 mm — purple */}
|
||||
<ZoneBox
|
||||
{/* Roll-cage D 1200×800×800 mm — purple with wireframe */}
|
||||
<RollCage
|
||||
position={[TWIN_LAYOUT.gateX + 0.4, cage.y / 2, TWIN_LAYOUT.zoneDZ]}
|
||||
size={[cage.x, cage.y, cage.z]}
|
||||
color={ROUTE_COLORS.D}
|
||||
|
||||
Reference in New Issue
Block a user