threepipe
Version:
A 3D viewer framework built on top of three.js in TypeScript with a focus on quality rendering, modularity and extensibility.
514 lines • 21.2 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { Object3D, OrthographicCamera, Vector3 } from 'three';
import { generateUiConfig, uiInput, uiNumber, uiToggle, uiVector } from 'uiconfig.js';
import { onChange, onChange2, onChange3, serialize } from 'ts-browser-helpers';
import { OrbitControls3 } from '../../three/controls/OrbitControls3';
import { ThreeSerialization } from '../../utils';
import { iCameraCommons } from '../object/iCameraCommons';
import { bindToValue } from '../../three/utils/decorators';
import { makeICameraCommonUiConfig } from '../object/IObjectUi';
import { CameraView } from './CameraView';
// todo: extract out common functions with perspective camera into iCameraCommons
// todo: maybe change domElement to some wrapper/base class of viewer
export class OrthographicCamera2 extends OrthographicCamera {
get controls() {
return this._controls;
}
get isMainCamera() {
return this.userData ? this.userData.__isMainCamera || false : false;
}
/**
* Frustum size of the camera. This is used to calculate bounds (left, right, top, bottom) based on aspect ratio.
* Set to 0 (or negative) value to disable automatic, and to set the bounds manually.
*/
get frustumSize() {
return this._frustumSize ?? 0;
}
set frustumSize(value) {
this._frustumSize = value <= 0 ? undefined : value;
this.refreshFrustum(false);
this.setDirty();
}
constructor(controlsMode, domElement, autoAspect, frustumSize, left, right, top, bottom, near, far, aspect) {
super(left, right, top, bottom, near, far);
this.assetType = 'camera';
this._currentControlsMode = '';
this.userData = {};
this._frustumSize = undefined;
/**
* The target position of the camera (where the camera looks at). Also syncs with the controls.target, so it's not required to set that separately.
* Note: this is always in world-space
* Note: {@link autoLookAtTarget} must be set to `true` to make the camera look at the target when no controls are enabled
*/
this.target = new Vector3(0, 0, 0);
/**
* Near clipping plane.
* This is managed by RootScene for active cameras
* To change the minimum that's possible set {@link minNearPlane}
* To use a fixed value set {@link autoNearFar} to false and set {@link minNearPlane}
*/
this.near = 0.01;
/**
* Far clipping plane.
* This is managed by RootScene for active cameras
* To change the maximum that's possible set {@link maxFarPlane}
* To use a fixed value set {@link autoNearFar} to false and set {@link maxFarPlane}
*/
this.far = 50;
/**
* Automatically make the camera look at the {@link target} on {@link setDirty} call
* Defaults to false. Note that this must be set to true to make the camera look at the target without any controls
*/
this.autoLookAtTarget = false; // bound to userData so that it's saved in the glb.
/**
* Automatically manage near and far clipping planes based on scene size.
*/
this.autoNearFar = true; // bound to userData so that it's saved in the glb.
/**
* Minimum near clipping plane allowed. (Distance from camera)
* Used in RootScene when {@link autoNearFar} is true.
* @default 0.2
*/
this.minNearPlane = 0.5;
/**
* Maximum far clipping plane allowed. (Distance from camera)
* Used in RootScene when {@link autoNearFar} is true.
*/
this.maxFarPlane = 1000;
this._interactionsDisabledBy = new Set();
this.refreshUi = iCameraCommons.refreshUi;
this.refreshTarget = iCameraCommons.refreshTarget;
this.activateMain = iCameraCommons.activateMain;
this.deactivateMain = iCameraCommons.deactivateMain;
// endregion
// region controls
// todo: move orbit to a plugin maybe? so that its not forced
this._controlsCtors = new Map([['orbit', (object, domElement) => {
const elem = domElement ? !domElement.ownerDocument ? domElement.documentElement : domElement : document.body;
const controls = new OrbitControls3(object, elem);
// this._controls.enabled = false
// set tab index so that we get keyboard events
if (elem.tabIndex === -1) {
elem.tabIndex = 1000;
// disable focus outline
elem.style.outline = 'none';
}
controls.listenToKeyEvents(elem); // optional // todo: make option for this
// controls.enableKeys = true
controls.screenSpacePanning = true;
return controls;
}]]);
this._controlsChanged = () => {
if (this._controls && this._controls.target)
this.refreshTarget(undefined, false);
this.setDirty({ change: 'controls' });
};
// endregion
// region utils/others
// for shader prop updater
this._positionWorld = new Vector3();
// endregion
// region ui
this._camUi = [
...generateUiConfig(this) || [],
{
type: 'input',
label: () => (this.autoNearFar ? 'Min' : '') + ' Near',
property: [this, 'minNearPlane'],
},
{
type: 'input',
label: () => (this.autoNearFar ? 'Max' : '') + ' Far',
property: [this, 'maxFarPlane'],
},
() => ({
type: 'dropdown',
label: 'Controls Mode',
property: [this, 'controlsMode'],
children: ['', 'orbit', ...this._controlsCtors.keys()].map(v => ({ label: v === '' ? 'none' : v, value: v })),
onChange: () => this.refreshCameraControls(),
}),
() => makeICameraCommonUiConfig.call(this, this.uiConfig),
];
this.uiConfig = {
type: 'folder',
label: () => this.name || 'Camera',
children: [
...this._camUi,
() => this._controls?.uiConfig,
],
};
this._canvas = domElement;
this.aspect = aspect || 1;
this._frustumSize = frustumSize ?? 4;
this.autoAspect = autoAspect ?? !!domElement;
iCameraCommons.upgradeCamera.call(this); // todo: test if autoUpgrade = false works as expected if we call upgradeObject3D externally after constructor, because we have setDirty, refreshTarget below.
this.controlsMode = controlsMode || '';
this.refreshTarget(undefined, false);
this.refreshFrustum(false);
// if (!camera)
// this.targetUpdated(false)
this.setDirty();
// if (domElement)
// domElement.style.touchAction = 'none' // this is done in orbit controls anyway
// this.refreshCameraControls() // this is done on set controlsMode
// const target = this.target
}
/**
* If interactions are enabled for this camera. It can be disabled by some code or plugin.
* see also {@link setInteractions}
* @deprecated use {@link canUserInteract} to check if the user can interact with this camera
* @readonly
*/
get interactionsEnabled() {
return this._interactionsDisabledBy.size === 0;
}
setInteractions(enabled, by) {
const size = this._interactionsDisabledBy.size;
if (enabled) {
this._interactionsDisabledBy.delete(by);
}
else {
this._interactionsDisabledBy.add(by);
}
if (size !== this._interactionsDisabledBy.size)
this.refreshCameraControls(true);
}
get canUserInteract() {
return this._interactionsDisabledBy.size === 0 && this.isMainCamera && this.controlsMode !== '';
}
// endregion
// region refreshing
setDirty(options) {
if (!this._positionWorld)
return; // class not initialized
// noinspection SuspiciousTypeOfGuard it can be string when called from bindToValue
const changeKey = typeof options === 'string' ? options : options?.key;
if (!changeKey || ['zoom', 'left', 'right', 'top', 'bottom', 'aspect', 'frustumSize'].includes(changeKey)) {
this.updateProjectionMatrix();
}
if (typeof options === 'string')
options = undefined;
this.getWorldPosition(this._positionWorld);
iCameraCommons.setDirty.call(this, options);
if (options?.last !== false)
this._camUi.forEach(u => u?.uiRefresh?.(false, 'postFrame', 1)); // because camera changes a lot. so we dont want to deep refresh ui on every change
}
/**
* when aspect ratio is set to auto it must be refreshed on resize, this is done by the viewer for the main camera.
* @param setDirty
*/
refreshAspect(setDirty = true) {
if (this.autoAspect) {
if (!this._canvas)
console.error('OrthographicCamera2: cannot calculate aspect ratio without canvas/container');
else {
let aspect = this._canvas.clientWidth / this._canvas.clientHeight;
if (!isFinite(aspect))
aspect = 1;
this.aspect = aspect;
this.refreshFrustum(false);
}
}
if (setDirty)
this.setDirty();
// console.log('refreshAspect', this._options.aspect)
}
_nearFarChanged() {
if (this.view === undefined)
return; // not initialized yet
this.updateProjectionMatrix && this.updateProjectionMatrix();
}
refreshFrustum(setDirty = true) {
if (this._frustumSize === undefined)
return;
this.top = this._frustumSize / 2;
this.bottom = -this.top;
this.left = this.bottom * this.aspect;
this.right = this.top * this.aspect;
setDirty && this.setDirty();
}
setControlsCtor(key, ctor, replace = false) {
if (!replace && this._controlsCtors.has(key)) {
console.error('OrthographicCamera2: ' + key + ' already exists.');
return;
}
this._controlsCtors.set(key, ctor);
}
removeControlsCtor(key) {
this._controlsCtors.delete(key);
}
_initCameraControls() {
const mode = this.controlsMode;
this._controls = this._controlsCtors.get(mode)?.(this, this._canvas) ?? undefined;
if (!this._controls && mode !== '')
console.error('OrthographicCamera2 - Unable to create controls with mode ' + mode + '. Are you missing a plugin?');
this._controls?.addEventListener('change', this._controlsChanged);
this._currentControlsMode = this._controls ? mode : '';
// todo maybe set target like this:
// if (this._controls) this._controls.target = this.target
}
_disposeCameraControls() {
if (this._controls) {
if (this._controls.target === this.target)
this._controls.target = new Vector3(); // just in case
this._controls?.removeEventListener('change', this._controlsChanged);
this._controls?.dispose();
}
this._currentControlsMode = '';
this._controls = undefined;
}
refreshCameraControls(setDirty = true) {
if (!this._controlsCtors)
return; // class not initialized
if (this._controls) {
if (this._currentControlsMode !== this.controlsMode ||
this !== this._controls.object ||
this._controls.domElement && this._canvas !== this._controls.domElement) { // in-case camera changed or mode changed
this._disposeCameraControls();
this._initCameraControls();
}
}
else {
this._initCameraControls();
}
// todo: only for orbit control like controls?
if (this._controls) {
const ce = this.canUserInteract;
this._controls.enabled = ce;
if (ce)
this.up.copy(Object3D.DEFAULT_UP);
}
if (setDirty)
this.setDirty();
this.refreshUi();
}
// endregion
// region serialization
/**
* Serializes this camera with controls to JSON.
* @param meta - metadata for serialization
* @param baseOnly - Calls only super.toJSON, does internal three.js serialization. Set it to true only if you know what you are doing.
*/
toJSON(meta, baseOnly = false) {
if (baseOnly)
return super.toJSON(meta);
return ThreeSerialization.Serialize(this, meta, true);
}
fromJSON(data, meta) {
ThreeSerialization.Deserialize(data, this, meta, true);
this.setDirty({ change: 'deserialize' });
return this;
}
// endregion
// region camera views
getView(worldSpace = true, _view) {
const up = new Vector3();
this.updateWorldMatrix(true, false);
const matrix = this.matrixWorld;
up.x = matrix.elements[4];
up.y = matrix.elements[5];
up.z = matrix.elements[6];
up.normalize();
const view = _view || new CameraView();
view.name = this.name;
view.position.copy(this.position);
view.target.copy(this.target);
view.quaternion.copy(this.quaternion);
view.zoom = this.zoom;
// view.up.copy(up)
const parent = this.parent;
if (parent) {
if (worldSpace) {
view.position.applyMatrix4(parent.matrixWorld);
this.getWorldQuaternion(view.quaternion);
// target, up is already in world space
}
else {
up.transformDirection(parent.matrixWorld.clone().invert());
// pos is already in local space
// target should always be in world space
}
}
view.isWorldSpace = worldSpace;
view.uiConfig?.uiRefresh?.(true, 'postFrame');
return view;
}
setView(view) {
this.position.copy(view.position);
this.target.copy(view.target);
// this.up.copy(view.up)
this.quaternion.copy(view.quaternion);
this.zoom = view.zoom;
this.setDirty();
}
setViewFromCamera(camera, distanceFromTarget, worldSpace = true) {
// todo: getView, setView can also be used, do we need copy? as that will copy all the properties
this.copy(camera, undefined, distanceFromTarget, worldSpace);
}
setViewToMain(eventOptions) {
this.dispatchEvent({ type: 'setView', ...eventOptions, camera: this, bubbleToParent: true });
}
/**
* See also cameraHelpers.glsl
* @param material
*/
updateShaderProperties(material) {
material.uniforms.cameraPositionWorld?.value?.copy(this._positionWorld);
material.uniforms.cameraNearFar?.value?.set(this.near, this.far);
if (material.uniforms.projection)
material.uniforms.projection.value = this.projectionMatrix; // todo: rename to projectionMatrix2?
material.defines.PERSPECTIVE_CAMERA = this.type === 'OrthographicCamera' ? '1' : '0';
material.defines.ORTHOGRAPHIC_CAMERA = this.type === 'OrthographicCamera' ? '1' : '0';
return this;
}
dispose() {
this._disposeCameraControls();
// todo: anything else?
// iObjectCommons.dispose and dispatch event dispose is called automatically because of updateObject3d
}
setCanvas(canvas, refresh = true) {
this._canvas = canvas;
if (!refresh)
return;
this.refreshCameraControls();
this.refreshAspect(false);
}
get isActiveCamera() {
return this.isMainCamera;
}
/**
* @deprecated use `<T>camera.controls` instead
*/
getControls() {
return this._controls;
}
/**
* @deprecated use `this` instead
*/
get cameraObject() {
return this;
}
/**
* @deprecated use `this` instead
*/
get modelObject() {
return this;
}
/**
* @deprecated - use setDirty directly
* @param setDirty
*/
targetUpdated(setDirty = true) {
if (setDirty)
this.setDirty();
}
}
__decorate([
uiInput('Name')
], OrthographicCamera2.prototype, "name", void 0);
__decorate([
serialize('camControls')
], OrthographicCamera2.prototype, "_controls", void 0);
__decorate([
onChange2(OrthographicCamera2.prototype.refreshCameraControls)
], OrthographicCamera2.prototype, "controlsMode", void 0);
__decorate([
serialize()
], OrthographicCamera2.prototype, "userData", void 0);
__decorate([
onChange3(OrthographicCamera2.prototype.setDirty),
uiNumber('Zoom'),
serialize()
], OrthographicCamera2.prototype, "zoom", void 0);
__decorate([
onChange3(OrthographicCamera2.prototype.setDirty),
uiNumber('Left', (t) => ({ hidden: () => t._frustumSize !== undefined })),
serialize()
], OrthographicCamera2.prototype, "left", void 0);
__decorate([
onChange3(OrthographicCamera2.prototype.setDirty),
uiNumber('Right', (t) => ({ hidden: () => t._frustumSize !== undefined })),
serialize()
], OrthographicCamera2.prototype, "right", void 0);
__decorate([
onChange3(OrthographicCamera2.prototype.setDirty),
uiNumber('Top', (t) => ({ hidden: () => t._frustumSize !== undefined })),
serialize()
], OrthographicCamera2.prototype, "top", void 0);
__decorate([
onChange3(OrthographicCamera2.prototype.setDirty),
uiNumber('Bottom', (t) => ({ hidden: () => t._frustumSize !== undefined })),
serialize()
], OrthographicCamera2.prototype, "bottom", void 0);
__decorate([
uiInput('Frustum Size' /* , (t)=>({hidden: ()=>t.frustumSize === undefined})*/)
], OrthographicCamera2.prototype, "frustumSize", null);
__decorate([
uiVector('Position', undefined, undefined, (that) => ({ onChange: () => that.setDirty() })),
serialize()
], OrthographicCamera2.prototype, "position", void 0);
__decorate([
uiVector('Target', undefined, undefined, (that) => ({ onChange: () => that.setDirty() })),
serialize()
], OrthographicCamera2.prototype, "target", void 0);
__decorate([
serialize(),
onChange2(OrthographicCamera2.prototype.refreshAspect),
uiToggle('Auto Aspect')
], OrthographicCamera2.prototype, "autoAspect", void 0);
__decorate([
serialize(),
onChange2(OrthographicCamera2.prototype.refreshAspect),
uiToggle('Aspect Ratio', (t) => ({ disabled: () => t.autoAspect }))
], OrthographicCamera2.prototype, "aspect", void 0);
__decorate([
onChange2(OrthographicCamera2.prototype._nearFarChanged)
], OrthographicCamera2.prototype, "near", void 0);
__decorate([
onChange2(OrthographicCamera2.prototype._nearFarChanged)
], OrthographicCamera2.prototype, "far", void 0);
__decorate([
bindToValue({ obj: 'userData', onChange: 'setDirty' })
], OrthographicCamera2.prototype, "autoLookAtTarget", void 0);
__decorate([
bindToValue({ obj: 'userData', onChange: 'setDirty' })
], OrthographicCamera2.prototype, "autoNearFar", void 0);
__decorate([
bindToValue({ obj: 'userData', onChange: 'setDirty' })
], OrthographicCamera2.prototype, "minNearPlane", void 0);
__decorate([
bindToValue({ obj: 'userData', onChange: 'setDirty' })
], OrthographicCamera2.prototype, "maxFarPlane", void 0);
__decorate([
onChange((k, v) => {
if (!v)
console.warn('Setting camera invisible is not supported', k, v);
})
], OrthographicCamera2.prototype, "visible", void 0);
/**
* Empty class with the constructor same as OrthographicCamera in three.js.
* This can be used to remain compatible with three.js construct signature.
*/
export class OrthographicCamera0 extends OrthographicCamera2 {
constructor(left, right, top, bottom, near, far) {
super(undefined, undefined, undefined, undefined, left, right, top, bottom, near, far, 1);
if (near !== undefined || far) {
this.autoNearFar = false;
if (near) {
this.near = near;
this.minNearPlane = near;
}
if (far) {
this.far = far;
this.maxFarPlane = far;
}
}
}
}
//# sourceMappingURL=OrthographicCamera2.js.map