diff --git a/apps/ui/app/components/cad-preview.tsx b/apps/ui/app/components/cad-preview.tsx
index f35c7d1fe..9e2423bf3 100644
--- a/apps/ui/app/components/cad-preview.tsx
+++ b/apps/ui/app/components/cad-preview.tsx
@@ -5,7 +5,7 @@ import { Loader } from '#components/ui/loader.js';
import { GraphicsProvider } from '#hooks/use-graphics.js';
import { useCadPreview } from '#hooks/use-cad-preview.js';
import { cn } from '#utils/ui.utils.js';
-import type { StageOptions } from '#components/geometry/graphics/three/stage.js';
+import type { StageOptions } from '@taucad/three/react';
/**
* Visual rendering settings for the CAD preview viewer.
diff --git a/apps/ui/app/components/geometry/cad/actor-bridge.tsx b/apps/ui/app/components/geometry/cad/actor-bridge.tsx
new file mode 100644
index 000000000..8e76b1f4e
--- /dev/null
+++ b/apps/ui/app/components/geometry/cad/actor-bridge.tsx
@@ -0,0 +1,74 @@
+import { useEffect } from 'react';
+import type { ReactNode } from 'react';
+import { useThree } from '@react-three/fiber';
+import { useActorRef } from '@xstate/react';
+import type { OrbitControls } from 'three/addons';
+import { updateCameraFov } from '@taucad/three';
+import { useViewerStore } from '@taucad/three/react';
+import { controlsListenerMachine } from '#machines/controls-listener.machine.js';
+import { useGraphics, useGraphicsSelector, useScreenshotCapability } from '#hooks/use-graphics.js';
+
+/**
+ * Bridges Three.js context with XState actors and syncs graphics machine
+ * state to zustand viewer stores. Sets up screenshot capability, controls
+ * listeners, and FOV updates.
+ */
+export function ActorBridge(): ReactNode {
+ const { gl, scene, camera, controls, invalidate } = useThree();
+ const screenshotCapabilityActor = useScreenshotCapability();
+ const graphicsActor = useGraphics();
+
+ const cameraFovAngle = useGraphicsSelector((state) => state.context.cameraFovAngle);
+ const enableGrid = useGraphicsSelector((state) => state.context.enableGrid);
+ const enableAxes = useGraphicsSelector((state) => state.context.enableAxes);
+ const enableMatcap = useGraphicsSelector((state) => state.context.enableMatcap);
+ const enableSurfaces = useGraphicsSelector((state) => state.context.enableSurfaces);
+ const enableLines = useGraphicsSelector((state) => state.context.enableLines);
+ const enableGizmo = useGraphicsSelector((state) => state.context.enableGizmo);
+ const enablePostProcessing = useGraphicsSelector((state) => state.context.enablePostProcessing);
+
+ const setFieldOfView = useViewerStore((s) => s.setFieldOfView);
+ const setEnableGrid = useViewerStore((s) => s.setEnableGrid);
+ const setEnableAxes = useViewerStore((s) => s.setEnableAxes);
+ const setEnableMatcap = useViewerStore((s) => s.setEnableMatcap);
+ const setEnableSurfaces = useViewerStore((s) => s.setEnableSurfaces);
+ const setEnableLines = useViewerStore((s) => s.setEnableLines);
+ const setEnableGizmo = useViewerStore((s) => s.setEnableGizmo);
+ const setEnablePostProcessing = useViewerStore((s) => s.setEnablePostProcessing);
+
+ useEffect(() => {
+ screenshotCapabilityActor.send({
+ type: 'registerCapture',
+ gl,
+ scene,
+ camera,
+ });
+
+ return () => {
+ screenshotCapabilityActor.send({ type: 'unregisterCapture', captureMode: 'threejs' });
+ };
+ }, [gl, scene, camera, screenshotCapabilityActor]);
+
+ useEffect(() => {
+ updateCameraFov({ camera, cameraFovAngle, invalidate });
+ }, [cameraFovAngle, camera, invalidate]);
+
+ // Sync xstate graphics machine state → zustand viewer store
+ useEffect(() => { setFieldOfView(cameraFovAngle); }, [cameraFovAngle, setFieldOfView]);
+ useEffect(() => { setEnableGrid(enableGrid); }, [enableGrid, setEnableGrid]);
+ useEffect(() => { setEnableAxes(enableAxes); }, [enableAxes, setEnableAxes]);
+ useEffect(() => { setEnableMatcap(enableMatcap); }, [enableMatcap, setEnableMatcap]);
+ useEffect(() => { setEnableSurfaces(enableSurfaces); }, [enableSurfaces, setEnableSurfaces]);
+ useEffect(() => { setEnableLines(enableLines); }, [enableLines, setEnableLines]);
+ useEffect(() => { setEnableGizmo(enableGizmo); }, [enableGizmo, setEnableGizmo]);
+ useEffect(() => { setEnablePostProcessing(enablePostProcessing); }, [enablePostProcessing, setEnablePostProcessing]);
+
+ useActorRef(controlsListenerMachine, {
+ input: {
+ graphicsActorRef: graphicsActor,
+ controls: controls as OrbitControls,
+ },
+ });
+
+ return null;
+}
diff --git a/apps/ui/app/components/geometry/cad/cad-viewer.tsx b/apps/ui/app/components/geometry/cad/cad-viewer.tsx
index 28306dc29..894531b42 100644
--- a/apps/ui/app/components/geometry/cad/cad-viewer.tsx
+++ b/apps/ui/app/components/geometry/cad/cad-viewer.tsx
@@ -1,17 +1,32 @@
-import { memo } from 'react';
+import { memo, useCallback, useEffect, useState } from 'react';
import type { Geometry } from '@taucad/types';
-import { GltfMesh } from '#components/geometry/graphics/three/react/gltf-mesh.js';
-import { ThreeProvider } from '#components/geometry/graphics/three/three-context.js';
-import type { ThreeViewerProperties } from '#components/geometry/graphics/three/three-context.js';
+import { GltfMesh, CadCanvas } from '@taucad/three/react';
+import type { StageOptions } from '@taucad/three/react';
import { SvgViewer } from '#components/geometry/graphics/svg/svg-viewer.js';
import { WebglErrorBoundary } from '#components/geometry/cad/webgl-error-boundary.js';
-import { WebglErrorFallback } from '#components/geometry/cad/webgl-fallback.js';
+import { WebglErrorFallback, WebglLimitFallback } from '#components/geometry/cad/webgl-fallback.js';
+import { WebglContextLostFallback } from '#components/geometry/cad/webgl-context-lost-fallback.js';
+import { ActorBridge } from '#components/geometry/cad/actor-bridge.js';
+import { cn } from '#utils/ui.utils.js';
+import { useWebglContextRef } from '#hooks/use-webgl-context-tracker.js';
-type CadViewerProperties = ThreeViewerProperties & {
+type CadViewerProperties = {
readonly geometries: Geometry[];
readonly enableSurfaces?: boolean;
readonly enableLines?: boolean;
readonly enableMatcap?: boolean;
+ readonly enableGizmo?: boolean;
+ readonly enableGrid?: boolean;
+ readonly enableAxes?: boolean;
+ readonly enableZoom?: boolean;
+ readonly enablePan?: boolean;
+ readonly enableDamping?: boolean;
+ readonly upDirection?: 'x' | 'y' | 'z';
+ readonly className?: string;
+ readonly enableCentering?: boolean;
+ readonly stageOptions?: StageOptions;
+ readonly zoomSpeed?: number;
+ readonly gizmoContainer?: HTMLElement | string;
};
export const CadViewer = memo(
@@ -20,11 +35,11 @@ export const CadViewer = memo(
enableSurfaces = true,
enableLines = true,
enableMatcap = false,
+ className,
...properties
}: CadViewerProperties): React.JSX.Element => {
const svgGeometries = geometries.filter((geometry) => geometry.format === 'svg');
- // If there are any SVG geometries, we render them in a SVG viewer
if (svgGeometries.length > 0) {
return (
@@ -33,7 +48,7 @@ export const CadViewer = memo(
return (
}>
-
+
{geometries.map((geometry) => {
switch (geometry.format) {
case 'gltf': {
@@ -62,8 +77,83 @@ export const CadViewer = memo(
}
}
})}
-
+
);
},
);
+
+type CadViewerCanvasProperties = {
+ readonly children: React.ReactNode;
+ readonly enableGizmo?: boolean;
+ readonly enableGrid?: boolean;
+ readonly enableAxes?: boolean;
+ readonly enableZoom?: boolean;
+ readonly enablePan?: boolean;
+ readonly enableDamping?: boolean;
+ readonly upDirection?: 'x' | 'y' | 'z';
+ readonly className?: string;
+ readonly enableCentering?: boolean;
+ readonly stageOptions?: StageOptions;
+ readonly zoomSpeed?: number;
+ readonly gizmoContainer?: HTMLElement | string;
+};
+
+function CadViewerCanvas({
+ children,
+ className,
+ ...properties
+}: CadViewerCanvasProperties): React.JSX.Element {
+ const [isContextLost, setIsContextLost] = useState(false);
+
+ const webglRef = useWebglContextRef();
+
+ // eslint-disable-next-line react/hook-use-state -- one-time snapshot, setter intentionally unused
+ const [isOverLimit] = useState(() => {
+ if (!webglRef) {
+ return false;
+ }
+
+ const snap = webglRef.getSnapshot();
+ return snap.context.count >= snap.context.limit;
+ });
+
+ useEffect(() => {
+ if (!webglRef || isOverLimit) {
+ return;
+ }
+
+ webglRef.send({ type: 'acquire' });
+ return () => {
+ webglRef.send({ type: 'release' });
+ };
+ }, [webglRef, isOverLimit]);
+
+ const [canvasKey, setCanvasKey] = useState(0);
+ const handleRetry = useCallback(() => {
+ setIsContextLost(false);
+ setCanvasKey((previous) => previous + 1);
+ }, []);
+
+ if (isOverLimit) {
+ return ;
+ }
+
+ if (isContextLost) {
+ return ;
+ }
+
+ return (
+ {
+ setIsContextLost(true);
+ }}
+ {...properties}
+ >
+ {children}
+
+
+ );
+}
diff --git a/apps/ui/app/components/geometry/graphics/three/webgl-context-lost-fallback.tsx b/apps/ui/app/components/geometry/cad/webgl-context-lost-fallback.tsx
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/webgl-context-lost-fallback.tsx
rename to apps/ui/app/components/geometry/cad/webgl-context-lost-fallback.tsx
diff --git a/apps/ui/app/components/geometry/graphics/three/actor-bridge.tsx b/apps/ui/app/components/geometry/graphics/three/actor-bridge.tsx
deleted file mode 100644
index 234a7f4a4..000000000
--- a/apps/ui/app/components/geometry/graphics/three/actor-bridge.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { useEffect } from 'react';
-import type { ReactNode } from 'react';
-import { useThree } from '@react-three/fiber';
-import { useActorRef } from '@xstate/react';
-import type { OrbitControls } from 'three/addons';
-import { controlsListenerMachine } from '#machines/controls-listener.machine.js';
-import { updateCameraFov } from '#components/geometry/graphics/three/utils/camera.utils.js';
-import { useGraphics, useGraphicsSelector, useScreenshotCapability } from '#hooks/use-graphics.js';
-
-/**
- * Component that bridges Three.js context with XState actors
- * Sets up screenshot capability, controls listeners, and FOV updates
- * Acts as the integration layer between Three.js and the graphics state machine
- */
-export function ActorBridge(): ReactNode {
- const { gl, scene, camera, controls, invalidate } = useThree();
- const screenshotCapabilityActor = useScreenshotCapability();
- const graphicsActor = useGraphics();
-
- // Subscribe to camera FOV angle from graphics actor
- const cameraFovAngle = useGraphicsSelector((state) => state.context.cameraFovAngle);
-
- // Setup screenshot capability
- useEffect(() => {
- screenshotCapabilityActor.send({
- type: 'registerCapture',
- gl,
- scene,
- camera,
- });
-
- return () => {
- screenshotCapabilityActor.send({ type: 'unregisterCapture', captureMode: 'threejs' });
- };
- }, [gl, scene, camera, screenshotCapabilityActor]);
-
- // Update camera FOV when angle changes, without resetting position
- // This preserves user's zoom and viewing angle while updating the FOV
- useEffect(() => {
- updateCameraFov({ camera, cameraFovAngle, invalidate });
- }, [cameraFovAngle, camera, invalidate]);
-
- // Setup controls listener
- useActorRef(controlsListenerMachine, {
- input: {
- graphicsActorRef: graphicsActor,
- controls: controls as OrbitControls,
- },
- });
-
- return null;
-}
diff --git a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-axes.tsx b/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-axes.tsx
deleted file mode 100644
index be71853b6..000000000
--- a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-axes.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-/* eslint-disable @typescript-eslint/no-unnecessary-condition -- TODO: review these types, some are actually required */
-import { useThree, useFrame } from '@react-three/fiber';
-import type { GizmoOptions } from 'three-viewport-gizmo';
-import { ViewportGizmo } from 'three-viewport-gizmo';
-import { useEffect, useCallback, useRef } from 'react';
-import * as THREE from 'three';
-import type { OrbitControls } from 'three/addons';
-import type { ReactNode } from 'react';
-import { useColor } from '#hooks/use-color.js';
-import { useTheme } from '#hooks/use-theme.js';
-import {
- resolveGizmoContainer,
- createGizmoCanvas,
- createGizmoRenderer,
- disposeGizmoResources,
-} from '#components/geometry/graphics/three/utils/gizmo.utils.js';
-
-type ViewportGizmoAxesProps = {
- readonly size?: number;
- /**
- * A container element or selector to append the gizmo to.
- *
- * When provided, the gizmo will be appended to this container instead of the renderer's parent.
- */
- readonly container?: HTMLElement | string;
- /**
- * Optional dependencies array that will be appended to the effect dependencies.
- * When any of these values change, the gizmo will be disposed and recreated.
- */
- readonly dependencies?: readonly unknown[];
-};
-
-const className = 'viewport-gizmo-axes';
-const emptyDependencies: readonly unknown[] = [];
-
-export function ViewportGizmoAxes({
- size = 96,
- container,
- dependencies = emptyDependencies,
-}: ViewportGizmoAxesProps): ReactNode {
- const camera = useThree((state) => state.camera) as THREE.PerspectiveCamera;
- const gl = useThree((state) => state.gl);
- const controls = useThree((state) => state.controls) as OrbitControls;
- const scene = useThree((state) => state.scene);
- const invalidate = useThree((state) => state.invalidate);
-
- const { serialized } = useColor();
- const { theme } = useTheme();
-
- // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React ref
- const gizmoRef = useRef(null);
- // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React ref
- const rendererRef = useRef(null);
-
- const handleChange = useCallback((): void => {
- invalidate();
- }, [invalidate]);
-
- // Demand-based gizmo rendering: only render when the R3F frame loop fires (on invalidation)
- useFrame(() => {
- if (rendererRef.current && gizmoRef.current) {
- rendererRef.current.toneMapping = THREE.NoToneMapping;
- gizmoRef.current.render();
- }
- });
-
- // Create DOM overlay for gizmo
- useEffect(() => {
- // Early return if we don't have the required components
- if (!camera || !gl || !controls) {
- return;
- }
-
- const canvas = createGizmoCanvas(className);
-
- const containerToUse = resolveGizmoContainer(container, gl.domElement);
- if (!containerToUse) {
- return;
- }
-
- containerToUse.append(canvas);
-
- const renderer = createGizmoRenderer(canvas, size);
-
- // Configure the gizmo options
- const gizmoConfig: GizmoOptions = {
- type: 'sphere',
- placement: 'bottom-right',
- size,
- resolution: 256,
- className,
- container: containerToUse,
- font: {
- weight: 'normal',
- family: 'monospace',
- },
- offset: {
- bottom: 0,
- right: 0,
- },
- };
-
- // Create the gizmo
- const gizmo = new ViewportGizmo(camera, renderer, gizmoConfig);
- gizmoRef.current = gizmo;
- rendererRef.current = renderer;
-
- // Add event listeners for the gizmo
- gizmo.addEventListener('change', handleChange);
-
- gizmo.scale.multiplyScalar(0.7);
-
- // Attach the controls to enable proper interaction
- gizmo.attachControls(controls);
-
- // Cleanup function
- return () => {
- // Clear refs so the useFrame callback cannot operate on disposed objects
- gizmoRef.current = null;
- rendererRef.current = null;
-
- disposeGizmoResources({ gizmo, renderer, canvas, handleChange });
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps -- dependencies array is user-provided for custom recreation triggers
- }, [camera, gl, controls, scene, serialized.hex, theme, size, handleChange, container, ...dependencies]);
-
- return null;
-}
diff --git a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-onshape.tsx b/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-onshape.tsx
deleted file mode 100644
index 011829d05..000000000
--- a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-onshape.tsx
+++ /dev/null
@@ -1,198 +0,0 @@
-/* eslint-disable @typescript-eslint/no-unnecessary-condition -- TODO: review these types, some are actually required */
-import { useThree, useFrame } from '@react-three/fiber';
-import type { GizmoAxisOptions, GizmoOptions } from 'three-viewport-gizmo';
-import { ViewportGizmo } from 'three-viewport-gizmo';
-import { useEffect, useCallback, useRef } from 'react';
-import * as THREE from 'three';
-import type { OrbitControls } from 'three/addons';
-import type { ReactNode } from 'react';
-import { useColor } from '#hooks/use-color.js';
-import { Theme, useTheme } from '#hooks/use-theme.js';
-import { createViewportGizmoCubeAxes } from '#components/geometry/graphics/three/controls/viewport-gizmo-cube-axes.js';
-import { useGraphicsSelector } from '#hooks/use-graphics.js';
-import {
- syncGizmoFov,
- resolveGizmoContainer,
- createGizmoCanvas,
- createGizmoRenderer,
- disposeGizmoResources,
-} from '#components/geometry/graphics/three/utils/gizmo.utils.js';
-
-type ViewportGizmoOnshapeProps = {
- readonly size?: number;
- /**
- * A container element or selector to append the gizmo to.
- *
- * When provided, the gizmo will be appended to this container instead of the renderer's parent.
- */
- readonly container?: HTMLElement | string;
- /**
- * Optional dependencies array that will be appended to the effect dependencies.
- * When any of these values change, the gizmo will be disposed and recreated.
- */
- readonly dependencies?: readonly unknown[];
-};
-
-const className = 'viewport-gizmo-onshape';
-const emptyDependencies: readonly unknown[] = [];
-
-export function ViewportGizmoOnshape({
- size = 96,
- container,
- dependencies = emptyDependencies,
-}: ViewportGizmoOnshapeProps): ReactNode {
- const camera = useThree((state) => state.camera);
- const gl = useThree((state) => state.gl);
- const controls = useThree((state) => state.controls) as OrbitControls;
- const scene = useThree((state) => state.scene);
- const invalidate = useThree((state) => state.invalidate);
-
- const { serialized } = useColor();
- const { theme } = useTheme();
-
- // Subscribe to the viewport FOV from the per-view graphics machine
- const cameraFovAngle = useGraphicsSelector((state) => state.context.cameraFovAngle);
-
- // Keep a ref to the current angle so the creation effect can read it without
- // adding cameraFovAngle as a dependency (which would cause expensive recreation)
- const cameraFovAngleRef = useRef(cameraFovAngle);
- cameraFovAngleRef.current = cameraFovAngle;
-
- // Ref to the live gizmo instance for the FOV sync effect
- const gizmoRef = useRef(null);
- // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React ref
- const rendererRef = useRef(null);
-
- const handleChange = useCallback((): void => {
- invalidate();
- }, [invalidate]);
-
- // Demand-based gizmo rendering: only render when the R3F frame loop fires (on invalidation)
- useFrame(() => {
- if (rendererRef.current && gizmoRef.current) {
- rendererRef.current.toneMapping = THREE.NoToneMapping;
- gizmoRef.current.render();
- }
- });
-
- // Create DOM overlay for gizmo
- useEffect(() => {
- // Early return if we don't have the required components
- if (!camera || !gl || !controls) {
- return;
- }
-
- const canvas = createGizmoCanvas(className);
-
- const containerToUse = resolveGizmoContainer(container, gl.domElement);
- if (!containerToUse) {
- return;
- }
-
- containerToUse.append(canvas);
-
- const renderer = createGizmoRenderer(canvas, size);
-
- const backgroundColor = theme === Theme.DARK ? 0x44_44_44 : 0xcc_cc_cc;
- const faceConfig = {
- color: theme === Theme.DARK ? 0x33_33_33 : 0xdd_dd_dd,
- labelColor: theme === Theme.DARK ? 0xff_ff_ff : 0x00_00_00,
- hover: {
- color: serialized.hex,
- },
- } as const satisfies GizmoAxisOptions;
- const edgeConfig = {
- color: theme === Theme.DARK ? 0x55_55_55 : 0xee_ee_ee,
- opacity: 1,
- hover: {
- color: serialized.hex,
- },
- } as const satisfies GizmoAxisOptions;
- const cornerConfig = {
- ...faceConfig,
- color: theme === Theme.DARK ? 0x33_33_33 : 0xdd_dd_dd,
- hover: {
- color: serialized.hex,
- },
- } as const satisfies GizmoAxisOptions;
-
- // Configure the gizmo options
- const gizmoConfig: GizmoOptions = {
- type: 'cube',
- placement: 'bottom-right',
- size,
- font: {
- weight: 'normal',
- family: 'monospace',
- },
- resolution: 256,
- className,
- container: containerToUse,
- background: {
- color: backgroundColor,
- hover: { color: backgroundColor },
- },
- offset: {
- bottom: 0,
- right: 0,
- },
- corners: cornerConfig,
- edges: edgeConfig,
- right: faceConfig,
- top: faceConfig,
- front: faceConfig,
- back: faceConfig,
- left: faceConfig,
- bottom: faceConfig,
- };
-
- // Create the gizmo
- const gizmo = new ViewportGizmo(camera, renderer, gizmoConfig);
- gizmoRef.current = gizmo;
- rendererRef.current = renderer;
-
- // Synchronize the gizmo's internal camera FOV with the current viewport FOV
- syncGizmoFov(gizmo, cameraFovAngleRef.current);
-
- // Add event listeners for the gizmo
- gizmo.addEventListener('change', handleChange);
-
- gizmo.scale.multiplyScalar(0.7);
- gizmo.add(
- createViewportGizmoCubeAxes({
- axesSize: 2.1,
- rendererSize: size,
- xAxisColor: 'red',
- yAxisColor: 'green',
- zAxisColor: 'rgb(37, 78, 136)',
- xLabelColor: 'red',
- yLabelColor: 'green',
- zLabelColor: 'rgb(37, 78, 136)',
- lineWidth: 2,
- }),
- );
-
- // Attach the controls to enable proper interaction
- gizmo.attachControls(controls);
-
- // Cleanup function
- return () => {
- // Clear refs so the useFrame and FOV sync effect cannot operate on disposed objects
- gizmoRef.current = null;
- rendererRef.current = null;
-
- disposeGizmoResources({ gizmo, renderer, canvas, handleChange });
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps -- dependencies array is user-provided for custom recreation triggers
- }, [camera, gl, controls, scene, serialized.hex, theme, size, handleChange, container, ...dependencies]);
-
- // Real-time FOV sync: update the gizmo's internal camera when the viewport FOV changes.
- // This is a separate effect to avoid expensive gizmo recreation on every slider tick.
- useEffect(() => {
- if (gizmoRef.current) {
- syncGizmoFov(gizmoRef.current, cameraFovAngle);
- }
- }, [cameraFovAngle]);
-
- return null;
-}
diff --git a/apps/ui/app/components/geometry/graphics/three/grid.tsx b/apps/ui/app/components/geometry/graphics/three/grid.tsx
deleted file mode 100644
index e702aa7e4..000000000
--- a/apps/ui/app/components/geometry/graphics/three/grid.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import React from 'react';
-import * as THREE from 'three';
-import { InfiniteGrid } from '#components/geometry/graphics/three/react/infinite-grid.js';
-import { Theme, useTheme } from '#hooks/use-theme.js';
-import { useGraphicsSelector } from '#hooks/use-graphics.js';
-
-/**
- * Grid component that renders the infinite grid using sizes from the graphics machine
- * and handles theme-aware color selection and coordinate system orientation.
- * Uses GraphicsProvider context for per-view state.
- */
-export const Grid = React.memo(() => {
- const gridSizes = useGraphicsSelector((state) => state.context.gridSizes);
- const upDirection = useGraphicsSelector((state) => state.context.upDirection);
- const { theme } = useTheme();
-
- // Calculate theme-aware grid color
- const gridColor = React.useMemo(
- () => (theme === Theme.LIGHT ? new THREE.Color('lightgrey') : new THREE.Color('grey')),
- [theme],
- );
-
- // Calculate grid axes based on the up direction
- // x: X-up (1,0,0) -> grid on YZ plane -> 'zyx'
- // y: Y-up (0,1,0) -> grid on XZ plane -> 'xzy'
- // z: Z-up (0,0,1) -> grid on XY plane -> 'xyz'
- const axes = upDirection === 'x' ? ('zyx' as const) : upDirection === 'y' ? ('xzy' as const) : ('xyz' as const);
-
- // Memoize materialProperties to prevent InfiniteGrid from recreating its
- // ShaderMaterial on every Grid re-render (the inline object would be a new reference each time).
- const materialProperties = React.useMemo(
- () => ({ smallSize: gridSizes.smallSize, largeSize: gridSizes.largeSize, color: gridColor }),
- [gridSizes.smallSize, gridSizes.largeSize, gridColor],
- );
-
- return ;
-});
diff --git a/apps/ui/app/components/geometry/graphics/three/three-context.tsx b/apps/ui/app/components/geometry/graphics/three/three-context.tsx
deleted file mode 100644
index c073d4be2..000000000
--- a/apps/ui/app/components/geometry/graphics/three/three-context.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-import type { CanvasProps } from '@react-three/fiber';
-import { Canvas } from '@react-three/fiber';
-import { useCallback, useEffect, useState } from 'react';
-import { Scene } from '#components/geometry/graphics/three/scene.js';
-import { SceneOverlay } from '#components/geometry/graphics/three/scene-overlay.js';
-import { PostProcessing } from '#components/geometry/graphics/three/post-processing.js';
-import type { StageOptions } from '#components/geometry/graphics/three/stage.js';
-import { Grid } from '#components/geometry/graphics/three/grid.js';
-import { AxesHelper } from '#components/geometry/graphics/three/react/axes-helper.js';
-import { ActorBridge } from '#components/geometry/graphics/three/actor-bridge.js';
-import { cn } from '#utils/ui.utils.js';
-import { useWebglContextRef } from '#hooks/use-webgl-context-tracker.js';
-import { WebglContextLostFallback } from '#components/geometry/graphics/three/webgl-context-lost-fallback.js';
-import { WebglLimitFallback } from '#components/geometry/cad/webgl-fallback.js';
-
-export type ThreeViewerProperties = {
- readonly enableGizmo?: boolean;
- readonly enableGrid?: boolean;
- readonly enableAxes?: boolean;
- readonly enableZoom?: boolean;
- readonly enablePan?: boolean;
- readonly enableDamping?: boolean;
- readonly upDirection?: 'x' | 'y' | 'z';
- readonly className?: string;
- readonly enableCentering?: boolean;
- readonly stageOptions?: StageOptions;
- readonly zoomSpeed?: number;
- readonly gizmoContainer?: HTMLElement | string;
-};
-
-export type ThreeContextProperties = CanvasProps & ThreeViewerProperties;
-
-export function ThreeProvider({
- children,
- enableGizmo = false,
- enableGrid = false,
- enableAxes = false,
- enableZoom = false,
- enablePan = false,
- enableDamping = false,
- upDirection = 'z',
- enableCentering = false,
- className,
- stageOptions,
- zoomSpeed = 2,
- gizmoContainer,
- ...properties
-}: ThreeContextProperties): React.JSX.Element {
- const dpr = Math.min(globalThis.devicePixelRatio, 2);
- const [isCanvasReady, setIsCanvasReady] = useState(false);
- const [isContextLost, setIsContextLost] = useState(false);
-
- // Read the actor snapshot once at mount to decide whether we can create a
- // new WebGL context. This is intentionally NON-reactive -- we never
- // subscribe to count changes. A reactive subscription in the parent
- // (CadViewer) or here would cause an infinite re-render loop because
- // acquire/release events (sent during effect mount/cleanup) would
- // synchronously flip `isAtLimit`, triggering mount -> acquire -> re-render ->
- // unmount -> release -> re-render -> mount ... ad infinitum.
- //
- // `webglRef` is `undefined` when no `` is
- // mounted above this component (e.g. single-viewer pages like the landing
- // page, converter, preview, etc.). In that case we skip tracking entirely.
- const webglRef = useWebglContextRef();
-
- // eslint-disable-next-line react/hook-use-state -- one-time snapshot, setter intentionally unused
- const [isOverLimit] = useState(() => {
- if (!webglRef) {
- return false;
- }
-
- const snap = webglRef.getSnapshot();
- return snap.context.count >= snap.context.limit;
- });
-
- useEffect(() => {
- if (!webglRef || isOverLimit) {
- return;
- }
-
- webglRef.send({ type: 'acquire' });
- return () => {
- webglRef.send({ type: 'release' });
- };
- }, [webglRef, isOverLimit]);
-
- // Remount the Canvas to recover from context loss or to retry after the
- // WebGL context limit was reached. Changing the key forces React to
- // unmount the old instance and mount a fresh one that re-evaluates the
- // snapshot.
- const [canvasKey, setCanvasKey] = useState(0);
- const handleRetry = useCallback(() => {
- setIsContextLost(false);
- setIsCanvasReady(false);
- setCanvasKey((previous) => previous + 1);
- }, []);
-
- if (isOverLimit) {
- return ;
- }
-
- if (isContextLost) {
- return ;
- }
-
- return (
-
- );
-}
diff --git a/apps/ui/app/components/geometry/graphics/three/use-geometry-bounds.ts b/apps/ui/app/components/geometry/graphics/three/use-geometry-bounds.ts
deleted file mode 100644
index c8133a379..000000000
--- a/apps/ui/app/components/geometry/graphics/three/use-geometry-bounds.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import { useEffect, useRef, useState } from 'react';
-import type { RefObject } from 'react';
-import * as THREE from 'three';
-import { useFrame } from '@react-three/fiber';
-import { useGraphics, useGraphicsSelector } from '#hooks/use-graphics.js';
-
-// Reusable temporaries for per-frame bounding calculations (avoids GC pressure).
-// Safe for multi-Canvas use because JavaScript is single-threaded and each
-// Canvas's render loop runs sequentially. Values are snapshotted into locals
-// before any state updater runs to prevent cross-contamination from batching.
-const _box3 = new THREE.Box3();
-const _centerPoint = new THREE.Vector3();
-const _sphere = new THREE.Sphere();
-
-type GeometryBoundsOptions = {
- /** When true, the outer group is translated to center geometry at the origin. */
- enableCentering?: boolean;
-};
-
-type GeometryBoundsResult = {
- /** The bounding sphere radius of the geometry. */
- geometryRadius: number;
- /** The bounding box center of the geometry. */
- geometryCenter: THREE.Vector3;
-};
-
-/**
- * Tracks the axis-aligned bounding box of the geometry inside `innerRef`,
- * exposes the bounding sphere radius and center as React state, and syncs
- * the radius to the graphics state machine.
- *
- * Integrates with the graphics machine's `geometryKey` to avoid expensive
- * scene traversals once bounds have stabilized — they are only recomputed
- * when new geometry loads (key change) and until the radius converges, then
- * skipped entirely during orbit/pan/zoom.
- *
- * Optionally applies a centering transform to `outerRef` so the geometry's
- * bounding box center sits at the world origin.
- */
-export function useGeometryBounds(
- // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React refs use null
- innerRef: RefObject,
- // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React refs use null
- outerRef: RefObject,
- options: GeometryBoundsOptions = {},
-): GeometryBoundsResult {
- const { enableCentering = false } = options;
-
- const geometryKey = useGraphicsSelector((state) => state.context.geometryKey);
-
- const [{ geometryRadius, geometryCenter }, set] = useState<{
- geometryRadius: number;
- geometryCenter: THREE.Vector3;
- }>({
- geometryRadius: 0,
- geometryCenter: new THREE.Vector3(),
- });
-
- // Track geometry key changes to avoid expensive per-frame scene traversal.
- // When geometryKey changes, bounds are recomputed until they stabilize,
- // then skipped entirely during orbit/pan/zoom.
- const lastGeometryKeyRef = useRef(undefined);
- const boundsStableRef = useRef(false);
-
- useFrame(() => {
- if (!innerRef.current) {
- return;
- }
-
- // When geometryKey changes, invalidate stability
- if (geometryKey !== lastGeometryKeyRef.current) {
- lastGeometryKeyRef.current = geometryKey;
- boundsStableRef.current = false;
- }
-
- // Skip expensive scene traversal and matrix updates once bounds have
- // stabilized. updateWorldMatrix(true, true) walks the full parent chain
- // and all descendants, so gating it behind the stability check avoids
- // unnecessary work during orbit/pan/zoom/resize.
- if (boundsStableRef.current) {
- return;
- }
-
- if (outerRef.current) {
- outerRef.current.updateWorldMatrix(true, true);
- }
-
- _box3.setFromObject(innerRef.current);
-
- // Don't mark stable or update state when the bounding box is empty
- // (geometry hasn't loaded yet -- GltfMesh parses GLTF asynchronously)
- if (_box3.isEmpty()) {
- return;
- }
-
- // Read the bounding box center and sphere from a single traversal.
- // The center is captured first for camera targeting, then the sphere
- // for radius. This avoids a redundant O(n) setFromObject call.
- _box3.getCenter(_centerPoint);
- _box3.getBoundingSphere(_sphere);
-
- if (enableCentering && outerRef.current) {
- // Snap to the negated center directly rather than accumulating deltas,
- // so repeated frames are idempotent and don't drift from floating-point error.
- outerRef.current.position.set(-_centerPoint.x, -_centerPoint.y, -_centerPoint.z);
- }
-
- // Snapshot values from shared temporaries BEFORE the state updater runs,
- // to guard against cross-contamination if React batches updates across
- // multiple Canvas instances sharing the same module-level _sphere / _centerPoint.
- const snapshotRadius = _sphere.radius;
- const snapshotCenter = _centerPoint.clone();
-
- // Only update state when the radius or center has actually changed to avoid unnecessary re-renders
- set((previous) => {
- const centerChanged = !previous.geometryCenter.equals(snapshotCenter);
-
- if (previous.geometryRadius === snapshotRadius && !centerChanged) {
- // Radius and center converged -- bounds are stable, stop polling
- boundsStableRef.current = true;
- return previous;
- }
-
- return {
- geometryRadius: snapshotRadius,
- geometryCenter: centerChanged ? snapshotCenter : previous.geometryCenter,
- };
- });
- });
-
- // Sync the real bounding-sphere radius to the graphics machine so other
- // components (and downstream consumers of geometryRadius) get the actual value
- // computed from the Three.js scene graph, not a placeholder.
- const graphicsActor = useGraphics();
- useEffect(() => {
- if (geometryRadius > 0) {
- graphicsActor.send({ type: 'sceneRadiusUpdated', radius: geometryRadius });
- }
- }, [graphicsActor, geometryRadius]);
-
- return { geometryRadius, geometryCenter };
-}
diff --git a/apps/ui/app/components/geometry/graphics/three/use-section-view.ts b/apps/ui/app/components/geometry/graphics/three/use-section-view.ts
deleted file mode 100644
index 43629089f..000000000
--- a/apps/ui/app/components/geometry/graphics/three/use-section-view.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { useEffect, useMemo, useRef } from 'react';
-import * as THREE from 'three';
-import { createStripedMaterial } from '#components/geometry/graphics/three/materials/striped-material.js';
-import { useGraphicsSelector } from '#hooks/use-graphics.js';
-
-export type SectionViewState = {
- /** The computed clipping plane for the active section view. */
- readonly plane: THREE.Plane;
- /** The capping material used for the cross-section surface. */
- readonly cappingMaterial: THREE.ShaderMaterial;
- /** Whether the section view is currently active and has a selected plane. */
- readonly isActive: boolean;
- /** The ID of the selected section view plane, if any. */
- readonly selectedId: string | undefined;
- /** Whether clipping lines are enabled. */
- readonly enableLines: boolean;
- /** Whether the clipping mesh (capping surface) is enabled. */
- readonly enableMesh: boolean;
-};
-
-const defaultPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
-
-/**
- * Reads section view state from the graphics context and computes the derived
- * THREE.Plane and capping material. Fully self-contained -- no external refs
- * or props required.
- *
- * The capping material is automatically disposed when its dependencies change
- * and on unmount to prevent GPU resource leaks.
- */
-export function useSectionView(): SectionViewState {
- const isSectionViewActive = useGraphicsSelector((state) => state.context.isSectionViewActive);
- const selectedSectionViewId = useGraphicsSelector((state) => state.context.selectedSectionViewId);
- const sectionViewRotation = useGraphicsSelector((state) => state.context.sectionViewRotation);
- const sectionViewDirection = useGraphicsSelector((state) => state.context.sectionViewDirection);
- const sectionViewPivot = useGraphicsSelector((state) => state.context.sectionViewPivot);
- const availableSectionViews = useGraphicsSelector((state) => state.context.availableSectionViews);
- const enableClippingLines = useGraphicsSelector((state) => state.context.enableClippingLines);
- const enableClippingMesh = useGraphicsSelector((state) => state.context.enableClippingMesh);
- const gridSizesComputed = useGraphicsSelector((state) => state.context.gridSizesComputed);
-
- // Compute the clipping plane from the selected section view configuration
- const plane = useMemo(() => {
- if (!selectedSectionViewId) {
- return defaultPlane;
- }
-
- const selectedPlane = availableSectionViews.find((p) => p.id === selectedSectionViewId);
- if (!selectedPlane) {
- return defaultPlane;
- }
-
- const normal = new THREE.Vector3(...selectedPlane.normal);
-
- // Apply rotation to the normal if rotation is set
- const [rotX, rotY, rotZ] = sectionViewRotation;
- if (rotX !== 0 || rotY !== 0 || rotZ !== 0) {
- const euler = new THREE.Euler(rotX, rotY, rotZ);
- normal.applyEuler(euler);
- }
-
- // Apply direction after rotation
- normal.multiplyScalar(-sectionViewDirection);
-
- // Compute plane constant from the world-space pivot point: n·p + c = 0
- // => c = -n·p. Using pivot as source of truth ensures the plane remains
- // anchored during rotations and flips while keeping display translation stable.
- const constant = -normal.dot(new THREE.Vector3(...sectionViewPivot));
-
- return new THREE.Plane(normal, constant);
- }, [selectedSectionViewId, sectionViewPivot, sectionViewRotation, sectionViewDirection, availableSectionViews]);
-
- // Create striped material for the capping surface.
- // Tracked via ref so the previous material can be disposed when deps change or on unmount.
- const cappingMaterialRef = useRef(undefined);
-
- const cappingMaterial = useMemo(() => {
- cappingMaterialRef.current?.dispose();
-
- const stripeSpacing = gridSizesComputed.largeSize * 0.1;
- const stripeWidth = stripeSpacing * 0.2;
-
- const material = createStripedMaterial({
- stripeFrequency: stripeSpacing,
- stripeWidth,
- });
-
- cappingMaterialRef.current = material;
- return material;
- }, [gridSizesComputed.largeSize]);
-
- // Dispose capping material on unmount
- useEffect(() => {
- return () => {
- cappingMaterialRef.current?.dispose();
- };
- }, []);
-
- return {
- plane,
- cappingMaterial,
- isActive: Boolean(isSectionViewActive && selectedSectionViewId),
- selectedId: selectedSectionViewId,
- enableLines: enableClippingLines,
- enableMesh: enableClippingMesh,
- };
-}
diff --git a/apps/ui/app/machines/screenshot-capability.machine.ts b/apps/ui/app/machines/screenshot-capability.machine.ts
index 571c77e97..601416acc 100644
--- a/apps/ui/app/machines/screenshot-capability.machine.ts
+++ b/apps/ui/app/machines/screenshot-capability.machine.ts
@@ -5,11 +5,11 @@ import type { ScreenshotOptions, CameraAngle, CompositeScreenshotOptions } from
import {
applyMatcapToClonedScene,
disposeClonedSceneMaterials,
-} from '#components/geometry/graphics/three/materials/gltf-matcap.js';
-import { ensureMatcapTextureLoaded } from '#components/geometry/graphics/three/materials/matcap-material.js';
-import { calculateFovDistanceCompensation } from '#components/geometry/graphics/three/utils/math.utils.js';
-import { computeViewFittingZoom } from '#components/geometry/graphics/three/utils/camera.utils.js';
-import { defaultStageOptions } from '#components/geometry/graphics/three/stage.js';
+ ensureMatcapTextureLoaded,
+ calculateFovDistanceCompensation,
+ computeViewFittingZoom,
+} from '@taucad/three';
+import { defaultStageOptions } from '@taucad/three/react';
// Capture mode discriminator
type CaptureMode = 'threejs' | 'svg';
diff --git a/apps/ui/app/machines/screenshot-capability.utils.test.ts b/apps/ui/app/machines/screenshot-capability.utils.test.ts
index a0e2cf930..cd4425ad3 100644
--- a/apps/ui/app/machines/screenshot-capability.utils.test.ts
+++ b/apps/ui/app/machines/screenshot-capability.utils.test.ts
@@ -4,10 +4,10 @@ import { calculateOptimalGrid } from '#machines/screenshot-capability.machine.js
import {
applyMatcapToClonedScene,
disposeClonedSceneMaterials,
-} from '#components/geometry/graphics/three/materials/gltf-matcap.js';
-import { calculateFovDistanceCompensation } from '#components/geometry/graphics/three/utils/math.utils.js';
-import { computeViewFittingZoom } from '#components/geometry/graphics/three/utils/camera.utils.js';
-import { defaultStageOptions } from '#components/geometry/graphics/three/stage.js';
+ calculateFovDistanceCompensation,
+ computeViewFittingZoom,
+} from '@taucad/three';
+import { defaultStageOptions } from '@taucad/three/react';
describe('calculateOptimalGrid', () => {
describe('edge cases', () => {
diff --git a/apps/ui/package.json b/apps/ui/package.json
index b3743983f..db2393c15 100644
--- a/apps/ui/package.json
+++ b/apps/ui/package.json
@@ -1,25 +1 @@
-{
- "name": "@taucad/ui",
- "version": "0.0.1",
- "type": "module",
- "private": true,
- "dependencies": {
- "@react-router/node": "catalog:",
- "@taucad/api-extractor": "workspace:*",
- "@taucad/converter": "workspace:*",
- "@taucad/json-schema": "workspace:*",
- "@taucad/tau-examples": "workspace:*",
- "@taucad/types": "workspace:*",
- "@taucad/units": "workspace:*",
- "@taucad/chat": "workspace:*",
- "@taucad/utils": "workspace:*",
- "@taucad/kernels": "workspace:*",
- "isbot": "catalog:"
- },
- "imports": {
- "#*": "./app/*"
- },
- "files": [
- "build"
- ]
-}
+{"name":"@taucad/ui","version":"0.0.1","type":"module","private":true,"dependencies":{"@react-router/node":"catalog:","@taucad/api-extractor":"workspace:*","@taucad/converter":"workspace:*","@taucad/json-schema":"workspace:*","@taucad/tau-examples":"workspace:*","@taucad/three":"workspace:*","@taucad/types":"workspace:*","@taucad/units":"workspace:*","@taucad/chat":"workspace:*","@taucad/utils":"workspace:*","@taucad/kernels":"workspace:*","isbot":"catalog:"},"imports":{"#*":"./app/*"},"files":["build"]}
diff --git a/packages/three/package.json b/packages/three/package.json
new file mode 100644
index 000000000..0cc13440e
--- /dev/null
+++ b/packages/three/package.json
@@ -0,0 +1,71 @@
+{
+ "name": "@taucad/three",
+ "version": "0.1.0",
+ "description": "CAD viewer components for Three.js and React Three Fiber",
+ "type": "module",
+ "private": false,
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/taucad/tau.git",
+ "directory": "packages/three"
+ },
+ "main": "./dist/cjs/index.cjs",
+ "types": "./dist/cjs/index.d.cts",
+ "module": "./dist/esm/index.js",
+ "files": [
+ "dist",
+ "README.md"
+ ],
+ "keywords": [
+ "cad",
+ "three",
+ "threejs",
+ "react-three-fiber",
+ "r3f",
+ "gltf",
+ "viewer",
+ "3d"
+ ],
+ "author": "Richard Fontein",
+ "license": "MIT",
+ "exports": {
+ ".": "./src/index.ts",
+ "./react": "./src/react/index.ts"
+ },
+ "publishConfig": {
+ "access": "public",
+ "exports": {
+ ".": {
+ "require": { "types": "./dist/cjs/index.d.cts", "default": "./dist/cjs/index.cjs" },
+ "import": { "types": "./dist/esm/index.d.ts", "default": "./dist/esm/index.js" }
+ },
+ "./react": {
+ "require": { "types": "./dist/cjs/react/index.d.cts", "default": "./dist/cjs/react/index.cjs" },
+ "import": { "types": "./dist/esm/react/index.d.ts", "default": "./dist/esm/react/index.js" }
+ }
+ }
+ },
+ "imports": {
+ "#*": "./src/*"
+ },
+ "dependencies": {
+ "zustand": "^5.0.0",
+ "three-viewport-gizmo": "https://codeload.github.com/taucad/three-viewport-gizmo/tar.gz/d5ae66010e7f5f067931aaeb6a74880a73d23b5d"
+ },
+ "peerDependencies": {
+ "react": ">=19.0.0",
+ "react-dom": ">=19.0.0",
+ "three": ">=0.170.0",
+ "@react-three/fiber": ">=9.0.0",
+ "@react-three/drei": ">=10.0.0",
+ "@react-three/postprocessing": ">=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@react-three/postprocessing": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@types/three": "^0.178.1"
+ }
+}
diff --git a/packages/three/project.json b/packages/three/project.json
new file mode 100644
index 000000000..a49fdad54
--- /dev/null
+++ b/packages/three/project.json
@@ -0,0 +1,8 @@
+{
+ "name": "three",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "packages/three",
+ "projectType": "library",
+ "tags": ["scope:shared", "type:lib"],
+ "targets": {}
+}
diff --git a/apps/ui/app/components/geometry/graphics/three/controls/transform-controls.ts b/packages/three/src/controls/transform-controls.ts
similarity index 99%
rename from apps/ui/app/components/geometry/graphics/three/controls/transform-controls.ts
rename to packages/three/src/controls/transform-controls.ts
index fff8ae203..9d2c49b6d 100644
--- a/apps/ui/app/components/geometry/graphics/three/controls/transform-controls.ts
+++ b/packages/three/src/controls/transform-controls.ts
@@ -32,13 +32,12 @@ import {
MeshMatcapMaterial,
LineDashedMaterial,
} from 'three';
-import translationArrowSvg from '#components/geometry/graphics/three/icons/translation-arrow.svg?raw';
-import rotationArrowSvg from '#components/geometry/graphics/three/icons/rotation-arrow.svg?raw';
-import { SvgGeometry } from '#components/geometry/graphics/three/geometries/svg-geometry.js';
-import { matcapMaterial } from '#components/geometry/graphics/three/materials/matcap-material.js';
-import { FontGeometry } from '#components/geometry/graphics/three/geometries/font-geometry.js';
-import { RoundedRectangleGeometry } from '#components/geometry/graphics/three/geometries/rounded-rectangle-geometry.js';
-import { CircleGeometry } from '#components/geometry/graphics/three/geometries/circle-geometry.js';
+import { translationArrowSvg, rotationArrowSvg } from '#icons/svg-data.js';
+import { SvgGeometry } from '#geometries/svg-geometry.js';
+import { matcapMaterial } from '#materials/matcap-material.js';
+import { FontGeometry } from '#geometries/font-geometry.js';
+import { RoundedRectangleGeometry } from '#geometries/rounded-rectangle-geometry.js';
+import { CircleGeometry } from '#geometries/circle-geometry.js';
export type TransformControlsPointerObject = {
x: number;
diff --git a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-cube-axes.ts b/packages/three/src/controls/viewport-gizmo-cube-axes.ts
similarity index 97%
rename from apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-cube-axes.ts
rename to packages/three/src/controls/viewport-gizmo-cube-axes.ts
index 5a01fa61b..622e1dd10 100644
--- a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-cube-axes.ts
+++ b/packages/three/src/controls/viewport-gizmo-cube-axes.ts
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { Line2, LineGeometry, LineMaterial } from 'three/addons';
-import { gizmoBaseDistance } from '#components/geometry/graphics/three/utils/math.utils.js';
+import { gizmoBaseDistance } from '#utils/math.utils.js';
export type ViewportGizmoCubeAxesProps = {
readonly axesSize?: number;
diff --git a/apps/ui/app/components/geometry/graphics/three/geometries/circle-geometry.ts b/packages/three/src/geometries/circle-geometry.ts
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/geometries/circle-geometry.ts
rename to packages/three/src/geometries/circle-geometry.ts
diff --git a/apps/ui/app/components/geometry/graphics/three/geometries/font-geometry.ts b/packages/three/src/geometries/font-geometry.ts
similarity index 72%
rename from apps/ui/app/components/geometry/graphics/three/geometries/font-geometry.ts
rename to packages/three/src/geometries/font-geometry.ts
index 893e15cfd..38707fed8 100644
--- a/apps/ui/app/components/geometry/graphics/three/geometries/font-geometry.ts
+++ b/packages/three/src/geometries/font-geometry.ts
@@ -2,12 +2,13 @@ import type { BufferGeometry } from 'three';
import { ExtrudeGeometry } from 'three';
import { FontLoader } from 'three/examples/jsm/Addons.js';
import type { FontData } from 'three/examples/jsm/Addons.js';
-import fontTypeface from '#components/geometry/graphics/three/geometries/geist-mono.typeface.json?raw';
+// eslint-disable-next-line import-x/no-extraneous-dependencies -- local file via package imports (#* → ./src/*)
+import fontData from '#geometries/geist-mono.typeface.json';
// eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js naming convention
export const FontGeometry = ({ text, depth, size }: { text: string; depth: number; size: number }): BufferGeometry => {
const loader = new FontLoader();
- const font = loader.parse(JSON.parse(fontTypeface) as FontData);
+ const font = loader.parse(fontData as unknown as FontData);
const shapes = font.generateShapes(text, size);
const geometry = new ExtrudeGeometry(shapes, { depth, bevelEnabled: false });
geometry.center();
diff --git a/apps/ui/app/components/geometry/graphics/three/geometries/geist-mono.typeface.json b/packages/three/src/geometries/geist-mono.typeface.json
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/geometries/geist-mono.typeface.json
rename to packages/three/src/geometries/geist-mono.typeface.json
diff --git a/apps/ui/app/components/geometry/graphics/three/geometries/label-geometry.ts b/packages/three/src/geometries/label-geometry.ts
similarity index 83%
rename from apps/ui/app/components/geometry/graphics/three/geometries/label-geometry.ts
rename to packages/three/src/geometries/label-geometry.ts
index e4228e30d..598b9a11b 100644
--- a/apps/ui/app/components/geometry/graphics/three/geometries/label-geometry.ts
+++ b/packages/three/src/geometries/label-geometry.ts
@@ -1,6 +1,6 @@
import type { BufferGeometry } from 'three';
-import { FontGeometry } from '#components/geometry/graphics/three/geometries/font-geometry.js';
-import { RoundedRectangleGeometry } from '#components/geometry/graphics/three/geometries/rounded-rectangle-geometry.js';
+import { FontGeometry } from '#geometries/font-geometry.js';
+import { RoundedRectangleGeometry } from '#geometries/rounded-rectangle-geometry.js';
// eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js naming convention
export const LabelTextGeometry = ({
diff --git a/apps/ui/app/components/geometry/graphics/three/geometries/rounded-rectangle-geometry.ts b/packages/three/src/geometries/rounded-rectangle-geometry.ts
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/geometries/rounded-rectangle-geometry.ts
rename to packages/three/src/geometries/rounded-rectangle-geometry.ts
diff --git a/apps/ui/app/components/geometry/graphics/three/geometries/svg-geometry.ts b/packages/three/src/geometries/svg-geometry.ts
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/geometries/svg-geometry.ts
rename to packages/three/src/geometries/svg-geometry.ts
diff --git a/apps/ui/app/components/geometry/graphics/three/icons/rotate-icon-single.svg b/packages/three/src/icons/rotate-icon-single.svg
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/icons/rotate-icon-single.svg
rename to packages/three/src/icons/rotate-icon-single.svg
diff --git a/apps/ui/app/components/geometry/graphics/three/icons/rotate-icon.svg b/packages/three/src/icons/rotate-icon.svg
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/icons/rotate-icon.svg
rename to packages/three/src/icons/rotate-icon.svg
diff --git a/apps/ui/app/components/geometry/graphics/three/icons/rotation-arrow.svg b/packages/three/src/icons/rotation-arrow.svg
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/icons/rotation-arrow.svg
rename to packages/three/src/icons/rotation-arrow.svg
diff --git a/packages/three/src/icons/svg-data.ts b/packages/three/src/icons/svg-data.ts
new file mode 100644
index 000000000..7a1e8238a
--- /dev/null
+++ b/packages/three/src/icons/svg-data.ts
@@ -0,0 +1,9 @@
+export const translationArrowSvg = ``;
+
+export const rotationArrowSvg = ``;
diff --git a/apps/ui/app/components/geometry/graphics/three/icons/translation-arrow.svg b/packages/three/src/icons/translation-arrow.svg
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/icons/translation-arrow.svg
rename to packages/three/src/icons/translation-arrow.svg
diff --git a/packages/three/src/index.ts b/packages/three/src/index.ts
new file mode 100644
index 000000000..15c4277d4
--- /dev/null
+++ b/packages/three/src/index.ts
@@ -0,0 +1,52 @@
+/**
+ * @taucad/three - Core Three.js utilities for CAD rendering.
+ *
+ * This entry point exports non-React utilities: materials, geometries,
+ * camera/lighting math, screenshot capture, and vanilla controls.
+ * For React components see `@taucad/three/react`.
+ *
+ * @packageDocumentation
+ */
+
+// ── Screenshot ────────────────────────────────────────────────────────────────
+
+export { captureScreenshot } from '#screenshot/capture-screenshot.js';
+export { createCompositeImage, calculateOptimalGrid } from '#screenshot/create-composite-image.js';
+export type {
+ CameraAngle,
+ CompositeScreenshotOptions,
+ ScreenshotOptions,
+ ScreenshotOutputOptions,
+} from '#screenshot/types.js';
+
+// ── Camera utilities ──────────────────────────────────────────────────────────
+
+export {
+ resetCamera,
+ updateCameraFov,
+ computeViewFittingZoom,
+} from '#utils/camera.utils.js';
+
+// ── Math utilities ────────────────────────────────────────────────────────────
+
+export {
+ calculateFovDistanceCompensation,
+} from '#utils/math.utils.js';
+
+// ── Lighting utilities ────────────────────────────────────────────────────────
+
+export {
+ applyLightingForCamera,
+} from '#utils/lights.utils.js';
+
+// ── Color utilities ───────────────────────────────────────────────────────────
+
+export { adjustHexColorBrightness } from '#utils/color.utils.js';
+
+// ── Materials ─────────────────────────────────────────────────────────────────
+
+export { matcapMaterial, ensureMatcapTextureLoaded } from '#materials/matcap-material.js';
+export { createStripedMaterial } from '#materials/striped-material.js';
+export { applyMatcap, applyMatcapToClonedScene, disposeClonedSceneMaterials } from '#materials/gltf-matcap.js';
+export { applyFatLineSegments, updateLineMaterialResolution } from '#materials/gltf-edges.js';
+export { infiniteGridMaterial } from '#materials/infinite-grid-material.js';
diff --git a/apps/ui/app/components/geometry/graphics/three/materials/gltf-edges.ts b/packages/three/src/materials/gltf-edges.ts
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/materials/gltf-edges.ts
rename to packages/three/src/materials/gltf-edges.ts
diff --git a/apps/ui/app/components/geometry/graphics/three/materials/gltf-matcap.ts b/packages/three/src/materials/gltf-matcap.ts
similarity index 97%
rename from apps/ui/app/components/geometry/graphics/three/materials/gltf-matcap.ts
rename to packages/three/src/materials/gltf-matcap.ts
index bf9c9fbca..9bca7872b 100644
--- a/apps/ui/app/components/geometry/graphics/three/materials/gltf-matcap.ts
+++ b/packages/three/src/materials/gltf-matcap.ts
@@ -2,7 +2,7 @@ import type { GLTF } from 'three/addons/loaders/GLTFLoader.js';
import type { Mesh, Material, Scene, Texture } from 'three';
import { DoubleSide, MeshMatcapMaterial } from 'three';
import { LineSegments2 } from 'three/addons';
-import { matcapMaterial } from '#components/geometry/graphics/three/materials/matcap-material.js';
+import { matcapMaterial } from '#materials/matcap-material.js';
/**
* Dispose a material or array of materials, releasing GPU resources.
diff --git a/apps/ui/app/components/geometry/graphics/three/materials/infinite-grid-material.ts b/packages/three/src/materials/infinite-grid-material.ts
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/materials/infinite-grid-material.ts
rename to packages/three/src/materials/infinite-grid-material.ts
diff --git a/apps/ui/app/components/geometry/graphics/three/materials/matcap-material.ts b/packages/three/src/materials/matcap-material.ts
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/materials/matcap-material.ts
rename to packages/three/src/materials/matcap-material.ts
diff --git a/apps/ui/app/components/geometry/graphics/three/materials/striped-material.ts b/packages/three/src/materials/striped-material.ts
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/materials/striped-material.ts
rename to packages/three/src/materials/striped-material.ts
diff --git a/apps/ui/app/components/geometry/graphics/three/react/axes-helper.tsx b/packages/three/src/react/axes-helper.tsx
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/react/axes-helper.tsx
rename to packages/three/src/react/axes-helper.tsx
diff --git a/packages/three/src/react/cad-canvas.tsx b/packages/three/src/react/cad-canvas.tsx
new file mode 100644
index 000000000..a6546de28
--- /dev/null
+++ b/packages/three/src/react/cad-canvas.tsx
@@ -0,0 +1,104 @@
+import type { CanvasProps } from '@react-three/fiber';
+import { Canvas } from '@react-three/fiber';
+import { Scene } from './scene.js';
+import { SceneOverlay } from './scene-overlay.js';
+import { PostProcessing } from './post-processing.js';
+import { Grid } from './grid.js';
+import { AxesHelper } from './axes-helper.js';
+import type { StageOptions } from './stage.js';
+import { CadStoreProvider } from './stores/store-context.js';
+import type { ViewerStore, MeasureStore, SectionViewStore } from './stores/index.js';
+
+type CadCanvasProperties = CanvasProps & {
+ readonly enableGizmo?: boolean;
+ readonly enableGrid?: boolean;
+ readonly enableAxes?: boolean;
+ readonly enableZoom?: boolean;
+ readonly enablePan?: boolean;
+ readonly enableDamping?: boolean;
+ readonly upDirection?: 'x' | 'y' | 'z';
+ readonly enableCentering?: boolean;
+ readonly stageOptions?: StageOptions;
+ readonly zoomSpeed?: number;
+ readonly gizmoContainer?: HTMLElement | string;
+ readonly className?: string;
+ readonly geometryKey?: string;
+ readonly onSceneRadiusChange?: (radius: number) => void;
+ readonly onResetCamera?: () => void;
+ readonly onContextLost?: () => void;
+ readonly viewerStore?: ViewerStore;
+ readonly measureStore?: MeasureStore;
+ readonly sectionViewStore?: SectionViewStore;
+};
+
+export function CadCanvas({
+ children,
+ enableGizmo = false,
+ enableGrid = false,
+ enableAxes = false,
+ enableZoom = true,
+ enablePan = true,
+ enableDamping = true,
+ upDirection = 'z',
+ enableCentering = false,
+ className,
+ stageOptions,
+ zoomSpeed = 2,
+ gizmoContainer,
+ geometryKey,
+ onSceneRadiusChange,
+ onResetCamera,
+ onContextLost,
+ viewerStore,
+ measureStore,
+ sectionViewStore,
+ ...canvasProps
+}: CadCanvasProperties): React.JSX.Element {
+ const dpr = Math.min(globalThis.devicePixelRatio ?? 1, 2);
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/three/src/react/cad-viewer.tsx b/packages/three/src/react/cad-viewer.tsx
new file mode 100644
index 000000000..06cccf47c
--- /dev/null
+++ b/packages/three/src/react/cad-viewer.tsx
@@ -0,0 +1,52 @@
+import { memo } from 'react';
+import { CadCanvas } from './cad-canvas.js';
+import { GltfMesh } from './gltf-mesh.js';
+import type { StageOptions } from './stage.js';
+import type { ViewerStore, MeasureStore, SectionViewStore } from './stores/index.js';
+
+type CadViewerProperties = {
+ readonly gltf: Uint8Array | ReadonlyArray>;
+ readonly enableGizmo?: boolean;
+ readonly enableGrid?: boolean;
+ readonly enableAxes?: boolean;
+ readonly enableZoom?: boolean;
+ readonly enablePan?: boolean;
+ readonly enableDamping?: boolean;
+ readonly enableMatcap?: boolean;
+ readonly enableSurfaces?: boolean;
+ readonly enableLines?: boolean;
+ readonly upDirection?: 'x' | 'y' | 'z';
+ readonly enableCentering?: boolean;
+ readonly stageOptions?: StageOptions;
+ readonly zoomSpeed?: number;
+ readonly gizmoContainer?: HTMLElement | string;
+ readonly className?: string;
+ readonly onContextLost?: () => void;
+ readonly viewerStore?: ViewerStore;
+ readonly measureStore?: MeasureStore;
+ readonly sectionViewStore?: SectionViewStore;
+};
+
+export const CadViewer = memo(function CadViewer({
+ gltf,
+ enableMatcap = false,
+ enableSurfaces = true,
+ enableLines = true,
+ ...canvasProps
+}: CadViewerProperties): React.JSX.Element {
+ const gltfArray = Array.isArray(gltf) ? gltf : [gltf];
+
+ return (
+
+ {gltfArray.map((file, index) => (
+
+ ))}
+
+ );
+});
diff --git a/apps/ui/app/components/geometry/graphics/three/controls.tsx b/packages/three/src/react/controls.tsx
similarity index 58%
rename from apps/ui/app/components/geometry/graphics/three/controls.tsx
rename to packages/three/src/react/controls.tsx
index bf3a508a8..643fd299d 100644
--- a/apps/ui/app/components/geometry/graphics/three/controls.tsx
+++ b/packages/three/src/react/controls.tsx
@@ -1,10 +1,12 @@
+/* eslint-disable react/require-default-props -- defaultProps is deprecated; optional props have no defaults */
import { OrbitControls } from '@react-three/drei';
import React from 'react';
import type * as THREE from 'three';
-import { ViewportGizmoCube } from '#components/geometry/graphics/three/controls/viewport-gizmo-cube.js';
-import { SectionViewControls } from '#components/geometry/graphics/three/react/section-view-controls.js';
-import { MeasureTool } from '#components/geometry/graphics/three/react/measure-tool.js';
-import { useGraphics, useGraphicsSelector } from '#hooks/use-graphics.js';
+import { ViewportGizmoCube } from '#react/viewport-gizmo.js';
+import { SectionViewControls } from '#react/section-view-controls.js';
+import { MeasureTool } from '#react/measure-tool.js';
+import type { PlaneSelectorId } from '#react/section-view-controls.js';
+import { useSectionViewStore, useViewerStore } from '#react/stores/store-context.js';
type ControlsProperties = {
/**
@@ -31,6 +33,10 @@ type ControlsProperties = {
* A container element or selector to append the gizmo to.
*/
readonly gizmoContainer?: HTMLElement | string;
+ /**
+ * Key used to invalidate cached meshes when geometry changes.
+ */
+ readonly geometryKey?: string;
};
export const Controls = React.memo(function ({
@@ -40,18 +46,23 @@ export const Controls = React.memo(function ({
enablePan,
zoomSpeed,
gizmoContainer,
-}: ControlsProperties) {
- const graphicsActor = useGraphics();
- const isActive = useGraphicsSelector((state) => state.context.isSectionViewActive);
- const selectedPlaneId = useGraphicsSelector((state) => state.context.selectedSectionViewId);
- const rotation = useGraphicsSelector((state) => state.context.sectionViewRotation);
- const pivot = useGraphicsSelector((state) => state.context.sectionViewPivot);
- const availablePlanes = useGraphicsSelector((state) => state.context.availableSectionViews);
- const planeName = useGraphicsSelector((state) => state.context.planeName);
- const hoveredSectionViewId = useGraphicsSelector((state) => state.context.hoveredSectionViewId);
- const upDirection = useGraphicsSelector((state) => state.context.upDirection);
+ geometryKey,
+}: ControlsProperties): React.JSX.Element {
+ const isActive = useSectionViewStore((s) => s.isActive);
+ const selectedPlaneId = useSectionViewStore((s) => s.selectedPlaneId);
+ const rotation = useSectionViewStore((s) => s.rotation);
+ const pivot = useSectionViewStore((s) => s.pivot);
+ const availablePlanes = useSectionViewStore((s) => s.availableSectionViews);
+ const planeName = useSectionViewStore((s) => s.planeName);
+ const hoveredSectionViewId = useSectionViewStore((s) => s.hoveredSectionViewId) as PlaneSelectorId | undefined;
+ const upDirection = useViewerStore((s) => s.upDirection);
+
+ const selectPlane = useSectionViewStore((s) => s.selectPlane);
+ const setDirection = useSectionViewStore((s) => s.setDirection);
+ const setRotation = useSectionViewStore((s) => s.setRotation);
+ const setPivot = useSectionViewStore((s) => s.setPivot);
+ const setHoveredSectionView = useSectionViewStore((s) => s.setHoveredSectionView);
- // Handlers to send events to xstate
const handleSelectPlane = (planeId: 'xy' | 'xz' | 'yz' | 'yx' | 'zx' | 'zy'): void => {
const id = planeId.toLowerCase() as 'xy' | 'xz' | 'yz' | 'yx' | 'zx' | 'zy';
const isInverse = id === 'yx' || id === 'zx' || id === 'zy';
@@ -67,23 +78,20 @@ export const Controls = React.memo(function ({
return 'yz';
})();
const newDir: 1 | -1 = isInverse ? -1 : 1;
- graphicsActor.send({ type: 'selectSectionView', payload: base });
- graphicsActor.send({ type: 'setSectionViewDirection', payload: newDir });
+ selectPlane(base);
+ setDirection(newDir);
};
const handleSetRotation = (eulerRotation: THREE.Euler): void => {
- graphicsActor.send({
- type: 'setSectionViewRotation',
- payload: [eulerRotation.x, eulerRotation.y, eulerRotation.z],
- });
+ setRotation([eulerRotation.x, eulerRotation.y, eulerRotation.z]);
};
const handleSetPivot = (value: [number, number, number]): void => {
- graphicsActor.send({ type: 'setSectionViewPivot', payload: value });
+ setPivot(value);
};
const handleHover = (planeId: 'xy' | 'xz' | 'yz' | 'yx' | 'zx' | 'zy' | undefined): void => {
- graphicsActor.send({ type: 'setHoveredSectionView', payload: planeId });
+ setHoveredSectionView(planeId);
};
return (
@@ -95,7 +103,7 @@ export const Controls = React.memo(function ({
enableDamping={enableDamping}
enableZoom={enableZoom}
/>
-
+
{
+ const gridSizes = useViewerStore((state) => state.gridSizes);
+ const upDirection = useViewerStore((state) => state.upDirection);
+ const theme = useViewerStore((state) => state.theme);
+
+ const gridColor = React.useMemo(
+ () => (theme === 'light' ? new THREE.Color('lightgrey') : new THREE.Color('grey')),
+ [theme],
+ );
+
+ // x: X-up (1,0,0) -> grid on YZ plane -> 'zyx'
+ // y: Y-up (0,1,0) -> grid on XZ plane -> 'xzy'
+ // z: Z-up (0,0,1) -> grid on XY plane -> 'xyz'
+ const axes = upDirection === 'x' ? ('zyx' as const) : upDirection === 'y' ? ('xzy' as const) : ('xyz' as const);
+
+ const materialProperties = React.useMemo(
+ () => ({ smallSize: gridSizes.smallSize, largeSize: gridSizes.largeSize, color: gridColor }),
+ [gridSizes.smallSize, gridSizes.largeSize, gridColor],
+ );
+
+ return ;
+});
diff --git a/apps/ui/app/components/geometry/graphics/three/use-camera-framing.test.ts b/packages/three/src/react/hooks/use-camera-framing.test.ts
similarity index 96%
rename from apps/ui/app/components/geometry/graphics/three/use-camera-framing.test.ts
rename to packages/three/src/react/hooks/use-camera-framing.test.ts
index 74405eb00..d57c7e088 100644
--- a/apps/ui/app/components/geometry/graphics/three/use-camera-framing.test.ts
+++ b/packages/three/src/react/hooks/use-camera-framing.test.ts
@@ -1,9 +1,12 @@
+/**
+ * @vitest-environment jsdom
+ */
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { Vector3 } from 'three';
-import type { StageOptions } from '#components/geometry/graphics/three/stage.js';
-import { defaultStageOptions } from '#components/geometry/graphics/three/stage.js';
-import { useCameraFraming } from '#components/geometry/graphics/three/use-camera-framing.js';
+import type { StageOptions } from '#react/stage.js';
+import { defaultStageOptions } from '#react/stage.js';
+import { useCameraFraming } from '#react/hooks/use-camera-framing.js';
// ── Controllable mocks ───────────────────────────────────────────────────────
@@ -14,8 +17,8 @@ vi.mock('@react-three/fiber', () => ({
useThree: () => ({ size: mockSize }),
}));
-vi.mock('#hooks/use-graphics.js', () => ({
- useGraphicsSelector: () => 50,
+vi.mock('#react/stores/store-context.js', () => ({
+ useViewerStore: (selector: (state: { fieldOfView: number }) => unknown) => selector({ fieldOfView: 50 }),
}));
/**
@@ -43,7 +46,7 @@ type CapturedResetParameters = {
/** Latest params forwarded to `useCameraReset` by the hook under test. */
let latestResetParameters: CapturedResetParameters;
-vi.mock('#components/geometry/graphics/three/use-camera-reset.js', () => ({
+vi.mock('#react/use-camera-reset.js', () => ({
useCameraReset: vi.fn((parameters: CapturedResetParameters) => {
latestResetParameters = parameters;
mockResetCamera.mockImplementation(() => {
diff --git a/apps/ui/app/components/geometry/graphics/three/use-camera-framing.ts b/packages/three/src/react/hooks/use-camera-framing.ts
similarity index 91%
rename from apps/ui/app/components/geometry/graphics/three/use-camera-framing.ts
rename to packages/three/src/react/hooks/use-camera-framing.ts
index 8c5ae88d1..9525904cf 100644
--- a/apps/ui/app/components/geometry/graphics/three/use-camera-framing.ts
+++ b/packages/three/src/react/hooks/use-camera-framing.ts
@@ -1,10 +1,10 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useThree } from '@react-three/fiber';
import type * as THREE from 'three';
-import { useCameraReset } from '#components/geometry/graphics/three/use-camera-reset.js';
-import { useGraphicsSelector } from '#hooks/use-graphics.js';
-import type { StageOptions } from '#components/geometry/graphics/three/stage.js';
-import { defaultStageOptions } from '#components/geometry/graphics/three/stage.js';
+import { useCameraReset } from '#react/use-camera-reset.js';
+import { useViewerStore } from '#react/stores/store-context.js';
+import type { StageOptions } from '#react/stage.js';
+import { defaultStageOptions } from '#react/stage.js';
const significantRadiusChangeRatio = 0.1;
const significantAspectChangeRatio = 0.1;
@@ -25,7 +25,7 @@ export function useCameraFraming(
geometryCenter: THREE.Vector3,
stageOptions: StageOptions = defaultStageOptions,
): (options?: { enableConfiguredAngles?: boolean }) => void {
- const cameraFovAngle = useGraphicsSelector((state) => state.context.cameraFovAngle);
+ const cameraFovAngle = useViewerStore((state) => state.fieldOfView);
// Merge caller options with defaults
const { offsetRatio, nearPlane, minimumFarPlane, farPlaneRadiusMultiplier, zoomLevel, rotation } = useMemo(
diff --git a/packages/three/src/react/hooks/use-geometry-bounds.ts b/packages/three/src/react/hooks/use-geometry-bounds.ts
new file mode 100644
index 000000000..800fceff8
--- /dev/null
+++ b/packages/three/src/react/hooks/use-geometry-bounds.ts
@@ -0,0 +1,113 @@
+import { useEffect, useRef, useState } from 'react';
+import type { RefObject } from 'react';
+import * as THREE from 'three';
+import { useFrame } from '@react-three/fiber';
+import { useViewerStore } from '#react/stores/store-context.js';
+
+const _box3 = new THREE.Box3();
+const _centerPoint = new THREE.Vector3();
+const _sphere = new THREE.Sphere();
+
+type GeometryBoundsOptions = {
+ enableCentering?: boolean;
+ geometryKey?: string;
+ onSceneRadiusChange?: (radius: number) => void;
+};
+
+type GeometryBoundsResult = {
+ geometryRadius: number;
+ geometryCenter: THREE.Vector3;
+};
+
+/**
+ * Tracks the axis-aligned bounding box of the geometry inside `innerRef`,
+ * exposes the bounding sphere radius and center as React state, and syncs
+ * the radius to the viewer store.
+ *
+ * Uses `geometryKey` to avoid expensive scene traversals once bounds have
+ * stabilized -- they are only recomputed when new geometry loads (key change)
+ * and until the radius converges, then skipped entirely during orbit/pan/zoom.
+ *
+ * Optionally applies a centering transform to `outerRef` so the geometry's
+ * bounding box center sits at the world origin.
+ */
+export function useGeometryBounds(
+ // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React refs use null
+ innerRef: RefObject,
+ // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React refs use null
+ outerRef: RefObject,
+ options: GeometryBoundsOptions = {},
+): GeometryBoundsResult {
+ const { enableCentering = false, geometryKey, onSceneRadiusChange } = options;
+
+ const setStoreSceneRadius = useViewerStore((state) => state.setSceneRadius);
+
+ const [{ geometryRadius, geometryCenter }, set] = useState<{
+ geometryRadius: number;
+ geometryCenter: THREE.Vector3;
+ }>({
+ geometryRadius: 0,
+ geometryCenter: new THREE.Vector3(),
+ });
+
+ const lastGeometryKeyRef = useRef(undefined);
+ const boundsStableRef = useRef(false);
+
+ useFrame(() => {
+ if (!innerRef.current) {
+ return;
+ }
+
+ if (geometryKey !== lastGeometryKeyRef.current) {
+ lastGeometryKeyRef.current = geometryKey;
+ boundsStableRef.current = false;
+ }
+
+ if (boundsStableRef.current) {
+ return;
+ }
+
+ if (outerRef.current) {
+ outerRef.current.updateWorldMatrix(true, true);
+ }
+
+ _box3.setFromObject(innerRef.current);
+
+ if (_box3.isEmpty()) {
+ return;
+ }
+
+ _box3.getCenter(_centerPoint);
+ _box3.getBoundingSphere(_sphere);
+
+ if (enableCentering && outerRef.current) {
+ outerRef.current.position.set(-_centerPoint.x, -_centerPoint.y, -_centerPoint.z);
+ }
+
+ const snapshotRadius = _sphere.radius;
+ const snapshotCenter = _centerPoint.clone();
+
+ set((previous) => {
+ const centerChanged = !previous.geometryCenter.equals(snapshotCenter);
+
+ if (previous.geometryRadius === snapshotRadius && !centerChanged) {
+ boundsStableRef.current = true;
+ return previous;
+ }
+
+ return {
+ geometryRadius: snapshotRadius,
+ geometryCenter: centerChanged ? snapshotCenter : previous.geometryCenter,
+ };
+ });
+ });
+
+ useEffect(() => {
+ if (geometryRadius > 0) {
+ setStoreSceneRadius(geometryRadius);
+ onSceneRadiusChange?.(geometryRadius);
+ }
+ }, [geometryRadius, setStoreSceneRadius, onSceneRadiusChange]);
+
+ return { geometryRadius, geometryCenter };
+}
diff --git a/packages/three/src/react/hooks/use-section-view.ts b/packages/three/src/react/hooks/use-section-view.ts
new file mode 100644
index 000000000..43da65cf4
--- /dev/null
+++ b/packages/three/src/react/hooks/use-section-view.ts
@@ -0,0 +1,92 @@
+import { useEffect, useMemo, useRef } from 'react';
+import * as THREE from 'three';
+import { createStripedMaterial } from '#materials/striped-material.js';
+import { useSectionViewStore, useViewerStore } from '#react/stores/store-context.js';
+
+export type SectionViewResult = {
+ readonly plane: THREE.Plane;
+ readonly cappingMaterial: THREE.ShaderMaterial;
+ readonly isActive: boolean;
+ readonly selectedId: string | undefined;
+ readonly enableLines: boolean;
+ readonly enableMesh: boolean;
+};
+
+const defaultPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
+
+/**
+ * Reads section view state from the zustand stores and computes the derived
+ * THREE.Plane and capping material. Fully self-contained -- no external refs
+ * or props required.
+ *
+ * The capping material is automatically disposed when its dependencies change
+ * and on unmount to prevent GPU resource leaks.
+ */
+export function useSectionView(): SectionViewResult {
+ const isSectionViewActive = useSectionViewStore((s) => s.isActive);
+ const selectedSectionViewId = useSectionViewStore((s) => s.selectedPlaneId);
+ const sectionViewRotation = useSectionViewStore((s) => s.rotation);
+ const sectionViewDirection = useSectionViewStore((s) => s.direction);
+ const sectionViewPivot = useSectionViewStore((s) => s.pivot);
+ const availableSectionViews = useSectionViewStore((s) => s.availableSectionViews);
+ const enableClippingLines = useSectionViewStore((s) => s.enableLines);
+ const enableClippingMesh = useSectionViewStore((s) => s.enableMesh);
+ const gridSizes = useViewerStore((s) => s.gridSizes);
+
+ const plane = useMemo(() => {
+ if (!selectedSectionViewId) {
+ return defaultPlane;
+ }
+
+ const selectedPlane = availableSectionViews.find((p) => p.id === selectedSectionViewId);
+ if (!selectedPlane) {
+ return defaultPlane;
+ }
+
+ const normal = new THREE.Vector3(...selectedPlane.normal);
+
+ const [rotX, rotY, rotZ] = sectionViewRotation;
+ if (rotX !== 0 || rotY !== 0 || rotZ !== 0) {
+ const euler = new THREE.Euler(rotX, rotY, rotZ);
+ normal.applyEuler(euler);
+ }
+
+ normal.multiplyScalar(-sectionViewDirection);
+
+ const constant = -normal.dot(new THREE.Vector3(...sectionViewPivot));
+
+ return new THREE.Plane(normal, constant);
+ }, [selectedSectionViewId, sectionViewPivot, sectionViewRotation, sectionViewDirection, availableSectionViews]);
+
+ const cappingMaterialRef = useRef(undefined);
+
+ const cappingMaterial = useMemo(() => {
+ cappingMaterialRef.current?.dispose();
+
+ const stripeSpacing = gridSizes.largeSize * 0.1;
+ const stripeWidth = stripeSpacing * 0.2;
+
+ const material = createStripedMaterial({
+ stripeFrequency: stripeSpacing,
+ stripeWidth,
+ });
+
+ cappingMaterialRef.current = material;
+ return material;
+ }, [gridSizes.largeSize]);
+
+ useEffect(() => {
+ return () => {
+ cappingMaterialRef.current?.dispose();
+ };
+ }, []);
+
+ return {
+ plane,
+ cappingMaterial,
+ isActive: Boolean(isSectionViewActive && selectedSectionViewId),
+ selectedId: selectedSectionViewId,
+ enableLines: enableClippingLines,
+ enableMesh: enableClippingMesh,
+ };
+}
diff --git a/packages/three/src/react/index.ts b/packages/three/src/react/index.ts
new file mode 100644
index 000000000..d1b943617
--- /dev/null
+++ b/packages/three/src/react/index.ts
@@ -0,0 +1,79 @@
+/**
+ * @taucad/three/react - React components, hooks, and stores for CAD rendering.
+ *
+ * Provides a complete set of React Three Fiber components for rendering
+ * CAD geometry with measurement, section view, and viewport gizmo features.
+ * All state is managed via zustand stores.
+ *
+ * @packageDocumentation
+ */
+
+// ── High-level components ─────────────────────────────────────────────────────
+
+export { CadViewer } from './cad-viewer.js';
+export { CadCanvas } from './cad-canvas.js';
+export { presets, type CadViewerPreset } from './presets.js';
+
+// ── Scene composition ─────────────────────────────────────────────────────────
+
+export { Scene } from './scene.js';
+export { Stage, defaultStageOptions, type StageOptions } from './stage.js';
+export { GltfMesh } from './gltf-mesh.js';
+export { Lights } from './lights.js';
+export { Grid } from './grid.js';
+export { AxesHelper } from './axes-helper.js';
+export { PostProcessing } from './post-processing.js';
+export { InfiniteGrid } from './infinite-grid.js';
+export { SceneOverlay } from './scene-overlay.js';
+export { Controls } from './controls.js';
+export { UpDirectionHandler } from './up-direction-handler.js';
+
+// ── Interactive features ──────────────────────────────────────────────────────
+
+export { MeasureTool } from './measure-tool.js';
+export { SectionView, type CutterProperties } from './section-view.js';
+export {
+ SectionViewControls,
+ type AvailablePlane,
+ type PlaneId,
+ type PlaneSelectorId,
+ type UpDirection,
+} from './section-view-controls.js';
+export { TransformControls, type TransformControlsProps } from './transform-controls-drei.js';
+export { ViewportGizmoCube } from './viewport-gizmo.js';
+
+// ── Hooks ─────────────────────────────────────────────────────────────────────
+
+export { useCameraFraming } from './hooks/use-camera-framing.js';
+export { useCameraReset } from './use-camera-reset.js';
+export { useGeometryBounds } from './hooks/use-geometry-bounds.js';
+export { useSectionView, type SectionViewResult } from './hooks/use-section-view.js';
+
+// ── Stores ────────────────────────────────────────────────────────────────────
+
+export {
+ createViewerStore,
+ createMeasureStore,
+ createSectionViewStore,
+ CadStoreProvider,
+ CadStoreContext,
+ useViewerStore,
+ useMeasureStore,
+ useSectionViewStore,
+} from './stores/index.js';
+
+export type {
+ ViewerStore,
+ ViewerStoreOptions,
+ ViewerState,
+ Measurement,
+ MeasureUnits,
+ MeasureStore,
+ MeasureStoreOptions,
+ MeasureState,
+ AvailableSectionView,
+ SectionViewStore,
+ SectionViewStoreOptions,
+ SectionViewState,
+ CadStores,
+} from './stores/index.js';
diff --git a/apps/ui/app/components/geometry/graphics/three/react/infinite-grid.tsx b/packages/three/src/react/infinite-grid.tsx
similarity index 91%
rename from apps/ui/app/components/geometry/graphics/three/react/infinite-grid.tsx
rename to packages/three/src/react/infinite-grid.tsx
index 1e48964c2..383856971 100644
--- a/apps/ui/app/components/geometry/graphics/three/react/infinite-grid.tsx
+++ b/packages/three/src/react/infinite-grid.tsx
@@ -1,7 +1,7 @@
import { Plane } from '@react-three/drei';
import React from 'react';
-import { infiniteGridMaterial } from '#components/geometry/graphics/three/materials/infinite-grid-material.js';
-import type { InfiniteGridMaterialProperties } from '#components/geometry/graphics/three/materials/infinite-grid-material.js';
+import { infiniteGridMaterial } from '#materials/infinite-grid-material.js';
+import type { InfiniteGridMaterialProperties } from '#materials/infinite-grid-material.js';
type InfiniteGridProperties = {
/**
diff --git a/apps/ui/app/components/geometry/graphics/three/react/lights.tsx b/packages/three/src/react/lights.tsx
similarity index 98%
rename from apps/ui/app/components/geometry/graphics/three/react/lights.tsx
rename to packages/three/src/react/lights.tsx
index 99acd6914..4e1029375 100644
--- a/apps/ui/app/components/geometry/graphics/three/react/lights.tsx
+++ b/packages/three/src/react/lights.tsx
@@ -9,8 +9,8 @@ import {
environmentBaseIntensity,
defaultHeadlampConfig,
lightingUserDataKeys,
-} from '#components/geometry/graphics/three/utils/lights.utils.js';
-import type { SceneLightingConfig } from '#components/geometry/graphics/three/utils/lights.utils.js';
+} from '#utils/lights.utils.js';
+import type { SceneLightingConfig } from '#utils/lights.utils.js';
/** Environment cubemap resolution (px). Higher = sharper specular reflections. */
const envResolution = 512;
diff --git a/apps/ui/app/components/geometry/graphics/three/react/measure-tool.tsx b/packages/three/src/react/measure-tool.tsx
similarity index 75%
rename from apps/ui/app/components/geometry/graphics/three/react/measure-tool.tsx
rename to packages/three/src/react/measure-tool.tsx
index 78b4973b1..1ab8c639c 100644
--- a/apps/ui/app/components/geometry/graphics/three/react/measure-tool.tsx
+++ b/packages/three/src/react/measure-tool.tsx
@@ -1,19 +1,13 @@
/* eslint-disable complexity -- Label/line sizing and camera-facing math in a single component */
-import { useEffect, useRef, useState, useMemo } from 'react';
+import React, { useEffect, useRef, useState, useMemo, useContext } from 'react';
import * as THREE from 'three';
import { useFrame, useThree } from '@react-three/fiber';
-import {
- LabelTextGeometry,
- LabelBackgroundGeometry,
-} from '#components/geometry/graphics/three/geometries/label-geometry.js';
-import {
- detectSnapPoints,
- findClosestSnapPoint,
-} from '#components/geometry/graphics/three/utils/snap-detection.utils.js';
-import type { SnapPoint } from '#components/geometry/graphics/three/utils/snap-detection.utils.js';
-import { computeAxisRotationForCamera } from '#components/geometry/graphics/three/utils/rotation.utils.js';
-import { matcapMaterial } from '#components/geometry/graphics/three/materials/matcap-material.js';
-import { useGraphics, useGraphicsSelector } from '#hooks/use-graphics.js';
+import { LabelTextGeometry, LabelBackgroundGeometry } from '#geometries/label-geometry.js';
+import { detectSnapPoints, findClosestSnapPoint } from '#utils/snap-detection.utils.js';
+import type { SnapPoint } from '#utils/snap-detection.utils.js';
+import { computeAxisRotationForCamera } from '#utils/rotation.utils.js';
+import { matcapMaterial } from '#materials/matcap-material.js';
+import { useMeasureStore, CadStoreContext } from '#react/stores/store-context.js';
function calculateScaleFromCamera(position: THREE.Vector3, camera: THREE.Camera): number {
const distanceToCamera = camera.position.distanceTo(position);
@@ -30,7 +24,7 @@ function calculateScaleFromCamera(position: THREE.Vector3, camera: THREE.Camera)
factor = distanceToCamera * Math.min((1.9 * Math.tan((Math.PI * perspCamera.fov) / 360)) / perspCamera.zoom, 7);
}
- const size = 1; // Base size (equivalent to this.size in transform-controls)
+ const size = 1;
return (factor * size) / 4000;
}
@@ -54,17 +48,27 @@ const _cameraUpProjected = new THREE.Vector3();
const _lineDir = new THREE.Vector3();
const _coneOffset = new THREE.Vector3();
-export function MeasureTool(): React.JSX.Element {
+type MeasureToolProperties = {
+ readonly geometryKey?: string;
+};
+
+/** Interactive measurement tool for Three.js scenes, providing snap-to-vertex/edge measurement with labels. */
+export function MeasureTool({ geometryKey = '' }: MeasureToolProperties): React.JSX.Element {
const { camera, gl, scene } = useThree();
- const graphicsActor = useGraphics();
- const geometryKey = useGraphicsSelector((state) => state.context.geometryKey);
- const measurements = useGraphicsSelector((state) => state.context.measurements);
- const currentStart = useGraphicsSelector((state) => state.context.currentMeasurementStart);
- const snapDistance = useGraphicsSelector((state) => state.context.measureSnapDistance);
- const lengthFactor = useGraphicsSelector((state) => state.context.units.length.factor);
- const lengthSymbol = useGraphicsSelector((state) => state.context.units.length.symbol);
- const hoveredMeasurementId = useGraphicsSelector((state) => state.context.hoveredMeasurementId);
- const isMeasureActive = useGraphicsSelector((state) => state.context.isMeasureActive);
+ const stores = useContext(CadStoreContext);
+ if (!stores) {
+ throw new Error('MeasureTool must be used within a CadStoreProvider');
+ }
+
+ const { measureStore } = stores;
+
+ const measurements = useMeasureStore((s) => s.measurements);
+ const currentStart = useMeasureStore((s) => s.currentStart);
+ const snapDistance = useMeasureStore((s) => s.snapDistance);
+ const lengthFactor = useMeasureStore((s) => s.units.factor);
+ const lengthSymbol = useMeasureStore((s) => s.units.symbol);
+ const hoveredMeasurementId = useMeasureStore((s) => s.hoveredMeasurementId);
+ const isMeasureActive = useMeasureStore((s) => s.isActive);
const [hoveredSnapPoints, setHoveredSnapPoints] = useState([]);
const [activeSnapPoint, setActiveSnapPoint] = useState();
@@ -91,7 +95,6 @@ export function MeasureTool(): React.JSX.Element {
// Invalidated when geometryKey changes (new geometry loaded/unloaded).
const cachedMeshesRef = useRef([]);
const cachedMeshKeyRef = useRef(undefined);
- // Keep scene ref in sync for getCachedMeshes (stable callback reference)
const sceneRef = useRef(scene);
sceneRef.current = scene;
const geometryKeyRef = useRef(geometryKey);
@@ -115,14 +118,11 @@ export function MeasureTool(): React.JSX.Element {
});
cachedMeshesRef.current = meshes;
cachedMeshKeyRef.current = currentKey;
- // Invalidate snap point cache when geometry changes
snapCacheRef.current.clear();
return meshes;
}).current;
- // Handle mouse move for snapping
useEffect(() => {
- // Only enable interactive listeners when measure mode is active
if (!isMeasureActive) {
return undefined;
}
@@ -134,20 +134,13 @@ export function MeasureTool(): React.JSX.Element {
raycasterRef.current.setFromCamera(mouseRef.current, camera);
- // Get all meshes in scene, using cached list when geometry hasn't changed
const meshes = getCachedMeshes();
-
- // Find the closest intersected mesh (top-most object)
const intersects = raycasterRef.current.intersectObjects(meshes, true);
const firstIntersection = intersects[0];
- // Only show snap points for the closest/top-most intersected object.
- // If there is no intersection, fall back to the last detected face's snap points.
let allSnapPoints: SnapPoint[] = [];
if (firstIntersection?.object) {
const topMesh = firstIntersection.object as THREE.Mesh;
- // Cache by mesh ID + face index to avoid re-running the expensive geometry
- // pipeline when hovering over the same face on consecutive mouse moves.
const cacheKey = `${topMesh.id}:${firstIntersection.faceIndex ?? -1}`;
const cached = snapCacheRef.current.get(cacheKey);
if (cached) {
@@ -169,57 +162,49 @@ export function MeasureTool(): React.JSX.Element {
camera,
canvas: gl.domElement,
snapDistancePx: snapDistance,
- snapPointBufferPx: 15, // Add buffer for hover persistence
+ snapPointBufferPx: 15,
});
setActiveSnapPoint(closest);
- // Update mouse position for preview line
if (closest) {
setMousePosition(closest.position);
} else if (firstIntersection) {
setMousePosition(firstIntersection.point);
} else if (lastSnapPointsRef.current?.[0]) {
- // Use the first snap point as a stable mouse position proxy when off-face
setMousePosition(lastSnapPointsRef.current[0].position);
}
};
const handlePointerDown = (event: MouseEvent): void => {
- // Track camera state at mouse down to detect rotations/translations during drag
if (event.button === 0 || event.button === 2) {
startCameraQuatRef.current.copy(camera.quaternion);
startCameraPosRef.current.copy(camera.position);
mouseIsDownRef.current = true;
}
- // Only handle left clicks for measurement from here
if (event.button !== 0) {
return;
}
- // Track if pointerdown happens on a mesh
raycasterRef.current.setFromCamera(mouseRef.current, camera);
const meshes = getCachedMeshes();
const intersects = raycasterRef.current.intersectObjects(meshes, true);
- // Consider a valid pointerdown when either on a mesh or over a valid snap indicator
pointerDownOnMeshRef.current = intersects.length > 0 || Boolean(activeSnapPointRef.current);
};
const handlePointerUp = (event: MouseEvent): void => {
- // Handle right click - cancel current measurement only if no camera movement
if (event.button === 2) {
if (mouseIsDownRef.current) {
const endQuat = camera.quaternion.clone();
const endPos = camera.position.clone();
const dot = Math.abs(startCameraQuatRef.current.dot(endQuat));
- const angle = 2 * Math.acos(Math.min(1, Math.max(-1, dot))); // Radians
- const rotated = angle > 0.01; // ~0.57°
+ const angle = 2 * Math.acos(Math.min(1, Math.max(-1, dot)));
+ const rotated = angle > 0.01;
const translated = startCameraPosRef.current.distanceTo(endPos) > 1e-3;
if (!rotated && !translated && currentStartRef.current) {
- // No camera movement: treat as explicit cancel
- graphicsActor.send({ type: 'cancelCurrentMeasurement' });
+ measureStore.getState().cancelMeasurement();
}
}
@@ -228,20 +213,17 @@ export function MeasureTool(): React.JSX.Element {
return;
}
- // Only handle left clicks for measurement
if (event.button !== 0) {
return;
}
- // If the camera rotated or translated while the mouse was held down, treat this as a view manipulation,
- // not a measurement click. This avoids registering a start/end point upon releasing the drag.
if (mouseIsDownRef.current) {
const endQuat = camera.quaternion.clone();
const endPos = camera.position.clone();
const dot = Math.abs(startCameraQuatRef.current.dot(endQuat));
- const angle = 2 * Math.acos(Math.min(1, Math.max(-1, dot))); // Radians
- const rotated = angle > 0.001; // ~0.057°
+ const angle = 2 * Math.acos(Math.min(1, Math.max(-1, dot)));
+ const rotated = angle > 0.001;
const translated = startCameraPosRef.current.distanceTo(endPos) > 1e-3;
@@ -252,24 +234,20 @@ export function MeasureTool(): React.JSX.Element {
}
}
- // Only process if interaction started on mesh OR we still have a valid snap indicator
if (!pointerDownOnMeshRef.current && !activeSnapPointRef.current) {
pointerDownOnMeshRef.current = false;
return;
}
- // Verify pointerup is also on a mesh by performing a fresh raycast
raycasterRef.current.setFromCamera(mouseRef.current, camera);
const meshes = getCachedMeshes();
const intersects = raycasterRef.current.intersectObjects(meshes, true);
if (intersects.length === 0 && !activeSnapPointRef.current) {
- // No intersection and no active snap target, ignore
pointerDownOnMeshRef.current = false;
return;
}
- // Use snap point if available, otherwise use intersection point
const point = activeSnapPointRef.current?.position ?? intersects[0]?.point;
if (!point) {
pointerDownOnMeshRef.current = false;
@@ -279,29 +257,25 @@ export function MeasureTool(): React.JSX.Element {
const pointArray: [number, number, number] = [point.x, point.y, point.z];
if (currentStartRef.current) {
- // Disallow 0-length measurements by ignoring a completion click
- // that lands effectively on the start point (within a small epsilon)
const startVec = new THREE.Vector3(...currentStartRef.current);
const endVec = new THREE.Vector3(...pointArray);
- const zeroLengthEpsilon = 1e-4; // Scene units
+ const zeroLengthEpsilon = 1e-4;
if (startVec.distanceTo(endVec) <= zeroLengthEpsilon) {
pointerDownOnMeshRef.current = false;
mouseIsDownRef.current = false;
return;
}
- graphicsActor.send({ type: 'completeMeasurement', payload: pointArray });
+ measureStore.getState().completeMeasurement(pointArray);
} else {
- graphicsActor.send({ type: 'startMeasurement', payload: pointArray });
+ measureStore.getState().startMeasurement(pointArray);
}
- // Reset the pointerdown flag
pointerDownOnMeshRef.current = false;
mouseIsDownRef.current = false;
};
const handleContextMenu = (event: MouseEvent): void => {
- // Prevent context menu from showing during measurement
event.preventDefault();
};
@@ -316,12 +290,10 @@ export function MeasureTool(): React.JSX.Element {
gl.domElement.removeEventListener('pointerup', handlePointerUp);
gl.domElement.removeEventListener('contextmenu', handleContextMenu);
};
- }, [camera, gl, scene, snapDistance, isMeasureActive, graphicsActor, getCachedMeshes]);
+ }, [camera, gl, scene, snapDistance, isMeasureActive, measureStore, getCachedMeshes]);
- // Choose which measurements to display: all during measure mode, otherwise only pinned
const visibleMeasurements = isMeasureActive ? measurements : measurements.filter((m) => m.isPinned);
- // Memoize currentStart Vector3 to avoid per-render allocation
const currentStartVec3 = useMemo(
() => (currentStart ? new THREE.Vector3(...currentStart) : undefined),
[currentStart],
@@ -329,7 +301,6 @@ export function MeasureTool(): React.JSX.Element {
return (
- {/* Render snap point indicators */}
{isMeasureActive
? hoveredSnapPoints.map((snapPoint) => {
const key = `snap-${snapPoint.position.x}-${snapPoint.position.y}-${snapPoint.position.z}`;
@@ -344,17 +315,14 @@ export function MeasureTool(): React.JSX.Element {
})
: null}
- {/* Persistent indicator for the selected start point */}
{isMeasureActive && currentStartVec3 ? (
) : null}
- {/* Render preview line */}
{isMeasureActive && currentStartVec3 && mousePosition ? (
) : null}
- {/* Render completed measurements */}
{visibleMeasurements.map((measurement) => (
{
const scale = calculateScaleFromCamera(position, camera);
- // Face camera -- reuse module-scope scratch objects
_snapDirection.subVectors(camera.position, position).normalize();
_snapQuaternion.setFromUnitVectors(_snapUp.set(0, 1, 0), _snapDirection);
@@ -408,7 +374,6 @@ function SnapPointIndicator({ position, isActive, camera }: SnapPointIndicatorPr
return (
- {/* Outer border (black) */}
- {/* Inner fill (white or green when active/hovered/selected) */}
-
+
(start instanceof THREE.Vector3 ? start : new THREE.Vector3(...start)), [start]);
const endVec = useMemo(() => (end instanceof THREE.Vector3 ? end : new THREE.Vector3(...end)), [end]);
@@ -519,10 +480,7 @@ function MeasurementLine({
const endConeMeshRef = useRef(null);
const [isLabelHovered, setIsLabelHovered] = useState(false);
const isHovered = isLabelHovered || isExternallyHovered;
- const graphicsActor = useGraphics();
- // Create matcap materials following transform-controls pattern.
- // Split into base materials (created once) and hover color update (cheap, per-hover).
const derivedMaterials = useMemo(() => {
if (materials && 'backgroundMaterial' in materials && 'textMaterial' in materials && 'coneMaterial' in materials) {
return {
@@ -544,7 +502,7 @@ function MeasurementLine({
toneMapped: false,
});
const basicMaterial = new THREE.MeshBasicMaterial({
- color: materials?.backgroundColor ?? 0xff_ff_ff, // White
+ color: materials?.backgroundColor ?? 0xff_ff_ff,
depthTest: false,
depthWrite: false,
transparent: true,
@@ -554,10 +512,10 @@ function MeasurementLine({
});
const backgroundMaterial = basicMaterial.clone();
- backgroundMaterial.color.set(materials?.backgroundColor ?? 0xff_ff_ff); // White
+ backgroundMaterial.color.set(materials?.backgroundColor ?? 0xff_ff_ff);
const textMaterial = baseMaterial.clone();
- textMaterial.color.set(materials?.textColor ?? 0x00_00_00); // Black
+ textMaterial.color.set(materials?.textColor ?? 0x00_00_00);
const coneMaterial = baseMaterial.clone();
coneMaterial.color.set(materials?.coneColor ?? 0x00_00_00);
@@ -565,38 +523,32 @@ function MeasurementLine({
return { backgroundMaterial, textMaterial, coneMaterial };
}, [materials]);
- // Memoize pin button matcap texture to avoid per-render texture creation
const pinMatcapTexture = useMemo(() => matcapMaterial(), []);
- // Update cone color on hover without recreating all materials
useEffect(() => {
if (materials && 'coneMaterial' in materials) {
- return; // Externally provided materials manage their own color
+ return;
}
const coneColor = isHovered ? 0x00_ff_00 : materials && 'coneColor' in materials ? materials.coneColor : 0x00_00_00;
(derivedMaterials.coneMaterial as THREE.MeshMatcapMaterial).color.set(coneColor);
}, [isHovered, derivedMaterials, materials]);
- // Calculate label position (midpoint)
const midpoint = useMemo(
() => new THREE.Vector3().addVectors(startVec, endVec).multiplyScalar(0.5),
[startVec, endVec],
);
- // Calculate distance if not provided
const calculatedDistance = distance ?? startVec.distanceTo(endVec);
const distanceInMm = calculatedDistance / lengthFactor;
const numericText = distanceInMm.toFixed(decimals);
const unitsText = enableUnits ? lengthSymbol : '';
const labelText = `${numericText}${enableUnits ? ` ${unitsText}` : ''}`;
- // Keep a constant width box reserved for the units portion of the label background
- const unitContainerChars = 3; // Reserve width for up to 3-char units
+ const unitContainerChars = 3;
const backgroundCharsLength = numericText.length + (enableUnits ? 1 + unitContainerChars : 0);
const backgroundPlaceholderText = '0'.repeat(Math.max(1, backgroundCharsLength));
- // Memoize geometries to avoid re-creating large buffers every render frame
const textGeometry = useMemo(
// eslint-disable-next-line new-cap -- Three.js convention
() => LabelTextGeometry({ text: labelText, size: textSize, depth: textDepth }),
@@ -607,7 +559,6 @@ function MeasurementLine({
() =>
// eslint-disable-next-line new-cap -- Three.js convention
LabelBackgroundGeometry({
- // Use placeholder string sized to reserve constant-width units area
text: backgroundPlaceholderText,
characterWidth: labelCharWidth,
padding: labelPadding,
@@ -632,24 +583,17 @@ function MeasurementLine({
[backgroundPlaceholderText, labelCharWidth, labelPadding, labelHeight, labelCornerRadius, labelDepth],
);
- // Track current scale for UI sizing
const scaleRef = useRef(1);
- // Memoize measurement line direction and quaternions to avoid per-render allocations
const lineDirection = useMemo(() => new THREE.Vector3().subVectors(endVec, startVec).normalize(), [startVec, endVec]);
- // Billboard behavior - rotate around line axis to face camera
- // All scratch objects are module-scoped to avoid per-frame GC pressure.
useFrame(() => {
const scale = calculateScaleFromCamera(midpoint, camera);
scaleRef.current = scale;
- // Scale and orient label group
if (labelGroupRef.current) {
- // 1) Establish base orientation: align X-axis with the measurement line
_baseQuat.setFromUnitVectors(_currentNormal.set(1, 0, 0), lineDirection);
- // 2) Compute rotation around the line axis so the label's normal faces the camera
_currentNormal.set(0, 0, 1).applyQuaternion(_baseQuat);
const axisRotation = computeAxisRotationForCamera({
axis: lineDirection,
@@ -658,10 +602,8 @@ function MeasurementLine({
referenceUp: _currentNormal,
});
- // 3) Combine rotations: base alignment then axis rotation in world space
_finalQuat.multiplyQuaternions(axisRotation, _baseQuat);
- // 4) Ensure text is upright relative to the camera
_labelNormal.set(0, 0, 1).applyQuaternion(_finalQuat).normalize();
_labelUp.set(0, 1, 0).applyQuaternion(_finalQuat).normalize();
@@ -669,24 +611,20 @@ function MeasurementLine({
_cameraUpProjected.copy(_cameraUp).addScaledVector(_labelNormal, -_cameraUp.dot(_labelNormal)).normalize();
if (_labelUp.dot(_cameraUpProjected) < 0) {
- // Flip around the label's normal so it stays facing the camera
_flipQuat.setFromAxisAngle(_labelNormal, Math.PI);
_finalQuat.copy(_axisRotation.multiplyQuaternions(_flipQuat, _finalQuat));
}
labelGroupRef.current.quaternion.copy(_finalQuat);
- // Enlarge label by 20% when hovered (from UI or viewport)
labelGroupRef.current.scale.setScalar(scale * (isHovered ? 1.2 : 1));
labelGroupRef.current.position.copy(midpoint);
}
- // Dynamically size cylinder and cones using transform scaling with unit geometries
_lineDir.subVectors(endVec, startVec).normalize();
- // Derive UI dimensions from scale using component props
- const coneHeightScaled = coneHeight * scale; // Height of arrow heads
- const coneRadiusScaled = coneRadius * scale; // Radius of arrow heads
- const cylinderRadiusScaled = cylinderRadius * scale; // Thickness of the line
+ const coneHeightScaled = coneHeight * scale;
+ const coneRadiusScaled = coneRadius * scale;
+ const cylinderRadiusScaled = cylinderRadius * scale;
const effectiveCone = isPreview ? 0 : coneHeightScaled;
const cylinderHeight = Math.max(0.0001, lineDistance - 2 * effectiveCone);
@@ -707,7 +645,6 @@ function MeasurementLine({
}
});
- // Memoize direction, distance, and quaternions for cylinder/cone rotation
const lineDistance = useMemo(() => startVec.distanceTo(endVec), [startVec, endVec]);
const { startQuaternion, endQuaternion, cylinderQuaternion } = useMemo(() => {
const up = new THREE.Vector3(0, 1, 0);
@@ -719,21 +656,17 @@ function MeasurementLine({
return (
- {/* Line group with scaling for cylinders and cones */}
- {/* Cylinder line */}
- {/* Unit geometry – scaled per-frame */}
- {/* Cone at start */}
{!isPreview && (
- {/* Unit geometry – scaled per-frame */}
)}
- {/* Cone at end */}
{!isPreview && (
- {/* Unit geometry – scaled per-frame */}
)}
- {/* Label */}
{!isPreview && (
- {/* Stable invisible hit area to prevent hover flicker when pin appears */}
{
event.stopPropagation();
setIsLabelHovered(false);
- graphicsActor.send({ type: 'setHoveredMeasurement', payload: undefined });
+ measureStore.getState().setHoveredMeasurement(undefined);
}}
>
{(() => {
@@ -797,7 +725,6 @@ function MeasurementLine({
);
})()}
- {/* Background */}
@@ -807,45 +734,39 @@ function MeasurementLine({
- {/* Text */}
- {/* Pin button in top-right over label */}
{id && isHovered ? (
{
- // Compute approximate background width from placeholder and char width/padding
const totalChars = backgroundPlaceholderText.length;
const width = totalChars * labelCharWidth + 2 * labelPadding;
- const buttonDiameter = 2 * labelCharWidth; // 2 characters width
+ const buttonDiameter = 2 * labelCharWidth;
const offsetX = width / 2 - buttonDiameter / 2 - Math.max(5, labelPadding * 0.2);
- const offsetY = 0; // Vertically centered
+ const offsetY = 0;
return [offsetX, offsetY, 0];
})()}
renderOrder={3}
userData={{ isMeasurementUi: true }}
>
- {/* Yellow/gold circular pin button (appears only on label hover) */}
{
event.stopPropagation();
- // Keep hover state active when over pin button
setIsLabelHovered(true);
if (id) {
- graphicsActor.send({ type: 'setHoveredMeasurement', payload: id });
+ measureStore.getState().setHoveredMeasurement(id);
}
}}
onPointerOut={(event) => {
event.stopPropagation();
- // Don't clear hover immediately - let the label group handle it
}}
onPointerDown={(event) => {
if (event.nativeEvent.button === 0 && id) {
- graphicsActor.send({ type: 'toggleMeasurementPinned', id });
+ measureStore.getState().togglePinned(id);
}
event.stopPropagation();
@@ -865,7 +786,6 @@ function MeasurementLine({
/>
- {/* Pin glyph using simple geometry */}
state.context.enablePostProcessing);
+ const enablePostProcessing = useViewerStore((state) => state.enablePostProcessing);
if (!enablePostProcessing) {
return undefined;
diff --git a/packages/three/src/react/presets.ts b/packages/three/src/react/presets.ts
new file mode 100644
index 000000000..c5c524bcc
--- /dev/null
+++ b/packages/three/src/react/presets.ts
@@ -0,0 +1,57 @@
+type CadViewerPreset = {
+ enableGrid: boolean;
+ enableAxes: boolean;
+ enableGizmo: boolean;
+ enableZoom: boolean;
+ enablePan: boolean;
+ enableDamping: boolean;
+ enableCentering: boolean;
+ upDirection: 'x' | 'y' | 'z';
+};
+
+function createDefaultPreset(): CadViewerPreset {
+ return {
+ enableGrid: true,
+ enableAxes: false,
+ enableGizmo: false,
+ enableZoom: true,
+ enablePan: true,
+ enableDamping: true,
+ enableCentering: false,
+ upDirection: 'z',
+ };
+}
+
+function createMinimalPreset(): CadViewerPreset {
+ return {
+ enableGrid: false,
+ enableAxes: false,
+ enableGizmo: false,
+ enableZoom: true,
+ enablePan: true,
+ enableDamping: false,
+ enableCentering: false,
+ upDirection: 'z',
+ };
+}
+
+function createFullPreset(): CadViewerPreset {
+ return {
+ enableGrid: true,
+ enableAxes: true,
+ enableGizmo: true,
+ enableZoom: true,
+ enablePan: true,
+ enableDamping: true,
+ enableCentering: true,
+ upDirection: 'z',
+ };
+}
+
+export const presets = {
+ default: createDefaultPreset,
+ minimal: createMinimalPreset,
+ full: createFullPreset,
+} as const;
+
+export type { CadViewerPreset };
diff --git a/apps/ui/app/components/geometry/graphics/three/scene-overlay.tsx b/packages/three/src/react/scene-overlay.tsx
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/scene-overlay.tsx
rename to packages/three/src/react/scene-overlay.tsx
diff --git a/apps/ui/app/components/geometry/graphics/three/scene.tsx b/packages/three/src/react/scene.tsx
similarity index 59%
rename from apps/ui/app/components/geometry/graphics/three/scene.tsx
rename to packages/three/src/react/scene.tsx
index b2161ca5b..fde9bc114 100644
--- a/apps/ui/app/components/geometry/graphics/three/scene.tsx
+++ b/packages/three/src/react/scene.tsx
@@ -1,8 +1,8 @@
import type { ReactNode } from 'react';
-import type { StageOptions } from '#components/geometry/graphics/three/stage.js';
-import { Stage } from '#components/geometry/graphics/three/stage.js';
-import { Controls } from '#components/geometry/graphics/three/controls.js';
-import { UpDirectionHandler } from '#components/geometry/graphics/three/up-direction-handler.js';
+import type { StageOptions } from './stage.js';
+import { Stage } from './stage.js';
+import { Controls } from './controls.js';
+import { UpDirectionHandler } from './up-direction-handler.js';
type SceneProperties = {
readonly children: ReactNode;
@@ -15,6 +15,9 @@ type SceneProperties = {
readonly enableCentering?: boolean;
readonly zoomSpeed: number;
readonly gizmoContainer?: HTMLElement | string;
+ readonly geometryKey?: string;
+ readonly onSceneRadiusChange?: (radius: number) => void;
+ readonly onResetCamera?: () => void;
};
export function Scene({
@@ -28,10 +31,13 @@ export function Scene({
enableCentering = false,
zoomSpeed,
gizmoContainer,
+ geometryKey,
+ onSceneRadiusChange,
+ onResetCamera,
}: SceneProperties): React.JSX.Element {
return (
<>
-
+
-
+
{children}
>
diff --git a/apps/ui/app/components/geometry/graphics/three/react/section-view-controls.tsx b/packages/three/src/react/section-view-controls.tsx
similarity index 97%
rename from apps/ui/app/components/geometry/graphics/three/react/section-view-controls.tsx
rename to packages/three/src/react/section-view-controls.tsx
index 43f610e79..d7d83b588 100644
--- a/apps/ui/app/components/geometry/graphics/three/react/section-view-controls.tsx
+++ b/packages/three/src/react/section-view-controls.tsx
@@ -2,11 +2,11 @@ import React, { useRef, useMemo } from 'react';
import type { ThreeEvent } from '@react-three/fiber';
import * as THREE from 'three';
import { useFrame, useThree } from '@react-three/fiber';
-import { TransformControls } from '#components/geometry/graphics/three/react/transform-controls-drei.js';
-import { pixelsToWorldUnits } from '#components/geometry/graphics/three/utils/spatial.utils.js';
-import { matcapMaterial } from '#components/geometry/graphics/three/materials/matcap-material.js';
-import { FontGeometry } from '#components/geometry/graphics/three/geometries/font-geometry.js';
-import { RoundedRectangleGeometry } from '#components/geometry/graphics/three/geometries/rounded-rectangle-geometry.js';
+import { TransformControls } from './transform-controls-drei.js';
+import { pixelsToWorldUnits } from '#utils/spatial.utils.js';
+import { matcapMaterial } from '#materials/matcap-material.js';
+import { FontGeometry } from '#geometries/font-geometry.js';
+import { RoundedRectangleGeometry } from '#geometries/rounded-rectangle-geometry.js';
import { adjustHexColorBrightness } from '#utils/color.utils.js';
// Module-scope scratch vectors for PlaneSelector useFrame (avoids per-frame allocations)
diff --git a/apps/ui/app/components/geometry/graphics/three/react/section-view.tsx b/packages/three/src/react/section-view.tsx
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/react/section-view.tsx
rename to packages/three/src/react/section-view.tsx
diff --git a/apps/ui/app/components/geometry/graphics/three/stage.tsx b/packages/three/src/react/stage.tsx
similarity index 65%
rename from apps/ui/app/components/geometry/graphics/three/stage.tsx
rename to packages/three/src/react/stage.tsx
index be158c1a4..ad217cf3f 100644
--- a/apps/ui/app/components/geometry/graphics/three/stage.tsx
+++ b/packages/three/src/react/stage.tsx
@@ -2,13 +2,14 @@ import React from 'react';
import type { ReactNode } from 'react';
import type * as THREE from 'three';
import { PerspectiveCamera } from '@react-three/drei';
-import { Lights } from '#components/geometry/graphics/three/react/lights.js';
-import { SectionView } from '#components/geometry/graphics/three/react/section-view.js';
-import { useSectionView } from '#components/geometry/graphics/three/use-section-view.js';
-import { useGeometryBounds } from '#components/geometry/graphics/three/use-geometry-bounds.js';
-import { useCameraFraming } from '#components/geometry/graphics/three/use-camera-framing.js';
-import { useGraphicsSelector } from '#hooks/use-graphics.js';
+import { Lights } from '#react/lights.js';
+import { SectionView } from '#react/section-view.js';
+import { useSectionView } from '#react/hooks/use-section-view.js';
+import { useGeometryBounds } from '#react/hooks/use-geometry-bounds.js';
+import { useCameraFraming } from '#react/hooks/use-camera-framing.js';
+import { useViewerStore } from '#react/stores/store-context.js';
+/** Configuration options for the 3D stage camera and view. */
export type StageOptions = {
/**
* The ratio of the scene's radius to offset the camera from the center. Adjusting this value will change the applied perspective of the scene.
@@ -43,7 +44,6 @@ export type StageOptions = {
};
};
-// Default configuration constants
export const defaultStageOptions = {
offsetRatio: 2,
nearPlane: 1e-3,
@@ -51,37 +51,42 @@ export const defaultStageOptions = {
farPlaneRadiusMultiplier: 5,
zoomLevel: 1,
rotation: {
- side: -Math.PI / 4, // Default rotation is 45 degrees counter-clockwise
- vertical: Math.PI / 6, // Default rotation is 30 degrees upwards
+ side: -Math.PI / 4,
+ vertical: Math.PI / 6,
},
} as const satisfies StageOptions;
type StageProperties = {
readonly children: ReactNode;
- readonly enableCentering?: boolean;
+ readonly isCenteringEnabled?: boolean;
readonly stageOptions?: StageOptions;
+ readonly geometryKey?: string;
+ readonly onSceneRadiusChange?: (radius: number) => void;
} & Omit, 'id'>;
export function Stage({
children,
- enableCentering = false,
+ isCenteringEnabled = false,
stageOptions = defaultStageOptions,
+ geometryKey,
+ onSceneRadiusChange,
...properties
}: StageProperties): React.JSX.Element {
const outer = React.useRef(null);
const inner = React.useRef(null);
- const enableMatcap = useGraphicsSelector((state) => state.context.enableMatcap);
- const environmentPreset = useGraphicsSelector((state) => state.context.environmentPreset);
- const upDirection = useGraphicsSelector((state) => state.context.upDirection);
+ const enableMatcap = useViewerStore((s) => s.enableMatcap);
+ const environmentPreset = useViewerStore((s) => s.environmentPreset);
+ const upDirection = useViewerStore((s) => s.upDirection);
- // Section view (clipping plane + capping material)
const sectionView = useSectionView();
- // Geometry bounds tracking (per-frame bounding sphere + optional centering)
- const { geometryRadius, geometryCenter } = useGeometryBounds(inner, outer, { enableCentering });
+ const { geometryRadius, geometryCenter } = useGeometryBounds(inner, outer, {
+ enableCentering: isCenteringEnabled,
+ geometryKey,
+ onSceneRadiusChange,
+ });
- // Camera framing policy (auto-reset on significant geometry changes)
useCameraFraming(geometryRadius, geometryCenter, stageOptions);
return (
diff --git a/packages/three/src/react/stores/index.ts b/packages/three/src/react/stores/index.ts
new file mode 100644
index 000000000..7513ce964
--- /dev/null
+++ b/packages/three/src/react/stores/index.ts
@@ -0,0 +1,22 @@
+export { createViewerStore } from './viewer-store.js';
+export type { ViewerStore, ViewerStoreOptions, ViewerState } from './viewer-store.js';
+
+export { createMeasureStore } from './measure-store.js';
+export type { Measurement, MeasureUnits, MeasureStore, MeasureStoreOptions, MeasureState } from './measure-store.js';
+
+export { createSectionViewStore } from './section-view-store.js';
+export type {
+ AvailableSectionView,
+ SectionViewStore,
+ SectionViewStoreOptions,
+ SectionViewState,
+} from './section-view-store.js';
+
+export {
+ CadStoreProvider,
+ CadStoreContext,
+ useViewerStore,
+ useMeasureStore,
+ useSectionViewStore,
+} from './store-context.js';
+export type { CadStores } from './store-context.js';
diff --git a/packages/three/src/react/stores/measure-store.ts b/packages/three/src/react/stores/measure-store.ts
new file mode 100644
index 000000000..7289fec6a
--- /dev/null
+++ b/packages/three/src/react/stores/measure-store.ts
@@ -0,0 +1,114 @@
+import { createStore } from 'zustand/vanilla';
+
+type Measurement = {
+ id: string;
+ startPoint: [number, number, number];
+ endPoint: [number, number, number];
+ distance: number;
+ name: string;
+ isPinned: boolean;
+};
+
+type MeasureUnits = {
+ factor: number;
+ symbol: string;
+};
+
+type MeasureStoreOptions = {
+ snapDistance?: number;
+ units?: MeasureUnits;
+};
+
+type MeasureState = {
+ isActive: boolean;
+ measurements: Measurement[];
+ currentStart: [number, number, number] | undefined;
+ snapDistance: number;
+ hoveredMeasurementId: string | undefined;
+ units: MeasureUnits;
+ setActive: (active: boolean) => void;
+ startMeasurement: (point: [number, number, number]) => void;
+ completeMeasurement: (endPoint: [number, number, number]) => void;
+ cancelMeasurement: () => void;
+ clearMeasurement: (id: string) => void;
+ clearAll: () => void;
+ clearUnpinned: () => void;
+ togglePinned: (id: string) => void;
+ setHoveredMeasurement: (id: string | undefined) => void;
+ setMeasurementName: (id: string, name: string) => void;
+ setUnits: (units: MeasureUnits) => void;
+ setSnapDistance: (distance: number) => void;
+};
+
+function computeDistance(start: [number, number, number], end: [number, number, number]): number {
+ const dx = end[0] - start[0];
+ const dy = end[1] - start[1];
+ const dz = end[2] - start[2];
+ return Math.sqrt(dx * dx + dy * dy + dz * dz);
+}
+
+export function createMeasureStore(options?: MeasureStoreOptions) {
+ return createStore((set) => ({
+ isActive: false,
+ measurements: [],
+ currentStart: undefined,
+ snapDistance: options?.snapDistance ?? 0.1,
+ hoveredMeasurementId: undefined,
+ units: options?.units ?? { factor: 1, symbol: 'mm' },
+ setActive: (active) => { set({ isActive: active }); },
+ startMeasurement: (point) => { set({ currentStart: point }); },
+ completeMeasurement: (endPoint) => {
+ set((state) => {
+ if (!state.currentStart) {
+ return state;
+ }
+
+ const measurement: Measurement = {
+ id: crypto.randomUUID(),
+ startPoint: state.currentStart,
+ endPoint,
+ distance: computeDistance(state.currentStart, endPoint),
+ name: `Measurement ${String(state.measurements.length + 1)}`,
+ isPinned: false,
+ };
+
+ return {
+ measurements: [...state.measurements, measurement],
+ currentStart: undefined,
+ };
+ });
+ },
+ cancelMeasurement: () => { set({ currentStart: undefined }); },
+ clearMeasurement: (id) => {
+ set((state) => ({
+ measurements: state.measurements.filter((m) => m.id !== id),
+ }));
+ },
+ clearAll: () => { set({ measurements: [], currentStart: undefined }); },
+ clearUnpinned: () => {
+ set((state) => ({
+ measurements: state.measurements.filter((m) => m.isPinned),
+ }));
+ },
+ togglePinned: (id) => {
+ set((state) => ({
+ measurements: state.measurements.map((m) =>
+ m.id === id ? { ...m, isPinned: !m.isPinned } : m,
+ ),
+ }));
+ },
+ setHoveredMeasurement: (id) => { set({ hoveredMeasurementId: id }); },
+ setMeasurementName: (id, name) => {
+ set((state) => ({
+ measurements: state.measurements.map((m) =>
+ m.id === id ? { ...m, name } : m,
+ ),
+ }));
+ },
+ setUnits: (units) => { set({ units }); },
+ setSnapDistance: (distance) => { set({ snapDistance: distance }); },
+ }));
+}
+
+export type { Measurement, MeasureUnits, MeasureStoreOptions, MeasureState };
+export type MeasureStore = ReturnType;
diff --git a/packages/three/src/react/stores/section-view-store.ts b/packages/three/src/react/stores/section-view-store.ts
new file mode 100644
index 000000000..9435ad98d
--- /dev/null
+++ b/packages/three/src/react/stores/section-view-store.ts
@@ -0,0 +1,72 @@
+import { createStore } from 'zustand/vanilla';
+
+type AvailableSectionView = {
+ id: 'xy' | 'xz' | 'yz';
+ normal: [number, number, number];
+ constant: number;
+};
+
+type SectionViewStoreOptions = {
+ enableLines?: boolean;
+ enableMesh?: boolean;
+ availableSectionViews?: AvailableSectionView[];
+};
+
+type SectionViewState = {
+ isActive: boolean;
+ selectedPlaneId: 'xy' | 'xz' | 'yz' | undefined;
+ rotation: [number, number, number];
+ direction: 1 | -1;
+ pivot: [number, number, number];
+ enableLines: boolean;
+ enableMesh: boolean;
+ hoveredSectionViewId: string | undefined;
+ planeName: 'cartesian' | 'face';
+ availableSectionViews: AvailableSectionView[];
+ setActive: (active: boolean) => void;
+ selectPlane: (planeId: 'xy' | 'xz' | 'yz' | undefined) => void;
+ setRotation: (rotation: [number, number, number]) => void;
+ setPivot: (pivot: [number, number, number]) => void;
+ toggleDirection: () => void;
+ setDirection: (direction: 1 | -1) => void;
+ setHoveredSectionView: (id: string | undefined) => void;
+ setPlaneName: (name: 'cartesian' | 'face') => void;
+ setEnableLines: (enabled: boolean) => void;
+ setEnableMesh: (enabled: boolean) => void;
+};
+
+const DEFAULT_SECTION_VIEWS: AvailableSectionView[] = [
+ { id: 'xy', normal: [0, 0, 1], constant: 0 },
+ { id: 'xz', normal: [0, 1, 0], constant: 0 },
+ { id: 'yz', normal: [1, 0, 0], constant: 0 },
+];
+
+export function createSectionViewStore(options?: SectionViewStoreOptions) {
+ return createStore((set) => ({
+ isActive: false,
+ selectedPlaneId: undefined,
+ rotation: [0, 0, 0],
+ direction: 1,
+ pivot: [0, 0, 0],
+ enableLines: options?.enableLines ?? true,
+ enableMesh: options?.enableMesh ?? true,
+ hoveredSectionViewId: undefined,
+ planeName: 'cartesian',
+ availableSectionViews: options?.availableSectionViews ?? DEFAULT_SECTION_VIEWS,
+ setActive: (active) => { set({ isActive: active }); },
+ selectPlane: (planeId) => { set({ selectedPlaneId: planeId }); },
+ setRotation: (rotation) => { set({ rotation }); },
+ setPivot: (pivot) => { set({ pivot }); },
+ toggleDirection: () => {
+ set((state) => ({ direction: state.direction === 1 ? -1 : 1 }));
+ },
+ setDirection: (direction) => { set({ direction }); },
+ setHoveredSectionView: (id) => { set({ hoveredSectionViewId: id }); },
+ setPlaneName: (name) => { set({ planeName: name }); },
+ setEnableLines: (enabled) => { set({ enableLines: enabled }); },
+ setEnableMesh: (enabled) => { set({ enableMesh: enabled }); },
+ }));
+}
+
+export type { AvailableSectionView, SectionViewStoreOptions, SectionViewState };
+export type SectionViewStore = ReturnType;
diff --git a/packages/three/src/react/stores/store-context.tsx b/packages/three/src/react/stores/store-context.tsx
new file mode 100644
index 000000000..81c6b2850
--- /dev/null
+++ b/packages/three/src/react/stores/store-context.tsx
@@ -0,0 +1,79 @@
+import React, { createContext, useContext, useRef } from 'react';
+import { useStore } from 'zustand';
+import { createViewerStore } from './viewer-store.js';
+import { createMeasureStore } from './measure-store.js';
+import { createSectionViewStore } from './section-view-store.js';
+import type { ViewerStore, ViewerStoreOptions, ViewerState } from './viewer-store.js';
+import type { MeasureStore, MeasureStoreOptions, MeasureState } from './measure-store.js';
+import type { SectionViewStore, SectionViewStoreOptions, SectionViewState } from './section-view-store.js';
+
+type CadStores = {
+ viewerStore: ViewerStore;
+ measureStore: MeasureStore;
+ sectionViewStore: SectionViewStore;
+};
+
+const CadStoreContext = createContext(undefined);
+
+type CadStoreProviderProperties = {
+ readonly children: React.ReactNode;
+ readonly viewerStore?: ViewerStore;
+ readonly measureStore?: MeasureStore;
+ readonly sectionViewStore?: SectionViewStore;
+ readonly viewerOptions?: ViewerStoreOptions;
+ readonly measureOptions?: MeasureStoreOptions;
+ readonly sectionViewOptions?: SectionViewStoreOptions;
+};
+
+export function CadStoreProvider({
+ children,
+ viewerStore,
+ measureStore,
+ sectionViewStore,
+ viewerOptions,
+ measureOptions,
+ sectionViewOptions,
+}: CadStoreProviderProperties): React.JSX.Element {
+ const storesRef = useRef(undefined);
+
+ if (!storesRef.current) {
+ storesRef.current = {
+ viewerStore: viewerStore ?? createViewerStore(viewerOptions),
+ measureStore: measureStore ?? createMeasureStore(measureOptions),
+ sectionViewStore: sectionViewStore ?? createSectionViewStore(sectionViewOptions),
+ };
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+function useCadStores(): CadStores {
+ const stores = useContext(CadStoreContext);
+ if (!stores) {
+ throw new Error('useCadStores must be used within a CadStoreProvider');
+ }
+
+ return stores;
+}
+
+export function useViewerStore(selector: (state: ViewerState) => T): T {
+ const { viewerStore } = useCadStores();
+ return useStore(viewerStore, selector);
+}
+
+export function useMeasureStore(selector: (state: MeasureState) => T): T {
+ const { measureStore } = useCadStores();
+ return useStore(measureStore, selector);
+}
+
+export function useSectionViewStore(selector: (state: SectionViewState) => T): T {
+ const { sectionViewStore } = useCadStores();
+ return useStore(sectionViewStore, selector);
+}
+
+export { CadStoreContext };
+export type { CadStores };
diff --git a/packages/three/src/react/stores/viewer-store.ts b/packages/three/src/react/stores/viewer-store.ts
new file mode 100644
index 000000000..e31b9d9d1
--- /dev/null
+++ b/packages/three/src/react/stores/viewer-store.ts
@@ -0,0 +1,85 @@
+import { createStore } from 'zustand/vanilla';
+
+type ViewerStoreOptions = {
+ enableGrid?: boolean;
+ enableAxes?: boolean;
+ enableMatcap?: boolean;
+ enablePostProcessing?: boolean;
+ enableSurfaces?: boolean;
+ enableLines?: boolean;
+ enableGizmo?: boolean;
+ fieldOfView?: number;
+ upDirection?: 'x' | 'y' | 'z';
+ environmentPreset?: 'studio' | 'neutral' | 'soft' | 'performance';
+ gridSizes?: { smallSize: number; largeSize: number };
+ theme?: 'light' | 'dark';
+ accentColor?: string;
+ sceneRadius?: number;
+};
+
+type ViewerState = {
+ enableGrid: boolean;
+ enableAxes: boolean;
+ enableMatcap: boolean;
+ enablePostProcessing: boolean;
+ enableSurfaces: boolean;
+ enableLines: boolean;
+ enableGizmo: boolean;
+ fieldOfView: number;
+ upDirection: 'x' | 'y' | 'z';
+ environmentPreset: 'studio' | 'neutral' | 'soft' | 'performance';
+ gridSizes: { smallSize: number; largeSize: number };
+ theme: 'light' | 'dark';
+ accentColor: string;
+ sceneRadius: number;
+ setFieldOfView: (angle: number) => void;
+ setUpDirection: (direction: 'x' | 'y' | 'z') => void;
+ setGridSizes: (sizes: { smallSize: number; largeSize: number }) => void;
+ setTheme: (theme: 'light' | 'dark') => void;
+ setAccentColor: (color: string) => void;
+ setSceneRadius: (radius: number) => void;
+ setEnableGrid: (enabled: boolean) => void;
+ setEnableAxes: (enabled: boolean) => void;
+ setEnableMatcap: (enabled: boolean) => void;
+ setEnablePostProcessing: (enabled: boolean) => void;
+ setEnableSurfaces: (enabled: boolean) => void;
+ setEnableLines: (enabled: boolean) => void;
+ setEnableGizmo: (enabled: boolean) => void;
+ setEnvironmentPreset: (preset: 'studio' | 'neutral' | 'soft' | 'performance') => void;
+};
+
+export function createViewerStore(options?: ViewerStoreOptions) {
+ return createStore((set) => ({
+ enableGrid: options?.enableGrid ?? false,
+ enableAxes: options?.enableAxes ?? false,
+ enableMatcap: options?.enableMatcap ?? false,
+ enablePostProcessing: options?.enablePostProcessing ?? false,
+ enableSurfaces: options?.enableSurfaces ?? true,
+ enableLines: options?.enableLines ?? true,
+ enableGizmo: options?.enableGizmo ?? false,
+ fieldOfView: options?.fieldOfView ?? 50,
+ upDirection: options?.upDirection ?? 'z',
+ environmentPreset: options?.environmentPreset ?? 'studio',
+ gridSizes: options?.gridSizes ?? { smallSize: 10, largeSize: 100 },
+ theme: options?.theme ?? 'light',
+ accentColor: options?.accentColor ?? '#3b82f6',
+ sceneRadius: options?.sceneRadius ?? 0,
+ setFieldOfView: (angle) => { set({ fieldOfView: angle }); },
+ setUpDirection: (direction) => { set({ upDirection: direction }); },
+ setGridSizes: (sizes) => { set({ gridSizes: sizes }); },
+ setTheme: (theme) => { set({ theme }); },
+ setAccentColor: (color) => { set({ accentColor: color }); },
+ setSceneRadius: (radius) => { set({ sceneRadius: radius }); },
+ setEnableGrid: (enabled) => { set({ enableGrid: enabled }); },
+ setEnableAxes: (enabled) => { set({ enableAxes: enabled }); },
+ setEnableMatcap: (enabled) => { set({ enableMatcap: enabled }); },
+ setEnablePostProcessing: (enabled) => { set({ enablePostProcessing: enabled }); },
+ setEnableSurfaces: (enabled) => { set({ enableSurfaces: enabled }); },
+ setEnableLines: (enabled) => { set({ enableLines: enabled }); },
+ setEnableGizmo: (enabled) => { set({ enableGizmo: enabled }); },
+ setEnvironmentPreset: (preset) => { set({ environmentPreset: preset }); },
+ }));
+}
+
+export type { ViewerStoreOptions, ViewerState };
+export type ViewerStore = ReturnType;
diff --git a/apps/ui/app/components/geometry/graphics/three/react/transform-controls-drei.tsx b/packages/three/src/react/transform-controls-drei.tsx
similarity index 98%
rename from apps/ui/app/components/geometry/graphics/three/react/transform-controls-drei.tsx
rename to packages/three/src/react/transform-controls-drei.tsx
index ed950be0b..b7a19b9ab 100644
--- a/apps/ui/app/components/geometry/graphics/three/react/transform-controls-drei.tsx
+++ b/packages/three/src/react/transform-controls-drei.tsx
@@ -3,7 +3,7 @@ import { useThree } from '@react-three/fiber';
import * as React from 'react';
import * as THREE from 'three';
import type { ForwardRefComponent } from '@react-three/drei/helpers/ts-utils.js';
-import { TransformControls as TransformControlsImpl } from '#components/geometry/graphics/three/controls/transform-controls.js';
+import { TransformControls as TransformControlsImpl } from '#controls/transform-controls.js';
type ControlsProto = {
enabled: boolean;
diff --git a/apps/ui/app/components/geometry/graphics/three/up-direction-handler.tsx b/packages/three/src/react/up-direction-handler.tsx
similarity index 52%
rename from apps/ui/app/components/geometry/graphics/three/up-direction-handler.tsx
rename to packages/three/src/react/up-direction-handler.tsx
index 8119eca37..fbb132f5d 100644
--- a/apps/ui/app/components/geometry/graphics/three/up-direction-handler.tsx
+++ b/packages/three/src/react/up-direction-handler.tsx
@@ -2,25 +2,20 @@ import { useEffect } from 'react';
import { useThree } from '@react-three/fiber';
import type { OrbitControls } from 'three/addons';
import * as THREE from 'three';
-import { useCameraCapability } from '#hooks/use-graphics.js';
type UpDirectionHandlerProperties = {
readonly upDirection: 'x' | 'y' | 'z';
+ readonly onResetCamera?: () => void;
};
/**
* Component that handles dynamic up direction changes for the camera and all scene objects.
* Must be inside the Canvas component to access the Three.js context.
*/
-export function UpDirectionHandler({ upDirection }: UpDirectionHandlerProperties): undefined {
+export function UpDirectionHandler({ upDirection, onResetCamera }: UpDirectionHandlerProperties): undefined {
const { camera, scene, controls, invalidate } = useThree();
- const cameraCapabilityActor = useCameraCapability();
useEffect(() => {
- // Define the new up direction based on the selected axis
- // x: X-up (1, 0, 0) - Alternative coordinate system
- // y: Y-up (0, 1, 0) - Standard Three.js
- // z: Z-up (0, 0, 1) - CAD/engineering default
const newUp =
upDirection === 'x'
? new THREE.Vector3(1, 0, 0)
@@ -28,37 +23,28 @@ export function UpDirectionHandler({ upDirection }: UpDirectionHandlerProperties
? new THREE.Vector3(0, 1, 0)
: new THREE.Vector3(0, 0, 1);
- // Set the global default for new objects
THREE.Object3D.DEFAULT_UP.copy(newUp);
- // Update the camera's up vector
camera.up.copy(newUp);
- // Set up vectors on all objects without matrix updates during traverse,
- // then call updateMatrixWorld once on the scene root. This reduces O(N²)
- // work (recursive update inside traverse) to a single O(N) pass.
scene.traverse((object) => {
object.up.copy(newUp);
});
scene.updateMatrixWorld(true);
- // Update the camera's orientation
camera.lookAt(0, 0, 0);
camera.updateProjectionMatrix();
- // Update controls if they exist (OrbitControls type)
if (controls && 'target' in controls && 'update' in controls) {
const orbitControls = controls as OrbitControls;
orbitControls.target.set(0, 0, 0);
orbitControls.update();
}
- // Trigger a camera reset to properly position the camera for the new up direction
- cameraCapabilityActor.send({ type: 'reset' });
+ onResetCamera?.();
- // Force a render
invalidate();
- }, [upDirection, camera, scene, controls, invalidate, cameraCapabilityActor]);
+ }, [upDirection, camera, scene, controls, invalidate, onResetCamera]);
return undefined;
}
diff --git a/apps/ui/app/components/geometry/graphics/three/use-camera-reset.tsx b/packages/three/src/react/use-camera-reset.ts
similarity index 61%
rename from apps/ui/app/components/geometry/graphics/three/use-camera-reset.tsx
rename to packages/three/src/react/use-camera-reset.ts
index 1401d7d86..a4a64c744 100644
--- a/apps/ui/app/components/geometry/graphics/three/use-camera-reset.tsx
+++ b/packages/three/src/react/use-camera-reset.ts
@@ -2,10 +2,8 @@ import { useCallback, useEffect, useRef } from 'react';
import { useThree } from '@react-three/fiber';
import type { RefObject } from 'react';
import type * as THREE from 'three';
-import { resetCamera as resetCameraFn } from '#components/geometry/graphics/three/utils/camera.utils.js';
-import { useCameraCapability } from '#hooks/use-graphics.js';
+import { resetCamera as resetCameraFn } from '#utils/camera.utils.js';
-// Define the specific types needed for camera reset
type ResetRotation = {
side: number;
vertical: number;
@@ -27,29 +25,21 @@ type ResetCameraParameters = {
setSceneRadius: (radius: number) => void;
originalDistanceReference?: RefObject;
cameraFovAngle: number;
+ onResetCamera?: (reset: () => void) => void;
};
/**
- * Hook that provides camera reset functionality and registers it with the graphics context
+ * Hook that provides camera reset functionality.
*
- * @param parameters - The parameters for the camera reset.
- * @returns The reset function.
+ * Consumers can pass an optional `onResetCamera` callback to register the
+ * reset function externally (e.g. with a capability actor in the host app).
*/
-export function useCameraReset(parameters: ResetCameraParameters): (options?: {
- /**
- * Whether to enable configured angles.
- * @default true
- */
- enableConfiguredAngles?: boolean;
-}) => void {
+export function useCameraReset(
+ parameters: ResetCameraParameters,
+): (options?: { enableConfiguredAngles?: boolean }) => void {
const { camera, controls, invalidate, size } = useThree();
const viewportAspect = size.width > 0 && size.height > 0 ? size.width / size.height : 1;
- const cameraCapabilityActor = useCameraCapability();
- const isRegistered = useRef(false);
- // Store viewportAspect in a ref so the resetCamera callback remains stable
- // during resize. The aspect is read lazily when reset is actually called,
- // preventing callback recreation on every resize pixel.
const viewportAspectRef = useRef(viewportAspect);
viewportAspectRef.current = viewportAspect;
@@ -61,11 +51,11 @@ export function useCameraReset(parameters: ResetCameraParameters): (options?: {
setSceneRadius,
originalDistanceReference,
cameraFovAngle,
+ onResetCamera,
} = parameters;
const resetCamera = useCallback(
(options?: { enableConfiguredAngles?: boolean }) => {
- // Reset original distance reference if available
if (originalDistanceReference?.current !== undefined) {
originalDistanceReference.current = undefined;
}
@@ -98,14 +88,9 @@ export function useCameraReset(parameters: ResetCameraParameters): (options?: {
],
);
- // Register the reset function with the camera capability actor only once
useEffect(() => {
- if (!isRegistered.current) {
- cameraCapabilityActor.send({ type: 'registerReset', reset: resetCamera });
- isRegistered.current = true;
- }
- }, [resetCamera, cameraCapabilityActor]);
+ onResetCamera?.(resetCamera);
+ }, [resetCamera, onResetCamera]);
- // Return the reset function for direct use if needed
return resetCamera;
}
diff --git a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-cube.tsx b/packages/three/src/react/viewport-gizmo.tsx
similarity index 66%
rename from apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-cube.tsx
rename to packages/three/src/react/viewport-gizmo.tsx
index 2f5d5f56c..8ce22ff8b 100644
--- a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-cube.tsx
+++ b/packages/three/src/react/viewport-gizmo.tsx
@@ -6,19 +6,17 @@ import { useEffect, useCallback, useRef } from 'react';
import * as THREE from 'three';
import type { OrbitControls } from 'three/addons';
import type { ReactNode } from 'react';
-import { useColor } from '#hooks/use-color.js';
-import { Theme, useTheme } from '#hooks/use-theme.js';
-import { createViewportGizmoCubeAxes } from '#components/geometry/graphics/three/controls/viewport-gizmo-cube-axes.js';
-import { useGraphicsSelector } from '#hooks/use-graphics.js';
+import { createViewportGizmoCubeAxes } from '#controls/viewport-gizmo-cube-axes.js';
+import { useViewerStore } from '#react/stores/store-context.js';
import {
syncGizmoFov,
resolveGizmoContainer,
createGizmoCanvas,
createGizmoRenderer,
disposeGizmoResources,
-} from '#components/geometry/graphics/three/utils/gizmo.utils.js';
+} from '#utils/gizmo.utils.js';
-type ViewportGizmoCubeProps = {
+type ViewportGizmoProperties = {
readonly size?: number;
/**
* A container element or selector to append the gizmo to.
@@ -46,25 +44,20 @@ export function ViewportGizmoCube({
size = 96,
container,
dependencies = emptyDependencies,
-}: ViewportGizmoCubeProps): ReactNode {
+}: ViewportGizmoProperties): ReactNode {
const camera = useThree((state) => state.camera) as THREE.PerspectiveCamera;
const gl = useThree((state) => state.gl);
const controls = useThree((state) => state.controls) as OrbitControls;
const scene = useThree((state) => state.scene);
const invalidate = useThree((state) => state.invalidate);
- const { serialized } = useColor();
- const { theme } = useTheme();
+ const accentColor = useViewerStore((state) => state.accentColor);
+ const theme = useViewerStore((state) => state.theme);
+ const fieldOfView = useViewerStore((state) => state.fieldOfView);
- // Subscribe to the viewport FOV from the per-view graphics machine
- const cameraFovAngle = useGraphicsSelector((state) => state.context.cameraFovAngle);
+ const fieldOfViewRef = useRef(fieldOfView);
+ fieldOfViewRef.current = fieldOfView;
- // Keep a ref to the current angle so the creation effect can read it without
- // adding cameraFovAngle as a dependency (which would cause expensive recreation)
- const cameraFovAngleRef = useRef(cameraFovAngle);
- cameraFovAngleRef.current = cameraFovAngle;
-
- // Ref to the live gizmo instance for the FOV sync effect
// eslint-disable-next-line @typescript-eslint/no-restricted-types -- React ref
const gizmoRef = useRef(null);
// eslint-disable-next-line @typescript-eslint/no-restricted-types -- React ref
@@ -74,9 +67,7 @@ export function ViewportGizmoCube({
invalidate();
}, [invalidate]);
- // Create DOM overlay for gizmo
useEffect(() => {
- // Early return if we don't have the required components
if (!camera || !gl || !controls) {
return;
}
@@ -93,28 +84,27 @@ export function ViewportGizmoCube({
const renderer = createGizmoRenderer(canvas, size);
const faceConfig = {
- color: theme === Theme.DARK ? 0x33_33_33 : 0xdd_dd_dd,
- labelColor: theme === Theme.DARK ? 0xff_ff_ff : 0x00_00_00,
+ color: theme === 'dark' ? 0x33_33_33 : 0xdd_dd_dd,
+ labelColor: theme === 'dark' ? 0xff_ff_ff : 0x00_00_00,
hover: {
- color: serialized.hex,
+ color: accentColor,
},
} as const satisfies GizmoAxisOptions;
const edgeConfig = {
- color: theme === Theme.DARK ? 0x55_55_55 : 0xee_ee_ee,
+ color: theme === 'dark' ? 0x55_55_55 : 0xee_ee_ee,
opacity: 1,
hover: {
- color: serialized.hex,
+ color: accentColor,
},
} as const satisfies GizmoAxisOptions;
const cornerConfig = {
...faceConfig,
- color: theme === Theme.DARK ? 0x33_33_33 : 0xdd_dd_dd,
+ color: theme === 'dark' ? 0x33_33_33 : 0xdd_dd_dd,
hover: {
- color: serialized.hex,
+ color: accentColor,
},
} as const satisfies GizmoAxisOptions;
- // Configure the gizmo options
const gizmoConfig: GizmoOptions = {
type: 'rounded-cube',
placement: 'bottom-right',
@@ -141,15 +131,12 @@ export function ViewportGizmoCube({
bottom: faceConfig,
};
- // Create the gizmo
const gizmo = new ViewportGizmo(camera, renderer, gizmoConfig);
gizmoRef.current = gizmo;
rendererRef.current = renderer;
- // Synchronize the gizmo's internal camera FOV with the current viewport FOV
- syncGizmoFov(gizmo, cameraFovAngleRef.current);
+ syncGizmoFov(gizmo, fieldOfViewRef.current);
- // Add event listeners for the gizmo
gizmo.addEventListener('change', handleChange);
gizmo.scale.multiplyScalar(0.7);
@@ -167,21 +154,17 @@ export function ViewportGizmoCube({
}),
);
- // Attach the controls to enable proper interaction
gizmo.attachControls(controls);
- // Cleanup function
return () => {
- // Clear refs so the useFrame and FOV sync effect cannot operate on disposed objects
gizmoRef.current = null;
rendererRef.current = null;
disposeGizmoResources({ gizmo, renderer, canvas, handleChange });
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- dependencies array is user-provided for custom recreation triggers
- }, [camera, gl, controls, scene, serialized.hex, theme, size, handleChange, container, ...dependencies]);
+ }, [camera, gl, controls, scene, accentColor, theme, size, handleChange, container, ...dependencies]);
- // Demand-based gizmo rendering: only render when the R3F frame loop fires (on invalidation)
useFrame(() => {
if (rendererRef.current && gizmoRef.current) {
rendererRef.current.toneMapping = THREE.NoToneMapping;
@@ -189,13 +172,11 @@ export function ViewportGizmoCube({
}
});
- // Real-time FOV sync: update the gizmo's internal camera when the viewport FOV changes.
- // This is a separate effect to avoid expensive gizmo recreation on every slider tick.
useEffect(() => {
if (gizmoRef.current) {
- syncGizmoFov(gizmoRef.current, cameraFovAngle);
+ syncGizmoFov(gizmoRef.current, fieldOfView);
}
- }, [cameraFovAngle]);
+ }, [fieldOfView]);
return null;
}
diff --git a/packages/three/src/screenshot/capture-screenshot.ts b/packages/three/src/screenshot/capture-screenshot.ts
new file mode 100644
index 000000000..9ad61888c
--- /dev/null
+++ b/packages/three/src/screenshot/capture-screenshot.ts
@@ -0,0 +1,219 @@
+import * as THREE from 'three';
+import type { CameraAngle, ScreenshotOptions } from '#screenshot/types.js';
+import { applyMatcapToClonedScene, disposeClonedSceneMaterials } from '#materials/gltf-matcap.js';
+import { ensureMatcapTextureLoaded } from '#materials/matcap-material.js';
+import { defaultStageOptions } from '#react/stage.js';
+import { computeViewFittingZoom } from '#utils/camera.utils.js';
+import { calculateFovDistanceCompensation } from '#utils/math.utils.js';
+
+const defaultOptions = {
+ aspectRatio: 16 / 9,
+ zoomLevel: 1.25,
+ cameraAngles: [{ phi: undefined, theta: undefined }] as CameraAngle[],
+ output: {
+ format: 'image/png' as const,
+ quality: 0.92,
+ isPreview: true,
+ },
+} satisfies ScreenshotOptions;
+
+/**
+ * Capture one or more screenshots of a Three.js scene from the given camera
+ * angles. The function creates a temporary off-screen renderer, applies matcap
+ * materials for lighting-independent rendering, and returns an array of data
+ * URLs — one per camera angle.
+ */
+export async function captureScreenshot({
+ gl,
+ scene,
+ camera,
+ options,
+}: {
+ gl: THREE.WebGLRenderer;
+ scene: THREE.Scene;
+ camera: THREE.Camera;
+ options?: ScreenshotOptions;
+}): Promise {
+ if (!gl.domElement.isConnected) {
+ throw new Error('Screenshot attempted on disconnected canvas - canvas may have been recreated');
+ }
+
+ const config = {
+ ...defaultOptions,
+ ...options,
+ output: {
+ ...defaultOptions.output,
+ ...options?.output,
+ },
+ };
+
+ if (config.cameraAngles.length === 0) {
+ config.cameraAngles = defaultOptions.cameraAngles;
+ }
+
+ const originalHeight = gl.domElement.height;
+
+ const targetAspect = config.aspectRatio;
+ let width = Math.round(originalHeight * targetAspect);
+ let height = originalHeight;
+
+ if (config.maxResolution) {
+ const maxDimension = Math.max(width, height);
+ if (maxDimension > config.maxResolution) {
+ const scale = config.maxResolution / maxDimension;
+ width = Math.round(width * scale);
+ height = Math.round(height * scale);
+ }
+ }
+
+ const screenshotCanvas = document.createElement('canvas');
+ screenshotCanvas.width = width;
+ screenshotCanvas.height = height;
+
+ const screenshotRenderer = new THREE.WebGLRenderer({
+ canvas: screenshotCanvas,
+ alpha: true,
+ antialias: true,
+ logarithmicDepthBuffer: true,
+ });
+
+ try {
+ screenshotRenderer.setSize(width, height, false);
+
+ const useHighDpi = config.cameraAngles.length === 1;
+ const pixelRatio = useHighDpi ? gl.getPixelRatio() : 1;
+ screenshotRenderer.setPixelRatio(pixelRatio);
+
+ screenshotRenderer.outputColorSpace = gl.outputColorSpace;
+
+ // Matcap materials produce values in displayable range — no filmic
+ // tone-mapping curve needed.
+ screenshotRenderer.toneMapping = THREE.NoToneMapping;
+ screenshotRenderer.toneMappingExposure = 1;
+
+ const dataUrls: string[] = [];
+
+ const screenshotScene = scene.clone();
+
+ if (config.output.isPreview) {
+ screenshotScene.traverse((object) => {
+ if (object.userData['isPreviewOnly']) {
+ object.visible = false;
+ }
+ });
+ }
+
+ // Replace all mesh materials with matcap for lighting-independent rendering.
+ const matcapTexture = await ensureMatcapTextureLoaded();
+ applyMatcapToClonedScene(screenshotScene, matcapTexture);
+
+ screenshotScene.environment = null;
+ screenshotScene.environmentIntensity = 0;
+
+ // Compute bounding-box center and sphere radius so the camera orbits and
+ // frames the actual model rather than the world origin.
+ const boundingBox = new THREE.Box3().setFromObject(screenshotScene);
+ const geometryCenter = new THREE.Vector3();
+ const boundingSphere = new THREE.Sphere();
+ boundingBox.getCenter(geometryCenter);
+ boundingBox.getBoundingSphere(boundingSphere);
+ const geometryRadius = boundingSphere.radius > 0 ? boundingSphere.radius : 1000;
+
+ for (const cameraAngle of config.cameraAngles) {
+ const screenshotCamera = (camera as THREE.PerspectiveCamera).clone();
+
+ // Fix FOV to 45° for consistent perspective across all screenshots.
+ // Compensate zoom so the same visible area is preserved.
+ const screenshotFov = 45;
+ const zoomCompensation = calculateFovDistanceCompensation(screenshotFov, screenshotCamera.fov, 1);
+ screenshotCamera.fov = screenshotFov;
+ screenshotCamera.zoom = config.zoomLevel * zoomCompensation;
+ screenshotCamera.aspect = config.aspectRatio;
+
+ if (cameraAngle.phi !== undefined && cameraAngle.theta !== undefined) {
+ const standardFov = 60;
+ const adjustedOffsetRatio =
+ defaultStageOptions.offsetRatio * calculateFovDistanceCompensation(standardFov, screenshotFov, 1);
+ const distance = geometryRadius * adjustedOffsetRatio;
+
+ const phiRad = (cameraAngle.phi * Math.PI) / 180;
+ const thetaRad = (cameraAngle.theta * Math.PI) / 180;
+
+ const upVector = THREE.Object3D.DEFAULT_UP.clone();
+
+ let ox: number;
+ let oy: number;
+ let oz: number;
+
+ if (upVector.z === 1) {
+ ox = distance * Math.sin(phiRad) * Math.cos(thetaRad);
+ oy = distance * Math.sin(phiRad) * Math.sin(thetaRad);
+ oz = distance * Math.cos(phiRad);
+ } else if (upVector.y === 1) {
+ ox = distance * Math.sin(phiRad) * Math.cos(thetaRad);
+ oz = distance * Math.sin(phiRad) * Math.sin(thetaRad);
+ oy = distance * Math.cos(phiRad);
+ } else {
+ oy = distance * Math.sin(phiRad) * Math.cos(thetaRad);
+ oz = distance * Math.sin(phiRad) * Math.sin(thetaRad);
+ ox = distance * Math.cos(phiRad);
+ }
+
+ screenshotCamera.position.set(geometryCenter.x + ox, geometryCenter.y + oy, geometryCenter.z + oz);
+ screenshotCamera.lookAt(geometryCenter);
+
+ screenshotCamera.zoom = computeViewFittingZoom({
+ cameraPosition: screenshotCamera.position,
+ target: geometryCenter,
+ boundingBox,
+ fovDeg: screenshotFov,
+ aspectRatio: config.aspectRatio,
+ });
+ }
+
+ screenshotCamera.updateProjectionMatrix();
+ screenshotCamera.updateMatrixWorld(true);
+
+ screenshotRenderer.render(screenshotScene, screenshotCamera);
+
+ // eslint-disable-next-line no-await-in-loop -- sequential processing required for shared canvas
+ const blob = await new Promise((resolve) => {
+ const mimeType = config.output.format;
+ const quality = mimeType === 'image/jpeg' || mimeType === 'image/webp' ? config.output.quality : undefined;
+
+ screenshotCanvas.toBlob(
+ (result) => {
+ resolve(result ?? undefined);
+ },
+ mimeType,
+ quality,
+ );
+ });
+
+ if (!blob) {
+ throw new Error('Failed to create blob from canvas');
+ }
+
+ // eslint-disable-next-line no-await-in-loop -- sequential processing required for shared canvas
+ const dataUrl = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.addEventListener('load', () => {
+ resolve(reader.result as string);
+ });
+ reader.addEventListener('error', reject);
+ reader.readAsDataURL(blob);
+ });
+
+ dataUrls.push(dataUrl);
+ }
+
+ disposeClonedSceneMaterials(screenshotScene);
+
+ return dataUrls;
+ } finally {
+ screenshotRenderer.dispose();
+ screenshotRenderer.forceContextLoss();
+ screenshotCanvas.width = 0;
+ screenshotCanvas.height = 0;
+ }
+}
diff --git a/packages/three/src/screenshot/create-composite-image.ts b/packages/three/src/screenshot/create-composite-image.ts
new file mode 100644
index 000000000..70fc9bdeb
--- /dev/null
+++ b/packages/three/src/screenshot/create-composite-image.ts
@@ -0,0 +1,194 @@
+import type { CompositeScreenshotOptions } from '#screenshot/types.js';
+
+const defaultCompositeOptions = {
+ enabled: true,
+ preferredRatio: { columns: 3, rows: 2 },
+ showLabels: true,
+ padding: 12,
+ labelHeight: 24,
+ backgroundColor: 'transparent',
+ dividerColor: '#666666',
+ dividerWidth: 1,
+} satisfies CompositeScreenshotOptions;
+
+/**
+ * Calculate optimal grid layout for given number of items.
+ *
+ * @param itemCount - Number of items to arrange in a grid.
+ * @param preferredRatio - Target column-to-row ratio.
+ * @param preferredRatio.columns - Preferred number of columns.
+ * @param preferredRatio.rows - Preferred number of rows.
+ */
+export function calculateOptimalGrid(
+ itemCount: number,
+ preferredRatio: { columns: number; rows: number } = defaultCompositeOptions.preferredRatio,
+): { columns: number; rows: number } {
+ if (itemCount <= 0) {
+ return { columns: 1, rows: 1 };
+ }
+
+ if (itemCount === 1) {
+ return { columns: 1, rows: 1 };
+ }
+
+ const targetRatio = preferredRatio.columns / preferredRatio.rows;
+
+ let bestColumns = 1;
+ let bestRows = itemCount;
+ let bestRatioDiff = Math.abs(bestColumns / bestRows - targetRatio);
+
+ for (let columns = 1; columns <= itemCount; columns++) {
+ const rows = Math.ceil(itemCount / columns);
+ const ratio = columns / rows;
+ const ratioDiff = Math.abs(ratio - targetRatio);
+
+ if (ratioDiff < bestRatioDiff) {
+ bestColumns = columns;
+ bestRows = rows;
+ bestRatioDiff = ratioDiff;
+ }
+ }
+
+ return { columns: bestColumns, rows: bestRows };
+}
+
+/**
+ * Create a composite grid image from multiple screenshots.
+ *
+ * @param screenshots - Array of labelled screenshot data URLs.
+ * @param options - Composite layout options.
+ * @returns A data URL of the composited image.
+ */
+export async function createCompositeImage(
+ screenshots: Array<{ label: string; dataUrl: string }>,
+ options: CompositeScreenshotOptions = defaultCompositeOptions,
+): Promise {
+ const mergedOptions = {
+ ...defaultCompositeOptions,
+ ...options,
+ };
+
+ const { padding, labelHeight, showLabels, backgroundColor, dividerColor, dividerWidth, preferredRatio } =
+ mergedOptions;
+
+ const canvas = document.createElement('canvas');
+ const context = canvas.getContext('2d');
+ if (!context) {
+ throw new Error('Could not get canvas context');
+ }
+
+ const images = await Promise.all(
+ screenshots.map(async (screenshot) => {
+ return new Promise<{ label: string; image: HTMLImageElement }>((resolve, reject) => {
+ const img = new globalThis.Image();
+ img.addEventListener('load', () => {
+ resolve({ label: screenshot.label, image: img });
+ });
+ img.addEventListener('error', reject);
+ img.src = screenshot.dataUrl;
+ });
+ }),
+ );
+
+ if (images.length === 0) {
+ throw new Error('No images to create composite image from');
+ }
+
+ const originalWidth = images[0]!.image.width;
+ const originalHeight = images[0]!.image.height;
+
+ const maxImageSize = 600;
+ const scale = Math.min(1, maxImageSize / Math.max(originalWidth, originalHeight));
+ const imageWidth = Math.round(originalWidth * scale);
+ const imageHeight = Math.round(originalHeight * scale);
+
+ const { columns, rows } = calculateOptimalGrid(images.length, preferredRatio);
+
+ const effectiveLabelHeight = showLabels ? labelHeight : 0;
+ const effectivePadding = Math.max(padding, Math.round(imageWidth * 0.02));
+
+ canvas.width = columns * imageWidth + (columns + 1) * effectivePadding;
+ canvas.height = rows * (imageHeight + effectiveLabelHeight) + (rows + 1) * effectivePadding;
+
+ context.imageSmoothingEnabled = true;
+ context.imageSmoothingQuality = 'low';
+
+ const isTransparent = backgroundColor === 'transparent';
+ if (!isTransparent) {
+ context.fillStyle = backgroundColor;
+ context.fillRect(0, 0, canvas.width, canvas.height);
+ }
+
+ if (showLabels) {
+ const fontSize = Math.max(12, Math.round(imageHeight * 0.06));
+ context.fillStyle = '#000000';
+ context.font = `bold ${fontSize}px Arial`;
+ context.textAlign = 'center';
+ }
+
+ for (const [index, item] of images.entries()) {
+ const col = index % columns;
+ const row = Math.floor(index / columns);
+
+ const x = effectivePadding + col * (imageWidth + effectivePadding);
+ const y = effectivePadding + row * (imageHeight + effectiveLabelHeight + effectivePadding);
+
+ context.drawImage(item.image, x, y, imageWidth, imageHeight);
+
+ if (showLabels) {
+ const labelX = x + imageWidth / 2;
+ const labelY = y + imageHeight + effectiveLabelHeight - 5;
+ context.fillText(item.label.toUpperCase(), labelX, labelY);
+ }
+ }
+
+ if (dividerColor !== 'transparent') {
+ context.strokeStyle = dividerColor;
+ context.lineWidth = dividerWidth;
+
+ context.beginPath();
+
+ for (let col = 1; col < columns; col++) {
+ const dividerX = effectivePadding + col * (imageWidth + effectivePadding) - effectivePadding / 2;
+ context.moveTo(dividerX, effectivePadding);
+ context.lineTo(dividerX, canvas.height - effectivePadding);
+ }
+
+ for (let row = 1; row < rows; row++) {
+ const dividerY =
+ effectivePadding + row * (imageHeight + effectiveLabelHeight + effectivePadding) - effectivePadding / 2;
+ context.moveTo(effectivePadding, dividerY);
+ context.lineTo(canvas.width - effectivePadding, dividerY);
+ }
+
+ context.stroke();
+ }
+
+ const outputFormat = 'image/webp';
+ const outputQuality = 0.75;
+
+ const blob = await new Promise((resolve) => {
+ canvas.toBlob(
+ (result) => {
+ resolve(result ?? undefined);
+ },
+ outputFormat,
+ outputQuality,
+ );
+ });
+
+ if (!blob) {
+ throw new Error('Failed to create blob from composite canvas');
+ }
+
+ const compositeDataUrl = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.addEventListener('load', () => {
+ resolve(reader.result as string);
+ });
+ reader.addEventListener('error', reject);
+ reader.readAsDataURL(blob);
+ });
+
+ return compositeDataUrl;
+}
diff --git a/packages/three/src/screenshot/index.ts b/packages/three/src/screenshot/index.ts
new file mode 100644
index 000000000..1f3f8e0f3
--- /dev/null
+++ b/packages/three/src/screenshot/index.ts
@@ -0,0 +1,8 @@
+export { captureScreenshot } from '#screenshot/capture-screenshot.js';
+export { createCompositeImage, calculateOptimalGrid } from '#screenshot/create-composite-image.js';
+export type {
+ CameraAngle,
+ CompositeScreenshotOptions,
+ ScreenshotOptions,
+ ScreenshotOutputOptions,
+} from '#screenshot/types.js';
diff --git a/packages/three/src/screenshot/types.ts b/packages/three/src/screenshot/types.ts
new file mode 100644
index 000000000..15bcda2a7
--- /dev/null
+++ b/packages/three/src/screenshot/types.ts
@@ -0,0 +1,52 @@
+/** Camera angle configuration for screenshots. */
+export type CameraAngle = {
+ /** Theta angle of the camera (angle from the XZ plane). */
+ theta?: number;
+ /** Phi angle of the camera (angle from the XY plane). */
+ phi?: number;
+ /** Human-readable label for this camera angle. */
+ label?: string;
+};
+
+/** Output format settings for screenshots. */
+export type ScreenshotOutputOptions = {
+ /** File format for the output image. */
+ format?: 'image/png' | 'image/jpeg' | 'image/webp';
+ /** Quality level for lossy formats (0.0 to 1.0). Only applies to jpeg and webp. */
+ quality?: number;
+ /**
+ * Whether to screenshot the scene as a preview.
+ * When true, objects marked with `isPreviewOnly` userData will be hidden.
+ */
+ isPreview?: boolean;
+};
+
+/** Options for configuring composite (grid) image creation. */
+export type CompositeScreenshotOptions = {
+ enabled: boolean;
+ maxColumns?: number;
+ maxRows?: number;
+ preferredRatio?: { columns: number; rows: number };
+ padding?: number;
+ labelHeight?: number;
+ showLabels?: boolean;
+ backgroundColor?: string | 'transparent';
+ dividerColor?: string | 'transparent';
+ dividerWidth?: number;
+};
+
+/** Options for configuring screenshot capture. */
+export type ScreenshotOptions = {
+ /** Aspect ratio of the screenshot (width/height). */
+ aspectRatio?: number;
+ /** Maximum resolution (largest dimension) for the screenshot in pixels. */
+ maxResolution?: number;
+ /** Zoom level multiplier (1.0 = no change). */
+ zoomLevel?: number;
+ /** Array of camera angles to capture. Each angle produces a separate image. */
+ cameraAngles?: CameraAngle[];
+ /** Output format settings. */
+ output?: ScreenshotOutputOptions;
+ /** Composite image settings for multi-angle captures. */
+ composite?: CompositeScreenshotOptions;
+};
diff --git a/apps/ui/app/components/geometry/graphics/three/utils/camera.utils.test.ts b/packages/three/src/utils/camera.utils.test.ts
similarity index 98%
rename from apps/ui/app/components/geometry/graphics/three/utils/camera.utils.test.ts
rename to packages/three/src/utils/camera.utils.test.ts
index 4e3182b47..b4e726b90 100644
--- a/apps/ui/app/components/geometry/graphics/three/utils/camera.utils.test.ts
+++ b/packages/three/src/utils/camera.utils.test.ts
@@ -1,15 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import * as THREE from 'three';
-import {
- computeViewFittingZoom,
- updateCameraFov,
- resetCamera,
-} from '#components/geometry/graphics/three/utils/camera.utils.js';
-import {
- calculateFovFromAngle,
- calculateFovDistanceCompensation,
- tanEpsilon,
-} from '#components/geometry/graphics/three/utils/math.utils.js';
+import { computeViewFittingZoom, updateCameraFov, resetCamera } from '#utils/camera.utils.js';
+import { calculateFovFromAngle, calculateFovDistanceCompensation, tanEpsilon } from '#utils/math.utils.js';
// ── Helpers ─────────────────────────────────────────────────────────────────
diff --git a/apps/ui/app/components/geometry/graphics/three/utils/camera.utils.ts b/packages/three/src/utils/camera.utils.ts
similarity index 98%
rename from apps/ui/app/components/geometry/graphics/three/utils/camera.utils.ts
rename to packages/three/src/utils/camera.utils.ts
index aadadeec9..5a6e36245 100644
--- a/apps/ui/app/components/geometry/graphics/three/utils/camera.utils.ts
+++ b/packages/three/src/utils/camera.utils.ts
@@ -1,9 +1,5 @@
import * as THREE from 'three';
-import {
- calculateFovFromAngle,
- calculateFovDistanceCompensation,
- tanEpsilon,
-} from '#components/geometry/graphics/three/utils/math.utils.js';
+import { calculateFovFromAngle, calculateFovDistanceCompensation, tanEpsilon } from '#utils/math.utils.js';
/**
* Calculates a 3D position from spherical coordinates.
diff --git a/packages/three/src/utils/color.utils.ts b/packages/three/src/utils/color.utils.ts
new file mode 100644
index 000000000..e7233d596
--- /dev/null
+++ b/packages/three/src/utils/color.utils.ts
@@ -0,0 +1,36 @@
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value));
+}
+
+/**
+ * Darken or lighten a hexadecimal color by a given amount. Applies clamp to ensure the color stays within the valid 0-255 range.
+ *
+ * @param hex - Hexadecimal color string (e.g., "#RRGGBB" or "RRGGBB")
+ * @param amount - Amount to darken (positive) or lighten (negative) the color (-1 to 1, where 0 = no change, 1 = subtract 255, -1 = add 255)
+ * @returns Modified hexadecimal color string with # prefix
+ */
+export function adjustHexColorBrightness(hex: string, amount = 0.1): string {
+ // Remove # if present
+ const cleanHex = hex.replace('#', '');
+
+ // Parse RGB components
+ const r = Number.parseInt(cleanHex.slice(0, 2), 16);
+ const g = Number.parseInt(cleanHex.slice(2, 4), 16);
+ const b = Number.parseInt(cleanHex.slice(4, 6), 16);
+
+ // Normalize amount to 0-255 range and apply to all channels equally
+ // Positive amount darkens (subtracts), negative amount lightens (adds)
+ const normalizedAmount = amount * 255;
+
+ // Clamp values to ensure they stay in valid 0-255 range
+ const modifiedR = Math.floor(clamp(r - normalizedAmount, 0, 255));
+ const modifiedG = Math.floor(clamp(g - normalizedAmount, 0, 255));
+ const modifiedB = Math.floor(clamp(b - normalizedAmount, 0, 255));
+
+ // Convert back to hex with padding
+ const modifiedHex = [modifiedR, modifiedG, modifiedB]
+ .map((channel) => channel.toString(16).padStart(2, '0'))
+ .join('');
+
+ return `#${modifiedHex}`;
+}
diff --git a/apps/ui/app/components/geometry/graphics/three/utils/gizmo.utils.ts b/packages/three/src/utils/gizmo.utils.ts
similarity index 98%
rename from apps/ui/app/components/geometry/graphics/three/utils/gizmo.utils.ts
rename to packages/three/src/utils/gizmo.utils.ts
index 973128d30..944710ef7 100644
--- a/apps/ui/app/components/geometry/graphics/three/utils/gizmo.utils.ts
+++ b/packages/three/src/utils/gizmo.utils.ts
@@ -15,7 +15,7 @@ import {
gizmoBaseDistance,
gizmoDepthMargin,
gizmoFocusOffset,
-} from '#components/geometry/graphics/three/utils/math.utils.js';
+} from '#utils/math.utils.js';
// ── FOV synchronization ─────────────────────────────────────────────────────
diff --git a/apps/ui/app/components/geometry/graphics/three/utils/lights.utils.test.ts b/packages/three/src/utils/lights.utils.test.ts
similarity index 99%
rename from apps/ui/app/components/geometry/graphics/three/utils/lights.utils.test.ts
rename to packages/three/src/utils/lights.utils.test.ts
index a6416116c..ee59f2615 100644
--- a/apps/ui/app/components/geometry/graphics/three/utils/lights.utils.test.ts
+++ b/packages/three/src/utils/lights.utils.test.ts
@@ -11,8 +11,8 @@ import {
environmentBaseIntensity,
lightingUserDataKeys,
poleFadeAngleDeg,
-} from '#components/geometry/graphics/three/utils/lights.utils.js';
-import type { HeadlampConfig, LightingConfig } from '#components/geometry/graphics/three/utils/lights.utils.js';
+} from '#utils/lights.utils.js';
+import type { HeadlampConfig, LightingConfig } from '#utils/lights.utils.js';
// ── Helpers ─────────────────────────────────────────────────────────────────
diff --git a/apps/ui/app/components/geometry/graphics/three/utils/lights.utils.ts b/packages/three/src/utils/lights.utils.ts
similarity index 95%
rename from apps/ui/app/components/geometry/graphics/three/utils/lights.utils.ts
rename to packages/three/src/utils/lights.utils.ts
index 21c32af72..024b25ec4 100644
--- a/apps/ui/app/components/geometry/graphics/three/utils/lights.utils.ts
+++ b/packages/three/src/utils/lights.utils.ts
@@ -7,7 +7,7 @@
*/
import * as THREE from 'three';
-import { calculateFovLightingCompensation } from '#components/geometry/graphics/three/utils/math.utils.js';
+import { calculateFovLightingCompensation } from '#utils/math.utils.js';
// ── Lighting constants ─────────────────────────────────────────────────────
// Exported so that both the Lights component and the screenshot capture
@@ -177,23 +177,20 @@ export function computeEnvironmentRotation(
* highlight remains biased toward screen upper-right. The target is placed
* forward of the camera with slight lower-left skew.
*
- * @param cameraPosition - The camera's world position.
- * @param cameraMatrixWorld - The camera's world matrix (used for basis vectors).
- * @param sceneRadius - The bounding sphere radius of the scene.
- * @param config - Offset multipliers for headlamp placement.
+ * @param options - Headlamp transform options.
+ * @param options.cameraPosition - The camera's world position.
+ * @param options.cameraMatrixWorld - The camera's world matrix (used for basis vectors).
+ * @param options.sceneRadius - The bounding sphere radius of the scene.
+ * @param options.config - Offset multipliers for headlamp placement.
* @returns The world-space position and target position for the headlamp.
*/
-export function computeHeadlampTransform({
- cameraPosition,
- cameraMatrixWorld,
- sceneRadius,
- config,
-}: {
+export function computeHeadlampTransform(options: {
cameraPosition: THREE.Vector3;
cameraMatrixWorld: THREE.Matrix4;
sceneRadius: number;
config: HeadlampConfig;
}): { position: THREE.Vector3; targetPosition: THREE.Vector3 } {
+ const { cameraPosition, cameraMatrixWorld, sceneRadius, config } = options;
// Camera basis vectors in world space:
// - column 0: camera-right (+X local)
// - column 1: camera-up (+Y local)
diff --git a/apps/ui/app/components/geometry/graphics/three/utils/math.utils.test.ts b/packages/three/src/utils/math.utils.test.ts
similarity index 99%
rename from apps/ui/app/components/geometry/graphics/three/utils/math.utils.test.ts
rename to packages/three/src/utils/math.utils.test.ts
index aca909cc1..0f6e542e1 100644
--- a/apps/ui/app/components/geometry/graphics/three/utils/math.utils.test.ts
+++ b/packages/three/src/utils/math.utils.test.ts
@@ -8,7 +8,7 @@ import {
fovCompensationReferenceFov,
fovCompensationEnvMin,
fovCompensationEnvMax,
-} from '#components/geometry/graphics/three/utils/math.utils.js';
+} from '#utils/math.utils.js';
describe('calculateFovFromAngle', () => {
describe('boundary values', () => {
diff --git a/apps/ui/app/components/geometry/graphics/three/utils/math.utils.ts b/packages/three/src/utils/math.utils.ts
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/utils/math.utils.ts
rename to packages/three/src/utils/math.utils.ts
diff --git a/apps/ui/app/components/geometry/graphics/three/utils/rotation.utils.ts b/packages/three/src/utils/rotation.utils.ts
similarity index 81%
rename from apps/ui/app/components/geometry/graphics/three/utils/rotation.utils.ts
rename to packages/three/src/utils/rotation.utils.ts
index a04902860..9f22182f1 100644
--- a/apps/ui/app/components/geometry/graphics/three/utils/rotation.utils.ts
+++ b/packages/three/src/utils/rotation.utils.ts
@@ -4,23 +4,21 @@ import * as THREE from 'three';
* Computes a quaternion that rotates an object around a given axis to face the camera.
* This creates a billboard effect constrained to rotation around a single axis.
*
- * @param axis - The axis to rotate around (should be normalized)
- * @param position - The position of the object in world space
- * @param camera - The camera to face towards
- * @param referenceUp - Optional reference "up" vector perpendicular to the axis (default: best perpendicular to axis)
+ * @param options - Axis rotation options.
+ * @param options.axis - The axis to rotate around (should be normalized)
+ * @param options.position - The position of the object in world space
+ * @param options.camera - The camera to face towards
+ * @param options.referenceUp - Optional reference "up" vector perpendicular to the axis (default: best perpendicular to axis)
* @returns Quaternion representing the rotation around the axis
*/
-export function computeAxisRotationForCamera({
- axis,
- position,
- camera,
- referenceUp,
-}: {
+export function computeAxisRotationForCamera(options: {
axis: THREE.Vector3;
position: THREE.Vector3;
camera: THREE.Camera;
referenceUp?: THREE.Vector3;
}): THREE.Quaternion {
+ const { axis, position, camera, referenceUp } = options;
+
// Calculate direction from object to camera
const eyeDirection = new THREE.Vector3();
eyeDirection.subVectors(camera.position, position).normalize();
diff --git a/apps/ui/app/components/geometry/graphics/three/utils/snap-detection.utils.ts b/packages/three/src/utils/snap-detection.utils.ts
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/utils/snap-detection.utils.ts
rename to packages/three/src/utils/snap-detection.utils.ts
diff --git a/apps/ui/app/components/geometry/graphics/three/utils/spatial.utils.ts b/packages/three/src/utils/spatial.utils.ts
similarity index 100%
rename from apps/ui/app/components/geometry/graphics/three/utils/spatial.utils.ts
rename to packages/three/src/utils/spatial.utils.ts
diff --git a/packages/three/src/vite-env.d.ts b/packages/three/src/vite-env.d.ts
new file mode 100644
index 000000000..5bc4cf710
--- /dev/null
+++ b/packages/three/src/vite-env.d.ts
@@ -0,0 +1,4 @@
+declare module '*?raw' {
+ const src: string;
+ export default src;
+}
diff --git a/packages/three/tsconfig.build.json b/packages/three/tsconfig.build.json
new file mode 100644
index 000000000..da40e74ec
--- /dev/null
+++ b/packages/three/tsconfig.build.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.lib.json",
+ "compilerOptions": {
+ "composite": false,
+ "declarationMap": false
+ }
+}
diff --git a/packages/three/tsconfig.json b/packages/three/tsconfig.json
new file mode 100644
index 000000000..62ebbd946
--- /dev/null
+++ b/packages/three/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ]
+}
diff --git a/packages/three/tsconfig.lib.json b/packages/three/tsconfig.lib.json
new file mode 100644
index 000000000..d8fa518c2
--- /dev/null
+++ b/packages/three/tsconfig.lib.json
@@ -0,0 +1,17 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/lib",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2024", "DOM", "DOM.Iterable"],
+ "types": ["node"],
+ "target": "ESNext",
+ "jsx": "react-jsx",
+ "paths": {
+ "#*": ["./src/*"]
+ }
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
+ "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
+}
diff --git a/packages/three/tsconfig.spec.json b/packages/three/tsconfig.spec.json
new file mode 100644
index 000000000..e1b837ea5
--- /dev/null
+++ b/packages/three/tsconfig.spec.json
@@ -0,0 +1,25 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/spec",
+ "types": ["node", "vitest/importMeta", "vitest"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["DOM", "DOM.Iterable", "ES2024"],
+ "target": "ESNext",
+ "jsx": "react-jsx",
+ "paths": {
+ "#*": ["./src/*"]
+ }
+ },
+ "include": [
+ "vitest.config.ts",
+ "tsdown.config.ts",
+ "src/**/*.test.ts",
+ "src/**/*.spec.ts",
+ "src/**/*.test-d.ts",
+ "src/**/*.d.ts",
+ "src/**/*.ts",
+ "src/**/*.tsx"
+ ]
+}
diff --git a/packages/three/tsdown.config.ts b/packages/three/tsdown.config.ts
new file mode 100644
index 000000000..ebfeae4a3
--- /dev/null
+++ b/packages/three/tsdown.config.ts
@@ -0,0 +1,39 @@
+import { defineConfig } from 'tsdown';
+import type { Options } from 'tsdown';
+
+const baseConfig: Options = {
+ entry: [
+ 'src/index.ts',
+ 'src/react/index.ts',
+ ],
+ sourcemap: false,
+ clean: true,
+ dts: true,
+ minify: true,
+ tsconfig: 'tsconfig.build.json',
+ unbundle: true,
+ external: [
+ 'react',
+ 'react-dom',
+ 'react/jsx-runtime',
+ 'three',
+ '@react-three/fiber',
+ '@react-three/drei',
+ '@react-three/postprocessing',
+ ],
+};
+
+const cjsConfig: Options = {
+ ...baseConfig,
+ format: 'cjs',
+ outDir: 'dist/cjs',
+ dts: false,
+};
+
+const esmConfig: Options = {
+ ...baseConfig,
+ format: 'esm',
+ outDir: 'dist/esm',
+};
+
+export default defineConfig([esmConfig, cjsConfig]);
diff --git a/packages/three/vitest.config.ts b/packages/three/vitest.config.ts
new file mode 100644
index 000000000..14ae66fa3
--- /dev/null
+++ b/packages/three/vitest.config.ts
@@ -0,0 +1,23 @@
+import { defineConfig } from 'vitest/config';
+import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
+
+export default defineConfig({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- vite type mismatch from pnpm duplicate @types/node resolutions
+ plugins: [nxViteTsPaths() as any],
+ test: {
+ environment: 'node',
+ typecheck: {
+ enabled: true,
+ include: ['**/*.test-d.ts'],
+ tsconfig: './tsconfig.spec.json',
+ ignoreSourceErrors: true,
+ },
+ reporters: ['verbose'],
+ coverage: {
+ provider: 'v8',
+ reportsDirectory: '../../coverage/packages/three',
+ include: ['src/**/*'],
+ exclude: ['src/**/*.{test,spec}.ts'],
+ },
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1a16a86b1..2c9f2311f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1095,6 +1095,9 @@ importers:
'@taucad/tau-examples':
specifier: workspace:*
version: link:../../libs/tau-examples
+ '@taucad/three':
+ specifier: workspace:*
+ version: link:../../packages/three
'@taucad/types':
specifier: workspace:*
version: link:../../libs/types
@@ -1287,6 +1290,37 @@ importers:
specifier: 'catalog:'
version: 1.7.0(next@15.5.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))
+ packages/three:
+ dependencies:
+ '@react-three/drei':
+ specifier: '>=10.0.0'
+ version: 10.7.7(@react-three/fiber@9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1))(@types/react@19.0.12)(@types/three@0.178.1)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1)
+ '@react-three/fiber':
+ specifier: '>=9.0.0'
+ version: 9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1)
+ '@react-three/postprocessing':
+ specifier: '>=3.0.0'
+ version: 3.0.4(@react-three/fiber@9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1))(@types/three@0.178.1)(react@19.2.4)(three@0.179.1)
+ react:
+ specifier: '>=19.0.0'
+ version: 19.2.4
+ react-dom:
+ specifier: '>=19.0.0'
+ version: 19.2.4(react@19.2.4)
+ three:
+ specifier: '>=0.170.0'
+ version: 0.179.1
+ three-viewport-gizmo:
+ specifier: https://codeload.github.com/taucad/three-viewport-gizmo/tar.gz/d5ae66010e7f5f067931aaeb6a74880a73d23b5d
+ version: https://codeload.github.com/taucad/three-viewport-gizmo/tar.gz/d5ae66010e7f5f067931aaeb6a74880a73d23b5d(three@0.179.1)
+ zustand:
+ specifier: ^5.0.0
+ version: 5.0.11(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+ devDependencies:
+ '@types/three':
+ specifier: ^0.178.1
+ version: 0.178.1
+
packages:
'@adobe/css-tools@4.3.3':
@@ -22718,6 +22752,39 @@ snapshots:
- '@types/three'
- immer
+ '@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1))(@types/react@19.0.12)(@types/three@0.178.1)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1)':
+ dependencies:
+ '@babel/runtime': 7.28.6
+ '@mediapipe/tasks-vision': 0.10.17
+ '@monogrid/gainmap-js': 3.4.0(three@0.179.1)
+ '@react-three/fiber': 9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1)
+ '@use-gesture/react': 10.3.1(react@19.2.4)
+ camera-controls: 3.1.2(three@0.179.1)
+ cross-env: 7.0.3
+ detect-gpu: 5.0.70
+ glsl-noise: 0.0.0
+ hls.js: 1.6.15
+ maath: 0.10.8(@types/three@0.178.1)(three@0.179.1)
+ meshline: 3.3.1(three@0.179.1)
+ react: 19.2.4
+ stats-gl: 2.4.2(@types/three@0.178.1)(three@0.179.1)
+ stats.js: 0.17.0
+ suspend-react: 0.1.3(react@19.2.4)
+ three: 0.179.1
+ three-mesh-bvh: 0.8.3(three@0.179.1)
+ three-stdlib: 2.36.1(three@0.179.1)
+ troika-three-text: 0.52.4(three@0.179.1)
+ tunnel-rat: 0.1.2(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4)
+ use-sync-external-store: 1.6.0(react@19.2.4)
+ utility-types: 3.11.0
+ zustand: 5.0.11(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+ optionalDependencies:
+ react-dom: 19.2.4(react@19.2.4)
+ transitivePeerDependencies:
+ - '@types/react'
+ - '@types/three'
+ - immer
+
'@react-three/fiber@9.5.0(@types/react@19.0.12)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1)':
dependencies:
'@babel/runtime': 7.28.6
@@ -22738,6 +22805,26 @@ snapshots:
- '@types/react'
- immer
+ '@react-three/fiber@9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1)':
+ dependencies:
+ '@babel/runtime': 7.28.6
+ '@types/webxr': 0.5.24
+ base64-js: 1.5.1
+ buffer: 6.0.3
+ its-fine: 2.0.0(@types/react@19.0.12)(react@19.2.4)
+ react: 19.2.4
+ react-use-measure: 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ scheduler: 0.27.0
+ suspend-react: 0.1.3(react@19.2.4)
+ three: 0.179.1
+ use-sync-external-store: 1.6.0(react@19.2.4)
+ zustand: 5.0.11(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+ optionalDependencies:
+ react-dom: 19.2.4(react@19.2.4)
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+
'@react-three/postprocessing@3.0.4(@react-three/fiber@9.5.0(@types/react@19.0.12)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1))(@types/three@0.178.1)(react@19.2.4)(three@0.179.1)':
dependencies:
'@react-three/fiber': 9.5.0(@types/react@19.0.12)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1)
@@ -22749,6 +22836,17 @@ snapshots:
transitivePeerDependencies:
- '@types/three'
+ '@react-three/postprocessing@3.0.4(@react-three/fiber@9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1))(@types/three@0.178.1)(react@19.2.4)(three@0.179.1)':
+ dependencies:
+ '@react-three/fiber': 9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1)
+ maath: 0.6.0(@types/three@0.178.1)(three@0.179.1)
+ n8ao: 1.10.1(postprocessing@6.38.3(three@0.179.1))(three@0.179.1)
+ postprocessing: 6.38.3(three@0.179.1)
+ react: 19.2.4
+ three: 0.179.1
+ transitivePeerDependencies:
+ - '@types/three'
+
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.0.12)(react@19.2.4)(redux@5.0.1))(react@19.2.4)':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -34906,6 +35004,14 @@ snapshots:
- immer
- react
+ tunnel-rat@0.1.2(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4):
+ dependencies:
+ zustand: 4.5.7(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4)
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+ - react
+
turbo-stream@2.4.0: {}
turbo-stream@2.4.1: {}
@@ -36137,6 +36243,14 @@ snapshots:
immer: 10.2.0
react: 19.2.4
+ zustand@4.5.7(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4):
+ dependencies:
+ use-sync-external-store: 1.6.0(react@19.2.4)
+ optionalDependencies:
+ '@types/react': 19.0.12
+ immer: 11.1.4
+ react: 19.2.4
+
zustand@5.0.11(@types/react@19.0.12)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
optionalDependencies:
'@types/react': 19.0.12
@@ -36144,4 +36258,11 @@ snapshots:
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
+ zustand@5.0.11(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
+ optionalDependencies:
+ '@types/react': 19.0.12
+ immer: 11.1.4
+ react: 19.2.4
+ use-sync-external-store: 1.6.0(react@19.2.4)
+
zwitch@2.0.4: {}