import React, {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Canvas, useThree } from '@react-three/fiber';
import { Html, OrbitControls } from '@react-three/drei';
import { gsap } from 'gsap';
import type { Object3D } from 'three';
import { AnimationTransport, GuidedTourOverlay } from '../../../components/animation';
import { useGuidedTourPlayer, useMachineAnimation } from '../../../animations';
import type { GuidedTourCamera, GuidedTourSinks } from '../../../animations/guidedTour';
import type { MachineCameraRequest } from '../../../animations/types';
import { getRelatedProceduralMachines } from './proceduralDemoManifest';
import { useProceduralDemoMachine } from './useProceduralDemoMachine';
import type {
PartVisualState,
ProceduralMachineId,
ProceduralMachineSceneProps,
} from './types';
function parseInitialMachineId(): string | null {
if (typeof window === 'undefined') return null;
return new URLSearchParams(window.location.search).get('machine');
}
function updateUrl(machineId: string, rpm: number, explode: number): void {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
url.searchParams.set('machine', machineId);
url.searchParams.set('rpm', Math.round(rpm).toString());
url.searchParams.set('explode', explode.toFixed(2));
window.history.replaceState({}, '', url.toString());
}
function ViewerCameraController({
request,
controlsRef,
}: {
request: MachineCameraRequest | GuidedTourCamera | null;
controlsRef: React.MutableRefObject<{ target?: { x: number; y: number; z: number }; update?: () => void } | null>;
}): null {
const { camera } = useThree();
useEffect(() => {
if (!request) return undefined;
const duration = request.duration ?? 1.1;
const easing = request.easing ?? 'power3.out';
const cameraTween = gsap.to(camera.position, {
x: request.position[0],
y: request.position[1],
z: request.position[2],
duration,
ease: easing,
onUpdate: () => {
camera.lookAt(request.target[0], request.target[1], request.target[2]);
controlsRef.current?.update?.();
},
});
let targetTween: gsap.core.Tween | undefined;
if (controlsRef.current?.target) {
targetTween = gsap.to(controlsRef.current.target, {
x: request.target[0],
y: request.target[1],
z: request.target[2],
duration,
ease: easing,
onUpdate: () => controlsRef.current?.update?.(),
});
}
if (request.fov && 'fov' in camera) {
gsap.to(camera, {
fov: request.fov,
duration,
ease: easing,
onUpdate: () => camera.updateProjectionMatrix(),
});
}
return () => {
cameraTween.kill();
targetTween?.kill();
};
}, [camera, controlsRef, request]);
return null;
}
function LoadingFallback(): JSX.Element {
return (
Loading procedural rig…
);
}
function MachineCanvas({
sceneProps,
Scene,
cameraRequest,
}: {
sceneProps: ProceduralMachineSceneProps;
Scene: React.ComponentType;
cameraRequest: MachineCameraRequest | GuidedTourCamera | null;
}): JSX.Element {
const controlsRef = useRef<{
target?: { x: number; y: number; z: number };
update?: () => void;
} | null>(null);
return (
);
}
export function ProceduralDemoExperience(): JSX.Element {
const {
machine,
machines,
selectedMachineId,
selectMachine,
selectNextMachine,
selectPreviousMachine,
} = useProceduralDemoMachine(parseInitialMachineId());
const animation = useMachineAnimation(machine.animationModule, { autoPlay: true });
const [selectedPartId, setSelectedPartId] = useState(null);
const [hoveredPartId, setHoveredPartId] = useState(null);
const [tourHighlightedPartIds, setTourHighlightedPartIds] = useState([]);
const [partState, setPartState] = useState>({});
const [explodedDistance, setExplodedDistance] = useState(0);
const [wireframe, setWireframe] = useState(false);
const [labelsVisible, setLabelsVisible] = useState(true);
const [cameraRequest, setCameraRequest] = useState(
machine.definition.cameraPresets[0],
);
useEffect(() => {
setSelectedPartId(null);
setHoveredPartId(null);
setTourHighlightedPartIds([]);
setPartState(
Object.fromEntries(
machine.definition.parts.map((part) => [
part.id,
{
visible: true,
opacity: part.defaultOpacity ?? 1,
},
]),
),
);
setCameraRequest(machine.definition.cameraPresets[0]);
animation.restart(true);
}, [machine.definition.id]);
useEffect(() => {
updateUrl(machine.definition.id, animation.snapshot.rpm, explodedDistance);
}, [animation.snapshot.rpm, explodedDistance, machine.definition.id]);
const tourSinks = useMemo(
() => ({
highlightParts: (partIds) => setTourHighlightedPartIds(partIds),
requestCamera: (camera, durationSeconds) => setCameraRequest({ ...camera, duration: durationSeconds }),
setAnimationPhase: (phaseRange, rpm) => {
if (rpm) animation.setRpm(rpm);
if (phaseRange) animation.seekCycleProgress(phaseRange[0]);
if (animation.snapshot.status !== 'playing') animation.play();
},
}),
[animation],
);
const tour = useGuidedTourPlayer(machine.definition.tour, tourSinks);
const highlightedPartIds = useMemo(
() => [
...new Set([
...animation.snapshot.highlightedPartIds,
...tourHighlightedPartIds,
...(selectedPartId ? [selectedPartId] : []),
...(hoveredPartId ? [hoveredPartId] : []),
]),
],
[
animation.snapshot.highlightedPartIds,
hoveredPartId,
selectedPartId,
tourHighlightedPartIds,
],
);
const selectedPart = selectedPartId
? machine.definition.parts.find((part) => part.id === selectedPartId)
: null;
const sceneProps = useMemo(
() => ({
registerPart: (partId: string, object: Object3D | null) => animation.registerPart(partId, object),
selectedPartId,
hoveredPartId,
highlightedPartIds,
partState,
explodedDistance,
wireframe,
labelsVisible,
onSelectPart: setSelectedPartId,
onHoverPart: setHoveredPartId,
}),
[
animation.registerPart,
explodedDistance,
highlightedPartIds,
hoveredPartId,
labelsVisible,
partState,
selectedPartId,
wireframe,
],
);
const relatedMachines = getRelatedProceduralMachines(machine.definition.id);
const copyShareLink = useCallback(async () => {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
url.searchParams.set('machine', machine.definition.id);
url.searchParams.set('rpm', Math.round(animation.snapshot.rpm).toString());
url.searchParams.set('explode', explodedDistance.toFixed(2));
try {
await navigator.clipboard.writeText(url.toString());
} catch {
window.prompt('Copy this Mechanica view link:', url.toString());
}
}, [animation.snapshot.rpm, explodedDistance, machine.definition.id]);
return (
Mechanica
Procedural machine animation lab
Current phase
{animation.snapshot.phaseLabel ?? machine.definition.summary}
{machine.definition.parts.length} components
{Math.round(animation.snapshot.rpm)} RPM
{machine.definition.typicalRpm}
);
}