import * as React from "react"; import { MotionPreferenceControl } from "../preferences/MotionPreferenceControl"; import { ShareLinkButton } from "../share/ShareLinkButton"; import { getRelatedMachines, type GuidedTourStep, type MachineDefinition, type MachinePart, } from "../../modules/machines/catalogue"; import { buildViewerShareState, cameraPresetOrder, isCameraPreset, type CameraPreset, useViewerStore, } from "../../store/productionViewerStore"; import type { CrossSectionAxis } from "../../utils/productionShareState"; export interface ViewerPanelProps { machine: MachineDefinition; } export interface ViewerToolbarProps extends ViewerPanelProps { onOpenShortcuts: () => void; } const crossSectionAxes: readonly CrossSectionAxis[] = ["none", "x", "y", "z"]; function buttonClass(extra = ""): string { return `control-button px-3 py-2 text-sm ${extra}`; } function formatPercent(value: number): string { return `${Math.round(value * 100)}%`; } export function ViewerSidebar({ machine }: ViewerPanelProps): JSX.Element { const selectedPartId = useViewerStore((state) => state.selectedPartId); const hiddenPartIds = useViewerStore((state) => state.hiddenPartIds); const partOpacities = useViewerStore((state) => state.partOpacities); const selectPart = useViewerStore((state) => state.selectPart); const togglePartVisibility = useViewerStore((state) => state.togglePartVisibility); const setPartOpacity = useViewerStore((state) => state.setPartOpacity); return ( ); } function TourControls({ machine }: ViewerPanelProps): JSX.Element { const tourStepIndex = useViewerStore((state) => state.tourStepIndex); const setTourStepIndex = useViewerStore((state) => state.setTourStepIndex); const selectPart = useViewerStore((state) => state.selectPart); const setCameraPreset = useViewerStore((state) => state.setCameraPreset); const setAnnotations = useViewerStore((state) => state.setAnnotations); const setPlaying = useViewerStore((state) => state.setPlaying); const activateStep = React.useCallback( (index: number | null) => { setTourStepIndex(index); if (index === null) { selectPart(null); return; } const step = machine.tour[index]; if (!step) { return; } setAnnotations(true); setPlaying(true); if (step.partId) { selectPart(step.partId); } if (isCameraPreset(step.cameraPreset)) { setCameraPreset(step.cameraPreset); } }, [machine.tour, selectPart, setAnnotations, setCameraPreset, setPlaying, setTourStepIndex], ); const currentStep = tourStepIndex === null ? null : machine.tour[tourStepIndex]; return (

Guided tour

Camera presets and component highlights explain the system in sequence.

{currentStep ? (

Step {(tourStepIndex ?? 0) + 1} of {machine.tour.length}

{currentStep.title}

{currentStep.caption}

) : null}
); } export function ViewerControlPanel({ machine }: ViewerPanelProps): JSX.Element { const playback = useViewerStore((state) => state.playback); const explodeDistance = useViewerStore((state) => state.explodeDistance); const wireframe = useViewerStore((state) => state.wireframe); const annotations = useViewerStore((state) => state.annotations); const crossSectionAxis = useViewerStore((state) => state.crossSectionAxis); const crossSectionOffset = useViewerStore((state) => state.crossSectionOffset); const setPlaying = useViewerStore((state) => state.setPlaying); const setRpm = useViewerStore((state) => state.setRpm); const setTimeScale = useViewerStore((state) => state.setTimeScale); const restartCycle = useViewerStore((state) => state.restartCycle); const stepCycle = useViewerStore((state) => state.stepCycle); const setExplodeDistance = useViewerStore((state) => state.setExplodeDistance); const setWireframe = useViewerStore((state) => state.setWireframe); const setAnnotations = useViewerStore((state) => state.setAnnotations); const setCrossSectionAxis = useViewerStore((state) => state.setCrossSectionAxis); const setCrossSectionOffset = useViewerStore((state) => state.setCrossSectionOffset); const relatedMachines = getRelatedMachines(machine); return ( ); } export function ViewerToolbar({ machine, onOpenShortcuts }: ViewerToolbarProps): JSX.Element { const activePreset = useViewerStore((state) => state.cameraPreset); const setCameraPreset = useViewerStore((state) => state.setCameraPreset); const resetView = useViewerStore((state) => state.resetView); const getShareState = React.useCallback( () => buildViewerShareState(useViewerStore.getState()), [], ); return ( <>
{cameraPresetOrder.map((preset, index) => ( ))}
{/* Copy link + Shortcuts pinned to the bottom-right so they no longer overlay the model beneath the top camera controls (was especially intrusive on mobile). */}
); } function partSpecs(part: MachinePart): JSX.Element | null { const entries = Object.entries(part.specs ?? {}); if (entries.length === 0) { return null; } return (
{entries.map(([label, value]) => (
{label}
{value}
))}
); } export function PartDetailDrawer({ machine }: ViewerPanelProps): JSX.Element { const selectedPartId = useViewerStore((state) => state.selectedPartId); const selectPart = useViewerStore((state) => state.selectPart); const part = machine.parts.find((candidate) => candidate.id === selectedPartId); return ( ); } function useFps(): number { const [fps, setFps] = React.useState(0); React.useEffect(() => { let frameCount = 0; let lastTime = performance.now(); let raf = 0; const tick = (time: number) => { frameCount += 1; if (time - lastTime >= 500) { setFps(Math.round((frameCount * 1000) / (time - lastTime))); frameCount = 0; lastTime = time; } raf = window.requestAnimationFrame(tick); }; raf = window.requestAnimationFrame(tick); return () => window.cancelAnimationFrame(raf); }, []); return fps; } export function ViewerStatusBar({ machine }: ViewerPanelProps): JSX.Element { const fps = useFps(); const hiddenPartIds = useViewerStore((state) => state.hiddenPartIds); const selectedPartId = useViewerStore((state) => state.selectedPartId); const playback = useViewerStore((state) => state.playback); const visibleCount = machine.parts.length - hiddenPartIds.length; const selectedPart = machine.parts.find((part) => part.id === selectedPartId); return ( ); } export function applyTourStep(machine: MachineDefinition, step: GuidedTourStep): void { const store = useViewerStore.getState(); if (step.partId) { store.selectPart(step.partId); } if (isCameraPreset(step.cameraPreset)) { store.setCameraPreset(step.cameraPreset as CameraPreset); } store.setAnnotations(true); }