import * as React from 'react' import * as THREE from 'three' import { useFrame, useThree } from '@react-three/fiber' export type SizeProps = { box: THREE.Box3 size: THREE.Vector3 center: THREE.Vector3 distance: number } export type BoundsApi = { getSize: () => SizeProps refresh(object?: THREE.Object3D | THREE.Box3): BoundsApi reset(): BoundsApi moveTo(position: THREE.Vector3 | [number, number, number]): BoundsApi lookAt({ target, up, }: { target?: THREE.Vector3 | [number, number, number] up?: THREE.Vector3 | [number, number, number] }): BoundsApi to({ position, target }: { position: [number, number, number]; target: [number, number, number] }): BoundsApi fit(): BoundsApi clip(): BoundsApi } export type BoundsProps = JSX.IntrinsicElements['group'] & { maxDuration?: number margin?: number observe?: boolean fit?: boolean clip?: boolean interpolateFunc?: (t: number) => number onFit?: (data: SizeProps) => void } type ControlsProto = { update(): void target: THREE.Vector3 maxDistance: number addEventListener: (event: string, callback: (event: any) => void) => void removeEventListener: (event: string, callback: (event: any) => void) => void } type OriginT = { camPos: THREE.Vector3 camRot: THREE.Quaternion camZoom: number } type GoalT = { camPos: THREE.Vector3 | undefined camRot: THREE.Quaternion | undefined camZoom: number | undefined camUp: THREE.Vector3 | undefined target: THREE.Vector3 | undefined } enum AnimationState { NONE = 0, START = 1, ACTIVE = 2, } const isOrthographic = (def: THREE.Camera): def is THREE.OrthographicCamera => def && (def as THREE.OrthographicCamera).isOrthographicCamera const isBox3 = (def: any): def is THREE.Box3 => def && (def as THREE.Box3).isBox3 const interpolateFuncDefault = (t: number) => { // Imitates the previously used THREE.MathUtils.damp return 1 - Math.exp(-5 * t) + 0.007 * t } const context = React.createContext(null!) export function Bounds({ children, maxDuration = 1.0, margin = 1.2, observe, fit, clip, interpolateFunc = interpolateFuncDefault, onFit, }: BoundsProps) { const ref = React.useRef(null!) const { camera, size, invalidate } = useThree() const controls = useThree((state) => state.controls as unknown as ControlsProto) const onFitRef = React.useRef<((data: SizeProps) => void) | undefined>(onFit) onFitRef.current = onFit const origin = React.useRef({ camPos: new THREE.Vector3(), camRot: new THREE.Quaternion(), camZoom: 1, }) const goal = React.useRef({ camPos: undefined, camRot: undefined, camZoom: undefined, camUp: undefined, target: undefined, }) const animationState = React.useRef(AnimationState.NONE) const t = React.useRef(0) // represent animation state from 0 to 1 const [box] = React.useState(() => new THREE.Box3()) const api: BoundsApi = React.useMemo(() => { function getSize() { const boxSize = box.getSize(new THREE.Vector3()) const center = box.getCenter(new THREE.Vector3()) const maxSize = Math.max(boxSize.x, boxSize.y, boxSize.z) const fitHeightDistance = isOrthographic(camera) ? maxSize * 4 : maxSize / (2 * Math.atan((Math.PI * camera.fov) / 360)) const fitWidthDistance = isOrthographic(camera) ? maxSize * 4 : fitHeightDistance / camera.aspect const distance = margin * Math.max(fitHeightDistance, fitWidthDistance) return { box, size: boxSize, center, distance } } return { getSize, refresh(object?: THREE.Object3D | THREE.Box3) { if (isBox3(object)) box.copy(object) else { const target = object || ref.current if (!target) return this target.updateWorldMatrix(true, true) box.setFromObject(target) } if (box.isEmpty()) { const max = camera.position.length() || 10 box.setFromCenterAndSize(new THREE.Vector3(), new THREE.Vector3(max, max, max)) } origin.current.camPos.copy(camera.position) origin.current.camRot.copy(camera.quaternion) isOrthographic(camera) && (origin.current.camZoom = camera.zoom) goal.current.camPos = undefined goal.current.camRot = undefined goal.current.camZoom = undefined goal.current.camUp = undefined goal.current.target = undefined return this }, reset() { const { center, distance } = getSize() const direction = camera.position.clone().sub(center).normalize() goal.current.camPos = center.clone().addScaledVector(direction, distance) goal.current.target = center.clone() const mCamRot = new THREE.Matrix4().lookAt(goal.current.camPos, goal.current.target, camera.up) goal.current.camRot = new THREE.Quaternion().setFromRotationMatrix(mCamRot) animationState.current = AnimationState.START t.current = 0 return this }, moveTo(position: THREE.Vector3 | [number, number, number]) { goal.current.camPos = Array.isArray(position) ? new THREE.Vector3(...position) : position.clone() animationState.current = AnimationState.START t.current = 0 return this }, lookAt({ target, up, }: { target: THREE.Vector3 | [number, number, number] up?: THREE.Vector3 | [number, number, number] }) { goal.current.target = Array.isArray(target) ? new THREE.Vector3(...target) : target.clone() if (up) { goal.current.camUp = Array.isArray(up) ? new THREE.Vector3(...up) : up.clone() } else { goal.current.camUp = camera.up.clone() } const mCamRot = new THREE.Matrix4().lookAt( goal.current.camPos || camera.position, goal.current.target, goal.current.camUp ) goal.current.camRot = new THREE.Quaternion().setFromRotationMatrix(mCamRot) animationState.current = AnimationState.START t.current = 0 return this }, /** * @deprecated Use moveTo and lookAt instead */ to({ position, target }: { position: [number, number, number]; target?: [number, number, number] }) { return this.moveTo(position).lookAt({ target }) }, fit() { if (!isOrthographic(camera)) { // For non-orthographic cameras, fit should behave exactly like reset return this.reset() } // For orthographic cameras, fit should only modify the zoom value let maxHeight = 0, maxWidth = 0 const vertices = [ new THREE.Vector3(box.min.x, box.min.y, box.min.z), new THREE.Vector3(box.min.x, box.max.y, box.min.z), new THREE.Vector3(box.min.x, box.min.y, box.max.z), new THREE.Vector3(box.min.x, box.max.y, box.max.z), new THREE.Vector3(box.max.x, box.max.y, box.max.z), new THREE.Vector3(box.max.x, box.max.y, box.min.z), new THREE.Vector3(box.max.x, box.min.y, box.max.z), new THREE.Vector3(box.max.x, box.min.y, box.min.z), ] // Transform the center and each corner to camera space const pos = goal.current.camPos || camera.position const target = goal.current.target || controls?.target const up = goal.current.camUp || camera.up const mCamWInv = target ? new THREE.Matrix4().lookAt(pos, target, up).setPosition(pos).invert() : camera.matrixWorldInverse for (const v of vertices) { v.applyMatrix4(mCamWInv) maxHeight = Math.max(maxHeight, Math.abs(v.y)) maxWidth = Math.max(maxWidth, Math.abs(v.x)) } maxHeight *= 2 maxWidth *= 2 const zoomForHeight = (camera.top - camera.bottom) / maxHeight const zoomForWidth = (camera.right - camera.left) / maxWidth goal.current.camZoom = Math.min(zoomForHeight, zoomForWidth) / margin animationState.current = AnimationState.START t.current = 0 onFitRef.current && onFitRef.current(this.getSize()) return this }, clip() { const { distance } = getSize() camera.near = distance / 100 camera.far = distance * 100 camera.updateProjectionMatrix() if (controls) { controls.maxDistance = distance * 10 controls.update() } invalidate() return this }, } }, [box, camera, controls, margin, invalidate]) React.useLayoutEffect(() => { if (controls) { // Try to prevent drag hijacking const callback = () => { if (controls && goal.current.target && animationState.current !== AnimationState.NONE) { const front = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 2) const d0 = origin.current.camPos.distanceTo(controls.target) const d1 = (goal.current.camPos || origin.current.camPos).distanceTo(goal.current.target) const d = (1 - t.current) * d0 + t.current * d1 controls.target.copy(camera.position).addScaledVector(front, -d) controls.update() } animationState.current = AnimationState.NONE } controls.addEventListener('start', callback) return () => controls.removeEventListener('start', callback) } }, [controls]) // Scale pointer on window resize const count = React.useRef(0) React.useLayoutEffect(() => { if (observe || count.current++ === 0) { api.refresh() if (fit) api.reset().fit() if (clip) api.clip() } }, [size, clip, fit, observe, camera, controls]) useFrame((state, delta) => { // This [additional animation step START] is needed to guarantee that delta used in animation isn't absurdly high (2-3 seconds) which is actually possible if rendering happens on demand... if (animationState.current === AnimationState.START) { animationState.current = AnimationState.ACTIVE invalidate() } else if (animationState.current === AnimationState.ACTIVE) { t.current += delta / maxDuration if (t.current >= 1) { goal.current.camPos && camera.position.copy(goal.current.camPos) goal.current.camRot && camera.quaternion.copy(goal.current.camRot) goal.current.camUp && camera.up.copy(goal.current.camUp) goal.current.camZoom && isOrthographic(camera) && (camera.zoom = goal.current.camZoom) camera.updateMatrixWorld() camera.updateProjectionMatrix() if (controls && goal.current.target) { controls.target.copy(goal.current.target) controls.update() } animationState.current = AnimationState.NONE } else { const k = interpolateFunc(t.current) goal.current.camPos && camera.position.lerpVectors(origin.current.camPos, goal.current.camPos, k) goal.current.camRot && camera.quaternion.slerpQuaternions(origin.current.camRot, goal.current.camRot, k) goal.current.camUp && camera.up.set(0, 1, 0).applyQuaternion(camera.quaternion) goal.current.camZoom && isOrthographic(camera) && (camera.zoom = (1 - k) * origin.current.camZoom + k * goal.current.camZoom) camera.updateMatrixWorld() camera.updateProjectionMatrix() } invalidate() } }) return ( {children} ) } export function useBounds() { return React.useContext(context) }