import { Html } from '@react-three/drei'; import { useFrame } from '@react-three/fiber'; import type { ThreeEvent } from '@react-three/fiber'; import { useMemo, useRef } from 'react'; import { Group, Plane, Vector3 } from 'three'; import { getPartColor, HOVERED_PART_COLOR, SELECTED_PART_COLOR } from './materials'; import type { MachinePart, MachineRegistryEntry } from '../modules/machines/schema'; import { useViewerStore } from '../store/viewerStore'; import type { CrossSectionAxis, DisplayMode, PartRenderState, VectorTuple } from '../types/viewer'; interface MachinePlaceholderModelProps { machine: MachineRegistryEntry; } interface PartMeshProps { part: MachinePart; index: number; total: number; explodedDistance: number; displayMode: DisplayMode; renderState: PartRenderState; isSelected: boolean; isHovered: boolean; isPlaying: boolean; rpm: number; timeScale: number; clippingPlanes: Plane[]; showAnnotations: boolean; } function createClippingPlanes(axis: CrossSectionAxis, offset: number): Plane[] { if (axis === 'none') { return []; } const normal = axis === 'x' ? new Vector3(-1, 0, 0) : axis === 'y' ? new Vector3(0, -1, 0) : new Vector3(0, 0, -1); return [new Plane(normal, offset)]; } function PartGeometry({ index }: { index: number }) { switch (index % 5) { case 0: return ; case 1: return ; case 2: return ; case 3: return ; default: return ; } } function getBasePosition(index: number, total: number): VectorTuple { const angle = (index / Math.max(total, 1)) * Math.PI * 2; const ring = Math.floor(index / 8); const radius = 1.05 + ring * 0.46 + (index % 2) * 0.18; const y = ((index % 3) - 1) * 0.32; return [Math.cos(angle) * radius, y, Math.sin(angle) * radius]; } function getExplodedPosition(basePosition: VectorTuple, index: number, total: number, distance: number): VectorTuple { const length = Math.hypot(basePosition[0], basePosition[1], basePosition[2]) || 1; const separation = distance * (0.55 + index / Math.max(total, 1) * 0.22); return [ basePosition[0] + (basePosition[0] / length) * separation, basePosition[1] + (basePosition[1] / length) * separation, basePosition[2] + (basePosition[2] / length) * separation ]; } function PartMesh({ part, index, total, explodedDistance, displayMode, renderState, isSelected, isHovered, isPlaying, rpm, timeScale, clippingPlanes, showAnnotations }: PartMeshProps) { const groupRef = useRef(null); const setSelectedPartId = useViewerStore((state) => state.setSelectedPartId); const setHoveredPartId = useViewerStore((state) => state.setHoveredPartId); const basePosition = useMemo(() => getBasePosition(index, total), [index, total]); const explodedPosition = useMemo( () => getExplodedPosition(basePosition, index, total, explodedDistance), [basePosition, explodedDistance, index, total] ); useFrame((state, delta) => { const group = groupRef.current; if (!group) { return; } if (isPlaying) { const angularVelocity = (rpm / 60) * Math.PI * 2 * timeScale; const direction = index % 2 === 0 ? 1 : -1; const pulse = Math.sin(state.clock.elapsedTime * Math.max(timeScale, 0.1) * 2 + index) * 0.035; group.rotation.y += angularVelocity * delta * direction * 0.12; group.rotation.x += angularVelocity * delta * 0.025; group.position.y = explodedPosition[1] + pulse; } else { group.position.y += (explodedPosition[1] - group.position.y) * 0.14; } }); if (!renderState.visible) { return null; } const effectiveOpacity = displayMode === 'xray' ? Math.min(renderState.opacity, 0.38) : renderState.opacity; const color = isSelected ? SELECTED_PART_COLOR : isHovered ? HOVERED_PART_COLOR : getPartColor(index); const handlePointerOver = (event: ThreeEvent) => { event.stopPropagation(); setHoveredPartId(part.id); }; const handlePointerOut = (event: ThreeEvent) => { event.stopPropagation(); setHoveredPartId(undefined); }; const handleClick = (event: ThreeEvent) => { event.stopPropagation(); setSelectedPartId(part.id); }; return ( 0.55} emissive={isSelected ? SELECTED_PART_COLOR : isHovered ? HOVERED_PART_COLOR : '#000000'} emissiveIntensity={isSelected ? 0.32 : isHovered ? 0.18 : 0} metalness={0.72} opacity={effectiveOpacity} roughness={0.36} transparent={effectiveOpacity < 0.995} wireframe={displayMode === 'wireframe'} /> {showAnnotations ? ( {part.name} ) : null} {isHovered ? ( {part.description} ) : null} ); } export function MachinePlaceholderModel({ machine }: MachinePlaceholderModelProps) { const partStates = useViewerStore((state) => state.partStates); const selectedPartId = useViewerStore((state) => state.selectedPartId); const hoveredPartId = useViewerStore((state) => state.hoveredPartId); const explodedDistance = useViewerStore((state) => state.explodedDistance); const displayMode = useViewerStore((state) => state.displayMode); const isPlaying = useViewerStore((state) => state.isPlaying); const rpm = useViewerStore((state) => state.rpm); const timeScale = useViewerStore((state) => state.timeScale); const crossSectionAxis = useViewerStore((state) => state.crossSectionAxis); const crossSectionOffset = useViewerStore((state) => state.crossSectionOffset); const showAnnotations = useViewerStore((state) => state.showAnnotations); const clippingPlanes = useMemo( () => createClippingPlanes(crossSectionAxis, crossSectionOffset), [crossSectionAxis, crossSectionOffset] ); return ( {machine.parts.map((part, index) => ( ))} ); }