import * as React from "react"; import { WebGLSupportBoundary } from "../components/fallbacks/WebGLSupportBoundary"; import { KeyboardShortcutsHelp } from "../components/keyboard/KeyboardShortcutsHelp"; import { ProceduralMachineScene } from "../components/viewer/ProceduralMachineScene"; import { PartDetailDrawer, ViewerControlPanel, ViewerSidebar, ViewerStatusBar, ViewerToolbar, } from "../components/viewer/ViewerPanels"; import { useKeyboardShortcuts, type KeyboardShortcut } from "../hooks/useKeyboardShortcuts"; import { defaultMachineId, getMachineById, type MachineDefinition, } from "../modules/machines/catalogue"; import { IntegratedErrorBoundary, IntegratedLoadingFallback, useIntegratedPageMeta, useIntegratedReducedMotion, } from "../production/integrationAdapters"; import { buildViewerShareState, cameraPresetOrder, useViewerStore, } from "../store/productionViewerStore"; import { buildMachineShareUrl, decodeProductionShareState, replaceUrlWithShareState, } from "../utils/productionShareState"; export interface MachineViewerPageProps { machineId?: string; search?: string; onBackToCatalogue?: () => void; } function getResolvedMachine(machineId: string | undefined): MachineDefinition { return getMachineById(machineId) ?? getMachineById(defaultMachineId)!; } async function copyText(text: string): Promise { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } const textarea = document.createElement("textarea"); textarea.value = text; textarea.setAttribute("readonly", "true"); textarea.style.position = "fixed"; textarea.style.left = "-9999px"; document.body.appendChild(textarea); textarea.select(); document.execCommand("copy"); document.body.removeChild(textarea); } function ViewerHeader({ machine, onBackToCatalogue, }: { machine: MachineDefinition; onBackToCatalogue?: () => void; }): JSX.Element { return (

{machine.category}

{machine.title}

{machine.description}

{onBackToCatalogue ? ( ) : null}
); } function DesktopMachineSummary({ machine }: { machine: MachineDefinition }): JSX.Element { return (

{machine.category} · {machine.difficulty}

{machine.title}

{machine.description}

{machine.facts.slice(0, 4).map((fact) => (
{fact.label}
{fact.value}
))}
); } function useViewerUrlSync(machine: MachineDefinition, search: string | undefined): void { const partKey = React.useMemo(() => machine.parts.map((part) => part.id).join("|"), [machine.parts]); React.useEffect(() => { const sharedState = decodeProductionShareState(search); useViewerStore .getState() .initializeMachine(machine.id, machine.parts.map((part) => part.id), { ...sharedState, machineId: machine.id, }); }, [machine.id, machine.parts, partKey, search]); React.useEffect(() => { if (typeof window === "undefined") { return undefined; } let timeoutId: number | undefined; const unsubscribe = useViewerStore.subscribe((state) => { if (!state.initialized || state.machineId !== machine.id) { return; } if (timeoutId) { window.clearTimeout(timeoutId); } timeoutId = window.setTimeout(() => { replaceUrlWithShareState(buildViewerShareState(useViewerStore.getState())); }, 220); }); return () => { if (timeoutId) { window.clearTimeout(timeoutId); } unsubscribe(); }; }, [machine.id]); } export default function MachineViewerPage({ machineId, search, onBackToCatalogue, }: MachineViewerPageProps): JSX.Element { const machine = React.useMemo(() => getResolvedMachine(machineId), [machineId]); const [shortcutsOpen, setShortcutsOpen] = React.useState(false); const [copyNotice, setCopyNotice] = React.useState(null); const reducedMotion = useIntegratedReducedMotion(); useIntegratedPageMeta({ title: `${machine.title} | Mechanica 3D Viewer`, description: machine.description, image: "/social/mechanica-og.svg", type: "article", }); useViewerUrlSync(machine, search); React.useEffect(() => { if (reducedMotion) { useViewerStore.getState().setPlaying(false); } }, [machine.id, reducedMotion]); const copyCurrentLink = React.useCallback(async () => { const url = buildMachineShareUrl(buildViewerShareState(useViewerStore.getState())); await copyText(url); setCopyNotice("Share link copied"); window.setTimeout(() => setCopyNotice(null), 1600); }, []); const shortcuts = React.useMemo(() => { const presetShortcuts = cameraPresetOrder.map((preset, index): KeyboardShortcut => ({ key: String(index + 1), label: String(index + 1), group: "Camera", description: `Switch to ${preset} camera`, handler: () => useViewerStore.getState().setCameraPreset(preset), })); return [ { key: " ", label: "Space", group: "Playback", description: "Play / pause animation", handler: () => useViewerStore.getState().togglePlayback(), }, { key: "r", label: "R", group: "Viewer", description: "Reset camera, display, and playback", handler: () => useViewerStore.getState().resetView(machine.parts.map((part) => part.id)), }, { key: "e", label: "E", group: "Display", description: "Toggle exploded view", handler: () => useViewerStore.getState().toggleExploded(), }, { key: "w", label: "W", group: "Display", description: "Toggle wireframe", handler: () => useViewerStore.getState().toggleWireframe(), }, { key: "l", label: "L", group: "Display", description: "Toggle annotation labels", handler: () => useViewerStore.getState().toggleAnnotations(), }, { key: "c", label: "C", group: "Sharing", description: "Copy current view link", handler: () => { void copyCurrentLink(); }, }, { key: "?", label: "?", group: "Help", description: "Open keyboard shortcut reference", handler: () => setShortcutsOpen(true), }, { key: "Escape", label: "Esc", group: "Selection", description: "Clear selected component", handler: () => useViewerStore.getState().selectPart(null), }, ...presetShortcuts, ]; }, [copyCurrentLink, machine.parts]); useKeyboardShortcuts(shortcuts); return (
{/* Absolute fill so the R3F canvas gets a definite-height containing block in BOTH layouts. On desktop the section is a grid cell (definite height); on mobile it is a flex item with only min-height (indefinite), where the canvas height:100% would otherwise collapse and the model would render top-aligned instead of centred. */}

Scene error

The 3D scene failed.

Try resetting the view or opening another machine from the catalogue.

} > } >
setShortcutsOpen(true)} /> {copyNotice ? (
{copyNotice}
) : null}
); }