feat: engineering measurement architecture in 3D demo

- Stepper motor length measurement (step counting)
- Laser rangefinder height measurement
- Stereo camera width/shape analysis
- PLC decision logic with C-priority
- Actuator pusher animation
- Full measurement overlay with live data
This commit is contained in:
root
2026-07-09 14:35:58 +02:00
parent fa770f9da9
commit 3aad908aaf
15 changed files with 1241 additions and 68 deletions

View File

@@ -1,43 +1,52 @@
/**
* CV Inspection Overlay — industrial camera monitor style panel.
* Shows detection, measurement, and classification data.
* CV Inspection Overlay — industrial measurement system monitor.
* Shows stepper, laser, stereo camera data and classification results.
*/
import type { InspectionData } from '../domain/inspectionViewModel';
import type { MeasurementData } from '../domain/measurementSystem';
import { getStageLabel } from '../domain/measurementSystem';
interface CVInspectionOverlayProps {
data: InspectionData;
data: MeasurementData;
visible: boolean;
}
const SHAPE_ICONS: Record<string, string> = {
box: '▭',
cylinder: '⬤',
round: '◯',
irregular: '◇',
unknown: '?',
};
export default function CVInspectionOverlay({ data }: CVInspectionOverlayProps) {
if (!data.visible) return null;
export default function CVInspectionOverlay({ data, visible }: CVInspectionOverlayProps) {
if (!visible) return null;
const {
phaseLabel,
itemTitle,
shape,
dimensions,
roundness,
stage,
stepCount,
mmPerStep,
measuredLengthMm,
pulseActive,
laserDistanceMm,
laserMountHeightMm,
measuredHeightMm,
laserBeamActive,
measuredWidthMm,
roundnessK,
stereoActive,
confidence,
isLowConfidence,
category,
dimensionsPass,
shapeResult,
finalCategory,
command,
warning,
dimensionsFail,
roundnessFail,
cPriority,
cPriorityApplied,
isLowConfidence,
itemTitle,
itemDimensions,
} = data;
const confidencePercent = Math.round(confidence * 100);
const roundnessPercent = Math.round(roundness * 100);
const roundnessPercent = Math.round(roundnessK * 100);
const stageLabel = getStageLabel(stage);
const categoryColors: Record<string, string> = {
B: '#22c55e',
@@ -45,15 +54,18 @@ export default function CVInspectionOverlay({ data }: CVInspectionOverlayProps)
D: '#8b5cf6',
};
const isActive = stage !== 'idle';
return (
<div className="cv-overlay">
<div className="cv-header">
<span className="cv-icon">📷</span>
<span className="cv-title">CV INSPECTION</span>
<span className={`cv-status ${data.phase}`}>{phaseLabel}</span>
<span className="cv-icon">⚙</span>
<span className="cv-title">MEASUREMENT</span>
<span className={`cv-status ${isActive ? 'active' : 'idle'}`}>{stageLabel}</span>
</div>
<div className="cv-body">
{/* Item info */}
<div className="cv-row cv-item-row">
<span className="cv-label">ITEM</span>
<span className="cv-value">{itemTitle}</span>
@@ -61,48 +73,106 @@ export default function CVInspectionOverlay({ data }: CVInspectionOverlayProps)
<div className="cv-divider" />
{/* Stepper motor section */}
<div className="cv-section-header">
<span className={`cv-indicator ${pulseActive ? 'pulse' : ''}`}>●</span>
STEPPER LENGTH
</div>
<div className="cv-row cv-compact">
<span className="cv-label">Pulses</span>
<span className="cv-value cv-mono">
{stepCount.toLocaleString()}
{pulseActive && <span className="cv-blink"> ↑</span>}
</span>
</div>
<div className="cv-row cv-compact">
<span className="cv-label">mm/step</span>
<span className="cv-value cv-mono">{mmPerStep.toFixed(3)}</span>
</div>
<div className="cv-row">
<span className="cv-label">SHAPE</span>
<span className="cv-label">Length</span>
<span className="cv-value cv-result">{measuredLengthMm} mm</span>
</div>
<div className="cv-divider" />
{/* Laser rangefinder section */}
<div className="cv-section-header">
<span className={`cv-indicator ${laserBeamActive ? 'active' : ''}`}>●</span>
LASER HEIGHT
</div>
<div className="cv-row cv-compact">
<span className="cv-label">Mount</span>
<span className="cv-value cv-mono">{laserMountHeightMm} mm</span>
</div>
<div className="cv-row cv-compact">
<span className="cv-label">Distance</span>
<span className="cv-value cv-mono">{laserDistanceMm} mm</span>
</div>
<div className="cv-row">
<span className="cv-label">Height</span>
<span className="cv-value cv-result">{measuredHeightMm} mm</span>
</div>
<div className="cv-divider" />
{/* Stereo camera section */}
<div className="cv-section-header">
<span className={`cv-indicator ${stereoActive ? 'active' : ''}`}>●</span>
STEREO WIDTH/SHAPE
</div>
<div className="cv-row">
<span className="cv-label">Width</span>
<span className="cv-value cv-result">{measuredWidthMm} mm</span>
</div>
<div className="cv-row">
<span className="cv-label">Shape</span>
<span className="cv-value cv-shape">
<span className="shape-icon">{SHAPE_ICONS[shape]}</span>
{shape}
<span className="shape-icon">{SHAPE_ICONS[shapeResult]}</span>
{shapeResult}
</span>
</div>
<div className="cv-row cv-dims-row">
<span className="cv-label">SIZE</span>
<span className={`cv-value cv-dims ${dimensionsFail ? 'fail' : 'pass'}`}>
{dimensions.width} × {dimensions.depth} × {dimensions.height} mm
</span>
</div>
<div className="cv-row">
<span className="cv-label">ROUNDNESS</span>
<span className={`cv-value ${roundnessFail ? 'fail' : 'pass'}`}>
K = {(roundness).toFixed(2)} ({roundnessPercent}%)
{roundnessFail && <span className="cv-flag"> ⚠ ≥0.7</span>}
</span>
</div>
<div className="cv-row">
<span className="cv-label">CONFIDENCE</span>
<span className={`cv-value ${isLowConfidence ? 'warning' : ''}`}>
{confidencePercent}%
{isLowConfidence && <span className="cv-flag"> ⚠ LOW</span>}
<span className="cv-label">Roundness</span>
<span className={`cv-value ${roundnessK >= 0.7 ? 'warning' : ''}`}>
K = {roundnessK.toFixed(2)} ({roundnessPercent}%)
{roundnessK >= 0.7 && <span className="cv-flag"> ≥0.7</span>}
</span>
</div>
<div className="cv-divider" />
{category && (
{/* Decision section */}
<div className="cv-section-header">
<span className="cv-indicator">●</span>
PLC DECISION
</div>
<div className="cv-row">
<span className="cv-label">Dims</span>
<span className={`cv-value ${dimensionsPass ? 'pass' : 'fail'}`}>
{dimensionsPass ? 'PASS' : 'FAIL'}
<span className="cv-dims-detail">
{' '}({itemDimensions.width}×{itemDimensions.depth}×{itemDimensions.height})
</span>
</span>
</div>
<div className="cv-row">
<span className="cv-label">Confidence</span>
<span className={`cv-value ${isLowConfidence ? 'warning' : ''}`}>
{confidencePercent}%
{isLowConfidence && <span className="cv-flag"> LOW</span>}
</span>
</div>
{finalCategory && (
<div className="cv-row cv-result-row">
<span className="cv-label">CLASS</span>
<span
className="cv-value cv-category"
style={{ color: categoryColors[category] }}
style={{ color: categoryColors[finalCategory] }}
>
{category}
{cPriority && <span className="cv-priority"> (C priority)</span>}
{finalCategory}
{cPriorityApplied && <span className="cv-priority"> (C priority)</span>}
</span>
</div>
)}
@@ -111,23 +181,31 @@ export default function CVInspectionOverlay({ data }: CVInspectionOverlayProps)
<span className="cv-label">CMD</span>
<span
className="cv-value cv-command"
style={{ color: category ? categoryColors[category] : '#64748b' }}
style={{ color: finalCategory ? categoryColors[finalCategory] : '#64748b' }}
>
{command}
</span>
</div>
{warning && (
{/* Warnings */}
{cPriorityApplied && (
<div className="cv-warning cv-cpriority">
<span className="warning-icon">⚠</span>
<span className="warning-text">Dims fail overrides roundness → C</span>
</div>
)}
{isLowConfidence && (
<div className="cv-warning">
<span className="warning-icon">⚠</span>
<span className="warning-text">{warning}</span>
<span className="warning-text">Low confidence, rule-based fallback</span>
</div>
)}
</div>
<div className="cv-footer">
<span className="cv-live">● LIVE</span>
<span className="cv-fps">60 FPS</span>
<span className="cv-fps">PLC</span>
</div>
</div>
);

View File

@@ -16,6 +16,7 @@ import type { ContinuousPlaybackState, CasePhase } from '../../domain/continuous
import { isDetectionActive, isRoutingActive, getPhaseProgress } from '../../domain/continuousPlayback';
import { getItemPosition, isItemVisible, getActiveRoute, getConveyorSpeedFactor } from '../../domain/conveyorPath';
import { shouldShowBoundingBox, shouldShowScanEffect, shouldShowShapeOutline, shouldHighlightCamera } from '../../domain/inspectionViewModel';
import { getMeasurementData, shouldShowLaserBeam, shouldShowStepperPulse, shouldShowPointCloud, shouldShowActuator } from '../../domain/measurementSystem';
import { getModelAsset } from '../../data/modelAssets';
import { ITEMS } from '../../data/items';
import type { Category } from '../../domain/types';
@@ -34,6 +35,8 @@ import {
DRIVE_ROLLER_RADIUS_M,
ZONES,
CAMERA_RIG,
LASER_HEIGHT_M,
STEREO_CAMERA,
} from '../../domain/physicalLayout';
export interface SorterDigitalTwinContinuousProps {
@@ -111,8 +114,18 @@ function BeltStripe({ offset, speedFactor }: { offset: number; speedFactor: numb
);
}
/** Stepper motor drive unit */
function StepperMotor({ position }: { position: [number, number, number] }) {
/** Stepper motor drive unit with pulse indicator */
function StepperMotor({ position, pulseActive }: { position: [number, number, number]; pulseActive: boolean }) {
const pulseRef = useRef<Mesh>(null);
const rotationRef = useRef(0);
useFrame((_, delta) => {
if (pulseRef.current && pulseActive) {
rotationRef.current += delta * 8;
pulseRef.current.rotation.z = rotationRef.current;
}
});
return (
<group position={position}>
{/* Motor body */}
@@ -120,8 +133,12 @@ function StepperMotor({ position }: { position: [number, number, number] }) {
<boxGeometry args={[MOTOR_WIDTH_M, MOTOR_HEIGHT_M, MOTOR_DEPTH_M]} />
<meshStandardMaterial color="#475569" metalness={0.6} roughness={0.3} />
</mesh>
{/* Motor shaft */}
<mesh position={[0, 0, CONVEYOR_WIDTH_M / 2 + 0.01]} rotation={[Math.PI / 2, 0, 0]}>
{/* Motor shaft - rotates when active */}
<mesh
ref={pulseRef}
position={[0, 0, CONVEYOR_WIDTH_M / 2 + 0.01]}
rotation={[Math.PI / 2, 0, 0]}
>
<cylinderGeometry args={[0.015, 0.015, 0.03, 8]} />
<meshStandardMaterial color="#94a3b8" metalness={0.7} roughness={0.2} />
</mesh>
@@ -130,15 +147,209 @@ function StepperMotor({ position }: { position: [number, number, number] }) {
<cylinderGeometry args={[0.035, 0.035, 0.025, 12]} />
<meshStandardMaterial color="#64748b" metalness={0.5} roughness={0.4} />
</mesh>
{/* Belt to drive roller (simplified) */}
{/* Belt to drive roller */}
<mesh position={[0, DRIVE_ROLLER_RADIUS_M / 2, CONVEYOR_WIDTH_M / 2 - 0.02]}>
<boxGeometry args={[0.01, DRIVE_ROLLER_RADIUS_M + 0.02, 0.015]} />
<meshStandardMaterial color="#1e293b" />
</mesh>
{/* Pulse indicator LED */}
<mesh position={[0, MOTOR_HEIGHT_M / 2 - 0.01, CONVEYOR_WIDTH_M / 2 + MOTOR_DEPTH_M + 0.025]}>
<sphereGeometry args={[0.008, 8, 8]} />
<meshStandardMaterial
color={pulseActive ? '#22d3ee' : '#475569'}
emissive={pulseActive ? '#22d3ee' : '#000'}
emissiveIntensity={pulseActive ? 0.8 : 0}
/>
</mesh>
</group>
);
}
/** Laser beam from rangefinder to item */
function LaserBeam({ active, itemY }: { active: boolean; itemY: number }) {
const beamRef = useRef<Mesh>(null);
const opacityRef = useRef(0.6);
useFrame((_, delta) => {
if (beamRef.current && active) {
opacityRef.current = 0.4 + Math.sin(Date.now() / 100) * 0.3;
}
});
if (!active) return null;
const beamLength = LASER_HEIGHT_M - itemY;
const beamCenterY = itemY + beamLength / 2;
return (
<group position={[ZONES.CAMERA.x, 0, 0]}>
{/* Main laser beam */}
<mesh ref={beamRef} position={[0, beamCenterY, 0]}>
<cylinderGeometry args={[0.003, 0.003, beamLength, 8]} />
<meshStandardMaterial
color="#22d3ee"
emissive="#22d3ee"
emissiveIntensity={0.6}
transparent
opacity={0.7}
/>
</mesh>
{/* Laser dot on item */}
<mesh position={[0, itemY + 0.01, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[0.015, 16]} />
<meshStandardMaterial
color="#22d3ee"
emissive="#22d3ee"
emissiveIntensity={0.8}
transparent
opacity={0.9}
/>
</mesh>
{/* Laser emitter */}
<mesh position={[0, LASER_HEIGHT_M, 0]}>
<boxGeometry args={[0.04, 0.02, 0.04]} />
<meshStandardMaterial
color="#0ea5e9"
emissive="#0ea5e9"
emissiveIntensity={active ? 0.4 : 0}
/>
</mesh>
</group>
);
}
/** Stereo camera lenses with view cones */
function StereoCameraLenses({ active, itemPosition }: { active: boolean; itemPosition: [number, number, number] }) {
const baseline = STEREO_CAMERA.baseline;
const mountY = STEREO_CAMERA.mountY;
const cameraX = ZONES.CAMERA.x;
return (
<group position={[cameraX, mountY, 0]}>
{/* Left lens */}
<mesh position={[0, 0, baseline / 2]}>
<cylinderGeometry args={[0.02, 0.02, 0.015, 12]} />
<meshStandardMaterial
color={active ? '#0ea5e9' : '#1e293b'}
emissive={active ? '#0ea5e9' : '#000'}
emissiveIntensity={active ? 0.3 : 0}
/>
</mesh>
{/* Right lens */}
<mesh position={[0, 0, -baseline / 2]}>
<cylinderGeometry args={[0.02, 0.02, 0.015, 12]} />
<meshStandardMaterial
color={active ? '#0ea5e9' : '#1e293b'}
emissive={active ? '#0ea5e9' : '#000'}
emissiveIntensity={active ? 0.3 : 0}
/>
</mesh>
{/* View cones when active */}
{active && (
<>
<Line
points={[
[0, 0, baseline / 2],
[0, itemPosition[1] - mountY, itemPosition[2]],
]}
color="#0ea5e9"
lineWidth={1}
transparent
opacity={0.3}
/>
<Line
points={[
[0, 0, -baseline / 2],
[0, itemPosition[1] - mountY, itemPosition[2]],
]}
color="#0ea5e9"
lineWidth={1}
transparent
opacity={0.3}
/>
</>
)}
</group>
);
}
/** Point cloud dots around item during stereo analysis */
function PointCloud({ active, itemPosition, scale, isRound }: {
active: boolean;
itemPosition: [number, number, number];
scale: number;
isRound: boolean;
}) {
if (!active) return null;
const points = useMemo(() => {
const pts: [number, number, number][] = [];
const count = 12;
const radius = scale * 0.4;
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2;
const r = isRound ? radius : radius * (1 + Math.cos(angle * 2) * 0.3);
pts.push([
Math.cos(angle) * r,
0,
Math.sin(angle) * r,
]);
}
return pts;
}, [scale, isRound]);
return (
<group position={[itemPosition[0], itemPosition[1] + scale * 0.3, itemPosition[2]]}>
{points.map((pt, i) => (
<mesh key={i} position={pt}>
<sphereGeometry args={[0.008, 6, 6]} />
<meshStandardMaterial
color="#38bdf8"
emissive="#38bdf8"
emissiveIntensity={0.5}
/>
</mesh>
))}
</group>
);
}
/** Actuator/pusher animation during routing */
function ActuatorPusher({ active, category }: { active: boolean; category: Category | null }) {
const pusherRef = useRef<Mesh>(null);
const extendRef = useRef(0);
useFrame((_, delta) => {
if (pusherRef.current) {
const target = active && (category === 'C' || category === 'D') ? 0.15 : 0;
extendRef.current += (target - extendRef.current) * delta * 5;
const direction = category === 'C' ? 1 : -1;
pusherRef.current.position.z = direction * extendRef.current;
}
});
const gateX = ZONES.GATE.x;
const color = category === 'C' ? COLORS.routeC : category === 'D' ? COLORS.routeD : COLORS.gateFrame;
return (
<mesh
ref={pusherRef}
position={[gateX + 0.3, BELT_Y + 0.08, 0]}
>
<boxGeometry args={[0.15, 0.08, 0.06]} />
<meshStandardMaterial
color={color}
emissive={active ? color : '#000'}
emissiveIntensity={active ? 0.3 : 0}
metalness={0.5}
roughness={0.4}
/>
</mesh>
);
}
/** Support leg from floor to frame */
function SupportLeg({ x }: { x: number }) {
const legHeight = FRAME_TOP_Y;
@@ -167,7 +378,7 @@ function SupportLeg({ x }: { x: number }) {
* Conveyor belt - realistic roller conveyor
* Belt top surface at 0.7m (BELT_TOP_Y)
*/
function ConveyorBelt({ speedFactor }: { speedFactor: number }) {
function ConveyorBelt({ speedFactor, pulseActive }: { speedFactor: number; pulseActive: boolean }) {
const rollerCount = Math.floor(CONVEYOR_LENGTH / ROLLER_SPACING_M);
const rollerPositions = useMemo(() => {
@@ -242,7 +453,7 @@ function ConveyorBelt({ speedFactor }: { speedFactor: number }) {
))}
{/* Stepper motor at drive end */}
<StepperMotor position={[CONVEYOR_END_X - 0.1, ROLLER_Y, 0]} />
<StepperMotor position={[CONVEYOR_END_X - 0.1, ROLLER_Y, 0]} pulseActive={pulseActive} />
{/* End caps / guards */}
<mesh position={[CONVEYOR_START_X, BELT_Y - 0.05, 0]}>
@@ -714,6 +925,25 @@ function ContinuousScene({ playback, simplified }: { playback: ContinuousPlaybac
const cameraHighlight = shouldHighlightCamera(phase);
const showScan = shouldShowScanEffect(phase);
// Measurement system states
const measurementData = getMeasurementData(playback);
const showLaser = shouldShowLaserBeam(phase);
const showPulse = shouldShowStepperPulse(phase);
const showCloud = shouldShowPointCloud(phase);
const showActuator = shouldShowActuator(phase);
// Get item position for measurement visualization
const itemPosition = getItemPosition(playback);
const itemPos: [number, number, number] = [itemPosition.x, itemPosition.y, itemPosition.z];
// Get item data for point cloud
const currentCase = playback.currentCase;
const itemId = currentCase.itemId.replace('-LC', '');
const itemData = ITEMS.find(i => i.id === itemId) ?? ITEMS[0];
const isRound = itemData.roundness >= 0.7;
const dims = itemData.dimensionsMm;
const itemScale = Math.max(dims.width, dims.depth, dims.height) / 1000 * 2;
return (
<>
@@ -747,7 +977,7 @@ function ContinuousScene({ playback, simplified }: { playback: ContinuousPlaybac
</mesh>
{/* Conveyor - belt top at 0.7m */}
<ConveyorBelt speedFactor={speedFactor} />
<ConveyorBelt speedFactor={speedFactor} pulseActive={showPulse} />
{/* Zone markers on floor */}
<ZoneMarker
@@ -778,6 +1008,20 @@ function ContinuousScene({ playback, simplified }: { playback: ContinuousPlaybac
{/* Camera rig - overhead above belt */}
<CameraRig active={cameraHighlight} />
{/* Stereo camera lenses */}
<StereoCameraLenses active={cameraHighlight} itemPosition={itemPos} />
{/* Laser beam for height measurement */}
<LaserBeam active={showLaser} itemY={itemPosition.y} />
{/* Point cloud for stereo analysis */}
<PointCloud
active={showCloud}
itemPosition={itemPos}
scale={itemScale}
isRound={isRound}
/>
{/* Inspection zone on belt surface */}
<InspectionZone active={cameraHighlight} />
@@ -786,6 +1030,9 @@ function ContinuousScene({ playback, simplified }: { playback: ContinuousPlaybac
{/* Gate/diverter */}
<GateZone category={category} />
{/* Actuator pusher for routing */}
<ActuatorPusher active={showActuator} category={category} />
{/* Route arrows on belt surface */}
<RouteArrows activeRoute={activeRoute} />