UNPKG

cione-comp-lib

Version:

`$ yarn add cione-comp-lib` 或者 `$ npm i cione-comp-lib -S`

537 lines (536 loc) 20.8 kB
import { Vector2, Vector3, EventDispatcher, Matrix3, Spherical, Euler } from "../../../../_three@0.150.1@three/build/three.module.mjs"; import { $panElement } from "../features/controls.mjs"; import { clamp } from "../utilities.mjs"; import { Damper, SETTLING_TIME } from "./Damper.mjs"; /* @license * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const PAN_SENSITIVITY = 0.018; const TAP_DISTANCE = 2; const TAP_MS = 300; const vector2 = new Vector2(); const vector3 = new Vector3(); const DEFAULT_OPTIONS = Object.freeze({ minimumRadius: 0, maximumRadius: Infinity, minimumPolarAngle: Math.PI / 8, maximumPolarAngle: Math.PI - Math.PI / 8, minimumAzimuthalAngle: -Infinity, maximumAzimuthalAngle: Infinity, minimumFieldOfView: 10, maximumFieldOfView: 45, touchAction: "none" }); const KEYBOARD_ORBIT_INCREMENT = Math.PI / 8; const ZOOM_SENSITIVITY = 0.04; const PAN_KEY_INCREMENT = 10; const ChangeSource = { USER_INTERACTION: "user-interaction", NONE: "none", AUTOMATIC: "automatic" }; class SmoothControls extends EventDispatcher { constructor(camera, element, scene) { super(); this.camera = camera; this.element = element; this.scene = scene; this.orbitSensitivity = 1; this.inputSensitivity = 1; this.changeSource = ChangeSource.NONE; this._interactionEnabled = false; this._disableZoom = false; this.isUserPointing = false; this.enablePan = true; this.enableTap = true; this.panProjection = new Matrix3(); this.panPerPixel = 0; this.spherical = new Spherical(); this.goalSpherical = new Spherical(); this.thetaDamper = new Damper(); this.phiDamper = new Damper(); this.radiusDamper = new Damper(); this.logFov = Math.log(DEFAULT_OPTIONS.maximumFieldOfView); this.goalLogFov = this.logFov; this.fovDamper = new Damper(); this.touchMode = null; this.pointers = []; this.startTime = 0; this.startPointerPosition = { clientX: 0, clientY: 0 }; this.lastSeparation = 0; this.touchDecided = false; this.onContext = (event) => { if (this.enablePan) { event.preventDefault(); } else { for (const pointer of this.pointers) { this.onPointerUp(new PointerEvent("pointercancel", Object.assign(Object.assign({}, this.startPointerPosition), { pointerId: pointer.id }))); } } }; this.touchModeZoom = (dx, dy) => { if (!this._disableZoom) { const touchDistance = this.twoTouchDistance(this.pointers[0], this.pointers[1]); const deltaZoom = ZOOM_SENSITIVITY * (this.lastSeparation - touchDistance) * 50 / this.scene.height; this.lastSeparation = touchDistance; this.userAdjustOrbit(0, 0, deltaZoom); } if (this.panPerPixel > 0) { this.movePan(dx, dy); } }; this.disableScroll = (event) => { event.preventDefault(); }; this.touchModeRotate = (dx, dy) => { const { touchAction } = this._options; if (!this.touchDecided && touchAction !== "none") { this.touchDecided = true; const dxMag = Math.abs(dx); const dyMag = Math.abs(dy); if (this.changeSource === ChangeSource.USER_INTERACTION && (touchAction === "pan-y" && dyMag > dxMag || touchAction === "pan-x" && dxMag > dyMag)) { this.touchMode = null; return; } else { this.element.addEventListener("touchmove", this.disableScroll, { passive: false }); } } this.handleSinglePointerMove(dx, dy); }; this.onPointerDown = (event) => { if (this.pointers.length > 2) { return; } const { element: element2 } = this; if (this.pointers.length === 0) { element2.addEventListener("pointermove", this.onPointerMove); element2.addEventListener("pointerup", this.onPointerUp); this.touchMode = null; this.touchDecided = false; this.startPointerPosition.clientX = event.clientX; this.startPointerPosition.clientY = event.clientY; this.startTime = performance.now(); } try { element2.setPointerCapture(event.pointerId); } catch (_a) { } this.pointers.push({ clientX: event.clientX, clientY: event.clientY, id: event.pointerId }); this.isUserPointing = false; if (event.pointerType === "touch") { this.changeSource = event.altKey ? ChangeSource.AUTOMATIC : ChangeSource.USER_INTERACTION; this.onTouchChange(event); } else { this.changeSource = ChangeSource.USER_INTERACTION; this.onMouseDown(event); } if (this.changeSource === ChangeSource.USER_INTERACTION) { this.dispatchEvent({ type: "user-interaction" }); } }; this.onPointerMove = (event) => { const pointer = this.pointers.find((pointer2) => pointer2.id === event.pointerId); if (pointer == null) { return; } const numTouches = this.pointers.length; const dx = (event.clientX - pointer.clientX) / numTouches; const dy = (event.clientY - pointer.clientY) / numTouches; if (dx === 0 && dy === 0) { return; } pointer.clientX = event.clientX; pointer.clientY = event.clientY; if (event.pointerType === "touch") { this.changeSource = event.altKey ? ChangeSource.AUTOMATIC : ChangeSource.USER_INTERACTION; if (this.touchMode !== null) { this.touchMode(dx, dy); } } else { this.changeSource = ChangeSource.USER_INTERACTION; if (this.panPerPixel > 0) { this.movePan(dx, dy); } else { this.handleSinglePointerMove(dx, dy); } } }; this.onPointerUp = (event) => { const { element: element2 } = this; const index = this.pointers.findIndex((pointer) => pointer.id === event.pointerId); if (index !== -1) { this.pointers.splice(index, 1); } if (this.panPerPixel > 0 && !event.altKey) { this.resetRadius(); } if (this.pointers.length === 0) { element2.removeEventListener("pointermove", this.onPointerMove); element2.removeEventListener("pointerup", this.onPointerUp); element2.removeEventListener("touchmove", this.disableScroll); if (this.enablePan && this.enableTap) { this.recenter(event); } } else if (this.touchMode !== null) { this.onTouchChange(event); } this.scene.element[$panElement].style.opacity = 0; element2.style.cursor = "grab"; this.panPerPixel = 0; if (this.isUserPointing) { this.dispatchEvent({ type: "pointer-change-end" }); } }; this.onWheel = (event) => { this.changeSource = ChangeSource.USER_INTERACTION; const deltaZoom = event.deltaY * (event.deltaMode == 1 ? 18 : 1) * ZOOM_SENSITIVITY / 30; this.userAdjustOrbit(0, 0, deltaZoom); event.preventDefault(); this.dispatchEvent({ type: "user-interaction" }); }; this.onKeyDown = (event) => { const { changeSource } = this; this.changeSource = ChangeSource.USER_INTERACTION; const relevantKey = event.shiftKey && this.enablePan ? this.panKeyCodeHandler(event) : this.orbitZoomKeyCodeHandler(event); if (relevantKey) { event.preventDefault(); this.dispatchEvent({ type: "user-interaction" }); } else { this.changeSource = changeSource; } }; this._options = Object.assign({}, DEFAULT_OPTIONS); this.setOrbit(0, Math.PI / 2, 1); this.setFieldOfView(100); this.jumpToGoal(); } get interactionEnabled() { return this._interactionEnabled; } enableInteraction() { if (this._interactionEnabled === false) { const { element } = this; element.addEventListener("pointerdown", this.onPointerDown); element.addEventListener("pointercancel", this.onPointerUp); if (!this._disableZoom) { element.addEventListener("wheel", this.onWheel); } element.addEventListener("keydown", this.onKeyDown); element.addEventListener("touchmove", () => { }, { passive: false }); element.addEventListener("contextmenu", this.onContext); this.element.style.cursor = "grab"; this._interactionEnabled = true; this.updateTouchActionStyle(); } } disableInteraction() { if (this._interactionEnabled === true) { const { element } = this; element.removeEventListener("pointerdown", this.onPointerDown); element.removeEventListener("pointermove", this.onPointerMove); element.removeEventListener("pointerup", this.onPointerUp); element.removeEventListener("pointercancel", this.onPointerUp); element.removeEventListener("wheel", this.onWheel); element.removeEventListener("keydown", this.onKeyDown); element.removeEventListener("contextmenu", this.onContext); element.style.cursor = ""; this.touchMode = null; this._interactionEnabled = false; this.updateTouchActionStyle(); } } get options() { return this._options; } set disableZoom(disable) { if (this._disableZoom != disable) { this._disableZoom = disable; if (disable === true) { this.element.removeEventListener("wheel", this.onWheel); } else { this.element.addEventListener("wheel", this.onWheel); } this.updateTouchActionStyle(); } } getCameraSpherical(target = new Spherical()) { return target.copy(this.spherical); } getFieldOfView() { return this.camera.fov; } applyOptions(_options) { Object.assign(this._options, _options); this.setOrbit(); this.setFieldOfView(Math.exp(this.goalLogFov)); } updateNearFar(nearPlane, farPlane) { this.camera.far = farPlane === 0 ? 2 : farPlane; this.camera.near = Math.max(nearPlane, this.camera.far / 1e3); this.camera.updateProjectionMatrix(); } updateAspect(aspect) { this.camera.aspect = aspect; this.camera.updateProjectionMatrix(); } setOrbit(goalTheta = this.goalSpherical.theta, goalPhi = this.goalSpherical.phi, goalRadius = this.goalSpherical.radius) { const { minimumAzimuthalAngle, maximumAzimuthalAngle, minimumPolarAngle, maximumPolarAngle, minimumRadius, maximumRadius } = this._options; const { theta, phi, radius } = this.goalSpherical; const nextTheta = clamp(goalTheta, minimumAzimuthalAngle, maximumAzimuthalAngle); if (!isFinite(minimumAzimuthalAngle) && !isFinite(maximumAzimuthalAngle)) { this.spherical.theta = this.wrapAngle(this.spherical.theta - nextTheta) + nextTheta; } const nextPhi = clamp(goalPhi, minimumPolarAngle, maximumPolarAngle); const nextRadius = clamp(goalRadius, minimumRadius, maximumRadius); if (nextTheta === theta && nextPhi === phi && nextRadius === radius) { return false; } if (!isFinite(nextTheta) || !isFinite(nextPhi) || !isFinite(nextRadius)) { return false; } this.goalSpherical.theta = nextTheta; this.goalSpherical.phi = nextPhi; this.goalSpherical.radius = nextRadius; this.goalSpherical.makeSafe(); return true; } setRadius(radius) { this.goalSpherical.radius = radius; this.setOrbit(); } setFieldOfView(fov) { const { minimumFieldOfView, maximumFieldOfView } = this._options; fov = clamp(fov, minimumFieldOfView, maximumFieldOfView); this.goalLogFov = Math.log(fov); } setDamperDecayTime(decayMilliseconds) { this.thetaDamper.setDecayTime(decayMilliseconds); this.phiDamper.setDecayTime(decayMilliseconds); this.radiusDamper.setDecayTime(decayMilliseconds); this.fovDamper.setDecayTime(decayMilliseconds); } adjustOrbit(deltaTheta, deltaPhi, deltaZoom) { const { theta, phi, radius } = this.goalSpherical; const { minimumRadius, maximumRadius, minimumFieldOfView, maximumFieldOfView } = this._options; const dTheta = this.spherical.theta - theta; const dThetaLimit = Math.PI - 1e-3; const goalTheta = theta - clamp(deltaTheta, -dThetaLimit - dTheta, dThetaLimit - dTheta); const goalPhi = phi - deltaPhi; const deltaRatio = deltaZoom === 0 ? 0 : ((deltaZoom > 0 ? maximumRadius : minimumRadius) - radius) / (Math.log(deltaZoom > 0 ? maximumFieldOfView : minimumFieldOfView) - this.goalLogFov); const goalRadius = radius + deltaZoom * (isFinite(deltaRatio) ? deltaRatio : (maximumRadius - minimumRadius) * 2); this.setOrbit(goalTheta, goalPhi, goalRadius); if (deltaZoom !== 0) { const goalLogFov = this.goalLogFov + deltaZoom; this.setFieldOfView(Math.exp(goalLogFov)); } } jumpToGoal() { this.update(0, SETTLING_TIME); } update(_time, delta) { if (this.isStationary()) { return false; } const { maximumPolarAngle, maximumRadius } = this._options; const dTheta = this.spherical.theta - this.goalSpherical.theta; if (Math.abs(dTheta) > Math.PI && !isFinite(this._options.minimumAzimuthalAngle) && !isFinite(this._options.maximumAzimuthalAngle)) { this.spherical.theta -= Math.sign(dTheta) * 2 * Math.PI; } this.spherical.theta = this.thetaDamper.update(this.spherical.theta, this.goalSpherical.theta, delta, Math.PI); this.spherical.phi = this.phiDamper.update(this.spherical.phi, this.goalSpherical.phi, delta, maximumPolarAngle); this.spherical.radius = this.radiusDamper.update(this.spherical.radius, this.goalSpherical.radius, delta, maximumRadius); this.logFov = this.fovDamper.update(this.logFov, this.goalLogFov, delta, 1); this.moveCamera(); return true; } updateTouchActionStyle() { const { style } = this.element; if (this._interactionEnabled) { const { touchAction } = this._options; if (this._disableZoom && touchAction !== "none") { style.touchAction = "manipulation"; } else { style.touchAction = touchAction; } } else { style.touchAction = ""; } } isStationary() { return this.goalSpherical.theta === this.spherical.theta && this.goalSpherical.phi === this.spherical.phi && this.goalSpherical.radius === this.spherical.radius && this.goalLogFov === this.logFov; } moveCamera() { this.spherical.makeSafe(); this.camera.position.setFromSpherical(this.spherical); this.camera.setRotationFromEuler(new Euler(this.spherical.phi - Math.PI / 2, this.spherical.theta, 0, "YXZ")); if (this.camera.fov !== Math.exp(this.logFov)) { this.camera.fov = Math.exp(this.logFov); this.camera.updateProjectionMatrix(); } } userAdjustOrbit(deltaTheta, deltaPhi, deltaZoom) { this.adjustOrbit(deltaTheta * this.orbitSensitivity * this.inputSensitivity, deltaPhi * this.orbitSensitivity * this.inputSensitivity, deltaZoom * this.inputSensitivity); } wrapAngle(radians) { const normalized = (radians + Math.PI) / (2 * Math.PI); const wrapped = normalized - Math.floor(normalized); return wrapped * 2 * Math.PI - Math.PI; } pixelLengthToSphericalAngle(pixelLength) { return 2 * Math.PI * pixelLength / this.scene.height; } twoTouchDistance(touchOne, touchTwo) { const { clientX: xOne, clientY: yOne } = touchOne; const { clientX: xTwo, clientY: yTwo } = touchTwo; const xDelta = xTwo - xOne; const yDelta = yTwo - yOne; return Math.sqrt(xDelta * xDelta + yDelta * yDelta); } handleSinglePointerMove(dx, dy) { const deltaTheta = this.pixelLengthToSphericalAngle(dx); const deltaPhi = this.pixelLengthToSphericalAngle(dy); if (this.isUserPointing === false) { this.isUserPointing = true; this.dispatchEvent({ type: "pointer-change-start" }); } this.userAdjustOrbit(deltaTheta, deltaPhi, 0); } initializePan() { const { theta, phi } = this.spherical; const psi = theta - this.scene.yaw; this.panPerPixel = PAN_SENSITIVITY / this.scene.height; this.panProjection.set(-Math.cos(psi), -Math.cos(phi) * Math.sin(psi), 0, 0, Math.sin(phi), 0, Math.sin(psi), -Math.cos(phi) * Math.cos(psi), 0); } movePan(dx, dy) { const { scene } = this; const dxy = vector3.set(dx, dy, 0).multiplyScalar(this.inputSensitivity); const metersPerPixel = this.spherical.radius * Math.exp(this.logFov) * this.panPerPixel; dxy.multiplyScalar(metersPerPixel); const target = scene.getTarget(); target.add(dxy.applyMatrix3(this.panProjection)); scene.boundingSphere.clampPoint(target, target); scene.setTarget(target.x, target.y, target.z); } recenter(pointer) { if (performance.now() > this.startTime + TAP_MS || Math.abs(pointer.clientX - this.startPointerPosition.clientX) > TAP_DISTANCE || Math.abs(pointer.clientY - this.startPointerPosition.clientY) > TAP_DISTANCE) { return; } const { scene } = this; const hit = scene.positionAndNormalFromPoint(scene.getNDC(pointer.clientX, pointer.clientY)); if (hit == null) { const { cameraTarget } = scene.element; scene.element.cameraTarget = ""; scene.element.cameraTarget = cameraTarget; this.userAdjustOrbit(0, 0, 1); } else { scene.target.worldToLocal(hit.position); scene.setTarget(hit.position.x, hit.position.y, hit.position.z); } } resetRadius() { const { scene } = this; const hit = scene.positionAndNormalFromPoint(vector2.set(0, 0)); if (hit == null) { return; } scene.target.worldToLocal(hit.position); const goalTarget = scene.getTarget(); const { theta, phi } = this.spherical; const psi = theta - scene.yaw; const n = vector3.set(Math.sin(phi) * Math.sin(psi), Math.cos(phi), Math.sin(phi) * Math.cos(psi)); const dr = n.dot(hit.position.sub(goalTarget)); goalTarget.add(n.multiplyScalar(dr)); scene.setTarget(goalTarget.x, goalTarget.y, goalTarget.z); this.setOrbit(void 0, void 0, this.goalSpherical.radius - dr); } onTouchChange(event) { if (this.pointers.length === 1) { this.touchMode = this.touchModeRotate; } else { if (this._disableZoom) { this.touchMode = null; this.element.removeEventListener("touchmove", this.disableScroll); return; } this.touchMode = this.touchDecided && this.touchMode === null ? null : this.touchModeZoom; this.touchDecided = true; this.element.addEventListener("touchmove", this.disableScroll, { passive: false }); this.lastSeparation = this.twoTouchDistance(this.pointers[0], this.pointers[1]); if (this.enablePan && this.touchMode != null) { this.initializePan(); if (!event.altKey) { this.scene.element[$panElement].style.opacity = 1; } } } } onMouseDown(event) { this.panPerPixel = 0; if (this.enablePan && (event.button === 2 || event.ctrlKey || event.metaKey || event.shiftKey)) { this.initializePan(); this.scene.element[$panElement].style.opacity = 1; } this.element.style.cursor = "grabbing"; } orbitZoomKeyCodeHandler(event) { let relevantKey = true; switch (event.key) { case "PageUp": this.userAdjustOrbit(0, 0, ZOOM_SENSITIVITY); break; case "PageDown": this.userAdjustOrbit(0, 0, -1 * ZOOM_SENSITIVITY); break; case "ArrowUp": this.userAdjustOrbit(0, -KEYBOARD_ORBIT_INCREMENT, 0); break; case "ArrowDown": this.userAdjustOrbit(0, KEYBOARD_ORBIT_INCREMENT, 0); break; case "ArrowLeft": this.userAdjustOrbit(-KEYBOARD_ORBIT_INCREMENT, 0, 0); break; case "ArrowRight": this.userAdjustOrbit(KEYBOARD_ORBIT_INCREMENT, 0, 0); break; default: relevantKey = false; break; } return relevantKey; } panKeyCodeHandler(event) { this.initializePan(); let relevantKey = true; switch (event.key) { case "ArrowUp": this.movePan(0, -1 * PAN_KEY_INCREMENT); break; case "ArrowDown": this.movePan(0, PAN_KEY_INCREMENT); break; case "ArrowLeft": this.movePan(-1 * PAN_KEY_INCREMENT, 0); break; case "ArrowRight": this.movePan(PAN_KEY_INCREMENT, 0); break; default: relevantKey = false; break; } return relevantKey; } } export { ChangeSource, DEFAULT_OPTIONS, SmoothControls };