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) => (
))}
);
}