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.
activateStep(tourStepIndex === null ? 0 : null)}
>
{tourStepIndex === null ? "Start" : "Stop"}
{currentStep ? (
Step {(tourStepIndex ?? 0) + 1} of {machine.tour.length}
{currentStep.title}
{currentStep.caption}
) : null}
{
const nextIndex =
tourStepIndex === null
? 0
: (tourStepIndex - 1 + machine.tour.length) % machine.tour.length;
activateStep(nextIndex);
}}
>
Previous
{
const nextIndex =
tourStepIndex === null ? 0 : (tourStepIndex + 1) % machine.tour.length;
activateStep(nextIndex);
}}
>
Next
);
}
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) => (
setCameraPreset(preset)}
>
{preset}
))}
resetView(machine.parts.map((part) => part.id))}
>
Reset
{/* 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). */}
Shortcuts
>
);
}
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 (
{machine.title}
{machine.category}
Parts {visibleCount}/{machine.parts.length}
FPS {fps || "—"}
RPM {Math.round(playback.rpm)}
{playback.isPlaying ? "Playing" : "Paused"}
{selectedPart ? `Selected: ${selectedPart.name}` : "No part selected"}
);
}
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);
}