diff --git a/packages/r3f/src/OrbitControls.ts b/packages/r3f/src/OrbitControls.ts new file mode 100644 index 0000000000..2050d4c6f7 --- /dev/null +++ b/packages/r3f/src/OrbitControls.ts @@ -0,0 +1,1074 @@ +import type {Camera, Matrix4} from 'three' +import { + EventDispatcher, + MOUSE, + OrthographicCamera, + PerspectiveCamera, + Quaternion, + Spherical, + TOUCH, + Vector2, + Vector3, +} from 'three' + +// This set of controls performs orbiting, dollying (zooming), and panning. +// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default). +// +// Orbit - left mouse / touch: one-finger move +// Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish +// Pan - right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move + +const moduloWrapAround = (offset: number, capacity: number) => + ((offset % capacity) + capacity) % capacity + +class OrbitControls extends EventDispatcher { + object: Camera + domElement: HTMLElement | undefined + // Set to false to disable this control + enabled = true + // "target" sets the location of focus, where the object orbits around + target = new Vector3() + // How far you can dolly in and out ( PerspectiveCamera only ) + minDistance = 0 + maxDistance = Infinity + // How far you can zoom in and out ( OrthographicCamera only ) + minZoom = 0 + maxZoom = Infinity + // How far you can orbit vertically, upper and lower limits. + // Range is 0 to Math.PI radians. + minPolarAngle = 0 // radians + maxPolarAngle = Math.PI // radians + // How far you can orbit horizontally, upper and lower limits. + // If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI ) + minAzimuthAngle = -Infinity // radians + maxAzimuthAngle = Infinity // radians + // Set to true to enable damping (inertia) + // If damping is enabled, you must call controls.update() in your animation loop + enableDamping = false + dampingFactor = 0.05 + // This option actually enables dollying in and out; left as "zoom" for backwards compatibility. + // Set to false to disable zooming + enableZoom = true + zoomSpeed = 1.0 + // Set to false to disable rotating + enableRotate = true + rotateSpeed = 1.0 + // Set to false to disable panning + enablePan = true + panSpeed = 1.0 + screenSpacePanning = true // if false, pan orthogonal to world-space direction camera.up + keyPanSpeed = 7.0 // pixels moved per arrow key push + // Set to true to automatically rotate around the target + // If auto-rotate is enabled, you must call controls.update() in your animation loop + autoRotate = false + autoRotateSpeed = 2.0 // 30 seconds per orbit when fps is 60 + reverseOrbit = false // true if you want to reverse the orbit to mouse drag from left to right = orbits left + // The four arrow keys + keys = { + LEFT: 'ArrowLeft', + UP: 'ArrowUp', + RIGHT: 'ArrowRight', + BOTTOM: 'ArrowDown', + } + // Mouse buttons + mouseButtons = { + LEFT: MOUSE.ROTATE, + MIDDLE: MOUSE.DOLLY, + RIGHT: MOUSE.PAN, + } + // Touch fingers + touches = {ONE: TOUCH.ROTATE, TWO: TOUCH.DOLLY_PAN} + target0: Vector3 + position0: Vector3 + zoom0: number + // the target DOM element for key events + _domElementKeyEvents: any = null + + getPolarAngle: () => number + getAzimuthalAngle: () => number + setPolarAngle: (x: number) => void + setAzimuthalAngle: (x: number) => void + getDistance: () => number + + listenToKeyEvents: (domElement: HTMLElement) => void + saveState: () => void + reset: () => void + update: () => void + connect: (domElement: HTMLElement) => void + dispose: () => void + + constructor(object: Camera, domElement?: HTMLElement) { + super() + + this.object = object + this.domElement = domElement + + // for reset + this.target0 = this.target.clone() + this.position0 = this.object.position.clone() + this.zoom0 = this.object instanceof PerspectiveCamera ? this.object.zoom : 1 + + // + // public methods + // + + this.getPolarAngle = (): number => spherical.phi + + this.getAzimuthalAngle = (): number => spherical.theta + + this.setPolarAngle = (value: number): void => { + // use modulo wrapping to safeguard value + let phi = moduloWrapAround(value, 2 * Math.PI) + let currentPhi = spherical.phi + + // convert to the equivalent shortest angle + if (currentPhi < 0) currentPhi += 2 * Math.PI + if (phi < 0) phi += 2 * Math.PI + let phiDist = Math.abs(phi - currentPhi) + if (2 * Math.PI - phiDist < phiDist) { + if (phi < currentPhi) { + phi += 2 * Math.PI + } else { + currentPhi += 2 * Math.PI + } + } + sphericalDelta.phi = phi - currentPhi + scope.update() + } + + this.setAzimuthalAngle = (value: number): void => { + // use modulo wrapping to safeguard value + let theta = moduloWrapAround(value, 2 * Math.PI) + let currentTheta = spherical.theta + + // convert to the equivalent shortest angle + if (currentTheta < 0) currentTheta += 2 * Math.PI + if (theta < 0) theta += 2 * Math.PI + let thetaDist = Math.abs(theta - currentTheta) + if (2 * Math.PI - thetaDist < thetaDist) { + if (theta < currentTheta) { + theta += 2 * Math.PI + } else { + currentTheta += 2 * Math.PI + } + } + sphericalDelta.theta = theta - currentTheta + scope.update() + } + + this.getDistance = (): number => + scope.object.position.distanceTo(scope.target) + + this.listenToKeyEvents = (domElement: HTMLElement): void => { + domElement.addEventListener('keydown', onKeyDown) + this._domElementKeyEvents = domElement + } + + this.saveState = (): void => { + scope.target0.copy(scope.target) + scope.position0.copy(scope.object.position) + scope.zoom0 = + scope.object instanceof PerspectiveCamera ? scope.object.zoom : 1 + } + + this.reset = (): void => { + scope.target.copy(scope.target0) + scope.object.position.copy(scope.position0) + if (scope.object instanceof PerspectiveCamera) { + scope.object.zoom = scope.zoom0 + scope.object.updateProjectionMatrix() + } + + scope.dispatchEvent(changeEvent) + + scope.update() + + state = STATE.NONE + } + + // this method is exposed, but perhaps it would be better if we can make it private... + this.update = ((): (() => void) => { + const offset = new Vector3() + + // so camera.up is the orbit axis + const quat = new Quaternion().setFromUnitVectors( + object.up, + new Vector3(0, 1, 0), + ) + const quatInverse = quat.clone().invert() + + const lastPosition = new Vector3() + const lastQuaternion = new Quaternion() + + const twoPI = 2 * Math.PI + + return function update(): boolean { + const position = scope.object.position + + offset.copy(position).sub(scope.target) + + // rotate offset to "y-axis-is-up" space + offset.applyQuaternion(quat) + + // angle from z-axis around y-axis + spherical.setFromVector3(offset) + + if (scope.autoRotate && state === STATE.NONE) { + rotateLeft(getAutoRotationAngle()) + } + + if (scope.enableDamping) { + spherical.theta += sphericalDelta.theta * scope.dampingFactor + spherical.phi += sphericalDelta.phi * scope.dampingFactor + } else { + spherical.theta += sphericalDelta.theta + spherical.phi += sphericalDelta.phi + } + + // restrict theta to be between desired limits + + let min = scope.minAzimuthAngle + let max = scope.maxAzimuthAngle + + if (isFinite(min) && isFinite(max)) { + if (min < -Math.PI) min += twoPI + else if (min > Math.PI) min -= twoPI + + if (max < -Math.PI) max += twoPI + else if (max > Math.PI) max -= twoPI + + if (min <= max) { + spherical.theta = Math.max(min, Math.min(max, spherical.theta)) + } else { + spherical.theta = + spherical.theta > (min + max) / 2 + ? Math.max(min, spherical.theta) + : Math.min(max, spherical.theta) + } + } + + // restrict phi to be between desired limits + spherical.phi = Math.max( + scope.minPolarAngle, + Math.min(scope.maxPolarAngle, spherical.phi), + ) + spherical.makeSafe() + spherical.radius *= scale + + // restrict radius to be between desired limits + spherical.radius = Math.max( + scope.minDistance, + Math.min(scope.maxDistance, spherical.radius), + ) + + // move target to panned location + + if (scope.enableDamping === true) { + scope.target.addScaledVector(panOffset, scope.dampingFactor) + } else { + scope.target.add(panOffset) + } + + offset.setFromSpherical(spherical) + + // rotate offset back to "camera-up-vector-is-up" space + offset.applyQuaternion(quatInverse) + + position.copy(scope.target).add(offset) + + scope.object.lookAt(scope.target) + + if (scope.enableDamping === true) { + sphericalDelta.theta *= 1 - scope.dampingFactor + sphericalDelta.phi *= 1 - scope.dampingFactor + + panOffset.multiplyScalar(1 - scope.dampingFactor) + } else { + sphericalDelta.set(0, 0, 0) + + panOffset.set(0, 0, 0) + } + + scale = 1 + + // update condition is: + // min(camera displacement, camera rotation in radians)^2 > EPS + // using small-angle approximation cos(x/2) = 1 - x^2 / 8 + + if ( + zoomChanged || + lastPosition.distanceToSquared(scope.object.position) > EPS || + 8 * (1 - lastQuaternion.dot(scope.object.quaternion)) > EPS + ) { + scope.dispatchEvent(changeEvent) + + lastPosition.copy(scope.object.position) + lastQuaternion.copy(scope.object.quaternion) + zoomChanged = false + + return true + } + + return false + } + })() + + // https://github.com/mrdoob/three.js/issues/20575 + this.connect = (domElement: HTMLElement): void => { + if ((domElement as any) === document) { + console.error( + 'THREE.OrbitControls: "document" should not be used as the target "domElement". Please use "renderer.domElement" instead.', + ) + } + scope.domElement = domElement + // disables touch scroll + // touch-action needs to be defined for pointer events to work on mobile + // https://stackoverflow.com/a/48254578 + scope.domElement.style.touchAction = 'none' + scope.domElement.addEventListener('contextmenu', onContextMenu) + scope.domElement.addEventListener('pointerdown', onPointerDown) + scope.domElement.addEventListener('pointercancel', onPointerCancel) + scope.domElement.addEventListener('wheel', onMouseWheel) + } + + this.dispose = (): void => { + scope.domElement?.removeEventListener('contextmenu', onContextMenu) + scope.domElement?.removeEventListener('pointerdown', onPointerDown) + scope.domElement?.removeEventListener('pointercancel', onPointerCancel) + scope.domElement?.removeEventListener('wheel', onMouseWheel) + scope.domElement?.ownerDocument.removeEventListener( + 'pointermove', + onPointerMove, + ) + scope.domElement?.ownerDocument.removeEventListener( + 'pointerup', + onPointerUp, + ) + if (scope._domElementKeyEvents !== null) { + scope._domElementKeyEvents.removeEventListener('keydown', onKeyDown) + } + //scope.dispatchEvent( { type: 'dispose' } ); // should this be added here? + } + + // + // internals + // + + const scope = this + + const changeEvent = {type: 'change'} + const startEvent = {type: 'start'} + const endEvent = {type: 'end'} + + const STATE = { + NONE: -1, + ROTATE: 0, + DOLLY: 1, + PAN: 2, + TOUCH_ROTATE: 3, + TOUCH_PAN: 4, + TOUCH_DOLLY_PAN: 5, + TOUCH_DOLLY_ROTATE: 6, + } + + let state = STATE.NONE + + const EPS = 0.000001 + + // current position in spherical coordinates + const spherical = new Spherical() + const sphericalDelta = new Spherical() + + let scale = 1 + const panOffset = new Vector3() + let zoomChanged = false + + const rotateStart = new Vector2() + const rotateEnd = new Vector2() + const rotateDelta = new Vector2() + + const panStart = new Vector2() + const panEnd = new Vector2() + const panDelta = new Vector2() + + const dollyStart = new Vector2() + const dollyEnd = new Vector2() + const dollyDelta = new Vector2() + + const pointers: PointerEvent[] = [] + const pointerPositions: {[key: string]: Vector2} = {} + + function getAutoRotationAngle(): number { + return ((2 * Math.PI) / 60 / 60) * scope.autoRotateSpeed + } + + function getZoomScale(): number { + return Math.pow(0.95, scope.zoomSpeed) + } + + function rotateLeft(angle: number): void { + if (scope.reverseOrbit) { + sphericalDelta.theta += angle + } else { + sphericalDelta.theta -= angle + } + } + + function rotateUp(angle: number): void { + if (scope.reverseOrbit) { + sphericalDelta.phi += angle + } else { + sphericalDelta.phi -= angle + } + } + + const panLeft = (() => { + const v = new Vector3() + + return function panLeft(distance: number, objectMatrix: Matrix4) { + v.setFromMatrixColumn(objectMatrix, 0) // get X column of objectMatrix + v.multiplyScalar(-distance) + + panOffset.add(v) + } + })() + + const panUp = (() => { + const v = new Vector3() + + return function panUp(distance: number, objectMatrix: Matrix4) { + if (scope.screenSpacePanning === true) { + v.setFromMatrixColumn(objectMatrix, 1) + } else { + v.setFromMatrixColumn(objectMatrix, 0) + v.crossVectors(scope.object.up, v) + } + + v.multiplyScalar(distance) + + panOffset.add(v) + } + })() + + // deltaX and deltaY are in pixels; right and down are positive + const pan = (() => { + const offset = new Vector3() + + return function pan(deltaX: number, deltaY: number) { + const element = scope.domElement + + if ( + element && + scope.object instanceof PerspectiveCamera && + scope.object.isPerspectiveCamera + ) { + // perspective + const position = scope.object.position + offset.copy(position).sub(scope.target) + let targetDistance = offset.length() + + // half of the fov is center to top of screen + targetDistance *= Math.tan(((scope.object.fov / 2) * Math.PI) / 180.0) + + // we use only clientHeight here so aspect ratio does not distort speed + panLeft( + (2 * deltaX * targetDistance) / element.clientHeight, + scope.object.matrix, + ) + panUp( + (2 * deltaY * targetDistance) / element.clientHeight, + scope.object.matrix, + ) + } else if ( + element && + scope.object instanceof OrthographicCamera && + scope.object.isOrthographicCamera + ) { + // orthographic + panLeft( + (deltaX * (scope.object.right - scope.object.left)) / + scope.object.zoom / + element.clientWidth, + scope.object.matrix, + ) + panUp( + (deltaY * (scope.object.top - scope.object.bottom)) / + scope.object.zoom / + element.clientHeight, + scope.object.matrix, + ) + } else { + // camera neither orthographic nor perspective + console.warn( + 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.', + ) + scope.enablePan = false + } + } + })() + + function dollyOut(dollyScale: number) { + if ( + scope.object instanceof PerspectiveCamera && + scope.object.isPerspectiveCamera + ) { + scale /= dollyScale + } else if ( + scope.object instanceof OrthographicCamera && + scope.object.isOrthographicCamera + ) { + scope.object.zoom = Math.max( + scope.minZoom, + Math.min(scope.maxZoom, scope.object.zoom * dollyScale), + ) + scope.object.updateProjectionMatrix() + zoomChanged = true + } else { + console.warn( + 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.', + ) + scope.enableZoom = false + } + } + + function dollyIn(dollyScale: number) { + if ( + scope.object instanceof PerspectiveCamera && + scope.object.isPerspectiveCamera + ) { + scale *= dollyScale + } else if ( + scope.object instanceof OrthographicCamera && + scope.object.isOrthographicCamera + ) { + scope.object.zoom = Math.max( + scope.minZoom, + Math.min(scope.maxZoom, scope.object.zoom / dollyScale), + ) + scope.object.updateProjectionMatrix() + zoomChanged = true + } else { + console.warn( + 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.', + ) + scope.enableZoom = false + } + } + + // + // event callbacks - update the object state + // + + function handleMouseDownRotate(event: MouseEvent) { + rotateStart.set(event.clientX, event.clientY) + } + + function handleMouseDownDolly(event: MouseEvent) { + dollyStart.set(event.clientX, event.clientY) + } + + function handleMouseDownPan(event: MouseEvent) { + panStart.set(event.clientX, event.clientY) + } + + function handleMouseMoveRotate(event: MouseEvent) { + rotateEnd.set(event.clientX, event.clientY) + rotateDelta + .subVectors(rotateEnd, rotateStart) + .multiplyScalar(scope.rotateSpeed) + + const element = scope.domElement + + if (element) { + rotateLeft((2 * Math.PI * rotateDelta.x) / element.clientHeight) // yes, height + rotateUp((2 * Math.PI * rotateDelta.y) / element.clientHeight) + } + rotateStart.copy(rotateEnd) + scope.update() + } + + function handleMouseMoveDolly(event: MouseEvent) { + dollyEnd.set(event.clientX, event.clientY) + dollyDelta.subVectors(dollyEnd, dollyStart) + + if (dollyDelta.y > 0) { + dollyOut(getZoomScale()) + } else if (dollyDelta.y < 0) { + dollyIn(getZoomScale()) + } + + dollyStart.copy(dollyEnd) + scope.update() + } + + function handleMouseMovePan(event: MouseEvent) { + panEnd.set(event.clientX, event.clientY) + panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed) + pan(panDelta.x, panDelta.y) + panStart.copy(panEnd) + scope.update() + } + + function handleMouseWheel(event: WheelEvent) { + if (event.deltaY < 0) { + dollyIn(getZoomScale()) + } else if (event.deltaY > 0) { + dollyOut(getZoomScale()) + } + + scope.update() + } + + function handleKeyDown(event: KeyboardEvent) { + let needsUpdate = false + + switch (event.code) { + case scope.keys.UP: + pan(0, scope.keyPanSpeed) + needsUpdate = true + break + + case scope.keys.BOTTOM: + pan(0, -scope.keyPanSpeed) + needsUpdate = true + break + + case scope.keys.LEFT: + pan(scope.keyPanSpeed, 0) + needsUpdate = true + break + + case scope.keys.RIGHT: + pan(-scope.keyPanSpeed, 0) + needsUpdate = true + break + } + + if (needsUpdate) { + // prevent the browser from scrolling on cursor keys + event.preventDefault() + scope.update() + } + } + + function handleTouchStartRotate() { + if (pointers.length == 1) { + rotateStart.set(pointers[0].pageX, pointers[0].pageY) + } else { + const x = 0.5 * (pointers[0].pageX + pointers[1].pageX) + const y = 0.5 * (pointers[0].pageY + pointers[1].pageY) + + rotateStart.set(x, y) + } + } + + function handleTouchStartPan() { + if (pointers.length == 1) { + panStart.set(pointers[0].pageX, pointers[0].pageY) + } else { + const x = 0.5 * (pointers[0].pageX + pointers[1].pageX) + const y = 0.5 * (pointers[0].pageY + pointers[1].pageY) + + panStart.set(x, y) + } + } + + function handleTouchStartDolly() { + const dx = pointers[0].pageX - pointers[1].pageX + const dy = pointers[0].pageY - pointers[1].pageY + const distance = Math.sqrt(dx * dx + dy * dy) + + dollyStart.set(0, distance) + } + + function handleTouchStartDollyPan() { + if (scope.enableZoom) handleTouchStartDolly() + if (scope.enablePan) handleTouchStartPan() + } + + function handleTouchStartDollyRotate() { + if (scope.enableZoom) handleTouchStartDolly() + if (scope.enableRotate) handleTouchStartRotate() + } + + function handleTouchMoveRotate(event: PointerEvent) { + if (pointers.length == 1) { + rotateEnd.set(event.pageX, event.pageY) + } else { + const position = getSecondPointerPosition(event) + const x = 0.5 * (event.pageX + position.x) + const y = 0.5 * (event.pageY + position.y) + rotateEnd.set(x, y) + } + + rotateDelta + .subVectors(rotateEnd, rotateStart) + .multiplyScalar(scope.rotateSpeed) + + const element = scope.domElement + + if (element) { + rotateLeft((2 * Math.PI * rotateDelta.x) / element.clientHeight) // yes, height + rotateUp((2 * Math.PI * rotateDelta.y) / element.clientHeight) + } + rotateStart.copy(rotateEnd) + } + + function handleTouchMovePan(event: PointerEvent) { + if (pointers.length == 1) { + panEnd.set(event.pageX, event.pageY) + } else { + const position = getSecondPointerPosition(event) + const x = 0.5 * (event.pageX + position.x) + const y = 0.5 * (event.pageY + position.y) + panEnd.set(x, y) + } + + panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed) + pan(panDelta.x, panDelta.y) + panStart.copy(panEnd) + } + + function handleTouchMoveDolly(event: PointerEvent) { + const position = getSecondPointerPosition(event) + const dx = event.pageX - position.x + const dy = event.pageY - position.y + const distance = Math.sqrt(dx * dx + dy * dy) + + dollyEnd.set(0, distance) + dollyDelta.set(0, Math.pow(dollyEnd.y / dollyStart.y, scope.zoomSpeed)) + dollyOut(dollyDelta.y) + dollyStart.copy(dollyEnd) + } + + function handleTouchMoveDollyPan(event: PointerEvent) { + if (scope.enableZoom) handleTouchMoveDolly(event) + if (scope.enablePan) handleTouchMovePan(event) + } + + function handleTouchMoveDollyRotate(event: PointerEvent) { + if (scope.enableZoom) handleTouchMoveDolly(event) + if (scope.enableRotate) handleTouchMoveRotate(event) + } + + // + // event handlers - FSM: listen for events and reset state + // + + function onPointerDown(event: PointerEvent) { + if (scope.enabled === false) return + + if (pointers.length === 0) { + scope.domElement?.ownerDocument.addEventListener( + 'pointermove', + onPointerMove, + ) + scope.domElement?.ownerDocument.addEventListener( + 'pointerup', + onPointerUp, + ) + } + + addPointer(event) + + if (event.pointerType === 'touch') { + onTouchStart(event) + } else { + onMouseDown(event) + } + } + + function onPointerMove(event: PointerEvent) { + if (scope.enabled === false) return + + if (event.pointerType === 'touch') { + onTouchMove(event) + } else { + onMouseMove(event) + } + } + + function onPointerUp(event: PointerEvent) { + removePointer(event) + + if (pointers.length === 0) { + scope.domElement?.releasePointerCapture(event.pointerId) + + scope.domElement?.ownerDocument.removeEventListener( + 'pointermove', + onPointerMove, + ) + scope.domElement?.ownerDocument.removeEventListener( + 'pointerup', + onPointerUp, + ) + } + + scope.dispatchEvent(endEvent) + + state = STATE.NONE + } + + function onPointerCancel(event: PointerEvent) { + removePointer(event) + } + + function onMouseDown(event: MouseEvent) { + let mouseAction + + switch (event.button) { + case 0: + mouseAction = scope.mouseButtons.LEFT + break + + case 1: + mouseAction = scope.mouseButtons.MIDDLE + break + + case 2: + mouseAction = scope.mouseButtons.RIGHT + break + + default: + mouseAction = -1 + } + + switch (mouseAction) { + case MOUSE.DOLLY: + if (scope.enableZoom === false) return + handleMouseDownDolly(event) + state = STATE.DOLLY + break + + case MOUSE.ROTATE: + if (event.ctrlKey || event.metaKey || event.shiftKey) { + if (scope.enablePan === false) return + handleMouseDownPan(event) + state = STATE.PAN + } else if (event.altKey) { + if (scope.enableRotate === false) return + handleMouseDownRotate(event) + state = STATE.ROTATE + } + break + + case MOUSE.PAN: + if (event.ctrlKey || event.metaKey || event.shiftKey) { + if (scope.enableRotate === false) return + handleMouseDownRotate(event) + state = STATE.ROTATE + } else { + if (scope.enablePan === false) return + handleMouseDownPan(event) + state = STATE.PAN + } + break + + default: + state = STATE.NONE + } + + if (state !== STATE.NONE) { + scope.dispatchEvent(startEvent) + } + } + + function onMouseMove(event: MouseEvent) { + if (scope.enabled === false) return + + switch (state) { + case STATE.ROTATE: + if (scope.enableRotate === false) return + handleMouseMoveRotate(event) + break + + case STATE.DOLLY: + if (scope.enableZoom === false) return + handleMouseMoveDolly(event) + break + + case STATE.PAN: + if (scope.enablePan === false) return + handleMouseMovePan(event) + break + } + } + + function onMouseWheel(event: WheelEvent) { + if ( + scope.enabled === false || + scope.enableZoom === false || + (state !== STATE.NONE && state !== STATE.ROTATE) + ) { + return + } + + event.preventDefault() + + scope.dispatchEvent(startEvent) + + handleMouseWheel(event) + + scope.dispatchEvent(endEvent) + } + + function onKeyDown(event: KeyboardEvent) { + if (scope.enabled === false || scope.enablePan === false) return + handleKeyDown(event) + } + + function onTouchStart(event: PointerEvent) { + trackPointer(event) + + switch (pointers.length) { + case 1: + switch (scope.touches.ONE) { + case TOUCH.ROTATE: + if (scope.enableRotate === false) return + handleTouchStartRotate() + state = STATE.TOUCH_ROTATE + break + + case TOUCH.PAN: + if (scope.enablePan === false) return + handleTouchStartPan() + state = STATE.TOUCH_PAN + break + + default: + state = STATE.NONE + } + + break + + case 2: + switch (scope.touches.TWO) { + case TOUCH.DOLLY_PAN: + if (scope.enableZoom === false && scope.enablePan === false) + return + handleTouchStartDollyPan() + state = STATE.TOUCH_DOLLY_PAN + break + + case TOUCH.DOLLY_ROTATE: + if (scope.enableZoom === false && scope.enableRotate === false) + return + handleTouchStartDollyRotate() + state = STATE.TOUCH_DOLLY_ROTATE + break + + default: + state = STATE.NONE + } + + break + + default: + state = STATE.NONE + } + + if (state !== STATE.NONE) { + scope.dispatchEvent(startEvent) + } + } + + function onTouchMove(event: PointerEvent) { + trackPointer(event) + + switch (state) { + case STATE.TOUCH_ROTATE: + if (scope.enableRotate === false) return + handleTouchMoveRotate(event) + scope.update() + break + + case STATE.TOUCH_PAN: + if (scope.enablePan === false) return + handleTouchMovePan(event) + scope.update() + break + + case STATE.TOUCH_DOLLY_PAN: + if (scope.enableZoom === false && scope.enablePan === false) return + handleTouchMoveDollyPan(event) + scope.update() + break + + case STATE.TOUCH_DOLLY_ROTATE: + if (scope.enableZoom === false && scope.enableRotate === false) return + handleTouchMoveDollyRotate(event) + scope.update() + break + + default: + state = STATE.NONE + } + } + + function onContextMenu(event: Event) { + if (scope.enabled === false) return + event.preventDefault() + } + + function addPointer(event: PointerEvent) { + pointers.push(event) + } + + function removePointer(event: PointerEvent) { + delete pointerPositions[event.pointerId] + + for (let i = 0; i < pointers.length; i++) { + if (pointers[i].pointerId == event.pointerId) { + pointers.splice(i, 1) + return + } + } + } + + function trackPointer(event: PointerEvent) { + let position = pointerPositions[event.pointerId] + + if (position === undefined) { + position = new Vector2() + pointerPositions[event.pointerId] = position + } + + position.set(event.pageX, event.pageY) + } + + function getSecondPointerPosition(event: PointerEvent) { + const pointer = + event.pointerId === pointers[0].pointerId ? pointers[1] : pointers[0] + return pointerPositions[pointer.pointerId] + } + + // connect events + if (domElement !== undefined) this.connect(domElement) + // force an update at start + this.update() + } +} + +// This set of controls performs orbiting, dollying (zooming), and panning. +// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default). +// This is very similar to OrbitControls, another set of touch behavior +// +// Orbit - right mouse, or left mouse + ctrl/meta/shiftKey / touch: two-finger rotate +// Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish +// Pan - left mouse, or arrow keys / touch: one-finger move + +class MapControls extends OrbitControls { + constructor(object: Camera, domElement?: HTMLElement) { + super(object, domElement) + + this.screenSpacePanning = false // pan orthogonal to world-space direction camera.up + + this.mouseButtons.LEFT = MOUSE.PAN + this.mouseButtons.RIGHT = MOUSE.ROTATE + + this.touches.ONE = TOUCH.PAN + this.touches.TWO = TOUCH.DOLLY_ROTATE + } +} + +export {OrbitControls, MapControls} diff --git a/packages/r3f/src/components/R3FOrbitControls.tsx b/packages/r3f/src/components/R3FOrbitControls.tsx new file mode 100644 index 0000000000..e8df3fe585 --- /dev/null +++ b/packages/r3f/src/components/R3FOrbitControls.tsx @@ -0,0 +1,107 @@ +import type {EventManager, ReactThreeFiber} from '@react-three/fiber' +import {useFrame, useThree} from '@react-three/fiber' +import * as React from 'react' +import {forwardRef, useEffect, useMemo} from 'react' +import type {Camera, Event} from 'three' +import {OrbitControls as OrbitControlsImpl} from '../OrbitControls' + +export type OrbitControlsProps = Omit< + ReactThreeFiber.Overwrite< + ReactThreeFiber.Object3DNode, + { + camera?: Camera + domElement?: HTMLElement + enableDamping?: boolean + makeDefault?: boolean + onChange?: (e?: Event) => void + onEnd?: (e?: Event) => void + onStart?: (e?: Event) => void + regress?: boolean + target?: ReactThreeFiber.Vector3 + } + >, + 'ref' +> + +export const OrbitControls = forwardRef( + ( + { + makeDefault, + camera, + regress, + domElement, + enableDamping = true, + onChange, + onStart, + onEnd, + ...restProps + }, + ref, + ) => { + const invalidate = useThree(({invalidate}) => invalidate) + const defaultCamera = useThree(({camera}) => camera) + const gl = useThree(({gl}) => gl) + const events = useThree(({events}) => events) as EventManager + const set = useThree(({set}) => set) + const get = useThree(({get}) => get) + const performance = useThree(({performance}) => performance) + const explCamera = camera || defaultCamera + const explDomElement = + domElement || + (typeof events.connected !== 'boolean' ? events.connected : gl.domElement) + const controls = useMemo( + () => new OrbitControlsImpl(explCamera), + [explCamera], + ) + + useFrame(() => { + if (controls.enabled) controls.update() + }) + + useEffect(() => { + const callback = (e: Event) => { + invalidate() + if (regress) performance.regress() + if (onChange) onChange(e) + } + + controls.connect(explDomElement) + controls.addEventListener('change', callback) + + if (onStart) controls.addEventListener('start', onStart) + if (onEnd) controls.addEventListener('end', onEnd) + + return () => { + controls.removeEventListener('change', callback) + if (onStart) controls.removeEventListener('start', onStart) + if (onEnd) controls.removeEventListener('end', onEnd) + controls.dispose() + } + }, [ + explDomElement, + onChange, + onStart, + onEnd, + regress, + controls, + invalidate, + ]) + + useEffect(() => { + if (makeDefault) { + const old = get().controls + set({controls}) + return () => set({controls: old}) + } + }, [makeDefault, controls]) + + return ( + + ) + }, +) diff --git a/packages/r3f/src/components/SnapshotEditor.tsx b/packages/r3f/src/components/SnapshotEditor.tsx index 494152cc05..fa2a09d8e0 100644 --- a/packages/r3f/src/components/SnapshotEditor.tsx +++ b/packages/r3f/src/components/SnapshotEditor.tsx @@ -1,4 +1,4 @@ -import {useCallback, useLayoutEffect} from 'react' +import {useCallback, useLayoutEffect, useRef} from 'react' import React from 'react' import {Canvas} from '@react-three/fiber' import type {BaseSheetObjectType} from '../store' @@ -6,6 +6,7 @@ import {allRegisteredObjects, useEditorStore} from '../store' import shallow from 'zustand/shallow' import root from 'react-shadow/styled-components' import ProxyManager from './ProxyManager' +import type {IScrub} from '@theatre/studio' import studio, {ToolbarIconButton} from '@theatre/studio' import {useVal} from '@theatre/react' import styled, {createGlobalStyle, StyleSheetManager} from 'styled-components' @@ -14,6 +15,7 @@ import type {ISheet} from '@theatre/core' import useSnapshotEditorCamera from './useSnapshotEditorCamera' import {getEditorSheet, getEditorSheetObject} from './editorStuff' import type {$IntentionalAny} from '@theatre/shared/utils/types' +import {ViewportGizmo} from './ViewportGizmo' const GlobalStyle = createGlobalStyle` :host { @@ -38,15 +40,19 @@ const EditorScene: React.FC<{snapshotEditorSheet: ISheet; paneId: string}> = ({ snapshotEditorSheet, paneId, }) => { + const [helpersRoot, editorCameraSheetObject] = useEditorStore( + (state) => [state.helpersRoot, state.editorCameraSheetObject], + shallow, + ) + const [editorCamera, orbitControlsRef] = useSnapshotEditorCamera( snapshotEditorSheet, paneId, + editorCameraSheetObject, ) const editorObject = getEditorSheetObject() - const helpersRoot = useEditorStore((state) => state.helpersRoot, shallow) - const showGrid = useVal(editorObject?.props.viewport.showGrid) ?? true const showAxes = useVal(editorObject?.props.viewport.showAxes) ?? true @@ -101,10 +107,15 @@ const SnapshotEditor: React.FC<{paneId: string}> = (props) => { const paneId = props.paneId const editorObject = getEditorSheetObject() - const [sceneSnapshot, createSnapshot] = useEditorStore( - (state) => [state.sceneSnapshot, state.createSnapshot], - shallow, - ) + const [sceneSnapshot, createSnapshot, editorCameraSheetObject] = + useEditorStore( + (state) => [ + state.sceneSnapshot, + state.createSnapshot, + state.editorCameraSheetObject, + ], + shallow, + ) const editorOpen = true useLayoutEffect(() => { @@ -135,6 +146,8 @@ const SnapshotEditor: React.FC<{paneId: string}> = (props) => { } }, []) + const scrub = useRef() + if (!editorObject) return <> return ( @@ -173,6 +186,39 @@ const SnapshotEditor: React.FC<{paneId: string}> = (props) => { snapshotEditorSheet={snapshotEditorSheet} paneId={paneId} /> + { + if (!scrub.current) { + scrub.current = studio.scrub() + } + scrub.current.capture((api) => { + api.set( + editorCameraSheetObject.props.transform, + value, + ) + }) + }} + permanentlySetValue={(value) => { + if (scrub.current) { + scrub.current.capture((api) => { + api.set( + editorCameraSheetObject.props.transform, + value, + ) + }) + scrub.current.commit() + scrub.current = undefined + } else { + studio.transaction((api) => { + api.set( + editorCameraSheetObject.props.transform, + value, + ) + }) + } + }} + cameraSheetObject={editorCameraSheetObject} + /> diff --git a/packages/r3f/src/components/ViewportGizmo.tsx b/packages/r3f/src/components/ViewportGizmo.tsx new file mode 100644 index 0000000000..fcd18e2e12 --- /dev/null +++ b/packages/r3f/src/components/ViewportGizmo.tsx @@ -0,0 +1,477 @@ +import * as React from 'react' +import type {ThreeEvent} from '@react-three/fiber' +import {createPortal, invalidate, useFrame, useThree} from '@react-three/fiber' +import type { + Camera, + Color, + Group, + Intersection, + Raycaster, + Texture, + Sprite, +} from 'three' +import { + CanvasTexture, + Matrix4, + Object3D, + PerspectiveCamera, + Quaternion, + Scene, + Vector3, +} from 'three' +import {OrthographicCamera} from '@react-three/drei' +import {useCamera} from '@react-three/drei' +import { + createContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import type {ISheetObject} from '@theatre/core' +import type {cameraSheetObjectType} from '../store' + +type GizmoHelperContext = { + tweenCamera: (direction: Vector3) => void + raycast: (raycaster: Raycaster, intersects: Intersection[]) => void +} + +const Context = createContext({} as GizmoHelperContext) + +export const useGizmoContext = () => { + return React.useContext(Context) +} + +const turnRate = 2 * Math.PI // turn rate in angles per second +const dummy = new Object3D() +const matrix = new Matrix4() +const [q1, q2] = [new Quaternion(), new Quaternion()] +const targetPosition = new Vector3() + +type AxisProps = { + color: string + rotation: [number, number, number] + scale?: [number, number, number] +} + +type AxisHeadProps = JSX.IntrinsicElements['sprite'] & { + parentScale: number + fillColor: string + label?: string + labelColor: string + permanentLabel?: boolean + disabled?: boolean +} + +type ViewportGizmoSceneProps = JSX.IntrinsicElements['group'] & { + axisScale?: [number, number, number] + labels?: [string, string, string, string, string, string] + labelColor?: string + disabled?: boolean +} + +function Axis({scale = [0.02, 0.02, 0.8], color, rotation}: AxisProps) { + return ( + + + + + + + ) +} + +function AxisHead({ + parentScale, + disabled, + fillColor, + label, + labelColor, + permanentLabel, + ...props +}: AxisHeadProps) { + const spriteRef = useRef(null!) + const [active, setActive] = useState(false) + const worldPosition = useMemo(() => new Vector3(), []) + const gl = useThree((state) => state.gl) + const canvas = useMemo(() => { + const canvas = document.createElement('canvas') + canvas.width = 64 + canvas.height = 64 + + return canvas + }, []) + + const texture = useMemo(() => { + return new CanvasTexture(canvas) + }, []) + + useLayoutEffect(() => { + invalidate() + }) + + useFrame(() => { + spriteRef.current.getWorldPosition(worldPosition) + const context = canvas.getContext('2d')! + context.beginPath() + context.arc(32, 32, 32, 0, 2 * Math.PI) + context.closePath() + context.fillStyle = fillColor + context.fill() + context.fillStyle = `rgba(0, 0, 0, ${ + 0.125 - worldPosition.z / parentScale / 8 + })` + context.fill() + + if (label && (permanentLabel || active)) { + context.font = '36px Inter var, Arial, sans-serif' + context.textAlign = 'center' + context.fillStyle = active ? '#fff' : fillColor + context.fillText(label, 32, 45) + context.fillStyle = active ? '#fff' : 'rgba(0, 0, 0, 0.7)' + context.fillText(label, 32, 45) + } + texture.needsUpdate = true + }) + + const handlePointerOver = (e: Event) => { + e.stopPropagation() + setActive(true) + } + const handlePointerOut = (e: Event) => { + e.stopPropagation() + setActive(false) + } + return ( + + + + ) +} + +export const ViewportGizmoScene = ({ + disabled, + axisScale, + labels = ['X', 'Y', 'Z', '-X', '-Y', '-Z'], + labelColor = '#000', + ...props +}: ViewportGizmoSceneProps) => { + const [colorX, colorY, colorZ] = ['#f52222', '#1bd366', '#3c9ff1'] + const {tweenCamera, raycast} = useGizmoContext() + const axisHeadProps = { + disabled, + labelColor, + raycast, + onClick: !disabled + ? (e: ThreeEvent) => { + tweenCamera(e.object.position) + e.stopPropagation() + } + : undefined, + } + + const scale = 40 + + return ( + + + + + + + + + + + + + + + + ) +} + +type SimpleVector = { + x: number + y: number + z: number +} + +export type ViewportGizmoProps = JSX.IntrinsicElements['group'] & { + alignment?: 'top-left' | 'top-right' | 'bottom-right' | 'bottom-left' + margin?: [number, number] + renderPriority?: number + temporarilySetValue: ({ + position, + up, + target, + }: { + position: SimpleVector + up: SimpleVector + target: SimpleVector + }) => void + permanentlySetValue: ({ + position, + up, + target, + }: { + position: SimpleVector + up: SimpleVector + target: SimpleVector + }) => void + cameraSheetObject: ISheetObject +} + +export const ViewportGizmo = ({ + alignment = 'bottom-right', + margin = [80, 80], + renderPriority = 0, + cameraSheetObject, + temporarilySetValue, + permanentlySetValue, +}: ViewportGizmoProps): any => { + const size = useThree(({size}) => size) + const [cameraProxy] = useState(() => new PerspectiveCamera()) + const gl = useThree(({gl}) => gl) + const scene = useThree(({scene}) => scene) + const invalidate = useThree(({invalidate}) => invalidate) + + const backgroundRef = React.useRef() + const gizmoRef = React.useRef() + const virtualCam = React.useRef(null!) + const [virtualScene] = React.useState(() => new Scene()) + + const animating = React.useRef(false) + const radius = React.useRef(0) + const focusPoint = React.useRef(new Vector3(0, 0, 0)) + + const isFirstFrame = useRef(true) + + const tweenCamera = (direction: Vector3) => { + animating.current = true + isFirstFrame.current = true + + focusPoint.current.set( + cameraSheetObject.value.transform.target.x, + cameraSheetObject.value.transform.target.y, + cameraSheetObject.value.transform.target.z, + ) + radius.current = cameraProxy.position.distanceTo(focusPoint.current) + + dummy.position.copy(focusPoint.current) + + // Rotate from current camera orientation + q1.copy(cameraProxy.quaternion) + + // To new current camera orientation + targetPosition + .copy(direction) + .multiplyScalar(radius.current) + .add(focusPoint.current) + dummy.lookAt(targetPosition) + q2.copy(dummy.quaternion) + + invalidate() + } + + const animateStep = (delta: number) => { + if (!animating.current) return + + if (q1.angleTo(q2) < 0.01) { + animating.current = false + + permanentlySetValue({ + position: { + x: targetPosition.x, + y: targetPosition.y, + z: targetPosition.z, + }, + up: { + x: 0, + y: 1, + z: 0, + }, + target: { + x: focusPoint.current.x, + y: focusPoint.current.y, + z: focusPoint.current.z, + }, + }) + return + } + + const step = delta * turnRate + + // animate position by doing a slerp and then scaling the position on the unit sphere + q1.rotateTowards(q2, step) + // animate orientation + cameraProxy.position + .set(0, 0, 1) + .applyQuaternion(q1) + .multiplyScalar(radius.current) + .add(focusPoint.current) + cameraProxy.up.set(0, 1, 0).applyQuaternion(q1).normalize() + cameraProxy.quaternion.copy(q1) + + temporarilySetValue({ + position: { + x: cameraProxy.position.x, + y: cameraProxy.position.y, + z: cameraProxy.position.z, + }, + up: { + x: cameraProxy.up.x, + y: cameraProxy.up.y, + z: cameraProxy.up.z, + }, + target: { + x: focusPoint.current.x, + y: focusPoint.current.y, + z: focusPoint.current.z, + }, + }) + + isFirstFrame.current = false + invalidate() + } + + React.useEffect(() => { + if (scene.background) { + //Interchange the actual scene background with the virtual scene + backgroundRef.current = scene.background + scene.background = null + virtualScene.background = backgroundRef.current + } + + return () => { + // reset on unmount + if (backgroundRef.current) { + scene.background = backgroundRef.current + } + } + }, []) + + useEffect(() => { + const syncWithTheatre = ( + values: ISheetObject['value'], + ) => { + // Sync camera proxy with theatre props + cameraProxy.position.set( + values.transform.position.x, + values.transform.position.y, + values.transform.position.z, + ) + cameraProxy.up.set( + values.transform.up.x, + values.transform.up.y, + values.transform.up.z, + ) + cameraProxy.lookAt( + values.transform.target.x, + values.transform.target.y, + values.transform.target.z, + ) + cameraProxy.updateMatrix() + // Sync gizmo with main camera orientation + matrix.copy(cameraProxy.matrix).invert() + gizmoRef.current?.quaternion.setFromRotationMatrix(matrix) + } + const unsub = cameraSheetObject.onValuesChange(syncWithTheatre) + syncWithTheatre(cameraSheetObject.value) + + return unsub + }, [cameraSheetObject, cameraProxy]) + + useFrame((_, delta) => { + if (virtualCam.current && gizmoRef.current) { + animateStep(isFirstFrame.current ? 0.016 : delta) + gl.autoClear = false + gl.clearDepth() + gl.render(virtualScene, virtualCam.current) + } + }, renderPriority) + + const gizmoHelperContext = { + tweenCamera, + raycast: useCamera(virtualCam), + } + + // Position gizmo component within scene + const [marginX, marginY] = margin + const x = alignment.endsWith('-left') + ? -size.width / 2 + marginX + : size.width / 2 - marginX + const y = alignment.startsWith('top-') + ? size.height / 2 - marginY + : -size.height / 2 + marginY + return createPortal( + + + + + + , + virtualScene, + ) +} diff --git a/packages/r3f/src/components/useSnapshotEditorCamera.tsx b/packages/r3f/src/components/useSnapshotEditorCamera.tsx index 8e81d50f7d..fe66e4e0bc 100644 --- a/packages/r3f/src/components/useSnapshotEditorCamera.tsx +++ b/packages/r3f/src/components/useSnapshotEditorCamera.tsx @@ -1,43 +1,22 @@ -import {OrbitControls, PerspectiveCamera} from '@react-three/drei' -import type {OrbitControls as OrbitControlsImpl} from 'three-stdlib' +import {PerspectiveCamera} from '@react-three/drei' +import {OrbitControls} from './R3FOrbitControls' +import type {OrbitControls as OrbitControlsImpl} from '../OrbitControls' import type {MutableRefObject} from 'react' -import {useLayoutEffect, useRef} from 'react' +import {useLayoutEffect} from 'react' import React from 'react' import useRefAndState from './useRefAndState' +import type {IScrub} from '@theatre/studio' import studio from '@theatre/studio' import type {PerspectiveCamera as PerspectiveCameraImpl} from 'three' import type {ISheet} from '@theatre/core' -import {types} from '@theatre/core' import type {ISheetObject} from '@theatre/core' import {useThree} from '@react-three/fiber' - -const camConf = { - transform: { - position: { - x: types.number(10), - y: types.number(10), - z: types.number(0), - }, - target: { - x: types.number(0), - y: types.number(0), - z: types.number(0), - }, - }, - lens: { - zoom: types.number(1, {range: [0.0001, 10]}), - fov: types.number(50, {range: [1, 1000]}), - near: types.number(0.1, {range: [0, Infinity]}), - far: types.number(2000, {range: [0, Infinity]}), - focus: types.number(10, {range: [0, Infinity]}), - filmGauge: types.number(35, {range: [0, Infinity]}), - filmOffset: types.number(0, {range: [0, Infinity]}), - }, -} +import type {cameraSheetObjectType} from '../store' export default function useSnapshotEditorCamera( snapshotEditorSheet: ISheet, paneId: string, + cameraSheetObject: ISheetObject, ): [ node: React.ReactNode, orbitControlsRef: MutableRefObject, @@ -51,19 +30,8 @@ export default function useSnapshotEditorCamera( undefined, ) - const objRef = useRef | null>(null) - - useLayoutEffect(() => { - if (!objRef.current) { - objRef.current = snapshotEditorSheet.object( - `Editor Camera ${paneId}`, - camConf, - ) - } - }, [paneId]) - - usePassValuesFromTheatreToCamera(cam, orbitControls, objRef) - usePassValuesFromOrbitControlsToTheatre(cam, orbitControls, objRef) + usePassValuesFromTheatreToCamera(cam, orbitControls, cameraSheetObject) + usePassValuesFromOrbitControlsToTheatre(cam, orbitControls, cameraSheetObject) const node = ( <> @@ -83,22 +51,26 @@ export default function useSnapshotEditorCamera( function usePassValuesFromOrbitControlsToTheatre( cam: PerspectiveCameraImpl | undefined, orbitControls: OrbitControlsImpl | null, - objRef: MutableRefObject | null>, + cameraSheetObject: ISheetObject, ) { useLayoutEffect(() => { if (!cam || orbitControls == null) return - let currentScrub: undefined | ReturnType + let currentScrub: undefined | IScrub let started = false const onStart = () => { started = true if (!currentScrub) { - currentScrub = studio.debouncedScrub(600) + currentScrub = studio.scrub() } } const onEnd = () => { + if (currentScrub) { + currentScrub.commit() + currentScrub = undefined + } started = false } @@ -108,16 +80,20 @@ function usePassValuesFromOrbitControlsToTheatre( const p = cam!.position const position = {x: p.x, y: p.y, z: p.z} + const u = cam!.up + const up = {x: u.x, y: u.y, z: u.z} + const t = orbitControls!.target const target = {x: t.x, y: t.y, z: t.z} const transform = { position, + up, target, } currentScrub!.capture(({set}) => { - set(objRef.current!.props.transform, transform) + set(cameraSheetObject.props.transform, transform) }) } @@ -136,18 +112,17 @@ function usePassValuesFromOrbitControlsToTheatre( function usePassValuesFromTheatreToCamera( cam: PerspectiveCameraImpl | undefined, orbitControls: OrbitControlsImpl | null, - objRef: MutableRefObject | null>, + cameraSheetObject: ISheetObject, ) { const invalidate = useThree(({invalidate}) => invalidate) useLayoutEffect(() => { if (!cam || orbitControls === null) return - const obj = objRef.current! const setFromTheatre = ( - props: ISheetObject['value'], + props: ISheetObject['value'], ): void => { - const {position, target} = props.transform + const {position, up, target} = props.transform cam.zoom = props.lens.zoom cam.fov = props.lens.fov cam.near = props.lens.near @@ -156,15 +131,16 @@ function usePassValuesFromTheatreToCamera( cam.filmGauge = props.lens.filmGauge cam.filmOffset = props.lens.filmOffset cam.position.set(position.x, position.y, position.z) + cam.up.set(up.x, up.y, up.z) cam.updateProjectionMatrix() orbitControls.target.set(target.x, target.y, target.z) orbitControls.update() invalidate() } - const unsub = obj.onValuesChange(setFromTheatre) - setFromTheatre(obj.value) + const unsub = cameraSheetObject.onValuesChange(setFromTheatre) + setFromTheatre(cameraSheetObject.value) return unsub - }, [cam, orbitControls, objRef, invalidate]) + }, [cam, orbitControls, invalidate]) } diff --git a/packages/r3f/src/store.ts b/packages/r3f/src/store.ts index 0de458b4a8..9bfb4227a6 100644 --- a/packages/r3f/src/store.ts +++ b/packages/r3f/src/store.ts @@ -4,6 +4,7 @@ import type {Object3D, Scene, WebGLRenderer} from 'three' import {Group} from 'three' import type {ISheetObject} from '@theatre/core' import {types} from '@theatre/core' +import {getEditorSheet} from './components/editorStuff' export type EditableType = | 'group' @@ -40,8 +41,39 @@ export const baseSheetObjectType = { }, } +export const cameraSheetObjectType = { + transform: { + position: { + x: types.number(10), + y: types.number(10), + z: types.number(0), + }, + up: { + x: types.number(0), + y: types.number(1), + z: types.number(0), + }, + target: { + x: types.number(0), + y: types.number(0), + z: types.number(0), + }, + }, + lens: { + zoom: types.number(1, {range: [0.0001, 10]}), + fov: types.number(50, {range: [1, 1000]}), + near: types.number(0.1, {range: [0, Infinity]}), + far: types.number(2000, {range: [0, Infinity]}), + focus: types.number(10, {range: [0, Infinity]}), + filmGauge: types.number(35, {range: [0, Infinity]}), + filmOffset: types.number(0, {range: [0, Infinity]}), + }, +} + export type BaseSheetObjectType = ISheetObject +export type CameraSheetObjectType = ISheetObject + export const allRegisteredObjects = new WeakSet() export interface AbstractEditable { @@ -144,6 +176,7 @@ export type EditorStore = { canvasName: string sceneSnapshot: Scene | null editablesSnapshot: Record | null + editorCameraSheetObject: CameraSheetObjectType init: ( scene: Scene, @@ -162,6 +195,8 @@ export type EditorStore = { } const config: StateCreator = (set, get) => { + const editorSheet = getEditorSheet() + return { sheet: null, editorObject: null, @@ -174,7 +209,10 @@ const config: StateCreator = (set, get) => { canvasName: 'default', sceneSnapshot: null, editablesSnapshot: null, - initialEditorCamera: {}, + editorCameraSheetObject: editorSheet.object( + `Editor Camera`, + cameraSheetObjectType, + ), init: (scene, gl, allowImplicitInstancing) => { set({