"use client"; import * as React from "react"; import { createControlHistoryGroupId, type ControlChangeMeta } from "@repo/ui"; import type { ToolcraftExternalStore } from "../../state/toolcraft-external-store"; import { useToolcraftTheme } from "../app-shell/theme-runtime"; import { beginToolcraftOrientationInteraction, type ToolcraftOrientationInteractionLease, } from "./orientation-interaction-coordinator"; import { easeToolcraftOrientationSnap, getToolcraftOrientationPoseFromPointerDelta, getToolcraftOrientationSnapDurationMs, interpolateToolcraftOrientationPose, projectToolcraftOrientationAxes, readToolcraftOrientationPose, snapToolcraftOrientationPose, type ToolcraftOrientationAxis, type ToolcraftOrientationAxisProjection, type ToolcraftOrientationPose, } from "./orientation-gizmo-math"; export const toolcraftOrientationGizmoCssSize = 70; export const toolcraftOrientationGizmoInset = 16; const pixelRatio = 2; const center = toolcraftOrientationGizmoCssSize / 2; const axisReach = 24.5; const dotRadius = 5.6; const hoverRadius = dotRadius * 1.3; const hitRadius = 7; const fallbackHitRadius = 8.4; const dragThresholdPixels = 3; const axisColors: Record<"x" | "y" | "z", string> = { x: "#ff215e", y: "#53ff55", z: "#3b69ff", }; export type ToolcraftOrientationGizmoProps = { defaultValue?: ToolcraftOrientationPose; onValueChange?: ( value: ToolcraftOrientationPose, meta?: ControlChangeMeta, ) => void; testId?: string; store: ToolcraftExternalStore; target: string; value: unknown; }; type GizmoGesture = { canvas: HTMLCanvasElement; clickAxis: ToolcraftOrientationAxis | null; dragged: boolean; historyGroup: string; interaction: ToolcraftOrientationInteractionLease; last: { x: number; y: number }; pointerId: number; start: { x: number; y: number }; }; function getAxisColor(axis: ToolcraftOrientationAxis): string { return axisColors[axis[1] as "x" | "y" | "z"]; } function isPositiveAxis(axis: ToolcraftOrientationAxis): boolean { return axis[0] === "+"; } function isUnmodifiedPrimaryPointer( event: Pick< React.PointerEvent, "altKey" | "button" | "ctrlKey" | "metaKey" | "shiftKey" >, ): boolean { return ( event.button === 0 && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey ); } function sortRearToFront( projections: readonly ToolcraftOrientationAxisProjection[], ): ToolcraftOrientationAxisProjection[] { return [...projections].sort((left, right) => right.depth - left.depth); } function findHoveredAxis( projections: readonly ToolcraftOrientationAxisProjection[], x: number, y: number, ): ToolcraftOrientationAxis | null { const ranked = projections .map((projection) => ({ axis: projection.axis, depth: projection.depth, distance: Math.hypot(x - projection.x, y - projection.y), })) .sort( (left, right) => left.distance - right.distance || left.depth - right.depth, ); const exact = ranked.find((item) => item.distance <= hitRadius); const nearest = exact ?? ranked.find((item) => item.distance <= fallbackHitRadius); return nearest?.axis ?? null; } function getLocalPointer( canvas: HTMLCanvasElement, clientX: number, clientY: number, ): { x: number; y: number } { const bounds = canvas.getBoundingClientRect(); return { x: ((clientX - bounds.left) / Math.max(1, bounds.width)) * toolcraftOrientationGizmoCssSize, y: ((clientY - bounds.top) / Math.max(1, bounds.height)) * toolcraftOrientationGizmoCssSize, }; } function drawGizmo( canvas: HTMLCanvasElement, pose: ToolcraftOrientationPose, hoveredAxis: ToolcraftOrientationAxis | null, ): void { const context = canvas.getContext("2d"); if (!context) { return; } context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); context.clearRect( 0, 0, toolcraftOrientationGizmoCssSize, toolcraftOrientationGizmoCssSize, ); context.lineCap = "round"; for (const projection of sortRearToFront( projectToolcraftOrientationAxes(pose, center, axisReach), )) { const color = getAxisColor(projection.axis); context.globalAlpha = projection.isFrontFacing ? 0.95 : 0.3; if (isPositiveAxis(projection.axis)) { context.strokeStyle = color; context.lineWidth = 2.1; context.beginPath(); context.moveTo(center, center); context.lineTo(projection.x, projection.y); context.stroke(); } context.fillStyle = color; context.beginPath(); context.arc( projection.x, projection.y, projection.axis === hoveredAxis ? hoverRadius : dotRadius, 0, Math.PI * 2, ); context.fill(); if (projection.axis === hoveredAxis) { context.globalAlpha = 0.7; context.strokeStyle = "#ffffff"; context.lineWidth = 1; context.stroke(); } } context.globalAlpha = 1; } export function ToolcraftOrientationGizmo({ defaultValue, onValueChange, store, target, testId = "toolcraft-orientation-gizmo", value, }: ToolcraftOrientationGizmoProps): React.JSX.Element { const { resolvedTheme } = useToolcraftTheme(); const canvasRef = React.useRef(null); const animationFrameRef = React.useRef(0); const pose = readToolcraftOrientationPose(value, defaultValue); const poseRef = React.useRef(pose); const gestureRef = React.useRef(null); const interactionRef = React.useRef(null); const [hoveredAxis, setHoveredAxis] = React.useState(null); poseRef.current = pose; React.useLayoutEffect(() => { if (canvasRef.current) { drawGizmo(canvasRef.current, pose, hoveredAxis); } }, [hoveredAxis, pose]); const finishLocalGesture = React.useCallback( (gesture: GizmoGesture): void => { if (gestureRef.current === gesture) { gestureRef.current = null; } setHoveredAxis(null); if (gesture.canvas.hasPointerCapture?.(gesture.pointerId)) { gesture.canvas.releasePointerCapture?.(gesture.pointerId); } }, [], ); const clearLocalInteraction = React.useCallback((): void => { window.cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = 0; const gesture = gestureRef.current; if (gesture) { finishLocalGesture(gesture); } else { setHoveredAxis(null); } }, [finishLocalGesture]); const releaseInteraction = React.useCallback( (interaction: ToolcraftOrientationInteractionLease): void => { if (interactionRef.current === interaction) { interactionRef.current = null; } interaction.release(); }, [], ); const commitPose = React.useCallback( ( nextPose: ToolcraftOrientationPose, interaction: ToolcraftOrientationInteractionLease, historyGroup: string, ): boolean => { if (interactionRef.current !== interaction) { return false; } return interaction.runOwnWrite(() => { poseRef.current = nextPose; onValueChange?.(nextPose, { history: "merge", historyGroup, }); }); }, [onValueChange], ); const animateSnap = React.useCallback( ( axis: ToolcraftOrientationAxis, interaction: ToolcraftOrientationInteractionLease, historyGroup: string, ) => { window.cancelAnimationFrame(animationFrameRef.current); const startPose = poseRef.current; const targetPose = snapToolcraftOrientationPose(startPose, axis); const durationMs = getToolcraftOrientationSnapDurationMs( startPose, targetPose, ); if (durationMs <= 1e-6) { releaseInteraction(interaction); return; } const startedAt = performance.now(); const animate = (now: number): void => { const progress = Math.min(1, (now - startedAt) / durationMs); const committed = commitPose( interpolateToolcraftOrientationPose( startPose, targetPose, easeToolcraftOrientationSnap(progress), ), interaction, historyGroup, ); if (!committed) { animationFrameRef.current = 0; return; } if (progress < 1) { animationFrameRef.current = window.requestAnimationFrame(animate); } else { animationFrameRef.current = 0; releaseInteraction(interaction); } }; animationFrameRef.current = window.requestAnimationFrame(animate); }, [commitPose, releaseInteraction], ); const beginInteraction = React.useCallback(() => { let interaction: ToolcraftOrientationInteractionLease; interaction = beginToolcraftOrientationInteraction({ onCancel: () => { if (interactionRef.current !== interaction) { return; } interactionRef.current = null; clearLocalInteraction(); }, store, target, }); interactionRef.current = interaction; return interaction; }, [clearLocalInteraction, store, target]); React.useEffect( () => () => { window.cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = 0; const gesture = gestureRef.current; gestureRef.current = null; const interaction = interactionRef.current; interactionRef.current = null; interaction?.release(); if (gesture?.canvas.hasPointerCapture?.(gesture.pointerId)) { gesture.canvas.releasePointerCapture?.(gesture.pointerId); } }, [], ); return ( <>