import { ThreeEvent, useFrame, useThree } from '@react-three/fiber'; import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { Color, Group, Material, Mesh, MeshBasicMaterial, MeshPhysicalMaterial, MeshStandardMaterial, Object3D, Plane, Vector3 } from 'three'; import { useReducedMotionPreference } from '../../hooks/useReducedMotionPreference'; import { useViewerStore } from '../../store/viewerStore'; import type { LoadedMachineAsset, Vec3, ViewerPart } from '../../types/viewer'; import { collectPartMeshes, collectPartRoots, findPartIdFromObject } from '../interaction/objectPartLookup'; import { normaliseDirection } from '../procedural/geometry'; interface MaterialRecord { material: Material; baseOpacity: number; baseTransparent: boolean; baseDepthWrite: boolean; baseEmissive?: Color; baseEmissiveIntensity?: number; } interface MeshRecord { mesh: Mesh; partId: string; materials: MaterialRecord[]; } interface RootRecord { object: Object3D; partId: string; basePosition: Vector3; } export interface PreparedMachineRootProps { asset: LoadedMachineAsset; } const HOVER_COLOR = new Color('#4c8dff'); const SELECTED_COLOR = new Color('#ffb04c'); export function PreparedMachineRoot({ asset }: PreparedMachineRootProps) { const groupRef = useRef(null); const meshRecordsRef = useRef([]); const rootRecordsRef = useRef([]); const gl = useThree((state) => state.gl); const reducedMotion = useReducedMotionPreference(); const parts = useViewerStore((state) => state.parts); const partSettings = useViewerStore((state) => state.partSettings); const display = useViewerStore((state) => state.display); const selectedPartId = useViewerStore((state) => state.selectedPartId); const hoveredPartId = useViewerStore((state) => state.hoveredPart?.partId ?? null); const setHoveredPart = useViewerStore((state) => state.setHoveredPart); const selectPart = useViewerStore((state) => state.selectPart); const partById = useMemo(() => { const map = new Map(); for (const part of parts) { map.set(part.id, part); } return map; }, [parts]); const clippingPlanes = useMemo( () => createClippingPlanes(display.crossSection), [display.crossSection] ); useLayoutEffect(() => { const meshRecords: MeshRecord[] = []; const partMeshes = collectPartMeshes(asset.root); const meshPartMap = new Map( partMeshes.map(({ object, partId }) => [object, partId]) ); asset.root.traverse((object) => { const mesh = object as Mesh; if (!mesh.isMesh) { return; } mesh.castShadow = true; mesh.receiveShadow = true; const partId = meshPartMap.get(mesh) ?? findPartIdFromObject(mesh); if (!partId) { return; } mesh.material = cloneMeshMaterial(mesh.material); const materials = materialArray(mesh.material).map((material) => ({ material, baseOpacity: material.opacity, baseTransparent: material.transparent, baseDepthWrite: material.depthWrite, baseEmissive: getEmissive(material)?.clone(), baseEmissiveIntensity: getEmissiveIntensity(material) })); meshRecords.push({ mesh, partId, materials }); }); meshRecordsRef.current = meshRecords; rootRecordsRef.current = collectPartRoots(asset.root).map(({ object, partId }) => ({ object, partId, basePosition: object.position.clone() })); return () => { for (const record of meshRecordsRef.current) { for (const materialRecord of record.materials) { materialRecord.material.dispose(); } } meshRecordsRef.current = []; rootRecordsRef.current = []; }; }, [asset.root]); useEffect(() => { gl.localClippingEnabled = true; }, [gl]); useEffect(() => { for (const record of meshRecordsRef.current) { const settings = partSettings[record.partId] ?? { visible: true, opacity: 1 }; const isSelected = selectedPartId === record.partId; const isHovered = hoveredPartId === record.partId; record.mesh.visible = settings.visible; for (const materialRecord of record.materials) { applyMaterialState(materialRecord, { opacity: settings.opacity, wireframe: display.wireframe, clippingPlanes, isHovered, isSelected }); } } for (const root of rootRecordsRef.current) { const settings = partSettings[root.partId] ?? { visible: true, opacity: 1 }; root.object.visible = settings.visible; } }, [ clippingPlanes, display.wireframe, hoveredPartId, partSettings, selectedPartId ]); useFrame((_, delta) => { const targetDistance = display.exploded.enabled ? display.exploded.distance : 0; const alpha = reducedMotion ? 1 : 1 - Math.exp(-delta * 9); for (const record of rootRecordsRef.current) { const part = partById.get(record.partId); const direction = normaliseDirection(part?.explodeDirection, [0, 1, 0]); const target = record.basePosition .clone() .add(direction.multiplyScalar(targetDistance)); record.object.position.lerp(target, alpha); } }); useEffect(() => { const domElement = gl.domElement; domElement.style.cursor = hoveredPartId ? 'pointer' : 'grab'; return () => { domElement.style.cursor = ''; }; }, [gl.domElement, hoveredPartId]); return ( ); function handlePointerMove(event: ThreeEvent) { const partId = findPartIdFromObject(event.object); if (!partId) { return; } const part = partById.get(partId); event.stopPropagation(); setHoveredPart({ partId, name: part?.name ?? partId, clientX: event.nativeEvent.clientX, clientY: event.nativeEvent.clientY, worldPosition: [event.point.x, event.point.y, event.point.z] }); } function handlePointerOut(event: ThreeEvent) { event.stopPropagation(); setHoveredPart(null); } function handleClick(event: ThreeEvent) { const partId = findPartIdFromObject(event.object); if (!partId) { return; } event.stopPropagation(); selectPart(partId); } } function createClippingPlanes(crossSection: { enabled: boolean; axis: 'x' | 'y' | 'z'; offset: number; invert: boolean; }): Plane[] { if (!crossSection.enabled) { return []; } const normal = new Vector3( crossSection.axis === 'x' ? 1 : 0, crossSection.axis === 'y' ? 1 : 0, crossSection.axis === 'z' ? 1 : 0 ); const sign = crossSection.invert ? -1 : 1; normal.multiplyScalar(sign); return [new Plane(normal, -crossSection.offset * sign)]; } function cloneMeshMaterial(material: Material | Material[]): Material | Material[] { return Array.isArray(material) ? material.map((entry) => entry.clone()) : material.clone(); } function materialArray(material: Material | Material[]): Material[] { return Array.isArray(material) ? material : [material]; } function applyMaterialState( record: MaterialRecord, options: { opacity: number; wireframe: boolean; clippingPlanes: Plane[]; isHovered: boolean; isSelected: boolean; } ): void { const material = record.material; const opacity = clamp(options.opacity, 0.08, 1); material.opacity = record.baseOpacity * opacity; material.transparent = record.baseTransparent || opacity < 0.999; material.depthWrite = opacity > 0.82 ? record.baseDepthWrite : false; material.clippingPlanes = options.clippingPlanes.length > 0 ? options.clippingPlanes : null; material.needsUpdate = true; if ('wireframe' in material) { (material as MeshStandardMaterial | MeshBasicMaterial).wireframe = options.wireframe; } const emissive = getEmissive(material); if (emissive) { if (options.isSelected) { emissive.copy(SELECTED_COLOR); setEmissiveIntensity(material, 0.52); } else if (options.isHovered) { emissive.copy(HOVER_COLOR); setEmissiveIntensity(material, 0.36); } else if (record.baseEmissive) { emissive.copy(record.baseEmissive); setEmissiveIntensity(material, record.baseEmissiveIntensity ?? 0); } else { emissive.set('#000000'); setEmissiveIntensity(material, 0); } } } function getEmissive(material: Material): Color | undefined { if ( material instanceof MeshStandardMaterial || material instanceof MeshPhysicalMaterial || 'emissive' in material ) { return (material as MeshStandardMaterial).emissive; } return undefined; } function getEmissiveIntensity(material: Material): number | undefined { if ('emissiveIntensity' in material) { return (material as MeshStandardMaterial).emissiveIntensity; } return undefined; } function setEmissiveIntensity(material: Material, intensity: number): void { if ('emissiveIntensity' in material) { (material as MeshStandardMaterial).emissiveIntensity = intensity; } } function clamp(value: number, min: number, max: number): number { if (!Number.isFinite(value)) { return max; } return Math.min(max, Math.max(min, value)); }