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:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user