diff --git a/src/CadViewer.tsx b/src/CadViewer.tsx index ca0b9aa4..6ba36b3e 100644 --- a/src/CadViewer.tsx +++ b/src/CadViewer.tsx @@ -27,8 +27,13 @@ const CadViewerInner = (props: any) => { return stored === "true" }) const [cameraPreset, setCameraPreset] = useState("Custom") - const { visibility, toggleLayer } = useLayerVisibility() - + const [shouldUseOrthographicCamera, setShouldUseOrthographicCamera] = + useState(() => { + const stored = window.localStorage.getItem( + "cadViewerUseOrthographicCamera", + ) + return stored === "true" + }) const cameraControllerRef = useRef(null) const externalCameraControllerReady = props.onCameraControllerReady as | ((controller: CameraController | null) => void) @@ -74,6 +79,10 @@ const CadViewerInner = (props: any) => { setAutoRotateUserToggled(true) }, []) + const toggleOrthographicCamera = useCallback(() => { + setShouldUseOrthographicCamera((prev) => !prev) + }, []) + const downloadGltf = useGlobalDownloadGltf() const closeMenu = useCallback(() => { @@ -84,11 +93,8 @@ const CadViewerInner = (props: any) => { (controller: CameraController | null) => { cameraControllerRef.current = controller externalCameraControllerReady?.(controller) - if (controller && cameraPreset !== "Custom") { - controller.animateToPreset(cameraPreset) - } }, - [cameraPreset, externalCameraControllerReady], + [externalCameraControllerReady], ) const { handleCameraPresetSelect } = useCameraPreset({ @@ -123,13 +129,15 @@ const CadViewerInner = (props: any) => { ) }, [autoRotateUserToggled]) - const viewerKey = props.circuitJson - ? JSON.stringify(props.circuitJson) - : undefined + useEffect(() => { + window.localStorage.setItem( + "cadViewerUseOrthographicCamera", + String(shouldUseOrthographicCamera), + ) + }, [shouldUseOrthographicCamera]) return (
{ autoRotateDisabled={props.autoRotateDisabled || !autoRotate} onUserInteraction={handleUserInteraction} onCameraControllerReady={handleCameraControllerReady} + shouldUseOrthographicCamera={shouldUseOrthographicCamera} /> ) : ( { autoRotateDisabled={props.autoRotateDisabled || !autoRotate} onUserInteraction={handleUserInteraction} onCameraControllerReady={handleCameraControllerReady} + shouldUseOrthographicCamera={shouldUseOrthographicCamera} /> )}
{ engine={engine} cameraPreset={cameraPreset} autoRotate={autoRotate} + shouldUseOrthographicCamera={shouldUseOrthographicCamera} onEngineSwitch={(newEngine) => { setEngine(newEngine) closeMenu() @@ -190,6 +201,10 @@ const CadViewerInner = (props: any) => { toggleAutoRotate() closeMenu() }} + onOrthographicToggle={() => { + toggleOrthographicCamera() + closeMenu() + }} onDownloadGltf={() => { downloadGltf() closeMenu() diff --git a/src/CadViewerContainer.tsx b/src/CadViewerContainer.tsx index fb17688c..cd9f105c 100644 --- a/src/CadViewerContainer.tsx +++ b/src/CadViewerContainer.tsx @@ -22,11 +22,24 @@ export type { CameraPreset, } from "./hooks/useCameraController" +declare global { + interface Window { + TSCI_MAIN_CAMERA_ROTATION: THREE.Euler + TSCI_MAIN_CAMERA_QUATERNION: THREE.Quaternion + } +} + +if (typeof window !== "undefined") { + window.TSCI_MAIN_CAMERA_ROTATION ??= new THREE.Euler(0, 0, 0) + window.TSCI_MAIN_CAMERA_QUATERNION ??= new THREE.Quaternion() +} + export const RotationTracker = () => { const { camera } = useThree() useFrame(() => { if (camera) { - window.TSCI_MAIN_CAMERA_ROTATION = camera.rotation + window.TSCI_MAIN_CAMERA_ROTATION.copy(camera.rotation) + window.TSCI_MAIN_CAMERA_QUATERNION.copy(camera.quaternion) } }) @@ -41,6 +54,7 @@ interface Props { boardCenter?: { x: number; y: number } onUserInteraction?: () => void onCameraControllerReady?: (controller: CameraController | null) => void + shouldUseOrthographicCamera?: boolean } export const CadViewerContainer = forwardRef< @@ -57,6 +71,7 @@ export const CadViewerContainer = forwardRef< boardCenter, onUserInteraction, onCameraControllerReady, + shouldUseOrthographicCamera, }, ref, ) => { @@ -91,11 +106,34 @@ export const CadViewerContainer = forwardRef< }, [orbitTarget]) const { cameraAnimatorProps, handleControlsChange } = useCameraController({ + isOrthographic: shouldUseOrthographicCamera ?? false, defaultTarget, initialCameraPosition, onCameraControllerReady, }) + const orthographicFrustumSize = useMemo(() => { + if (boardDimensions) { + const width = boardDimensions.width ?? 0 + const height = boardDimensions.height ?? 0 + const maxDimension = Math.max(width, height) + return Math.max(maxDimension * 1.5, 10) + } + const [x, y, z] = initialCameraPosition + const maxComponent = Math.max(Math.abs(x), Math.abs(y), Math.abs(z)) + return Math.max(maxComponent * 2, 10) + }, [initialCameraPosition, boardDimensions]) + + const mutableInitialCameraPosition = useMemo( + () => + [ + initialCameraPosition[0], + initialCameraPosition[1], + initialCameraPosition[2], + ] as [number, number, number], + [initialCameraPosition], + ) + return (
{ cameraRef.current = camera if (!restoredOnceRef.current && controlsRef.current) { @@ -130,7 +174,7 @@ export const CadViewerContainer = forwardRef< ) if (restored) restoredOnceRef.current = true } - // If nothing to restore, persist the initial state once controls exist + // If nothing to restore, persist the initial state once controls exist if (controlsRef.current && !restoredOnceRef.current) { setTimeout(() => { if (cameraRef.current && controlsRef.current) { @@ -140,10 +184,14 @@ export const CadViewerContainer = forwardRef< } }} > - + {isInteractionEnabled && ( void onCameraControllerReady?: (controller: CameraController | null) => void + shouldUseOrthographicCamera?: boolean } export const CadViewerJscad = forwardRef< @@ -46,6 +47,7 @@ export const CadViewerJscad = forwardRef< clickToInteractEnabled, onUserInteraction, onCameraControllerReady, + shouldUseOrthographicCamera, }, ref, ) => { @@ -134,6 +136,7 @@ export const CadViewerJscad = forwardRef< boardCenter={boardCenter} onUserInteraction={onUserInteraction} onCameraControllerReady={onCameraControllerReady} + shouldUseOrthographicCamera={shouldUseOrthographicCamera} > {boardStls.map(({ stlData, color, layerType }, index) => ( void onCameraControllerReady?: (controller: CameraController | null) => void + shouldUseOrthographicCamera?: boolean } & ( | { circuitJson: AnyCircuitElement[]; children?: React.ReactNode } | { circuitJson?: never; children: React.ReactNode } @@ -134,6 +135,7 @@ const CadViewerManifold: React.FC = ({ onUserInteraction, children, onCameraControllerReady, + shouldUseOrthographicCamera, }) => { const childrenCircuitJson = useConvertChildrenToCircuitJson(children) const circuitJson = useMemo(() => { @@ -313,6 +315,7 @@ try { boardCenter={boardCenter} onUserInteraction={onUserInteraction} onCameraControllerReady={onCameraControllerReady} + shouldUseOrthographicCamera={shouldUseOrthographicCamera} > void onCameraPresetSelect: (preset: CameraPreset) => void onAutoRotateToggle: () => void + onOrthographicToggle: () => void onDownloadGltf: () => void } @@ -102,9 +104,11 @@ export const ContextMenu: React.FC = ({ engine, cameraPreset, autoRotate, + shouldUseOrthographicCamera, onEngineSwitch, onCameraPresetSelect, onAutoRotateToggle, + onOrthographicToggle, onDownloadGltf, }) => { const [cameraSubOpen, setCameraSubOpen] = useState(false) @@ -229,6 +233,29 @@ export const ContextMenu: React.FC = ({ + e.preventDefault()} + onPointerDown={(e) => { + e.preventDefault() + onOrthographicToggle() + }} + onMouseEnter={() => setHoveredItem("orthographic")} + onMouseLeave={() => setHoveredItem(null)} + > + + {shouldUseOrthographicCamera && } + + + Orthographic camera + + + {/* Appearance Menu */} diff --git a/src/hooks/useCameraController.ts b/src/hooks/useCameraController.ts index 6d55cf96..8c11b58b 100644 --- a/src/hooks/useCameraController.ts +++ b/src/hooks/useCameraController.ts @@ -1,5 +1,5 @@ import type * as React from "react" -import { useCallback, useEffect, useMemo, useRef } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import * as THREE from "three" import type { OrbitControls as ThreeOrbitControls } from "three/examples/jsm/controls/OrbitControls.js" import { useFrame, useThree } from "../react-three/ThreeContext" @@ -32,6 +32,7 @@ export interface CameraController { export interface CameraAnimatorProps { defaultTarget: THREE.Vector3 controlsRef: React.MutableRefObject + controlsVersion: number onReady?: ( controller: { animateTo: CameraController["animateTo"] } | null, ) => void @@ -40,31 +41,42 @@ export interface CameraAnimatorProps { export const CameraAnimator: React.FC = ({ defaultTarget, controlsRef, + controlsVersion, onReady, }) => { const { camera } = useThree() + const cameraRef = useRef(camera) + cameraRef.current = camera + const animationRef = useRef<{ fromPosition: THREE.Vector3 toPosition: THREE.Vector3 fromTarget: THREE.Vector3 toTarget: THREE.Vector3 + fromQuaternion: THREE.Quaternion toQuaternion: THREE.Quaternion - rollFrom: THREE.Quaternion - rollTo: THREE.Quaternion + finalQuaternion: THREE.Quaternion + finalUp: THREE.Vector3 + fromZoom?: number + toZoom?: number startTime: number duration: number } | null>(null) const tempQuaternion = useRef(new THREE.Quaternion()) const tempTarget = useRef(new THREE.Vector3()) - const tempUp = useRef(new THREE.Vector3()) - const tempRoll = useRef(new THREE.Quaternion()) - const tempRollTarget = useRef(new THREE.Quaternion()) - const baseOrientationHelper = useRef(new THREE.Object3D()) const orientationHelper = useRef(new THREE.Object3D()) const animateTo = useCallback( ({ position, target, up, durationMs = 600 }) => { - if (!camera) return + const currentCamera = cameraRef.current + + if (!currentCamera) { + return + } + + if (!controlsRef.current) { + return + } const currentTarget = controlsRef.current?.target ?? defaultTarget @@ -76,74 +88,148 @@ export const CameraAnimator: React.FC = ({ const resolvedTarget = target ? new THREE.Vector3(target[0], target[1], target[2]) : defaultTarget.clone() - const resolvedUp = new THREE.Vector3(...(up ?? [0, 0, 1])).normalize() + const resolvedUp = new THREE.Vector3(...(up ?? [0, 0, 1])) + const viewDirection = resolvedTarget.clone().sub(toPosition).normalize() + + if (viewDirection.lengthSq() > 0) { + const cross = new THREE.Vector3().crossVectors( + viewDirection, + resolvedUp, + ) + if (cross.lengthSq() < 1e-6) { + const fallbackAxes = [ + new THREE.Vector3(0, 1, 0), + new THREE.Vector3(1, 0, 0), + new THREE.Vector3(0, 0, 1), + ] + for (const axis of fallbackAxes) { + if (Math.abs(viewDirection.dot(axis)) < 0.95) { + resolvedUp.copy(axis) + break + } + } + } + } + + resolvedUp.normalize() const toOrientationHelper = orientationHelper.current toOrientationHelper.position.copy(toPosition) toOrientationHelper.up.copy(resolvedUp) toOrientationHelper.lookAt(resolvedTarget) - const toQuaternion = toOrientationHelper.quaternion.clone() - const fromQuaternion = camera.quaternion.clone() - const fromPosition = camera.position.clone() + const toQuaternion = toOrientationHelper.quaternion.clone().normalize() + const fromQuaternion = currentCamera.quaternion.clone().normalize() + const fromPosition = currentCamera.position.clone() const fromTarget = currentTarget.clone() - const baseHelper = baseOrientationHelper.current - baseHelper.up.set(0, 0, 1) - baseHelper.position.copy(fromPosition) - baseHelper.lookAt(fromTarget) - const baseFromQuaternion = baseHelper.quaternion.clone() - - baseHelper.up.set(0, 0, 1) - baseHelper.position.copy(toPosition) - baseHelper.lookAt(resolvedTarget) - const baseToQuaternion = baseHelper.quaternion.clone() - - const rollFrom = baseFromQuaternion - .clone() - .invert() - .multiply(fromQuaternion) - .normalize() - const rollTo = baseToQuaternion - .clone() - .invert() - .multiply(toQuaternion) - .normalize() + const slerpTarget = toQuaternion.clone() + if (fromQuaternion.dot(slerpTarget) < 0) { + slerpTarget.x *= -1 + slerpTarget.y *= -1 + slerpTarget.z *= -1 + slerpTarget.w *= -1 + } + + let fromZoom: number | undefined + let toZoom: number | undefined + + if (currentCamera instanceof THREE.OrthographicCamera) { + fromZoom = currentCamera.zoom + + const toDistance = toPosition.distanceTo(resolvedTarget) + const baseFrustumHeight = currentCamera.top - currentCamera.bottom + const targetFOV = 45 * (Math.PI / 180) + const idealVisibleHeight = 2 * Math.tan(targetFOV / 2) * toDistance + const calculatedZoom = baseFrustumHeight / idealVisibleHeight + + toZoom = Math.max(0.1, Math.min(10, calculatedZoom)) + } + + if (controlsRef.current) { + controlsRef.current.enabled = false + } animationRef.current = { fromPosition, toPosition, fromTarget, toTarget: resolvedTarget, - toQuaternion, - rollFrom, - rollTo, + fromQuaternion, + toQuaternion: slerpTarget, + finalQuaternion: toQuaternion, + finalUp: resolvedUp.clone(), + fromZoom, + toZoom, startTime: performance.now(), duration: durationMs, } }, - [camera, controlsRef, defaultTarget], + [controlsRef, defaultTarget], ) useEffect(() => { - if (!onReady || !camera) return - onReady({ animateTo }) + if (!onReady || !cameraRef.current) { + if (onReady) onReady(null) + return + } + + const hasControls = controlsRef.current !== null + if (hasControls) { + onReady({ animateTo }) + } else { + onReady(null) + } + return () => { onReady(null) } - }, [animateTo, camera, onReady]) + }, [animateTo, onReady, controlsVersion]) + + useEffect(() => { + const currentCamera = cameraRef.current + if (!currentCamera || !controlsRef.current) return + + const controls = controlsRef.current + const savedPosition = currentCamera.position.clone() + const savedQuaternion = currentCamera.quaternion.clone() + const savedTarget = controls.target.clone() + + const wasEnabled = controls.enabled + const wasDamping = controls.enableDamping + controls.enabled = false + controls.enableDamping = false + + controls.update() + + if (!currentCamera.position.equals(savedPosition)) { + currentCamera.position.copy(savedPosition) + currentCamera.quaternion.copy(savedQuaternion) + controls.target.copy(savedTarget) + currentCamera.updateMatrixWorld() + } + + controls.enableDamping = wasDamping + controls.enabled = wasEnabled + }, [camera]) useFrame(() => { - if (!camera || !animationRef.current) return + if (!animationRef.current) return + + const currentCamera = cameraRef.current + if (!currentCamera) return const { fromPosition, toPosition, fromTarget, toTarget, + fromQuaternion, toQuaternion, - rollFrom, - rollTo, + finalQuaternion, + finalUp, + fromZoom, + toZoom, startTime, duration, } = animationRef.current @@ -152,52 +238,56 @@ export const CameraAnimator: React.FC = ({ const progress = duration <= 0 ? 1 : Math.min(elapsed / duration, 1) const eased = easeInOutCubic(progress) - camera.position.lerpVectors(fromPosition, toPosition, eased) + currentCamera.position.lerpVectors(fromPosition, toPosition, eased) const nextTarget = tempTarget.current nextTarget.copy(fromTarget).lerp(toTarget, eased) - const baseHelper = baseOrientationHelper.current - baseHelper.up.set(0, 0, 1) - baseHelper.position.copy(camera.position) - baseHelper.lookAt(nextTarget) - - const baseQuaternion = tempQuaternion.current - baseQuaternion.copy(baseHelper.quaternion) - - const interpolatedRoll = tempRoll.current - interpolatedRoll.copy(rollFrom) - const rollTarget = tempRollTarget.current - rollTarget.copy(rollTo) - if (rollFrom.dot(rollTo) < 0) { - rollTarget.x *= -1 - rollTarget.y *= -1 - rollTarget.z *= -1 - rollTarget.w *= -1 - } - rollTarget.normalize() - interpolatedRoll.slerp(rollTarget, eased) + const interpolatedQuaternion = tempQuaternion.current + interpolatedQuaternion.copy(fromQuaternion).slerp(toQuaternion, eased) - camera.quaternion - .copy(baseQuaternion) - .multiply(interpolatedRoll) - .normalize() + currentCamera.quaternion.copy(interpolatedQuaternion) - const upVector = tempUp.current - upVector.set(0, 1, 0).applyQuaternion(camera.quaternion).normalize() - camera.up.copy(upVector) + if ( + currentCamera instanceof THREE.OrthographicCamera && + fromZoom !== undefined && + toZoom !== undefined + ) { + currentCamera.zoom = fromZoom + (toZoom - fromZoom) * eased + currentCamera.updateProjectionMatrix() + } controlsRef.current?.target.copy(nextTarget) - camera.updateMatrixWorld() + currentCamera.updateMatrixWorld() controlsRef.current?.update() if (progress >= 1) { - camera.position.copy(toPosition) - camera.quaternion.copy(toQuaternion) - camera.up.set(0, 0, 1) - camera.updateMatrixWorld() + currentCamera.position.copy(toPosition) + currentCamera.quaternion.copy(finalQuaternion) + currentCamera.up.copy(finalUp) + + if ( + currentCamera instanceof THREE.OrthographicCamera && + toZoom !== undefined + ) { + currentCamera.zoom = toZoom + currentCamera.updateProjectionMatrix() + } + + currentCamera.updateMatrixWorld() controlsRef.current?.target.copy(toTarget) - controlsRef.current?.update() + + if (controlsRef.current) { + controlsRef.current.enabled = true + controlsRef.current.update() + if ( + "saveState" in controlsRef.current && + typeof controlsRef.current.saveState === "function" + ) { + controlsRef.current.saveState() + } + } + animationRef.current = null } }) @@ -206,6 +296,7 @@ export const CameraAnimator: React.FC = ({ } interface UseCameraControllerOptions { + isOrthographic: boolean defaultTarget: THREE.Vector3 initialCameraPosition?: readonly [number, number, number] onCameraControllerReady?: (controller: CameraController | null) => void @@ -217,11 +308,13 @@ interface UseCameraControllerResult { } export const useCameraController = ({ + isOrthographic, defaultTarget, initialCameraPosition, onCameraControllerReady, }: UseCameraControllerOptions): UseCameraControllerResult => { const controlsRef = useRef(null) + const [controlsVersion, setControlsVersion] = useState(0) const baseDistance = useMemo(() => { const [x, y, z] = initialCameraPosition ?? [5, -5, 5] @@ -231,7 +324,7 @@ export const useCameraController = ({ z - defaultTarget.z, ) return distance > 0 ? distance : 5 - }, [initialCameraPosition, defaultTarget]) + }, [initialCameraPosition, defaultTarget, isOrthographic]) const getPresetConfig = useCallback( (preset: CameraPreset): CameraAnimationConfig | null => { @@ -344,23 +437,32 @@ export const useCameraController = ({ onCameraControllerReady(enhancedController) }, - [getPresetConfig, onCameraControllerReady], + [getPresetConfig, onCameraControllerReady, isOrthographic], ) + useEffect(() => { + controlsRef.current = null + setControlsVersion(0) + }, [isOrthographic]) + const handleControlsChange = useCallback( (controls: ThreeOrbitControls | null) => { controlsRef.current = controls + if (controls !== null) { + setControlsVersion((v) => v + 1) + } }, - [], + [isOrthographic], ) const cameraAnimatorProps = useMemo( () => ({ defaultTarget, controlsRef, + controlsVersion, onReady: handleControllerReady, }), - [defaultTarget, handleControllerReady], + [defaultTarget, handleControllerReady, controlsVersion, isOrthographic], ) return { cameraAnimatorProps, handleControlsChange } diff --git a/src/react-three/Canvas.tsx b/src/react-three/Canvas.tsx index bc3a13ce..e50cdeab 100644 --- a/src/react-three/Canvas.tsx +++ b/src/react-three/Canvas.tsx @@ -19,10 +19,17 @@ declare global { } } +type CanvasCameraProps = { + up?: [number, number, number] + position?: [number, number, number] + type?: "perspective" | "orthographic" + frustumSize?: number +} + interface CanvasProps { children: React.ReactNode scene?: Record - camera?: Record + camera?: CanvasCameraProps style?: React.CSSProperties onCreated?: (state: { camera: THREE.Camera @@ -39,12 +46,25 @@ export const Canvas = forwardRef( const [contextState, setContextState] = useState( null, ) - const frameListeners = useRef void>>( - [], - ) + const frameListeners = useRef void>>([]) + const lastOrthographicStateRef = useRef<{ + position: THREE.Vector3 + quaternion: THREE.Quaternion + up: THREE.Vector3 + zoom: number + target: THREE.Vector3 + } | null>(null) + const lastPerspectiveStateRef = useRef<{ + position: THREE.Vector3 + quaternion: THREE.Quaternion + up: THREE.Vector3 + target: THREE.Vector3 + } | null>(null) + const currentCameraRef = useRef(null) const onCreatedRef = useRef(undefined) onCreatedRef.current = onCreated + const addFrameListener = useCallback( (listener: (time: number, delta: number) => void) => { frameListeners.current.push(listener) @@ -72,34 +92,149 @@ export const Canvas = forwardRef( useEffect(() => { if (!mountRef.current) return + const isOrthographic = cameraProps?.type === "orthographic" + + const existingCamera = currentCameraRef.current + const wasSwitchingCameraType = existingCamera && ( + (isOrthographic && existingCamera instanceof THREE.PerspectiveCamera) || + (!isOrthographic && existingCamera instanceof THREE.OrthographicCamera) + ) + + if (existingCamera) { + const wasOrthographic = + existingCamera instanceof THREE.OrthographicCamera + + if (wasOrthographic) { + const zoom = (existingCamera as THREE.OrthographicCamera).zoom + lastOrthographicStateRef.current = { + position: existingCamera.position.clone(), + quaternion: existingCamera.quaternion.clone(), + up: existingCamera.up.clone(), + zoom, + target: contextState?.controls?.target?.clone() ?? new THREE.Vector3(), + } + } else { + lastPerspectiveStateRef.current = { + position: existingCamera.position.clone(), + quaternion: existingCamera.quaternion.clone(), + up: existingCamera.up.clone(), + target: contextState?.controls?.target?.clone() ?? new THREE.Vector3(), + } + } + } + removeExistingCanvases(mountRef.current) const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }) configureRenderer(renderer) - renderer.setSize( - mountRef.current.clientWidth, - mountRef.current.clientHeight, - ) + const width = mountRef.current.clientWidth || 1 + const height = mountRef.current.clientHeight || 1 + renderer.setSize(width, height) renderer.setPixelRatio(window.devicePixelRatio) mountRef.current.appendChild(renderer.domElement) - const camera = new THREE.PerspectiveCamera( - 75, - mountRef.current.clientWidth / mountRef.current.clientHeight, - 0.1, - 1000, - ) - if (cameraProps?.up) { - camera.up.set(cameraProps.up[0], cameraProps.up[1], cameraProps.up[2]) - } - if (cameraProps?.position) { - camera.position.set( - cameraProps.position[0], - cameraProps.position[1], - cameraProps.position[2], - ) + const frustumSize = cameraProps?.frustumSize ?? 20 + const aspect = width / height || 1 + + const camera = isOrthographic + ? new THREE.OrthographicCamera( + (-frustumSize * aspect) / 2, + (frustumSize * aspect) / 2, + frustumSize / 2, + -frustumSize / 2, + 0.1, + 2000, + ) + : new THREE.PerspectiveCamera(75, aspect, 0.1, 1000) + + currentCameraRef.current = camera + + // Only restore state when explicitly switching camera types + // Don't restore on circuit changes or first load + const stateToRestore = wasSwitchingCameraType + ? isOrthographic + ? lastPerspectiveStateRef.current + : lastOrthographicStateRef.current + : null + + const isRestoringFromOtherType = wasSwitchingCameraType && !!stateToRestore + + if (stateToRestore) { + camera.position.copy(stateToRestore.position) + camera.quaternion.copy(stateToRestore.quaternion) + camera.up.copy(stateToRestore.up) + + if ( + isOrthographic && + "zoom" in stateToRestore && + typeof stateToRestore.zoom === "number" + ) { + ;(camera as THREE.OrthographicCamera).zoom = stateToRestore.zoom + } + + if (isRestoringFromOtherType) { + let target: THREE.Vector3 + if (stateToRestore.target) { + target = stateToRestore.target.clone() + } else { + const forward = new THREE.Vector3(0, 0, -1).applyQuaternion( + camera.quaternion, + ) + const estimatedDistance = camera.position.length() + const toOrigin = new THREE.Vector3() + .sub(camera.position) + .normalize() + const dotWithForward = forward.dot(toOrigin) + if (dotWithForward > 0.5) { + target = new THREE.Vector3(0, 0, 0) + } else { + target = camera.position + .clone() + .add(forward.multiplyScalar(estimatedDistance)) + } + } + + const distance = camera.position.distanceTo(target) + + if (isOrthographic) { + const perspectiveFOV = 75 + const fovRadians = (perspectiveFOV * Math.PI) / 180 + const visibleHeight = + 2 * Math.tan(fovRadians / 2) * Math.max(distance, 0.1) + const orthoCameraHeight = frustumSize + const matchingZoom = Math.max( + 0.1, + Math.min(10, orthoCameraHeight / visibleHeight), + ) + ;(camera as THREE.OrthographicCamera).zoom = matchingZoom + camera.updateProjectionMatrix() + } else { + camera.updateProjectionMatrix() + } + + camera.updateMatrixWorld() + } else { + camera.updateProjectionMatrix() + camera.updateMatrixWorld() + } + } else { + if (cameraProps?.up) { + camera.up.set( + cameraProps.up[0], + cameraProps.up[1], + cameraProps.up[2], + ) + } + if (cameraProps?.position) { + camera.position.set( + cameraProps.position[0], + cameraProps.position[1], + cameraProps.position[2], + ) + } + camera.lookAt(0, 0, 0) + camera.updateProjectionMatrix() } - camera.lookAt(0, 0, 0) scene.add(rootObject.current) window.__TSCIRCUIT_THREE_OBJECT = rootObject.current @@ -126,21 +261,30 @@ export const Canvas = forwardRef( animate() const handleResize = () => { - if (mountRef.current) { - camera.aspect = - mountRef.current.clientWidth / mountRef.current.clientHeight - camera.updateProjectionMatrix() - renderer.setSize( - mountRef.current.clientWidth, - mountRef.current.clientHeight, - ) + if (!mountRef.current) return + const newWidth = mountRef.current.clientWidth || 1 + const newHeight = mountRef.current.clientHeight || 1 + const newAspect = newWidth / newHeight || 1 + if (camera instanceof THREE.PerspectiveCamera) { + camera.aspect = newAspect + } else if (camera instanceof THREE.OrthographicCamera) { + const nextFrustumSize = cameraProps?.frustumSize ?? frustumSize + const halfHeight = nextFrustumSize / 2 + const halfWidth = halfHeight * newAspect + camera.left = -halfWidth + camera.right = halfWidth + camera.top = halfHeight + camera.bottom = -halfHeight } + camera.updateProjectionMatrix() + renderer.setSize(newWidth, newHeight) } window.addEventListener("resize", handleResize) return () => { window.removeEventListener("resize", handleResize) cancelAnimationFrame(animationFrameId) + if (mountRef.current && renderer.domElement) { mountRef.current.removeChild(renderer.domElement) } @@ -149,8 +293,15 @@ export const Canvas = forwardRef( if (window.__TSCIRCUIT_THREE_OBJECT === rootObject.current) { window.__TSCIRCUIT_THREE_OBJECT = undefined } + setContextState(null) } - }, [scene, addFrameListener, removeFrameListener]) + }, [ + scene, + addFrameListener, + removeFrameListener, + cameraProps?.type, + cameraProps?.frustumSize, + ]) return (
diff --git a/src/react-three/OrbitControls.tsx b/src/react-three/OrbitControls.tsx index 94f399c9..e8dcce80 100644 --- a/src/react-three/OrbitControls.tsx +++ b/src/react-three/OrbitControls.tsx @@ -3,6 +3,7 @@ import { OrbitControls as ThreeOrbitControls } from "three/examples/jsm/controls import { useFrame, useThree } from "./ThreeContext" interface OrbitControlsProps { + key?: React.Key autoRotate?: boolean autoRotateSpeed?: number onStart?: () => void @@ -16,6 +17,7 @@ interface OrbitControlsProps { } export const OrbitControls: React.FC = ({ + key, // key is used by React and not passed down autoRotate, autoRotateSpeed, onStart, @@ -72,6 +74,7 @@ export const OrbitControls: React.FC = ({ } }, [ controls, + camera, autoRotate, autoRotateSpeed, panSpeed, @@ -84,9 +87,16 @@ export const OrbitControls: React.FC = ({ useEffect(() => { if (!controls || !onStart) return - controls.addEventListener("start", onStart) + + const handleStart = () => { + // Don't fire callback if controls are disabled (during camera animation) + if (!controls.enabled) return + onStart() + } + + controls.addEventListener("start", handleStart) return () => { - controls.removeEventListener("start", onStart) + controls.removeEventListener("start", handleStart) } }, [controls, onStart]) diff --git a/src/three-components/cube-with-labeled-sides.tsx b/src/three-components/cube-with-labeled-sides.tsx index 9895b554..08e50a5d 100644 --- a/src/three-components/cube-with-labeled-sides.tsx +++ b/src/three-components/cube-with-labeled-sides.tsx @@ -6,28 +6,18 @@ import { useFrame, useThree } from "src/react-three/ThreeContext" declare global { interface Window { TSCI_MAIN_CAMERA_ROTATION: THREE.Euler + TSCI_MAIN_CAMERA_QUATERNION: THREE.Quaternion } } if (typeof window !== "undefined") { window.TSCI_MAIN_CAMERA_ROTATION = new THREE.Euler(0, 0, 0) + window.TSCI_MAIN_CAMERA_QUATERNION = new THREE.Quaternion() } -function computePointInFront(rotationVector, distance) { - // Create a quaternion from the rotation vector - const quaternion = new THREE.Quaternion().setFromEuler( - new THREE.Euler(rotationVector.x, rotationVector.y, rotationVector.z), - ) - - // Create a vector pointing forward (along the negative z-axis) +function computePointInFront(quaternion: THREE.Quaternion, distance: number) { const forwardVector = new THREE.Vector3(0, 0, 1) - - // Apply the rotation to the forward vector forwardVector.applyQuaternion(quaternion) - - // Scale the rotated vector by the distance - const result = forwardVector.multiplyScalar(distance) - - return result + return forwardVector.multiplyScalar(distance) } export const CubeWithLabeledSides = ({}: any) => { @@ -45,8 +35,8 @@ export const CubeWithLabeledSides = ({}: any) => { useFrame(() => { if (!camera) return - const mainRot = window.TSCI_MAIN_CAMERA_ROTATION - const cameraPosition = computePointInFront(mainRot, 2) + const mainQuaternion = window.TSCI_MAIN_CAMERA_QUATERNION + const cameraPosition = computePointInFront(mainQuaternion, 2) camera.position.copy(cameraPosition) camera.lookAt(0, 0, 0)