import { ContactShadows, Environment, Html, OrbitControls } from "@react-three/drei"; import { Canvas, type ThreeEvent, useFrame, useThree } from "@react-three/fiber"; import { LOCAL_ENVIRONMENT_HDR } from "../../three/lighting/localEnvironment"; import * as React from "react"; import * as THREE from "three"; import type { MachineDefinition, MachinePart } from "../../modules/machines/catalogue"; import { useIntegratedReducedMotion } from "../../production/integrationAdapters"; import { cameraPresets, type CameraPreset, useViewerStore, } from "../../store/productionViewerStore"; type Axis = "x" | "y" | "z"; type Shape = | { kind: "box"; size: [number, number, number] } | { kind: "cylinder"; radius: number; depth: number; axis: Axis } | { kind: "sphere"; radius: number } | { kind: "torus"; radius: number; tube: number; axis: Axis } | { kind: "gear"; radius: number; thickness: number; teeth: number; axis: Axis } | { kind: "fan"; radius: number; thickness: number; blades: number; axis: Axis } | { kind: "capsule"; radius: number; length: number; axis: Axis } | { kind: "rack"; length: number; width: number; teeth: number } | { kind: "belt"; radius: number; tube: number }; type Motion = | { kind: "static" } | { kind: "spin"; axis: Axis; speed: number } | { kind: "reciprocate"; axis: Axis; amplitude: number } | { kind: "oscillate"; axis: Axis; amplitude: number } | { kind: "orbit"; radius: number; speed: number } | { kind: "pulse"; amplitude: number }; interface PartLayout { part: MachinePart; color: string; position: THREE.Vector3; rotation: THREE.Euler; explodeDirection: THREE.Vector3; shape: Shape; motion: Motion; phase: number; labelOffset: THREE.Vector3; } interface MaterialConfig { color: string; role: string; opacity: number; selected: boolean; hovered: boolean; wireframe: boolean; clippingPlanes: THREE.Plane[]; } export interface ProceduralMachineSceneProps { machine: MachineDefinition; } const ROLE_COLORS: Record = { housing: "#334155", piston: "#d1d5db", crankshaft: "#f59e0b", camshaft: "#c084fc", valve: "#60a5fa", injector: "#f97316", shaft: "#94a3b8", gear: "#facc15", "ring-gear": "#fde68a", "planet-carrier": "#eab308", rotor: "#fb923c", fan: "#38bdf8", compressor: "#38bdf8", turbine: "#fb7185", combustor: "#ef4444", nozzle: "#f97316", belt: "#111827", pulley: "#5eead4", impeller: "#22d3ee", fluid: "#4c8dff", port: "#4c8dff", linkage: "#a3e635", slider: "#86efac", cam: "#f472b6", follower: "#fbcfe8", rack: "#34d399", spring: "#fda4af", bearing: "#cbd5e1", ball: "#e2e8f0", roller: "#cbd5e1", cage: "#64748b", "brake-disc": "#94a3b8", caliper: "#ef4444", pad: "#f97316", governor: "#93c5fd", seal: "#64748b", }; function roleIncludes(part: MachinePart, token: string): boolean { const role = part.role.toLowerCase(); const id = part.id.toLowerCase(); const name = part.name.toLowerCase(); return role.includes(token) || id.includes(token) || name.includes(token); } function roleColor(part: MachinePart, fallback: string): string { return ROLE_COLORS[part.role] ?? fallback; } function axisRotation(axis: Axis): [number, number, number] { if (axis === "x") { return [0, 0, Math.PI / 2]; } if (axis === "z") { return [Math.PI / 2, 0, 0]; } return [0, 0, 0]; } function normaliseDirection(vector: THREE.Vector3, fallbackAngle: number): THREE.Vector3 { if (vector.lengthSq() > 0.001) { return vector.clone().normalize(); } return new THREE.Vector3(Math.cos(fallbackAngle), 0.2, Math.sin(fallbackAngle)).normalize(); } function baseGridPosition(index: number, total: number): THREE.Vector3 { const centered = index - (total - 1) / 2; return new THREE.Vector3(centered * 0.52, 0, Math.sin(index * 1.7) * 0.28); } function layoutEnginePart( machine: MachineDefinition, part: MachinePart, index: number, total: number, ): Pick { const grid = baseGridPosition(index, total); const rotation = new THREE.Euler(0, 0, 0); if (roleIncludes(part, "block") || roleIncludes(part, "housing") || roleIncludes(part, "bank")) { return { position: new THREE.Vector3(0, 0.05, 0), rotation, shape: { kind: "box", size: machine.id === "v8-engine" ? [3.9, 1.45, 2.2] : [2.7, 1.45, 1.55] }, motion: { kind: "static" }, }; } if (roleIncludes(part, "piston")) { return { position: new THREE.Vector3(grid.x, 0.42, grid.z), rotation: new THREE.Euler(...axisRotation("y")), shape: { kind: "cylinder", radius: 0.22, depth: 0.48, axis: "y" }, motion: { kind: "reciprocate", axis: "y", amplitude: 0.34 }, }; } if (roleIncludes(part, "rod") || roleIncludes(part, "linkage")) { return { position: new THREE.Vector3(grid.x, -0.08, grid.z), rotation: new THREE.Euler(0, 0, Math.PI / 6), shape: { kind: "capsule", radius: 0.07, length: 0.95, axis: "y" }, motion: { kind: "oscillate", axis: "z", amplitude: 0.42 }, }; } if (roleIncludes(part, "crank") || roleIncludes(part, "shaft")) { return { position: new THREE.Vector3(0, -0.58, 0), rotation, shape: { kind: "cylinder", radius: 0.13, depth: 3.1, axis: "x" }, motion: { kind: "spin", axis: "x", speed: 1 }, }; } if (roleIncludes(part, "cam")) { return { position: new THREE.Vector3(0, 1.08, -0.45), rotation, shape: { kind: "cylinder", radius: 0.11, depth: 2.8, axis: "x" }, motion: { kind: "spin", axis: "x", speed: 0.5 }, }; } if (roleIncludes(part, "valve")) { return { position: new THREE.Vector3(grid.x, 1.12, 0.45), rotation, shape: { kind: "capsule", radius: 0.055, length: 0.72, axis: "y" }, motion: { kind: "reciprocate", axis: "y", amplitude: 0.16 }, }; } if (roleIncludes(part, "fan") || roleIncludes(part, "compressor") || roleIncludes(part, "turbine")) { return { position: new THREE.Vector3(grid.x * 0.6, 0.08, 0), rotation, shape: { kind: "fan", radius: 0.58, thickness: 0.18, blades: 14, axis: "x" }, motion: { kind: "spin", axis: "x", speed: roleIncludes(part, "fan") ? 0.55 : 1.25 }, }; } if (roleIncludes(part, "rotor")) { return { position: new THREE.Vector3(0, 0.05, 0), rotation: new THREE.Euler(0, 0, Math.PI / 6), shape: { kind: "gear", radius: 0.62, thickness: 0.22, teeth: 3, axis: "z" }, motion: { kind: "orbit", radius: 0.14, speed: 0.6 }, }; } if (roleIncludes(part, "fluid") || roleIncludes(part, "manifold") || roleIncludes(part, "duct") || roleIncludes(part, "port")) { return { position: new THREE.Vector3(grid.x, 0.62, 0.88), rotation: new THREE.Euler(0, Math.PI / 2, 0), shape: { kind: "capsule", radius: 0.12, length: 0.9, axis: "x" }, motion: { kind: "pulse", amplitude: 0.045 }, }; } return { position: new THREE.Vector3(grid.x, 0.25, grid.z), rotation, shape: { kind: "box", size: [0.42, 0.42, 0.42] }, motion: { kind: "static" }, }; } function layoutGearboxPart( part: MachinePart, index: number, total: number, ): Pick { const angle = (index / total) * Math.PI * 2; const radius = 1.05; const rotation = new THREE.Euler(0, 0, 0); if (roleIncludes(part, "ring")) { return { position: new THREE.Vector3(0, 0, 0), rotation, shape: { kind: "torus", radius: 1.25, tube: 0.08, axis: "z" }, motion: { kind: "spin", axis: "z", speed: -0.18 }, }; } if (roleIncludes(part, "gear") || roleIncludes(part, "pinion") || roleIncludes(part, "wheel")) { const isCentral = roleIncludes(part, "sun") || roleIncludes(part, "pinion"); return { position: isCentral ? new THREE.Vector3(0, 0, 0) : new THREE.Vector3(Math.cos(angle) * radius, Math.sin(angle) * radius * 0.55, 0), rotation, shape: { kind: roleIncludes(part, "bevel") ? "fan" : "gear", radius: isCentral ? 0.46 : 0.36, thickness: 0.28, teeth: roleIncludes(part, "planet") ? 14 : 20, axis: roleIncludes(part, "bevel") ? "x" : "z", blades: 12, } as Shape, motion: { kind: "spin", axis: roleIncludes(part, "bevel") ? "x" : "z", speed: isCentral ? 1.1 : -1.3 }, }; } if (roleIncludes(part, "belt")) { return { position: new THREE.Vector3(0, 0, 0), rotation, shape: { kind: "belt", radius: 1.15, tube: 0.045 }, motion: { kind: "spin", axis: "z", speed: 0.22 }, }; } if (roleIncludes(part, "pulley")) { return { position: new THREE.Vector3(index % 2 === 0 ? -0.95 : 0.95, 0, 0), rotation, shape: { kind: "cylinder", radius: 0.52, depth: 0.32, axis: "z" }, motion: { kind: "spin", axis: "z", speed: index % 2 === 0 ? 0.8 : -0.55 }, }; } if (roleIncludes(part, "shaft")) { return { position: new THREE.Vector3(0, -0.72 + (index % 3) * 0.42, 0), rotation, shape: { kind: "cylinder", radius: 0.08, depth: 3.2, axis: "x" }, motion: { kind: "spin", axis: "x", speed: 0.85 }, }; } if (roleIncludes(part, "rack") || roleIncludes(part, "fork") || roleIncludes(part, "rail")) { return { position: new THREE.Vector3(0, -1.05, 0.42), rotation, shape: { kind: "rack", length: 2.3, width: 0.18, teeth: 13 }, motion: { kind: "reciprocate", axis: "x", amplitude: 0.18 }, }; } return { position: new THREE.Vector3(Math.cos(angle) * 1.35, Math.sin(angle) * 0.75, 0), rotation, shape: { kind: "box", size: [0.46, 0.36, 0.3] }, motion: { kind: "static" }, }; } function layoutPumpPart( part: MachinePart, index: number, total: number, ): Pick { const grid = baseGridPosition(index, total); const rotation = new THREE.Euler(0, 0, 0); if (roleIncludes(part, "housing") || roleIncludes(part, "casing") || roleIncludes(part, "barrel") || roleIncludes(part, "cylinder")) { return { position: new THREE.Vector3(0, 0, 0), rotation, shape: { kind: "box", size: [2.5, 1.25, 1.25] }, motion: { kind: "static" }, }; } if (roleIncludes(part, "impeller") || roleIncludes(part, "gear")) { return { position: new THREE.Vector3(roleIncludes(part, "idler") ? 0.45 : roleIncludes(part, "drive") ? -0.45 : 0, 0, 0), rotation, shape: { kind: roleIncludes(part, "gear") ? "gear" : "fan", radius: 0.45, thickness: 0.26, teeth: 18, blades: 12, axis: "z" } as Shape, motion: { kind: "spin", axis: "z", speed: roleIncludes(part, "idler") ? -1 : 1 }, }; } if (roleIncludes(part, "piston")) { return { position: new THREE.Vector3(-0.35, 0, 0), rotation, shape: { kind: "cylinder", radius: 0.23, depth: 0.48, axis: "x" }, motion: { kind: "reciprocate", axis: "x", amplitude: 0.46 }, }; } if (roleIncludes(part, "rod") || roleIncludes(part, "crank")) { return { position: new THREE.Vector3(-1.08, 0, 0), rotation, shape: roleIncludes(part, "crank") ? { kind: "gear", radius: 0.32, thickness: 0.18, teeth: 8, axis: "z" } : { kind: "capsule", radius: 0.06, length: 0.82, axis: "x" }, motion: roleIncludes(part, "crank") ? { kind: "spin", axis: "z", speed: 1 } : { kind: "oscillate", axis: "z", amplitude: 0.38 }, }; } if (roleIncludes(part, "valve")) { return { position: new THREE.Vector3(grid.x, 0.72, 0), rotation, shape: { kind: "capsule", radius: 0.07, length: 0.42, axis: "y" }, motion: { kind: "reciprocate", axis: "y", amplitude: 0.08 }, }; } if (roleIncludes(part, "fluid") || roleIncludes(part, "port") || roleIncludes(part, "nozzle") || roleIncludes(part, "pressure")) { return { position: new THREE.Vector3(grid.x, 0.18, 0.82), rotation: new THREE.Euler(0, Math.PI / 2, 0), shape: { kind: "capsule", radius: 0.1, length: 0.82, axis: "x" }, motion: { kind: "pulse", amplitude: 0.05 }, }; } if (roleIncludes(part, "seal")) { return { position: new THREE.Vector3(0.9, 0, 0), rotation, shape: { kind: "torus", radius: 0.25, tube: 0.035, axis: "x" }, motion: { kind: "static" }, }; } return { position: new THREE.Vector3(grid.x, 0.25, grid.z), rotation, shape: { kind: "box", size: [0.38, 0.38, 0.38] }, motion: { kind: "static" }, }; } function layoutMechanismPart( part: MachinePart, index: number, ): Pick { const rotation = new THREE.Euler(0, 0, 0); if (roleIncludes(part, "crank") || roleIncludes(part, "drive-wheel")) { return { position: new THREE.Vector3(-1.05, 0, 0), rotation, shape: { kind: "gear", radius: 0.46, thickness: 0.2, teeth: 10, axis: "z" }, motion: { kind: "spin", axis: "z", speed: 1 }, }; } if (roleIncludes(part, "gear") || roleIncludes(part, "pinion") || roleIncludes(part, "geneva")) { return { position: new THREE.Vector3(0.45, 0, 0), rotation, shape: { kind: "gear", radius: roleIncludes(part, "geneva") ? 0.62 : 0.42, thickness: 0.22, teeth: roleIncludes(part, "geneva") ? 6 : 18, axis: "z" }, motion: { kind: "spin", axis: "z", speed: roleIncludes(part, "geneva") ? 0.25 : -1 }, }; } if (roleIncludes(part, "rack")) { return { position: new THREE.Vector3(0.35, -0.62, 0), rotation, shape: { kind: "rack", length: 2.2, width: 0.18, teeth: 14 }, motion: { kind: "reciprocate", axis: "x", amplitude: 0.48 }, }; } if (roleIncludes(part, "slider") || roleIncludes(part, "yoke") || roleIncludes(part, "load") || roleIncludes(part, "pad")) { return { position: new THREE.Vector3(0.85, 0, 0), rotation, shape: { kind: "box", size: [0.62, 0.34, 0.34] }, motion: { kind: "reciprocate", axis: "x", amplitude: 0.58 }, }; } if (roleIncludes(part, "link") || roleIncludes(part, "rod") || roleIncludes(part, "handle") || roleIncludes(part, "arm")) { return { position: new THREE.Vector3(-0.08 + index * 0.08, 0.32, 0), rotation: new THREE.Euler(0, 0, Math.PI / 5), shape: { kind: "capsule", radius: 0.06, length: 1.05, axis: "x" }, motion: { kind: "oscillate", axis: "z", amplitude: 0.45 }, }; } if (roleIncludes(part, "cam")) { return { position: new THREE.Vector3(-0.65, 0, 0), rotation, shape: { kind: "gear", radius: 0.5, thickness: 0.2, teeth: 1, axis: "z" }, motion: { kind: "spin", axis: "z", speed: 0.85 }, }; } if (roleIncludes(part, "follower")) { return { position: new THREE.Vector3(0.2, 0.72, 0), rotation, shape: { kind: "cylinder", radius: 0.12, depth: 0.44, axis: "z" }, motion: { kind: "reciprocate", axis: "y", amplitude: 0.34 }, }; } if (roleIncludes(part, "spring")) { return { position: new THREE.Vector3(0.7, 0.72, 0), rotation, shape: { kind: "torus", radius: 0.22, tube: 0.025, axis: "y" }, motion: { kind: "pulse", amplitude: 0.08 }, }; } return { position: new THREE.Vector3((index - 2) * 0.42, -0.82, 0), rotation, shape: { kind: "box", size: [0.42, 0.2, 0.22] }, motion: { kind: "static" }, }; } function layoutStructuralPart( part: MachinePart, index: number, total: number, ): Pick { const angle = (index / total) * Math.PI * 2; const rotation = new THREE.Euler(0, 0, 0); if (roleIncludes(part, "bearing") || roleIncludes(part, "race")) { return { position: new THREE.Vector3(0, 0, 0), rotation, shape: { kind: "torus", radius: roleIncludes(part, "outer") ? 1.05 : 0.58, tube: 0.08, axis: "z" }, motion: roleIncludes(part, "inner") ? { kind: "spin", axis: "z", speed: 0.7 } : { kind: "static" }, }; } if (roleIncludes(part, "ball") || roleIncludes(part, "roller")) { return { position: new THREE.Vector3(Math.cos(angle) * 0.82, Math.sin(angle) * 0.82, 0), rotation, shape: roleIncludes(part, "roller") ? { kind: "cylinder", radius: 0.12, depth: 0.48, axis: "x" } : { kind: "sphere", radius: 0.14 }, motion: { kind: "orbit", radius: 0.1, speed: 0.75 }, }; } if (roleIncludes(part, "cage")) { return { position: new THREE.Vector3(0, 0, 0), rotation, shape: { kind: "torus", radius: 0.82, tube: 0.025, axis: "z" }, motion: { kind: "spin", axis: "z", speed: 0.35 }, }; } if (roleIncludes(part, "brake") || roleIncludes(part, "rotor")) { return { position: new THREE.Vector3(0, 0, 0), rotation, shape: { kind: "cylinder", radius: 0.98, depth: 0.12, axis: "z" }, motion: { kind: "spin", axis: "z", speed: 0.85 }, }; } if (roleIncludes(part, "caliper")) { return { position: new THREE.Vector3(0.9, 0.2, 0), rotation, shape: { kind: "box", size: [0.52, 1.15, 0.52] }, motion: { kind: "static" }, }; } if (roleIncludes(part, "pad") || roleIncludes(part, "piston")) { return { position: new THREE.Vector3(index % 2 === 0 ? 0.45 : 1.32, 0.18, 0), rotation, shape: { kind: "box", size: [0.18, 0.7, 0.18] }, motion: { kind: "reciprocate", axis: "x", amplitude: 0.07 }, }; } if (roleIncludes(part, "compressor") || roleIncludes(part, "turbine") || roleIncludes(part, "fan")) { return { position: new THREE.Vector3(roleIncludes(part, "turbine") ? 0.82 : -0.82, 0, 0), rotation, shape: { kind: "fan", radius: 0.52, thickness: 0.22, blades: 13, axis: "x" }, motion: { kind: "spin", axis: "x", speed: roleIncludes(part, "turbine") ? 1.45 : 1.05 }, }; } if (roleIncludes(part, "shaft")) { return { position: new THREE.Vector3(0, 0, 0), rotation, shape: { kind: "cylinder", radius: 0.08, depth: 2.1, axis: "x" }, motion: { kind: "spin", axis: "x", speed: 1.2 }, }; } if (roleIncludes(part, "fluid") || roleIncludes(part, "flow") || roleIncludes(part, "line")) { return { position: new THREE.Vector3(0, 0.82, 0.48), rotation, shape: { kind: "capsule", radius: 0.09, length: 1.4, axis: "x" }, motion: { kind: "pulse", amplitude: 0.05 }, }; } return { position: new THREE.Vector3(Math.cos(angle) * 1.2, Math.sin(angle) * 0.75, 0), rotation, shape: { kind: "box", size: [0.34, 0.34, 0.34] }, motion: { kind: "static" }, }; } function buildPartLayouts(machine: MachineDefinition): PartLayout[] { const total = machine.parts.length; return machine.parts.map((machinePart, index) => { const common = machine.category === "Engines" ? layoutEnginePart(machine, machinePart, index, total) : machine.category === "Gearboxes & Drives" ? layoutGearboxPart(machinePart, index, total) : machine.category === "Pumps & Fluid Systems" ? layoutPumpPart(machinePart, index, total) : machine.category === "Mechanisms" ? layoutMechanismPart(machinePart, index) : layoutStructuralPart(machinePart, index, total); const angle = (index / Math.max(total, 1)) * Math.PI * 2; const explodeDirection = normaliseDirection(common.position, angle); const labelOffset = new THREE.Vector3(0, 0.42, 0).add(explodeDirection.clone().multiplyScalar(0.18)); return { part: machinePart, color: roleColor(machinePart, machine.accentColor), phase: index * 0.55, explodeDirection, labelOffset, ...common, }; }); } function addAxisValue(target: THREE.Vector3 | THREE.Euler, axis: Axis, value: number): void { if (axis === "x") { target.x += value; } else if (axis === "y") { target.y += value; } else { target.z += value; } } function EngineeringMaterial({ color, role, opacity, selected, hovered, wireframe, clippingPlanes, }: MaterialConfig): JSX.Element { const isFluid = role.includes("fluid") || role.includes("port") || role.includes("nozzle"); const effectiveOpacity = isFluid ? Math.min(opacity, 0.42) : opacity; const transparent = effectiveOpacity < 0.999 || isFluid; const emissive = selected ? "#4c8dff" : hovered ? "#ffb04c" : "#000000"; return ( 0.55} wireframe={wireframe} clippingPlanes={clippingPlanes} /> ); } function CylinderShape({ shape, material, }: { shape: Extract; material: MaterialConfig; }): JSX.Element { return ( ); } function TorusShape({ shape, material, }: { shape: Extract; material: MaterialConfig; }): JSX.Element { return ( ); } function GearShape({ shape, material, }: { shape: Extract; material: MaterialConfig; }): JSX.Element { const teeth = React.useMemo( () => Array.from({ length: Math.max(1, shape.teeth) }, (_, index) => { const angle = (index / Math.max(1, shape.teeth)) * Math.PI * 2; return { index, angle }; }), [shape.teeth], ); return ( {teeth.map(({ index, angle }) => ( ))} ); } function FanShape({ shape, material, }: { shape: Extract; material: MaterialConfig; }): JSX.Element { const blades = React.useMemo( () => Array.from({ length: shape.blades }, (_, index) => { const angle = (index / shape.blades) * Math.PI * 2; return { index, angle }; }), [shape.blades], ); return ( {blades.map(({ index, angle }) => ( ))} ); } function CapsuleShape({ shape, material, }: { shape: Extract; material: MaterialConfig; }): JSX.Element { const axis = shape.axis; const half = shape.length / 2; const firstCap: [number, number, number] = axis === "x" ? [-half, 0, 0] : axis === "y" ? [0, -half, 0] : [0, 0, -half]; const secondCap: [number, number, number] = axis === "x" ? [half, 0, 0] : axis === "y" ? [0, half, 0] : [0, 0, half]; return ( ); } function RackShape({ shape, material, }: { shape: Extract; material: MaterialConfig; }): JSX.Element { const teeth = React.useMemo( () => Array.from({ length: shape.teeth }, (_, index) => ({ index, x: -shape.length / 2 + (index + 0.5) * (shape.length / shape.teeth), })), [shape.length, shape.teeth], ); return ( {teeth.map(({ index, x }) => ( ))} ); } function BeltShape({ shape, material, }: { shape: Extract; material: MaterialConfig; }): JSX.Element { return ( ); } function ShapeMesh({ shape, material, }: { shape: Shape; material: MaterialConfig; }): JSX.Element { switch (shape.kind) { case "box": return ( ); case "cylinder": return ; case "sphere": return ( ); case "torus": return ; case "gear": return ; case "fan": return ; case "capsule": return ; case "rack": return ; case "belt": return ; } } function createClippingPlanes(): THREE.Plane[] { const { crossSectionAxis, crossSectionOffset } = useViewerStore.getState(); if (crossSectionAxis === "none") { return []; } const normal = crossSectionAxis === "x" ? new THREE.Vector3(-1, 0, 0) : crossSectionAxis === "y" ? new THREE.Vector3(0, -1, 0) : new THREE.Vector3(0, 0, -1); return [new THREE.Plane(normal, crossSectionOffset)]; } function AnimatedPart({ layout }: { layout: PartLayout }): JSX.Element { const groupRef = React.useRef(null); const selectedPartId = useViewerStore((state) => state.selectedPartId); const hoveredPartId = useViewerStore((state) => state.hoveredPartId); const hiddenPartIds = useViewerStore((state) => state.hiddenPartIds); const opacity = useViewerStore((state) => state.partOpacities[layout.part.id] ?? 1); const wireframe = useViewerStore((state) => state.wireframe); const crossSectionAxis = useViewerStore((state) => state.crossSectionAxis); const crossSectionOffset = useViewerStore((state) => state.crossSectionOffset); const annotations = useViewerStore((state) => state.annotations); const reducedMotion = useIntegratedReducedMotion(); const phaseRef = React.useRef(0); const restartNonceRef = React.useRef(0); const isHidden = hiddenPartIds.includes(layout.part.id); const isSelected = selectedPartId === layout.part.id; const isHovered = hoveredPartId === layout.part.id; const clippingPlanes = React.useMemo( () => createClippingPlanes(), [crossSectionAxis, crossSectionOffset], ); useFrame((_, delta) => { const group = groupRef.current; if (!group) { return; } const state = useViewerStore.getState(); if (state.playback.restartNonce !== restartNonceRef.current) { phaseRef.current = 0; restartNonceRef.current = state.playback.restartNonce; } if (state.playback.isPlaying && !reducedMotion) { phaseRef.current += delta * (state.playback.rpm / 60) * Math.PI * 2 * state.playback.timeScale * 0.16; } const phase = phaseRef.current + state.playback.phaseOffset + layout.phase; const explodedPosition = layout.position .clone() .add(layout.explodeDirection.clone().multiplyScalar(state.explodeDistance)); group.position.copy(explodedPosition); group.rotation.copy(layout.rotation); group.scale.setScalar(1); switch (layout.motion.kind) { case "spin": addAxisValue(group.rotation, layout.motion.axis, phase * layout.motion.speed); break; case "reciprocate": addAxisValue(group.position, layout.motion.axis, Math.sin(phase) * layout.motion.amplitude); break; case "oscillate": addAxisValue(group.rotation, layout.motion.axis, Math.sin(phase) * layout.motion.amplitude); break; case "orbit": group.position.x += Math.cos(phase * layout.motion.speed) * layout.motion.radius; group.position.y += Math.sin(phase * layout.motion.speed) * layout.motion.radius * 0.35; break; case "pulse": { const scale = 1 + Math.sin(phase) * layout.motion.amplitude; group.scale.setScalar(scale); break; } case "static": break; } }); const handlePointerOver = (event: ThreeEvent) => { event.stopPropagation(); useViewerStore.getState().setHoveredPart(layout.part.id); if (typeof document !== "undefined") { document.body.style.cursor = "pointer"; } }; const handlePointerOut = (event: ThreeEvent) => { event.stopPropagation(); useViewerStore.getState().setHoveredPart(null); if (typeof document !== "undefined") { document.body.style.cursor = ""; } }; const handleClick = (event: ThreeEvent) => { event.stopPropagation(); useViewerStore.getState().selectPart(layout.part.id); }; return ( {annotations ? ( {layout.part.name} ) : null} ); } function CameraPresetController(): null { const { camera } = useThree(); const preset = useViewerStore((state) => state.cameraPreset); const cameraTarget = useViewerStore((state) => state.cameraTarget); const reducedMotion = useIntegratedReducedMotion(); const animationRef = React.useRef<{ active: boolean; elapsed: number; from: THREE.Vector3; to: THREE.Vector3; target: THREE.Vector3; }>({ active: false, elapsed: 0, from: new THREE.Vector3(...cameraPresets.isometric.position), to: new THREE.Vector3(...cameraPresets.isometric.position), target: new THREE.Vector3(...cameraPresets.isometric.target), }); const lastPoseSyncRef = React.useRef(0); React.useEffect(() => { const pose = cameraPresets[preset] ?? cameraPresets.isometric; const to = new THREE.Vector3(...pose.position); const target = new THREE.Vector3(...pose.target); if (reducedMotion) { camera.position.copy(to); camera.lookAt(target); useViewerStore.getState().setCameraPose([to.x, to.y, to.z], [target.x, target.y, target.z]); animationRef.current.active = false; return; } animationRef.current = { active: true, elapsed: 0, from: camera.position.clone(), to, target, }; }, [camera, preset, reducedMotion]); useFrame((state, delta) => { const animation = animationRef.current; if (animation.active) { animation.elapsed += delta; const duration = 0.72; const progress = Math.min(1, animation.elapsed / duration); const eased = 1 - (1 - progress) ** 3; camera.position.lerpVectors(animation.from, animation.to, eased); camera.lookAt(animation.target); if (progress >= 1) { animation.active = false; } } if (state.clock.elapsedTime - lastPoseSyncRef.current > 0.45) { lastPoseSyncRef.current = state.clock.elapsedTime; useViewerStore.getState().setCameraPose( [ Number(camera.position.x.toFixed(3)), Number(camera.position.y.toFixed(3)), Number(camera.position.z.toFixed(3)), ], cameraTarget, ); } }); return null; } function SceneContents({ layouts }: { layouts: readonly PartLayout[] }): JSX.Element { const cameraTarget = useViewerStore((state) => state.cameraTarget); const reducedMotion = useIntegratedReducedMotion(); return ( <> {layouts.map((layout) => ( ))} ); } export function ProceduralMachineScene({ machine }: ProceduralMachineSceneProps): JSX.Element { const layouts = React.useMemo(() => buildPartLayouts(machine), [machine]); const dpr: [number, number] = typeof window !== "undefined" && window.innerWidth < 768 ? [1, 1.25] : [1, 1.75]; return ( useViewerStore.getState().selectPart(null)} onCreated={({ gl }) => { gl.setClearColor("#0B0E14", 1); gl.localClippingEnabled = true; gl.toneMapping = THREE.ACESFilmicToneMapping; gl.outputColorSpace = THREE.SRGBColorSpace; gl.shadowMap.enabled = true; gl.shadowMap.type = THREE.PCFSoftShadowMap; }} > ); } export default ProceduralMachineScene;