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 ( - { - // Neutral ACES exposure -- depth contrast comes from AO and targeted directional lights. - gl.toneMappingExposure = 1; - - // Listen for WebGL context loss events on the underlying canvas element. - // preventDefault() tells the browser we intend to handle restoration ourselves. - const canvas = gl.domElement; - canvas.addEventListener('webglcontextlost', (event) => { - event.preventDefault(); - setIsContextLost(true); - }); - - setIsCanvasReady(true); - }} - {...properties} - > - - {children} - - - - {enableAxes ? : null} - {enableGrid ? : null} - - {isCanvasReady ? : null} - - ); -} 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 ( + + { + gl.toneMappingExposure = 1; + const canvas = gl.domElement; + canvas.addEventListener('webglcontextlost', (event) => { + event.preventDefault(); + onContextLost?.(); + }); + }} + {...canvasProps} + > + + {children} + + + + {enableAxes ? : undefined} + {enableGrid ? : undefined} + + + + ); +} 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: {}