pex-cam
Version:
Cameras models and controllers for 3D rendering in PEX.
365 lines (301 loc) • 9.38 kB
JavaScript
import { vec2, vec3, utils } from "pex-math";
import { ray } from "pex-geom";
import interpolateAngle from "interpolate-angle";
import latLonToXyz from "latlon-to-xyz";
import xyzToLatLon from "xyz-to-latlon";
import eventOffset from "mouse-event-offset";
/**
* Camera controls to orbit around a target
*/
class OrbiterControls {
static get DEFAULT_OPTIONS() {
return {
element: document,
easing: 0.1,
zoom: true,
pan: true,
drag: true,
minDistance: 0.01,
maxDistance: Infinity,
minLat: -89.5,
maxLat: 89.5,
minLon: -Infinity,
maxLon: Infinity,
panSlowdown: 4,
zoomSlowdown: 400,
dragSlowdown: 4,
autoUpdate: true,
};
}
get domElement() {
return this.element === document ? this.element.body : this.element;
}
/**
* Create an instance of OrbiterControls
* @param {import("./types.js").OrbiterControlsOptions} opts
*/
constructor(opts) {
// Internals
// Set initially by .set
this.lat = null; // Y
this.lon = null; // XZ
this.currentLat = null;
this.currentLon = null;
this.distance = null;
this.currentDistance = null;
// Updated by user interaction
this.panning = false;
this.dragging = false;
this.zooming = false;
this.width = 0;
this.height = 0;
this.zoomTouchDistance = null;
this.panPlane = null;
this.clickTarget = [0, 0, 0];
this.clickPosWorld = [0, 0, 0];
this.clickPosPlane = [0, 0, 0];
this.dragPos = [0, 0, 0];
this.dragPosWorld = [0, 0, 0];
this.dragPosPlane = [0, 0, 0];
// TODO: add ability to set lat/lng instead of position/target
this.set({
...OrbiterControls.DEFAULT_OPTIONS,
...opts,
});
this.setup();
}
/**
* Update the control
* @param {import("./types.js").OrbiterOptions} opts
*/
set(opts) {
Object.assign(this, opts);
if (opts.camera) {
const latLon = xyzToLatLon(
vec3.normalize(
vec3.sub(vec3.copy(opts.camera.position), opts.camera.target),
),
);
const distance =
opts.distance ||
vec3.distance(opts.camera.position, opts.camera.target);
this.lat = latLon[0];
this.lon = latLon[1];
this.currentLat = this.lat;
this.currentLon = this.lon;
this.distance = distance;
this.currentDistance = this.distance;
}
if (Object.getOwnPropertyDescriptor(opts, "autoUpdate")) {
if (this.autoUpdate) {
const self = this;
this.rafHandle = requestAnimationFrame(function tick() {
self.updateCamera();
if (self.autoUpdate) self.rafHandle = requestAnimationFrame(tick);
});
} else if (this.rafHandle) {
cancelAnimationFrame(this.rafHandle);
}
}
}
updateCamera() {
// instad of rotating the object we want to move camera around it
if (!this.camera) return;
const position = this.camera.position;
const target = this.camera.target;
this.lat = utils.clamp(this.lat, this.minLat, this.maxLat);
if (this.minLon !== -Infinity && this.maxLon !== Infinity) {
this.lon = utils.clamp(this.lon, this.minLon, this.maxLon) % 360;
}
this.currentLat = utils.toDegrees(
interpolateAngle(
(utils.toRadians(this.currentLat) + 2 * Math.PI) % (2 * Math.PI),
(utils.toRadians(this.lat) + 2 * Math.PI) % (2 * Math.PI),
this.easing,
),
);
this.currentLon += (this.lon - this.currentLon) * this.easing;
this.currentDistance = utils.lerp(
this.currentDistance,
this.distance,
this.easing,
);
// Set position from lat/lon
latLonToXyz(this.currentLat, this.currentLon, position);
// Move position according to distance and target
vec3.scale(position, this.currentDistance);
vec3.add(position, target);
if (this.camera.zoom !== undefined) {
this.camera.set({ zoom: vec3.length(position) });
}
this.camera.set({ position });
}
updateWindowSize() {
const width = this.domElement.clientWidth || this.domElement.innerWidth;
const height = this.domElement.clientHeight || this.domElement.innerHeight;
if (width !== this.width) this.width = width;
if (height !== this.height) this.height = height;
}
handleDragStart(position) {
this.dragging = true;
this.dragPos = position;
}
handlePanZoomStart(touch0, touch1) {
this.dragging = false;
if (this.zoom && touch1) {
this.zooming = true;
this.zoomTouchDistance = vec2.distance(touch1, touch0);
}
const camera = this.camera;
if (this.pan && camera) {
this.panning = true;
this.updateWindowSize();
// TODO: use dragPos?
const clickPosWindow = touch1
? [(touch0[0] + touch1[0]) * 0.5, (touch0[1] + touch1[1]) * 0.5]
: touch0;
vec3.set(this.clickTarget, camera.target);
const targetInViewSpace = vec3.multMat4(
vec3.copy(this.clickTarget),
camera.viewMatrix,
);
this.panPlane = [targetInViewSpace, [0, 0, 1]];
ray.hitTestPlane(
camera.getViewRay(
clickPosWindow[0],
clickPosWindow[1],
this.width,
this.height,
),
this.panPlane,
this.clickPosPlane,
);
}
}
handleDragMove(position) {
const dx = position[0] - this.dragPos[0];
const dy = position[1] - this.dragPos[1];
this.lat += dy / this.dragSlowdown;
this.lon -= dx / this.dragSlowdown;
this.dragPos = position;
}
handlePanZoomMove(touch0, touch1) {
if (this.zoom && touch1) {
const distance = vec2.distance(touch1, touch0);
this.handleZoom(this.zoomTouchDistance - distance);
this.zoomTouchDistance = distance;
}
const camera = this.camera;
if (this.pan && camera && this.panPlane) {
const dragPosWindow = touch1
? [(touch0[0] + touch1[0]) * 0.5, (touch0[1] + touch1[1]) * 0.5]
: touch0;
ray.hitTestPlane(
camera.getViewRay(
dragPosWindow[0],
dragPosWindow[1],
this.width,
this.height,
),
this.panPlane,
this.dragPosPlane,
);
vec3.multMat4(
vec3.set(this.clickPosWorld, this.clickPosPlane),
camera.invViewMatrix,
);
vec3.multMat4(
vec3.set(this.dragPosWorld, this.dragPosPlane),
camera.invViewMatrix,
);
const diffWorld = vec3.sub(
vec3.copy(this.dragPosWorld),
this.clickPosWorld,
);
camera.set({
distance: this.distance,
target: vec3.sub(vec3.copy(this.clickTarget), diffWorld),
});
}
}
handleZoom(dy) {
this.distance *= 1 + dy / this.zoomSlowdown;
this.distance = utils.clamp(
this.distance,
this.minDistance,
this.maxDistance,
);
}
handleEnd() {
this.dragging = false;
this.panning = false;
this.zooming = false;
this.panPlane = null;
}
setup() {
this.onPointerDown = (event) => {
const pan =
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
(event.touches && event.touches.length === 2);
const touch0 = eventOffset(
event.touches ? event.touches[0] : event,
this.domElement,
);
if (this.drag && !pan) {
this.handleDragStart(touch0);
} else if ((this.pan || this.zoom) && pan) {
const touch1 =
event.touches && eventOffset(event.touches[1], this.domElement);
this.handlePanZoomStart(touch0, touch1);
}
};
this.onPointerMove = (event) => {
const touch0 = eventOffset(
event.touches ? event.touches[0] : event,
this.domElement,
);
if (this.dragging) {
this.handleDragMove(touch0);
} else if (this.panning || this.zooming) {
if (event.touches && !event.touches[1]) return;
const touch1 =
event.touches && eventOffset(event.touches[1], this.domElement);
this.handlePanZoomMove(touch0, touch1);
}
};
this.onPointerUp = () => {
this.handleEnd();
};
this.onTouchStart = (event) => {
event.preventDefault();
if (event.touches.length <= 2) this.onPointerDown(event);
};
this.onTouchMove = (event) => {
!!event.cancelable && event.preventDefault();
if (event.touches.length <= 2) this.onPointerMove(event);
};
this.onWheel = (event) => {
if (!this.zoom) return;
event.preventDefault();
this.handleZoom(event.deltaY);
};
this.element.addEventListener("pointerdown", this.onPointerDown);
this.element.addEventListener("wheel", this.onWheel, { passive: false });
document.addEventListener("pointermove", this.onPointerMove);
document.addEventListener("pointerup", this.onPointerUp);
this.domElement.style.touchAction = "none";
}
/**
* Remove all event listeners
*/
dispose() {
if (this.rafHandle) cancelAnimationFrame(this.rafHandle);
this.element.removeEventListener("pointerdown", this.onPointerDown);
this.element.removeEventListener("wheel", this.onWheel);
document.removeEventListener("pointermove", this.onPointerMove);
document.removeEventListener("pointerup", this.onPointerUp);
}
}
export default OrbiterControls;