cione-comp-lib
Version:
`$ yarn add cione-comp-lib` 或者 `$ npm i cione-comp-lib -S`
537 lines (536 loc) • 20.8 kB
JavaScript
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 };