Add physicalItemMotion as the single source of item pose, synchronize belt/item speed at 1 m/s, route items through chutes, keep C/D items settled inside roll-cages, render historical settled items, and use STL models with explicit fallbacks.
134 lines
5.2 KiB
TypeScript
134 lines
5.2 KiB
TypeScript
import { Suspense, useMemo } from 'react';
|
|
import { getPhysicalItemPose } from '../../domain/physicalItemMotion';
|
|
import { getModelAsset } from '../../data/modelAssets';
|
|
import { ITEMS } from '../../data/items';
|
|
import type { PlaylistCase } from '../../domain/demoPlaylist';
|
|
import { getRenderedItemDimensions } from '../../domain/physicalLayout';
|
|
import * as THREE from 'three';
|
|
import { useLoader } from '@react-three/fiber';
|
|
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
|
|
|
const COLORS = {
|
|
B: '#16a34a',
|
|
C: '#ea580c',
|
|
D: '#7c3aed',
|
|
sensorAccent: '#3b82f6',
|
|
};
|
|
|
|
const ITEM_MATERIALS: Record<string, { color: string; roughness: number; metalness?: number }> = {
|
|
'SKU-001': { color: '#b68b58', roughness: 0.82 },
|
|
'SKU-002': { color: '#e8eef6', roughness: 0.5 },
|
|
'SKU-004': { color: '#c49a6c', roughness: 0.82 },
|
|
'SKU-006': { color: '#f8fafc', roughness: 0.42 },
|
|
'SKU-007': { color: '#7dd3fc', roughness: 0.28 },
|
|
'SKU-008': { color: '#cbd5e1', roughness: 0.35, metalness: 0.15 },
|
|
};
|
|
|
|
function STLGeometry({ path, scale, color, accentColor, emissiveIntensity, roughness, metalness }: any) {
|
|
const geometry = useLoader(STLLoader, path) as THREE.BufferGeometry;
|
|
useMemo(() => {
|
|
if (geometry) {
|
|
geometry.center();
|
|
geometry.computeVertexNormals();
|
|
}
|
|
}, [geometry]);
|
|
return (
|
|
<group scale={scale}>
|
|
<mesh geometry={geometry} castShadow>
|
|
<meshStandardMaterial color={color} emissive={accentColor} emissiveIntensity={emissiveIntensity} roughness={roughness} metalness={metalness} />
|
|
</mesh>
|
|
<lineSegments geometry={new THREE.EdgesGeometry(geometry, 35)}>
|
|
<lineBasicMaterial color={accentColor} transparent opacity={0.28} />
|
|
</lineSegments>
|
|
</group>
|
|
);
|
|
}
|
|
|
|
function FallbackPrimitive({ type, color, accentColor, emissiveIntensity, roughness, metalness, w, h, d }: any) {
|
|
if (type === 'cylinder' || type === 'sphere') {
|
|
return (
|
|
<group>
|
|
<mesh castShadow>
|
|
<cylinderGeometry args={[Math.max(w, d) / 2, Math.max(w, d) / 2, h, 16]} />
|
|
<meshStandardMaterial color={color} emissive={accentColor} emissiveIntensity={emissiveIntensity} roughness={roughness} metalness={metalness} />
|
|
</mesh>
|
|
</group>
|
|
);
|
|
}
|
|
return (
|
|
<group>
|
|
<mesh castShadow>
|
|
<boxGeometry args={[w, h, d]} />
|
|
<meshStandardMaterial color={color} emissive={accentColor} emissiveIntensity={emissiveIntensity} roughness={roughness} metalness={metalness} />
|
|
</mesh>
|
|
<lineSegments>
|
|
<edgesGeometry args={[new THREE.BoxGeometry(w, h, d), 35]} />
|
|
<lineBasicMaterial color={accentColor} transparent opacity={0.35} />
|
|
</lineSegments>
|
|
</group>
|
|
);
|
|
}
|
|
|
|
export function PhysicalPlaybackItem({
|
|
caseData,
|
|
elapsedMs,
|
|
slotIndex = 0
|
|
}: {
|
|
caseData: PlaylistCase;
|
|
elapsedMs: number;
|
|
slotIndex?: number;
|
|
}) {
|
|
const itemId = caseData.itemId.replace('-LC', '');
|
|
const itemData = useMemo(() => ITEMS.find(i => i.id === itemId) ?? ITEMS[0], [itemId]);
|
|
const asset = getModelAsset(itemId);
|
|
|
|
const dims = getRenderedItemDimensions(itemData.dimensionsMm);
|
|
|
|
const pose = getPhysicalItemPose({
|
|
caseId: caseData.id,
|
|
slotIndex,
|
|
dimensionsMm: itemData.dimensionsMm,
|
|
targetCategory: caseData.expectedCategory,
|
|
elapsedMs
|
|
});
|
|
|
|
const { position, rotation, isSettled, phase } = pose;
|
|
const isRouting = phase === 'routing';
|
|
|
|
// Do not render B items that are settled (they exit the factory)
|
|
if (isSettled && caseData.expectedCategory === 'B') {
|
|
return null;
|
|
}
|
|
|
|
// Before spawn, don't show
|
|
if (elapsedMs < 0) return null;
|
|
|
|
const accentColor = COLORS[caseData.expectedCategory] ?? COLORS.sensorAccent;
|
|
const material = ITEM_MATERIALS[itemId] ?? { color: '#d8c3a5', roughness: 0.75 };
|
|
const emissiveIntensity = isRouting ? 0.16 : 0.03;
|
|
|
|
const useSTL = asset?.loaderType === 'stl' && asset?.frontendAssetPath;
|
|
const stlPath = asset?.frontendAssetPath ?? '';
|
|
const fallbackType = asset?.fallbackPrimitive ?? 'box';
|
|
|
|
return (
|
|
<group position={position} rotation={rotation}>
|
|
{useSTL ? (
|
|
<Suspense fallback={<FallbackPrimitive type={fallbackType} color={material.color} accentColor={accentColor} emissiveIntensity={emissiveIntensity} roughness={material.roughness} metalness={material.metalness ?? 0.05} w={dims.width} h={dims.height} d={dims.depth} />}>
|
|
<STLGeometry path={stlPath} scale={[0.001, 0.001, 0.001]} color={material.color} accentColor={accentColor} emissiveIntensity={emissiveIntensity} roughness={material.roughness} metalness={material.metalness ?? 0.05} />
|
|
</Suspense>
|
|
) : (
|
|
<FallbackPrimitive type={fallbackType} color={material.color} accentColor={accentColor} emissiveIntensity={emissiveIntensity} roughness={material.roughness} metalness={material.metalness ?? 0.05} w={dims.width} h={dims.height} d={dims.depth} />
|
|
)}
|
|
|
|
{/* Shadow on belt (only if on belt or chute) */}
|
|
{!isSettled && (
|
|
<mesh position={[0, -dims.height / 2 + 0.001, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
|
<circleGeometry args={[Math.max(dims.width, dims.depth) / 2 + 0.01, 16]} />
|
|
<meshStandardMaterial color="#475569" transparent opacity={0.15} />
|
|
</mesh>
|
|
)}
|
|
</group>
|
|
);
|
|
}
|