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 (
);
}
export default ProceduralMachineScene;