import { Box3, MathUtils, PerspectiveCamera, Sphere, Vector3, type Object3D, } from 'three'; const EPSILON = 1e-8; const PARALLEL_UP_DOT_THRESHOLD = 0.98; const DEFAULT_MARGIN = 1.15; const DEFAULT_FALLBACK_RADIUS = 1; const DEFAULT_MIN_NEAR = 0.01; const DEFAULT_CAMERA_FIT_DIRECTION = new Vector3(1, 0.65, 1).normalize(); const DEFAULT_CAMERA_FIT_UP = new Vector3(0, 1, 0); export type CameraFitVector = | Vector3 | readonly [number, number, number] | { x: number; y: number; z: number }; export interface CameraFitBasis { /** * Normalized direction from the look target toward the camera position. */ viewDirection: Vector3; /** * Normalized direction the camera looks along. */ forward: Vector3; /** * Normalized camera-space right vector. */ right: Vector3; /** * Normalized camera-space up vector after resolving parallel-up edge cases. */ up: Vector3; } export interface PerspectiveCameraFitOptions { /** * Direction from target to camera. Defaults to an engineering-style isometric view. */ direction?: CameraFitVector; /** * Preferred world-up vector. If this is parallel to direction, a stable fallback is used. */ up?: CameraFitVector; /** * Optional look target. Defaults to the bounds center. */ target?: CameraFitVector; /** * Screen-space padding multiplier. 1.15 means the fitted bounds occupy roughly 87% of the view. */ margin?: number; /** * Lower clamp for camera distance in world units. */ minDistance?: number; /** * Upper clamp for camera distance in world units. If this constrains the result, the returned * constrainedByMaxDistance flag is true and the bounds may not fully fit. */ maxDistance?: number; /** * Radius used when bounds are empty, invalid, or effectively point-sized. */ fallbackRadius?: number; /** * Minimum near clipping plane. The final near plane is still kept in front of the nearest bound. */ minNear?: number; /** * Additional far-plane padding in world units. Defaults to a value derived from distance/radius. */ farPadding?: number; } export interface PerspectiveCameraFitResult { target: Vector3; position: Vector3; basis: CameraFitBasis; center: Vector3; size: Vector3; radius: number; effectiveRadius: number; distance: number; requiredDistance: number; near: number; far: number; fov: number; aspect: number; margin: number; constrainedByMaxDistance: boolean; } /** * Returns true when a Box3 can be used for camera fitting. Zero-size boxes are valid; empty boxes * and boxes containing infinities/NaN are not. */ export function isFiniteBox(box: Box3): boolean { return ( !box.isEmpty() && Number.isFinite(box.min.x) && Number.isFinite(box.min.y) && Number.isFinite(box.min.z) && Number.isFinite(box.max.x) && Number.isFinite(box.max.y) && Number.isFinite(box.max.z) ); } /** * Returns the eight corners of a finite Box3. Invalid/empty boxes return an empty array. */ export function getBoxCorners(box: Box3): Vector3[] { if (!isFiniteBox(box)) { return []; } const { min, max } = box; return [ new Vector3(min.x, min.y, min.z), new Vector3(min.x, min.y, max.z), new Vector3(min.x, max.y, min.z), new Vector3(min.x, max.y, max.z), new Vector3(max.x, min.y, min.z), new Vector3(max.x, min.y, max.z), new Vector3(max.x, max.y, min.z), new Vector3(max.x, max.y, max.z), ]; } /** * Builds a stable camera basis from an intended view direction and preferred up vector. */ export function createCameraFitBasis( direction?: CameraFitVector, up?: CameraFitVector, ): CameraFitBasis { const viewDirection = normalizeOrFallback( vectorFromInput(direction, DEFAULT_CAMERA_FIT_DIRECTION), DEFAULT_CAMERA_FIT_DIRECTION, ); let resolvedUp = normalizeOrFallback( vectorFromInput(up, DEFAULT_CAMERA_FIT_UP), DEFAULT_CAMERA_FIT_UP, ); if (Math.abs(resolvedUp.dot(viewDirection)) > PARALLEL_UP_DOT_THRESHOLD) { resolvedUp = chooseLeastParallelUp(viewDirection); } const right = new Vector3().crossVectors(resolvedUp, viewDirection); if (right.lengthSq() <= EPSILON) { right.crossVectors(chooseLeastParallelUp(viewDirection), viewDirection); } right.normalize(); const orthogonalUp = new Vector3().crossVectors(viewDirection, right).normalize(); return { viewDirection, forward: viewDirection.clone().negate(), right, up: orthogonalUp, }; } /** * Computes a perspective camera pose that frames a bounds box by projecting all eight corners into * the requested view basis. This is tighter and more predictable than bounding-sphere-only fitting, * while still keeping robust fallbacks for empty and zero-size assets. */ export function computePerspectiveCameraFit( inputBox: Box3, fovDegrees: number, aspectRatio: number, options: PerspectiveCameraFitOptions = {}, ): PerspectiveCameraFitResult { const fallbackRadius = resolvePositiveFinite(options.fallbackRadius, DEFAULT_FALLBACK_RADIUS); const explicitTarget = options.target !== undefined; const fallbackTarget = vectorFromInput(options.target, new Vector3(0, 0, 0)); const fitBox = isFiniteBox(inputBox) ? inputBox.clone() : createFallbackBox(fallbackTarget, fallbackRadius); const center = fitBox.getCenter(new Vector3()); const target = explicitTarget ? vectorFromInput(options.target, center) : center.clone(); const size = fitBox.getSize(new Vector3()); const sphere = fitBox.getBoundingSphere(new Sphere()); const radius = Number.isFinite(sphere.radius) ? Math.max(0, sphere.radius) : 0; const effectiveRadius = Math.max(radius, fallbackRadius); const basis = createCameraFitBasis(options.direction, options.up); const fov = Number.isFinite(fovDegrees) ? MathUtils.clamp(fovDegrees, 1, 175) : 50; const aspect = resolvePositiveFinite(aspectRatio, 1); const margin = Math.max(1, resolvePositiveFinite(options.margin, DEFAULT_MARGIN)); const tanHalfVertical = Math.tan(MathUtils.degToRad(fov) / 2); const tanHalfHorizontal = tanHalfVertical * aspect; const corners = getBoxCorners(fitBox); let minDepthOffset = Number.POSITIVE_INFINITY; let maxDepthOffset = Number.NEGATIVE_INFINITY; let requiredDistance = 0; for (const corner of corners) { const relative = corner.clone().sub(target); const depthOffset = relative.dot(basis.viewDirection); const horizontal = Math.abs(relative.dot(basis.right)); const vertical = Math.abs(relative.dot(basis.up)); minDepthOffset = Math.min(minDepthOffset, depthOffset); maxDepthOffset = Math.max(maxDepthOffset, depthOffset); requiredDistance = Math.max( requiredDistance, depthOffset + (horizontal * margin) / tanHalfHorizontal, depthOffset + (vertical * margin) / tanHalfVertical, ); } if (!Number.isFinite(minDepthOffset)) { minDepthOffset = -fallbackRadius; } if (!Number.isFinite(maxDepthOffset)) { maxDepthOffset = fallbackRadius; } if (!Number.isFinite(requiredDistance)) { requiredDistance = fallbackRadius; } const halfMinimumFov = Math.atan(Math.min(tanHalfVertical, tanHalfHorizontal)); const zeroSizeDistance = (fallbackRadius * margin) / Math.tan(Math.max(halfMinimumFov, EPSILON)); const depthComfortDistance = maxDepthOffset + Math.max(fallbackRadius * 0.25, radius * 0.05); const unconstrainedDistance = Math.max( requiredDistance, radius <= EPSILON ? zeroSizeDistance : depthComfortDistance, EPSILON, ); const minDistance = resolvePositiveFinite(options.minDistance, EPSILON); const maxDistance = resolveOptionalPositiveFinite(options.maxDistance); const safeMaxDistance = maxDistance === undefined ? undefined : Math.max(maxDistance, minDistance); let distance = Math.max(unconstrainedDistance, minDistance); let constrainedByMaxDistance = false; if (safeMaxDistance !== undefined && distance > safeMaxDistance) { distance = safeMaxDistance; constrainedByMaxDistance = true; } const position = target.clone().addScaledVector(basis.viewDirection, distance); const nearestDepth = Math.max(EPSILON, distance - maxDepthOffset); const farthestDepth = Math.max(nearestDepth + EPSILON, distance - minDepthOffset); const minNear = resolvePositiveFinite(options.minNear, DEFAULT_MIN_NEAR); let near = Math.max(minNear, nearestDepth * 0.25); near = Math.min(near, nearestDepth * 0.9); near = Math.max(near, EPSILON); const autoFarPadding = Math.max(effectiveRadius * 2, distance * 0.25, 1); const farPadding = resolveNonNegativeFinite(options.farPadding, autoFarPadding); const far = Math.max(near + EPSILON, farthestDepth + farPadding); return { target, position, basis, center, size, radius, effectiveRadius, distance, requiredDistance, near, far, fov, aspect, margin, constrainedByMaxDistance, }; } /** * Mutates a PerspectiveCamera to the computed fit pose and updates its projection/world matrices. * OrbitControls targets should be set by the caller from the returned target. */ export function applyPerspectiveCameraFit( camera: PerspectiveCamera, box: Box3, options: PerspectiveCameraFitOptions = {}, ): PerspectiveCameraFitResult { const fit = computePerspectiveCameraFit(box, camera.fov, camera.aspect, { ...options, up: options.up ?? camera.up, }); camera.position.copy(fit.position); camera.up.copy(fit.basis.up); camera.near = fit.near; camera.far = fit.far; camera.lookAt(fit.target); camera.updateProjectionMatrix(); camera.updateMatrixWorld(); return fit; } /** * Computes world-space bounds for an Object3D subtree before fitting. Useful when procedural * assemblies and GLTF scenes share the same camera-reset path. */ export function getObjectWorldBounds(object: Object3D): Box3 { object.updateWorldMatrix(true, true); return new Box3().setFromObject(object); } export function computePerspectiveCameraFitFromObject( object: Object3D, fovDegrees: number, aspectRatio: number, options: PerspectiveCameraFitOptions = {}, ): PerspectiveCameraFitResult { return computePerspectiveCameraFit( getObjectWorldBounds(object), fovDegrees, aspectRatio, options, ); } function vectorFromInput(value: CameraFitVector | undefined, fallback: Vector3): Vector3 { if (value === undefined) { return fallback.clone(); } if (Array.isArray(value)) { const [x, y, z] = value; return areFiniteNumbers(x, y, z) ? new Vector3(x, y, z) : fallback.clone(); } const possibleVector = value as { x?: unknown; y?: unknown; z?: unknown }; return areFiniteNumbers(possibleVector.x, possibleVector.y, possibleVector.z) ? new Vector3( possibleVector.x as number, possibleVector.y as number, possibleVector.z as number, ) : fallback.clone(); } function normalizeOrFallback(vector: Vector3, fallback: Vector3): Vector3 { if (!isFiniteVector(vector) || vector.lengthSq() <= EPSILON) { return fallback.clone().normalize(); } return vector.normalize(); } function isFiniteVector(vector: Vector3): boolean { return ( Number.isFinite(vector.x) && Number.isFinite(vector.y) && Number.isFinite(vector.z) ); } function chooseLeastParallelUp(viewDirection: Vector3): Vector3 { const candidates = [ DEFAULT_CAMERA_FIT_UP, new Vector3(0, 0, 1), new Vector3(1, 0, 0), ]; let bestCandidate = candidates[0]; let bestDot = Number.POSITIVE_INFINITY; for (const candidate of candidates) { const dot = Math.abs(candidate.dot(viewDirection)); if (dot < bestDot) { bestDot = dot; bestCandidate = candidate; } } return bestCandidate.clone().normalize(); } function createFallbackBox(target: Vector3, radius: number): Box3 { return new Box3( target.clone().addScalar(-radius), target.clone().addScalar(radius), ); } function resolvePositiveFinite(value: unknown, fallback: number): number { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback; } function resolveOptionalPositiveFinite(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined; } function resolveNonNegativeFinite(value: unknown, fallback: number): number { return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback; } function areFiniteNumbers(...values: unknown[]): boolean { return values.every((value) => typeof value === 'number' && Number.isFinite(value)); }