diff --git a/packages/viewer-sandbox/src/main.ts b/packages/viewer-sandbox/src/main.ts index 6110a4aab8..c06e3967bb 100644 --- a/packages/viewer-sandbox/src/main.ts +++ b/packages/viewer-sandbox/src/main.ts @@ -4,9 +4,9 @@ import { SelectionEvent, ViewerEvent, Viewer, - CameraController, ViewModes, - SelectionExtension + SelectionExtension, + HybridCameraController } from '@speckle/viewer' import './style.css' @@ -20,6 +20,7 @@ import { import { SectionTool } from '@speckle/viewer' import { SectionOutlines } from '@speckle/viewer' import { ViewModesKeys } from './Extensions/ViewModesKeys' +// import { JSONSpeckleStream } from './JSONSpeckleStream' import { BoxSelection } from './Extensions/BoxSelection' import { PassReader } from './Extensions/PassReader' @@ -45,7 +46,7 @@ const createViewer = async (containerName: string, _stream: string) => { const viewer: Viewer = new Viewer(container, params) await viewer.init() - const cameraController = viewer.createExtension(CameraController) + const cameraController = viewer.createExtension(HybridCameraController) const selection = viewer.createExtension(SelectionExtension) const sections = viewer.createExtension(SectionTool) viewer.createExtension(SectionOutlines) @@ -458,6 +459,8 @@ const getStream = () => { // Perfectly flat // 'https://app.speckle.systems/projects/344f803f81/models/5582ab673e' + // big baker + // 'https://latest.speckle.systems/projects/126cd4b7bb/models/032d09f716' // 'https://speckle.xyz/streams/27e89d0ad6/commits/5ed4b74252' //Gingerbread diff --git a/packages/viewer/src/modules/LegacyViewer.ts b/packages/viewer/src/modules/LegacyViewer.ts index c9c6b2f177..4af3f9a499 100644 --- a/packages/viewer/src/modules/LegacyViewer.ts +++ b/packages/viewer/src/modules/LegacyViewer.ts @@ -44,6 +44,7 @@ import { BatchObject } from './batching/BatchObject.js' import { SpeckleLoader } from './loaders/Speckle/SpeckleLoader.js' import Logger from './utils/Logger.js' import { ViewModes } from './extensions/ViewModes.js' +import { HybridCameraController } from './extensions/HybridCameraController.js' class LegacySelectionExtension extends SelectionExtension { /** FE2 'manually' selects objects pon it's own, so we're disabling the extension's event handler @@ -120,7 +121,7 @@ export class LegacyViewer extends Viewer { params: ViewerParams = DefaultViewerParams ) { super(container, params) - this.cameraController = this.createExtension(CameraController) + this.cameraController = this.createExtension(HybridCameraController) this.selection = this.createExtension(LegacySelectionExtension) this.sections = this.createExtension(SectionTool) this.createExtension(SectionOutlines) diff --git a/packages/viewer/src/modules/Viewer.ts b/packages/viewer/src/modules/Viewer.ts index d0f77a118f..e8d742a3c4 100644 --- a/packages/viewer/src/modules/Viewer.ts +++ b/packages/viewer/src/modules/Viewer.ts @@ -202,7 +202,7 @@ export class Viewer extends EventEmitter implements IViewer { } private update() { - const delta = this.clock.getDelta() + const delta = this.clock.getDelta() * 1000 // turn to miliseconds const extensions = Object.values(this.extensions) extensions.forEach((ext: Extension) => { ext.onEarlyUpdate(delta) diff --git a/packages/viewer/src/modules/extensions/CameraController.ts b/packages/viewer/src/modules/extensions/CameraController.ts index da4783164a..150808a821 100644 --- a/packages/viewer/src/modules/extensions/CameraController.ts +++ b/packages/viewer/src/modules/extensions/CameraController.ts @@ -23,9 +23,6 @@ import { SmoothOrbitControls } from './controls/SmoothOrbitControls.js' -// const UP: Vector3 = new Vector3(0, 1, 0) -// const quatBuff = new Quaternion() - export enum NearPlaneCalculation { EMPIRIC, ACCURATE @@ -94,10 +91,13 @@ export const DefaultOrbitControlsOptions: Required = { touchAction: 'none', infiniteZoom: true, zoomToCursor: true, + orbitAroundCursor: true, + showOrbitPoint: true, lookSpeed: 1, moveSpeed: 1, damperDecay: 30, enableLook: true, + relativeUpDown: false, nearPlaneCalculation: NearPlaneCalculation.ACCURATE } @@ -108,7 +108,8 @@ export class CameraController extends Extension implements SpeckleCamera { protected _lastCameraChanged: boolean = false protected _options: Required = DefaultOrbitControlsOptions protected _activeControls: SpeckleControls - protected _controlsList: SpeckleControls[] = [] + protected _orbitControls: SmoothOrbitControls + protected _flyControls: FlyControls get renderingCamera(): PerspectiveCamera | OrthographicCamera { return this._renderingCamera @@ -149,9 +150,8 @@ export class CameraController extends Extension implements SpeckleCamera { public set options(value: CameraControllerOptions) { Object.assign(this._options, value) - this._controlsList.forEach((controls: SpeckleControls) => { - controls.options = value - }) + this._orbitControls.options = value + this._flyControls.options = value } public constructor(viewer: IViewer) { @@ -180,32 +180,28 @@ export class CameraController extends Extension implements SpeckleCamera { /** Perspective camera as default on startup */ this.renderingCamera = this.perspectiveCamera - const flyControls = new FlyControls( + this._flyControls = new FlyControls( this._renderingCamera, this.viewer.getContainer(), this.viewer.World, this._options ) - flyControls.enabled = false - flyControls.setDamperDecayTime(30) - flyControls.up = new Vector3(0, 0, 1) + this._flyControls.enabled = false + this._flyControls.setDamperDecayTime(30) + this._flyControls.up = new Vector3(0, 0, 1) - const orbitControls = new SmoothOrbitControls( + this._orbitControls = new SmoothOrbitControls( this.perspectiveCamera, this.viewer.getContainer(), this.viewer.World, - this.viewer.getRenderer().scene, - this.viewer.getRenderer().intersections, + this.viewer.getRenderer(), this._options ) - orbitControls.enabled = true + this._orbitControls.enabled = true this.viewer.getRenderer().speckleCamera = this - this._controlsList.push(orbitControls) - this._controlsList.push(flyControls) - - this._activeControls = orbitControls + this._activeControls = this._orbitControls this.default() } @@ -238,18 +234,19 @@ export class CameraController extends Extension implements SpeckleCamera { let newControls: SpeckleControls | undefined = undefined if (this._activeControls instanceof SmoothOrbitControls) { - newControls = this._controlsList[1] + newControls = this._flyControls } else if (this._activeControls instanceof FlyControls) { - newControls = this._controlsList[0] + newControls = this._orbitControls } if (!newControls) throw new Error('Not controls found!') oldControls.enabled = false newControls.enabled = true + newControls.fromPositionAndTarget( - oldControls.getPosition(), - oldControls.getTarget() + oldControls.getCurrentPosition(), + oldControls.getCurrentTarget() ) newControls.jumpToGoal() this._activeControls = newControls @@ -295,8 +292,8 @@ export class CameraController extends Extension implements SpeckleCamera { this.emit(CameraEvent.Dynamic) } - public onEarlyUpdate() { - const changed = this._activeControls.update() + public onEarlyUpdate(_delta?: number) { + const changed = this._activeControls.update(_delta) if (changed !== this._lastCameraChanged) { this.emit(changed ? CameraEvent.Dynamic : CameraEvent.Stationary) } @@ -424,6 +421,9 @@ export class CameraController extends Extension implements SpeckleCamera { fallback?: number ): number | undefined { const minDist = this.getClosestGeometryDistance(fallback) + this._flyControls.minDist = minDist + this._orbitControls.minDist = minDist + if (minDist === Number.POSITIVE_INFINITY) { return this.computeNearCameraPlaneEmpiric(targetVolume, offsetScale) } @@ -472,8 +472,8 @@ export class CameraController extends Extension implements SpeckleCamera { } protected getClosestGeometryDistance(fallback?: number): number { - const cameraPosition = this._renderingCamera.position - const cameraTarget = this.getTarget() + const cameraPosition = this._activeControls.getCurrentPosition() + const cameraTarget = this._activeControls.getCurrentTarget() const cameraDir = new Vector3().subVectors(cameraTarget, cameraPosition).normalize() const batches = this.viewer diff --git a/packages/viewer/src/modules/extensions/HybridCameraController.ts b/packages/viewer/src/modules/extensions/HybridCameraController.ts index b8ebf500c9..1202714f76 100644 --- a/packages/viewer/src/modules/extensions/HybridCameraController.ts +++ b/packages/viewer/src/modules/extensions/HybridCameraController.ts @@ -1,4 +1,3 @@ -import { clamp } from 'three/src/math/MathUtils.js' import { IViewer } from '../../IViewer.js' import { CameraController } from './CameraController.js' type MoveType = 'forward' | 'back' | 'left' | 'right' | 'up' | 'down' @@ -12,13 +11,28 @@ export class HybridCameraController extends CameraController { up: false, down: false } + + protected contextMenuTriggered = false + public constructor(viewer: IViewer) { super(viewer) document.addEventListener('keydown', this.onKeyDown.bind(this)) + document.addEventListener('keyup', this.onKeyUp.bind(this)) + document.addEventListener('contextmenu', this.onContextMenu.bind(this)) + } + + public onEarlyUpdate(_delta?: number): void { + super.onEarlyUpdate(_delta) + /** We do this because sometimes while holding a kewy down you get an extra + * key down event **after** the context menu event, locking it in place + */ + if (this.contextMenuTriggered) { + this.cancelMove() + this.contextMenuTriggered = false + } } protected onKeyDown(event: KeyboardEvent) { - let moveSpeed = this.options.moveSpeed ? this.options.moveSpeed : 1 switch (event.code) { case 'ArrowUp': case 'KeyW': @@ -41,27 +55,17 @@ export class HybridCameraController extends CameraController { break case 'PageUp': - case 'KeyQ': + case 'KeyE': this.keyMap.up = true break case 'PageDown': - case 'KeyE': + case 'KeyQ': this.keyMap.down = true break - case 'KeyF': - moveSpeed += 0.25 - moveSpeed = clamp(moveSpeed, 0.1, 5) - this.options = { moveSpeed } - break - case 'KeyC': - moveSpeed -= 0.25 - moveSpeed = clamp(moveSpeed, 0.1, 5) - this.options = { moveSpeed } - break } if ( - !this._controlsList[1].enabled && + !this._flyControls.enabled && Object.values(this.keyMap).some((v) => v === true) ) this.toggleControls() @@ -90,17 +94,35 @@ export class HybridCameraController extends CameraController { break case 'PageUp': - case 'KeyQ': + case 'KeyE': this.keyMap.up = false break case 'PageDown': - case 'KeyE': + case 'KeyQ': this.keyMap.down = false break } if ( - this._controlsList[1].enabled && + this._flyControls.enabled && + Object.values(this.keyMap).every((v) => v === false) + ) + this.toggleControls() + } + + protected onContextMenu() { + this.contextMenuTriggered = true + } + + protected cancelMove() { + this.keyMap.back = false + this.keyMap.forward = false + this.keyMap.down = false + this.keyMap.up = false + this.keyMap.left = false + this.keyMap.right = false + if ( + this._flyControls.enabled && Object.values(this.keyMap).every((v) => v === false) ) this.toggleControls() diff --git a/packages/viewer/src/modules/extensions/controls/FlyControls.ts b/packages/viewer/src/modules/extensions/controls/FlyControls.ts index cc4fa8ee81..7e42c11dbb 100644 --- a/packages/viewer/src/modules/extensions/controls/FlyControls.ts +++ b/packages/viewer/src/modules/extensions/controls/FlyControls.ts @@ -18,6 +18,9 @@ const _changeEvent = { type: 'change' } const _PI_2 = Math.PI / 2 type MoveType = 'forward' | 'back' | 'left' | 'right' | 'up' | 'down' const walkingSpeed = 1.42 // m/s +const closeRelativeFactor = 0.03 +const farRelativeFactor = 0.2 +const relativeMinTargetDistance = 0.01 export interface FlyControlsOptions { [name: string]: unknown @@ -25,6 +28,7 @@ export interface FlyControlsOptions { lookSpeed?: number moveSpeed?: number damperDecay?: number + relativeUpDown?: boolean } class FlyControls extends SpeckleControls { @@ -44,6 +48,7 @@ class FlyControls extends SpeckleControls { up: false, down: false } + protected contextMenuTriggered = false protected eulerXDamper: Damper = new Damper() protected eulerYDamper: Damper = new Damper() @@ -56,6 +61,8 @@ class FlyControls extends SpeckleControls { private _basisTransform: Matrix4 = new Matrix4() private _basisTransformInv: Matrix4 = new Matrix4() + protected _minDist: number + private world: World public get enabled(): boolean { @@ -63,8 +70,6 @@ class FlyControls extends SpeckleControls { } public set enabled(value: boolean) { - if (value) this.connect() - else this.disconnect() this._enabled = value } @@ -97,6 +102,10 @@ class FlyControls extends SpeckleControls { this._basisTransformInv.invert() } + public set minDist(value: number) { + this._minDist = value + } + constructor( camera: PerspectiveCamera | OrthographicCamera, container: HTMLElement, @@ -109,6 +118,8 @@ class FlyControls extends SpeckleControls { this.container = container this.world = world this._options = Object.assign({}, options) + + this.connect() } public isStationary(): boolean { @@ -120,12 +131,29 @@ class FlyControls extends SpeckleControls { } public update(delta?: number): boolean { + /** We do this because sometimes while holding a kewy down you get an extra + * key down event **after** the context menu event, locking it in place + */ + if (this.contextMenuTriggered) { + this.cancelMove() + this.contextMenuTriggered = false + } + const now = performance.now() delta = delta !== undefined ? delta : now - this._lastTick this._lastTick = now + + if (!this._enabled) return false + + let relativeFactor = this.world.getRelativeOffset(farRelativeFactor) + if (this._minDist) { + if (this._minDist < relativeFactor * 0.5) + relativeFactor = this.world.getRelativeOffset(closeRelativeFactor) + } + const deltaSeconds = delta / 1000 + const scaledWalkingSpeed = relativeFactor * walkingSpeed - const scaledWalkingSpeed = this.world.getRelativeOffset(0.2) * walkingSpeed if (this.keyMap.forward) this.velocity.z = -scaledWalkingSpeed * this._options.moveSpeed * deltaSeconds if (this.keyMap.back) @@ -148,6 +176,12 @@ class FlyControls extends SpeckleControls { this.moveBy(this.velocity) + this.updatePositionRotation(delta) + + return true + } + + protected updatePositionRotation(delta: number) { const diagonal = this.world.worldBox.min.distanceTo(this.world.worldBox.max) const minMaxRange = diagonal < 1 ? diagonal : 1 this.position.x = this.positionXDamper.update( @@ -175,12 +209,10 @@ class FlyControls extends SpeckleControls { this.rotate(this.euler) this._targetCamera.position.copy(this.position) - - return true } public jumpToGoal(): void { - this.update(SETTLING_TIME) + this.updatePositionRotation(SETTLING_TIME) } public fitToSphere(sphere: Sphere): void { @@ -192,14 +224,14 @@ class FlyControls extends SpeckleControls { this.goalPosition.copy(pos) } - /** The input position and target will be in a basis with (0,1,0) as up */ + /** The input position and target will be in a basis with (0,0,1) as up */ public fromPositionAndTarget(position: Vector3, target: Vector3): void { const cPos = this.getPosition() const cTarget = this.getTarget() if (cPos.equals(position) && cTarget.equals(target)) return - const tPosition = new Vector3().copy(position).applyMatrix4(this._basisTransform) - const tTarget = new Vector3().copy(target).applyMatrix4(this._basisTransform) + const tPosition = new Vector3().copy(position) + const tTarget = new Vector3().copy(target) const matrix = new Matrix4() .lookAt(tPosition, tTarget, this._up) .premultiply(this._basisTransformInv) @@ -208,7 +240,7 @@ class FlyControls extends SpeckleControls { this.goalPosition.copy(tPosition) } - /** The returned vector needs to be in a basis with (0,1,0) as up */ + /** The returned vector needs to be in a basis with (0,0,1) as up */ public getTarget(): Vector3 { const target = new Vector3().copy(this.goalPosition) const matrix = new Matrix4().makeRotationFromEuler(this.goalEuler) @@ -216,13 +248,40 @@ class FlyControls extends SpeckleControls { .setFromMatrixColumn(matrix, 2) .applyMatrix4(this._basisTransform) .normalize() - target.addScaledVector(forward, -this.world.getRelativeOffset(0.2)) - return target.applyMatrix4(this._basisTransformInv) + target.addScaledVector( + forward, + -this.world.getRelativeOffset(relativeMinTargetDistance) + ) + return target } - /** The returned vector needs to be in a basis with (0,1,0) as up */ + /** The returned vector needs to be in a basis with (0,0,1) as up */ public getPosition(): Vector3 { - return new Vector3().copy(this.goalPosition).applyMatrix4(this._basisTransformInv) + return new Vector3().copy(this.goalPosition) + } + + /** + * Gets the current goal position + */ + public getCurrentPosition(): Vector3 { + return this.position + } + + /** + * Gets the point in model coordinates the model should orbit/pivot around. + */ + public getCurrentTarget(): Vector3 { + const target = new Vector3().copy(this.position) + const matrix = new Matrix4().makeRotationFromEuler(this.euler) + const forward = new Vector3() + .setFromMatrixColumn(matrix, 2) + .applyMatrix4(this._basisTransform) + .normalize() + target.addScaledVector( + forward, + -this.world.getRelativeOffset(relativeMinTargetDistance) + ) + return target } /** @@ -241,7 +300,9 @@ class FlyControls extends SpeckleControls { const camera = this._targetCamera _vectorBuff0.setFromMatrixColumn(camera.matrix, 2) this.goalPosition.addScaledVector(_vectorBuff0, amount.z) - _vectorBuff0.setFromMatrixColumn(camera.matrix, 1) + this._options.relativeUpDown + ? _vectorBuff0.setFromMatrixColumn(camera.matrix, 1) + : _vectorBuff0.copy(this.up) this.goalPosition.addScaledVector(_vectorBuff0, amount.y) _vectorBuff0.setFromMatrixColumn(camera.matrix, 0) this.goalPosition.addScaledVector(_vectorBuff0, amount.x) @@ -265,6 +326,7 @@ class FlyControls extends SpeckleControls { this.container.addEventListener('pointermove', this.onMouseMove) document.addEventListener('keydown', this.onKeyDown) document.addEventListener('keyup', this.onKeyUp) + document.addEventListener('contextmenu', this.onContextMenu) } protected disconnect() { @@ -273,6 +335,7 @@ class FlyControls extends SpeckleControls { this.container.removeEventListener('pointermove', this.onMouseMove) document.removeEventListener('keydown', this.onKeyDown) document.removeEventListener('keyup', this.onKeyUp) + document.removeEventListener('contextmenu', this.onContextMenu) for (const k in this.keyMap) this.keyMap[k as MoveType] = false } @@ -292,7 +355,7 @@ class FlyControls extends SpeckleControls { // event listeners protected onMouseMove = (event: PointerEvent) => { - if (event.buttons !== 1) return + if (event.buttons !== 1 || !this._enabled) return const movementX = event.movementX || 0 const movementY = event.movementY || 0 @@ -327,12 +390,12 @@ class FlyControls extends SpeckleControls { break case 'PageUp': - case 'KeyQ': + case 'KeyE': this.keyMap.up = true break case 'PageDown': - case 'KeyE': + case 'KeyQ': this.keyMap.down = true break } @@ -361,15 +424,28 @@ class FlyControls extends SpeckleControls { break case 'PageUp': - case 'KeyQ': + case 'KeyE': this.keyMap.up = false break case 'PageDown': - case 'KeyE': + case 'KeyQ': this.keyMap.down = false break } } + + protected onContextMenu = () => { + this.contextMenuTriggered = true + } + + protected cancelMove() { + this.keyMap.forward = false + this.keyMap.left = false + this.keyMap.back = false + this.keyMap.right = false + this.keyMap.up = false + this.keyMap.down = false + } } export { FlyControls } diff --git a/packages/viewer/src/modules/extensions/controls/PivotalControls.ts b/packages/viewer/src/modules/extensions/controls/PivotalControls.ts new file mode 100644 index 0000000000..5806ef33bb --- /dev/null +++ b/packages/viewer/src/modules/extensions/controls/PivotalControls.ts @@ -0,0 +1,53 @@ +// import { PerspectiveCamera, OrthographicCamera, Sphere, Vector3 } from 'three' +// import { SpeckleControls } from './SpeckleControls.js' + +// export interface PivotalControlsOptions {} + +// export class PivotalControls extends SpeckleControls { +// private _enabled: boolean = false +// private _options: Required = {} + +// get options(): Partial { +// return this._options +// } +// set options(value: Partial) { +// Object.assign(this._options, value) +// } + +// get enabled(): boolean { +// return this._enabled +// } +// set enabled(value: boolean) { +// this._enabled = value +// } + +// set targetCamera(target: PerspectiveCamera | OrthographicCamera) { +// throw new Error('Method not implemented.') +// } + +// isStationary(): boolean { +// throw new Error('Method not implemented.') +// } + +// update(delta?: number): boolean { +// throw new Error('Method not implemented.') +// } +// jumpToGoal(): void { +// throw new Error('Method not implemented.') +// } +// fitToSphere(sphere: Sphere): void { +// throw new Error('Method not implemented.') +// } +// dispose(): void { +// throw new Error('Method not implemented.') +// } +// fromPositionAndTarget(position: Vector3, target: Vector3): void { +// throw new Error('Method not implemented.') +// } +// getTarget(): Vector3 { +// throw new Error('Method not implemented.') +// } +// getPosition(): Vector3 { +// throw new Error('Method not implemented.') +// } +// } diff --git a/packages/viewer/src/modules/extensions/controls/SmoothOrbitControls.ts b/packages/viewer/src/modules/extensions/controls/SmoothOrbitControls.ts index ed254e6bbe..3fde991853 100644 --- a/packages/viewer/src/modules/extensions/controls/SmoothOrbitControls.ts +++ b/packages/viewer/src/modules/extensions/controls/SmoothOrbitControls.ts @@ -24,16 +24,19 @@ import { OrthographicCamera, Quaternion, Euler, - Scene + Mesh, + SphereGeometry } from 'three' import { Damper, SETTLING_TIME } from '../../utils/Damper.js' import { World } from '../../World.js' import { SpeckleControls } from './SpeckleControls.js' -import { Intersections } from '../../Intersections.js' import { lerp } from 'three/src/math/MathUtils.js' import { computeOrthographicSize } from '../CameraController.js' +import { ObjectLayers } from '../../../IViewer.js' +import SpeckleBasicMaterial from '../../materials/SpeckleBasicMaterial.js' +import SpeckleRenderer from '../../SpeckleRenderer.js' /** * @param {Number} value @@ -45,6 +48,7 @@ const clamp = (value: number, lowerLimit: number, upperLimit: number): number => Math.max(lowerLimit, Math.min(upperLimit, value)) const PAN_SENSITIVITY = 0.018 +const MOVEMENT_EPSILON = 1e-5 const vector3 = new Vector3() export type TouchMode = null | ((dx: number, dy: number) => void) @@ -93,6 +97,10 @@ export interface SmoothOrbitControlsOptions { infiniteZoom?: boolean // Zoom to cursor zoomToCursor?: boolean + // Orbit around cursor + orbitAroundCursor?: boolean + // Show orbit point + showOrbitPoint?: boolean // Dampening damperDecay?: number } @@ -104,6 +112,11 @@ export enum PointerChangeEvent { PointerChangeEnd = 'pointer-change-end' } +const closeRelativeFactorPan = 0.06 +const farRelativeFactorPan = 0.4 +const relativeMinTargetDistance = 0.01 +const relativeMaxTargetDistance = 0.2 + /** * SmoothControls is a Three.js helper for adding delightful pointer and * keyboard-based input to a staged Three.js scene. Its API is very similar to @@ -123,49 +136,56 @@ export enum PointerChangeEvent { * ensure that the camera's matrixWorld is in sync before using SmoothControls. */ export class SmoothOrbitControls extends SpeckleControls { - private _enabled: boolean = false - private _options: Required - private isUserPointing = false + protected _enabled: boolean = false + protected _options: Required + protected isUserPointing = false // Pan state public enablePan = true public enableTap = true - private panProjection = new Matrix3() - private panPerPixel = 0 + protected panProjection = new Matrix3() + protected panPerPixel = 0 // Internal orbital position state public spherical = new Spherical() - private goalSpherical = new Spherical() - private origin = new Vector3() - private goalOrigin = new Vector3() - private targetDamperX = new Damper() - private targetDamperY = new Damper() - private targetDamperZ = new Damper() - private thetaDamper = new Damper() - private phiDamper = new Damper() - private radiusDamper = new Damper() - private logFov = Math.log(55) - private goalLogFov = this.logFov - private fovDamper = new Damper() + protected goalSpherical = new Spherical() + protected origin = new Vector3() + protected pivotalOrigin: Vector3 = new Vector3() + protected goalOrigin = new Vector3() + protected targetDamperX = new Damper() + protected targetDamperY = new Damper() + protected targetDamperZ = new Damper() + protected thetaDamper = new Damper() + protected phiDamper = new Damper() + protected radiusDamper = new Damper() + protected logFov = Math.log(55) + protected goalLogFov = this.logFov + protected fovDamper = new Damper() // Pointer state - private touchMode: TouchMode = null - private pointers: Pointer[] = [] - private startPointerPosition = { clientX: 0, clientY: 0 } - private lastSeparation = 0 - private touchDecided = false - private zoomControlCoord: Vector2 = new Vector2() - - private _targetCamera: PerspectiveCamera | OrthographicCamera - private _container: HTMLElement - private _lastTick: number = 0 - private _basisTransform: Matrix4 = new Matrix4() - private _basisTransformInv: Matrix4 = new Matrix4() - private _radiusDelta: number = 0 - - private scene: Scene - private world: World - private intersections: Intersections + protected touchMode: TouchMode = null + protected pointers: Pointer[] = [] + protected startPointerPosition = { clientX: 0, clientY: 0 } + protected lastSeparation = 0 + protected touchDecided = false + protected zoomControlCoord: Vector2 = new Vector2() + + protected _targetCamera: PerspectiveCamera | OrthographicCamera + protected _container: HTMLElement + protected _lastTick: number = 0 + protected _basisTransform: Matrix4 = new Matrix4() + protected _basisTransformInv: Matrix4 = new Matrix4() + protected _radiusDelta: number = 0 + + protected world: World + protected renderer: SpeckleRenderer + + protected orbitSphere: Mesh + protected pivotPoint: Vector3 = new Vector3() + protected lastPivotPoint: Vector3 = new Vector3() + protected usePivotal = false + + protected _minDist: number public get enabled(): boolean { return this._enabled @@ -173,7 +193,10 @@ export class SmoothOrbitControls extends SpeckleControls { public set enabled(value: boolean) { if (value) { this.enableInteraction() - } else this.disableInteraction() + } else { + this.disableInteraction() + this.orbitSphere.visible = false + } this._enabled = value } @@ -194,20 +217,31 @@ export class SmoothOrbitControls extends SpeckleControls { camera: PerspectiveCamera | OrthographicCamera, container: HTMLElement, world: World, - scene: Scene, - intersections: Intersections, + renderer: SpeckleRenderer, options: Required ) { super() this._targetCamera = camera this._container = container this.world = world - this.intersections = intersections - this.scene = scene + this.renderer = renderer this._options = Object.assign({}, options) this.setDamperDecayTime(this._options.damperDecay) - this.scene - this.intersections + + const billboardMaterial = new SpeckleBasicMaterial({ color: 0x047efb }, [ + 'BILLBOARD_FIXED' + ]) + billboardMaterial.opacity = 0.75 + billboardMaterial.transparent = true + billboardMaterial.color.convertSRGBToLinear() + billboardMaterial.toneMapped = false + billboardMaterial.depthTest = false + billboardMaterial.billboardPixelHeight = 15 * window.devicePixelRatio + + this.orbitSphere = new Mesh(new SphereGeometry(0.5, 32, 16), billboardMaterial) + this.orbitSphere.layers.set(ObjectLayers.OVERLAY) + this.orbitSphere.visible = false + this.renderer.scene.add(this.orbitSphere) } /** @@ -223,9 +257,21 @@ export class SmoothOrbitControls extends SpeckleControls { set targetCamera(value: PerspectiveCamera | OrthographicCamera) { this._targetCamera = value + this.usePivotal = this._options.orbitAroundCursor + + /** We move the lat pivot point somwhere outside of world bounds, in order to force a pivotal origin recompute */ + this.lastPivotPoint.set( + this.world.worldOrigin.x + this.world.worldSize.x, + this.world.worldOrigin.y + this.world.worldSize.y, + this.world.worldOrigin.z + this.world.worldSize.z + ) this.moveCamera() } + public set minDist(value: number) { + this._minDist = value + } + /** The input position and target will be in a basis with (0,1,0) as up */ public fromPositionAndTarget(position: Vector3, target: Vector3): void { /** This check is targeted exclusevely towards the frontend which calls this method pointlessly each frame @@ -245,6 +291,7 @@ export class SmoothOrbitControls extends SpeckleControls { /** Three.js Spherical assumes (0, 1, 0) as up... */ v1.applyMatrix4(this._basisTransformInv) this.setTarget(v1.x, v1.y, v1.z) + this.usePivotal = false } /** @@ -263,13 +310,16 @@ export class SmoothOrbitControls extends SpeckleControls { this.setTarget(nativeOrigin.x, nativeOrigin.y, nativeOrigin.z) this.setRadius(sphere.radius) + this.usePivotal = false } /** * Gets the current goal position */ public getPosition(): Vector3 { - return this.positionFromSpherical(this.goalSpherical, this.goalOrigin) + return this.positionFromSpherical(this.goalSpherical, this.goalOrigin).applyMatrix4( + this._basisTransform + ) } /** @@ -279,13 +329,30 @@ export class SmoothOrbitControls extends SpeckleControls { return this.goalOrigin.clone().applyMatrix4(this._basisTransform) } + /** + * Gets the current goal position + */ + public getCurrentPosition(): Vector3 { + return this.positionFromSpherical(this.spherical, this.origin).applyMatrix4( + this._basisTransform + ) + } + + /** + * Gets the point in model coordinates the model should orbit/pivot around. + */ + public getCurrentTarget(): Vector3 { + return this.origin.clone().applyMatrix4(this._basisTransform) + } + public isStationary(): boolean { return ( this.goalSpherical.theta === this.spherical.theta && this.goalSpherical.phi === this.spherical.phi && this.goalSpherical.radius === this.spherical.radius && this.goalLogFov === this.logFov && - this.goalOrigin.equals(this.origin) + this.goalOrigin.equals(this.origin) && + this.pivotPoint.equals(this.lastPivotPoint) ) } @@ -448,8 +515,8 @@ export class SmoothOrbitControls extends SpeckleControls { ) worldSizeOffset = clamp( worldSizeOffset, - this.world.getRelativeOffset(0.01), - this.world.getRelativeOffset(0.2) + this.world.getRelativeOffset(relativeMinTargetDistance), + this.world.getRelativeOffset(relativeMaxTargetDistance) ) const zoomAmount = worldSizeOffset * Math.sign(deltaZoom) //deltaZoom * this.spherical.radius * Math.tan(fov * 0.5) @@ -593,17 +660,123 @@ export class SmoothOrbitControls extends SpeckleControls { ) this.origin.set(x, y, z) - this.moveCamera() + return this.moveCamera() + } - return true + /** Function expects the position argument to be in a CS where Y is up */ + protected polarFromPivotal(position: Vector3) { + const quaternion = this.quaternionFromSpherical(this.spherical) + /** Forward direction */ + const dir = new Vector3().setFromMatrixColumn( + new Matrix4().makeRotationFromQuaternion(quaternion), + 2 + ) + const camPos = new Vector3().copy(position) + + /** Pivot needs to be transformed in a Y up CS */ + const pivotPoint = new Vector3() + .copy(this.pivotPoint) + .applyMatrix4(this._basisTransformInv) + + const cameraPivotDist = camPos.distanceTo(pivotPoint) + const cameraPivotDir = new Vector3().copy(camPos).sub(pivotPoint) + cameraPivotDir.normalize() + + const dot = Math.min(Math.max(dir.dot(cameraPivotDir), -1), 1) + const angle = Math.acos(dot) + /** We compute a new distanced based on the pivot point */ + const polarRadius = cameraPivotDist * Math.cos(angle) + /** We compute a new origin based on the pivot point, but keeping it along the camera's current forward direction */ + const polarOrigin = camPos.sub(new Vector3().copy(dir).multiplyScalar(polarRadius)) + + this.goalOrigin.copy(polarOrigin) + this.origin.copy(polarOrigin) + + /** For orthographica camera's we don't need to update the radius because it will break their orthographic size */ + if (this._targetCamera instanceof PerspectiveCamera) { + this.goalSpherical.radius = polarRadius + this.spherical.radius = polarRadius + } + } + + /** Function expects the origin argument to be in a CS where Y is up */ + protected positionFromPivotal(origin: Vector3, quaternion: Quaternion) { + const pivotPoint = new Vector3() + .copy(this.pivotPoint) + .applyMatrix4(this._basisTransformInv) + + const position = new Vector3() + position.copy(origin) + + position.sub(pivotPoint) + position.applyQuaternion(quaternion) + position.add(pivotPoint) + + return position } - protected moveCamera() { - // Derive the new camera position from the updated spherical: + /** Function expects the pivotPoint and position arguments to be in a CS where Y is up */ + protected getPivotalOrigin( + pivotPoint: Vector3, + position: Vector3, + quaternion: Quaternion + ) { + const pivotalOrigin = new Vector3().copy(position) + + pivotalOrigin.sub(pivotPoint) + pivotalOrigin.applyQuaternion(new Quaternion().copy(quaternion).invert()) + pivotalOrigin.add(pivotPoint) + + return pivotalOrigin + } + + protected moveCamera(): boolean { + const lastCameraPos = new Vector3().copy(this._targetCamera.position) + const lastCameraQuat = new Quaternion().copy(this._targetCamera.quaternion) + this.spherical.makeSafe() - const position = this.positionFromSpherical(this.spherical, this.origin) + + /** We get the current position and rotation based off the latest polar params + * The ground truth is going to always be the polar CS! + */ const quaternion = this.quaternionFromSpherical(this.spherical) + let position = this.positionFromSpherical(this.spherical, this.origin) + + if (this.usePivotal) { + /** We transform both current and previous pivots in a CS where Y us up */ + const pivotPoint = new Vector3() + .copy(this.pivotPoint) + .applyMatrix4(this._basisTransformInv) + const prevPivotPoint = new Vector3() + .copy(this.lastPivotPoint) + .applyMatrix4(this._basisTransformInv) + + const deltaPivot = prevPivotPoint.sub(pivotPoint) + + /** We recompute the pivotal origin/pivotal offset, but only when required! */ + if (deltaPivot.length() > 0) { + this.pivotalOrigin.copy(this.getPivotalOrigin(pivotPoint, position, quaternion)) + } + + /** We get a new position in the pivotal CS */ + position = this.positionFromPivotal(this.pivotalOrigin, quaternion) + /** We update the polar CS based off the new pivotal camera position, + * essentially creating a virtual pair polar CS which can reproduce the pivotal position */ + this.polarFromPivotal(position) + /** Update the last pivot */ + this.lastPivotPoint.copy(this.pivotPoint) + } + /** We transform both position and quaternion in the required basis */ + position.applyQuaternion( + new Quaternion().setFromRotationMatrix(this._basisTransform) + ) + quaternion.premultiply(new Quaternion().setFromRotationMatrix(this._basisTransform)) + + /** This is a trick we do for ortographic projection which stops the near plane from clipping into geometry + * In orthographic projection the camera's 'depth' along it's forward does not matter. Zoooming is achieved by + * varying the orthographic size, not by moving the camera. + */ if (this._targetCamera instanceof OrthographicCamera) { const cameraDirection = new Vector3() .setFromSpherical(this.spherical) @@ -617,14 +790,19 @@ export class SmoothOrbitControls extends SpeckleControls { ) ) } + /** Apply values and update transform */ this._targetCamera.position.copy(position) this._targetCamera.quaternion.copy(quaternion) + this._targetCamera.updateMatrixWorld(true) + /** Fov update */ if (this._targetCamera instanceof PerspectiveCamera) if (this._targetCamera.fov !== Math.exp(this.logFov)) { this._targetCamera.fov = Math.exp(this.logFov) this._targetCamera.updateProjectionMatrix() } + + /** Compute the correct orthographic size based on the polar radius */ if (this._targetCamera instanceof OrthographicCamera) { const orthographicSize = computeOrthographicSize( this.spherical.radius, @@ -638,9 +816,22 @@ export class SmoothOrbitControls extends SpeckleControls { this._targetCamera.bottom = orthographicSize.y / -2 this._targetCamera.updateProjectionMatrix() } + + /** Update the debug origin sphere */ + this.orbitSphere.position.copy( + this._options.orbitAroundCursor && this.usePivotal + ? this.pivotPoint + : new Vector3().copy(this.origin).applyMatrix4(this._basisTransform) + ) + + return ( + lastCameraPos.sub(this._targetCamera.position).length() > MOVEMENT_EPSILON || + lastCameraQuat.angleTo(this._targetCamera.quaternion) > MOVEMENT_EPSILON + ) } - /* Ortho height to distance functions + /* + // Ortho height to distance function. Keeping for reference private orthographicHeightToDistance(height: number) { if (!(this._targetCamera instanceof OrthographicCamera)) return this.spherical.radius @@ -654,9 +845,6 @@ export class SmoothOrbitControls extends SpeckleControls { position.setFromSpherical(spherical) if (origin) position.add(origin) - position.applyQuaternion( - new Quaternion().setFromRotationMatrix(this._basisTransform) - ) return position } @@ -666,7 +854,7 @@ export class SmoothOrbitControls extends SpeckleControls { quaternion.setFromEuler( new Euler(spherical.phi - Math.PI / 2, spherical.theta, 0, 'YXZ') ) - quaternion.premultiply(new Quaternion().setFromRotationMatrix(this._basisTransform)) + return quaternion } @@ -819,23 +1007,59 @@ export class SmoothOrbitControls extends SpeckleControls { protected movePan(dx: number, dy: number) { const dxy = vector3.set(dx, dy, 0).multiplyScalar(this._options.inputSensitivity) + let relativeFactor = this.world.getRelativeOffset(farRelativeFactorPan) + if (this._minDist) { + if (this._minDist < relativeFactor * 0.5) { + relativeFactor = this.world.getRelativeOffset(closeRelativeFactorPan) + } + } + + const radiusFactor = clamp( + this.spherical.radius, + this.world.getRelativeOffset(0.025), + Number.MAX_VALUE + ) const metersPerPixel = - clamp( - this.spherical.radius, - this.world.getRelativeOffset(0.025), - Number.MAX_VALUE - ) * - Math.exp(this.logFov) * - this.panPerPixel + Math.max(relativeFactor, radiusFactor) * Math.exp(this.logFov) * this.panPerPixel dxy.multiplyScalar(metersPerPixel) /** This panProjection assumes (0, 1, 0) as up... */ const target = this.getTarget().applyMatrix4(this._basisTransformInv) target.add(dxy.applyMatrix3(this.panProjection)) this.setTarget(target.x, target.y, target.z) + this.usePivotal = false + this.orbitSphere.visible = false } protected onPointerDown = (event: PointerEvent) => { + if (this._options.orbitAroundCursor) { + const x = + ((event.clientX - this._container.offsetLeft) / this._container.offsetWidth) * + 2 - + 1 + + const y = + ((event.clientY - this._container.offsetTop) / this._container.offsetHeight) * + -2 + + 1 + const res = this.renderer.intersections.intersect( + this.renderer.scene, + this._targetCamera as PerspectiveCamera, + new Vector2(x, y), + ObjectLayers.STREAM_CONTENT_MESH, + true, + this.renderer.clippingVolume + ) + if (res && res.length) { + this.pivotPoint.copy(res[0].point) + this.usePivotal = true + this.orbitSphere.visible = this._options.showOrbitPoint + } else { + this.usePivotal = false + this.orbitSphere.visible = false + } + } + if (this.pointers.length > 2) { return } @@ -849,11 +1073,6 @@ export class SmoothOrbitControls extends SpeckleControls { this.startPointerPosition.clientY = event.clientY } - // try { - // this._container.setPointerCapture(event.pointerId) - // } catch (e) { - // e - // } this.pointers.push({ clientX: event.clientX, clientY: event.clientY, @@ -935,6 +1154,7 @@ export class SmoothOrbitControls extends SpeckleControls { if (this.isUserPointing) { this.emit(PointerChangeEvent.PointerChangeEnd) } + this.orbitSphere.visible = false } protected onTouchChange(event: PointerEvent) { @@ -971,7 +1191,7 @@ export class SmoothOrbitControls extends SpeckleControls { (event.button === 2 || event.ctrlKey || event.metaKey || event.shiftKey) ) { this.initializePan() - // ;(this.scene.element as any)[$panElement].style.opacity = 1 + this.orbitSphere.visible = false } // this.element.style.cursor = 'grabbing' } @@ -997,6 +1217,8 @@ export class SmoothOrbitControls extends SpeckleControls { 60 this.userAdjustOrbit(0, 0, deltaZoom) event.preventDefault() + this.usePivotal = false + this.orbitSphere.visible = false // TO DO // this.dispatchEvent({ type: 'user-interaction' }) } diff --git a/packages/viewer/src/modules/extensions/controls/SpeckleControls.ts b/packages/viewer/src/modules/extensions/controls/SpeckleControls.ts index 37eea47e42..4fff1f8585 100644 --- a/packages/viewer/src/modules/extensions/controls/SpeckleControls.ts +++ b/packages/viewer/src/modules/extensions/controls/SpeckleControls.ts @@ -3,6 +3,8 @@ import EventEmitter from '../../EventEmitter.js' export abstract class SpeckleControls extends EventEmitter { protected _up: Vector3 = new Vector3(0, 1, 0) + protected _minDist: number = 0 + public get up() { return this._up } @@ -10,6 +12,14 @@ export abstract class SpeckleControls extends EventEmitter { this._up.copy(value) } + public get minDist() { + return this._minDist + } + + public set minDist(value: number) { + this._minDist = value + } + abstract get options(): Partial> abstract set options(value: Partial>) @@ -26,4 +36,6 @@ export abstract class SpeckleControls extends EventEmitter { abstract fromPositionAndTarget(position: Vector3, target: Vector3): void abstract getTarget(): Vector3 abstract getPosition(): Vector3 + abstract getCurrentTarget(): Vector3 + abstract getCurrentPosition(): Vector3 } diff --git a/packages/viewer/src/modules/materials/SpeckleBasicMaterial.ts b/packages/viewer/src/modules/materials/SpeckleBasicMaterial.ts index f336252047..d9868d5c20 100644 --- a/packages/viewer/src/modules/materials/SpeckleBasicMaterial.ts +++ b/packages/viewer/src/modules/materials/SpeckleBasicMaterial.ts @@ -95,6 +95,7 @@ class SpeckleBasicMaterial extends ExtendedMeshBasicMaterial { this.userData.billboardSize.value.copy(SpeckleBasicMaterial.vecBuff) SpeckleBasicMaterial.matBuff.copy(camera.projectionMatrix).invert() this.userData.invProjection.value.copy(SpeckleBasicMaterial.matBuff) + this.userData.billboardPos.value.copy(object.position) } if (this.defines && this.defines['USE_RTE']) {