threepipe
Version:
A modern 3D viewer framework built on top of three.js, written in TypeScript, designed to make creating high-quality, modular, and extensible 3D experiences on the web simple and enjoyable.
496 lines • 20.8 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, PerspectiveCamera, Vector3 } from 'three';
import { generateUiConfig, uiInput, uiNumber, uiSlider, 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, objectExtensionsUiConfig } from '../object/IObjectUi';
// todo: maybe change domElement to some wrapper/base class of viewer
/**
* A camera class that extends {@link PerspectiveCamera} with additional features and built-in control support.
*/
export class PerspectiveCamera2 extends PerspectiveCamera {
get controls() {
return this._controls;
}
get isMainCamera() {
return this.userData ? this.userData.__isMainCamera || false : false;
}
constructor(controlsMode, domElement, autoAspect, fov, aspect) {
super(fov, aspect);
this.assetType = 'camera';
this._currentControlsMode = '';
this.userData = {};
/**
* 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;
/**
* Automatically move the camera(dolly) when the field of view(fov) changes.
* Works when controls are enabled or `autoLookAtTarget` is `true`.
*
* Note - this is not exact
*/
this.dollyFov = false; // bound to userData so that it's saved in the glb.
// @serialize('camOptions') //todo handle deserialization of this
// region interactionsEnabled
// private _interactionsEnabled = true
//
// get interactionsEnabled(): boolean {
// return this._interactionsEnabled
// }
//
// set interactionsEnabled(value: boolean) {
// if (this._interactionsEnabled !== value) {
// this._interactionsEnabled = value
// this.refreshCameraControls(true)
// }
// }
this._interactionsDisabledBy = new Set();
/**
* 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
*/
this.refreshAspect = iCameraCommons.refreshAspect;
this.refreshUi = iCameraCommons.refreshUi;
this.refreshTarget = iCameraCommons.refreshTarget;
this.activateMain = iCameraCommons.activateMain;
this.deactivateMain = iCameraCommons.deactivateMain;
// @ts-expect-error ts issue
this.updateShaderProperties = iCameraCommons.updateShaderProperties;
// 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 camera views
this.getView = iCameraCommons.getView;
this.setView = iCameraCommons.setView;
this.setViewFromCamera = iCameraCommons.setViewFromCamera;
this.setViewToMain = iCameraCommons.setViewToMain;
// 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: 'input',
label: 'Auto Near Far',
property: [this, 'autoNearFar'],
},
{
type: 'input',
label: 'Dolly FoV',
property: [this, 'dollyFov'],
},
() => ({
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),
objectExtensionsUiConfig.call(this),
];
this.uiConfig = {
type: 'folder',
label: () => this.name || 'Camera',
children: [
...this._camUi,
// todo hack for zoom in and out for now.
() => this._controls?.zoomIn ? {
type: 'button',
label: 'Zoom in',
value: () => this._controls?.zoomIn(1),
} : {},
() => this._controls?.zoomOut ? {
type: 'button',
label: 'Zoom out',
value: () => this._controls?.zoomOut(1),
} : {},
() => this._controls?.uiConfig,
],
};
this._canvas = domElement;
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);
// if (!camera)
// this.targetUpdated(false)
this.setDirty();
// if (domElement)
// domElement.style.touchAction = 'none' // this is done in orbit controls anyway
// const ae = this._canvas.addEventListener
// todo: this breaks tweakpane UI.
// this._canvas.addEventListener = (type: string, listener: any, options1: any) => { // see https://github.com/mrdoob/three.js/pull/19782
// ae(type, listener, type === 'wheel' && typeof options1 !== 'boolean' ? {
// ...typeof options1 === 'object' ? options1 : {},
// capture: false,
// passive: false,
// } : options1)
// }
// 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, setDirty = true) {
const size = this._interactionsDisabledBy.size;
if (enabled) {
this._interactionsDisabledBy.delete(by);
}
else {
this._interactionsDisabledBy.add(by);
}
if (size !== this._interactionsDisabledBy.size)
this.refreshCameraControls(setDirty);
}
get canUserInteract() {
return this._interactionsDisabledBy.size === 0 && this.isMainCamera && this.controlsMode !== '';
}
// endregion
// region refreshing
setDirty(options) {
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
}
_nearFarChanged() {
if (this.view === undefined)
return; // not initialized yet
this.updateProjectionMatrix && this.updateProjectionMatrix();
}
setControlsCtor(key, ctor, replace = false) {
if (!replace && this._controlsCtors.has(key)) {
console.error('PerspectiveCamera2: ' + 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('PerspectiveCamera2 - 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 _internal - Calls only super.toJSON, does internal three.js serialization and `@serialize` tags. Set it to true only if you know what you are doing. This is used in Serialization->serializer
*/
toJSON(meta, _internal = false) {
if (_internal)
return {
...super.toJSON(meta),
...ThreeSerialization.Serialize(this, meta, true), // this will serialize the properties of this class(like defined with @serialize and @serialize attribute)
};
// todo add camOptions for backwards compatibility?
return ThreeSerialization.Serialize(this, meta, false); // this will call toJSON again, but with _internal=true, that's why we set isThis to false.
}
fromJSON(data, meta) {
if (data.camOptions || data.aspect === 'auto')
data = { ...data };
if (data.camOptions) {
const op = data.camOptions;
if (op.fov)
data.fov = op.fov;
if (op.focus)
data.focus = op.focus;
if (op.zoom)
data.zoom = op.zoom;
if (op.aspect)
data.aspect = op.aspect;
if (op.controlsMode)
data.controlsMode = op.controlsMode;
// todo: add support for this
// if (op.left) data.left = op.left
// if (op.right) data.right = op.right
// if (op.top) data.top = op.top
// if (op.bottom) data.bottom = op.bottom
// if (op.frustumSize) data.frustumSize = op.frustumSize
// if (op.controlsEnabled) data.controlsEnabled = op.controlsEnabled
delete data.camOptions;
}
if (data.aspect === 'auto') {
data.aspect = this.aspect;
this.autoAspect = true;
}
// if (data.cameraObject) this._camera.fromJSON(data.cameraObject)
// todo: add check for OrbitControls being not deserialized(inited properly) if it doesn't exist yet (if it is not inited properly)
// console.log(JSON.parse(JSON.stringify(data)))
ThreeSerialization.Deserialize(data, this, meta, true);
this.refreshAspect(false);
this.setDirty({ change: 'deserialize' });
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')
], PerspectiveCamera2.prototype, "name", void 0);
__decorate([
serialize('camControls')
], PerspectiveCamera2.prototype, "_controls", void 0);
__decorate([
onChange2(PerspectiveCamera2.prototype.refreshCameraControls)
], PerspectiveCamera2.prototype, "controlsMode", void 0);
__decorate([
serialize()
], PerspectiveCamera2.prototype, "userData", void 0);
__decorate([
onChange3(PerspectiveCamera2.prototype.setDirty),
uiSlider('Field Of View', [1, 180], 0.001),
serialize()
], PerspectiveCamera2.prototype, "fov", void 0);
__decorate([
onChange3(PerspectiveCamera2.prototype.setDirty),
serialize()
], PerspectiveCamera2.prototype, "focus", void 0);
__decorate([
onChange3(PerspectiveCamera2.prototype.setDirty),
uiNumber('FoV Zoom'),
serialize()
], PerspectiveCamera2.prototype, "zoom", void 0);
__decorate([
uiVector('Position', undefined, undefined, (t) => ({ onChange: () => t.setDirty({ change: 'position' }) })),
serialize()
], PerspectiveCamera2.prototype, "position", void 0);
__decorate([
uiVector('Up', undefined, undefined, (t) => ({ onChange: () => t.setDirty({ change: 'up' }) })),
serialize()
], PerspectiveCamera2.prototype, "up", void 0);
__decorate([
uiVector('Rotation', undefined, undefined, (t) => ({ onChange: () => t.setDirty({ change: 'rotation' }), disabled: () => t.autoLookAtTarget }))
/* @serialize()*/
], PerspectiveCamera2.prototype, "rotation", void 0);
__decorate([
uiVector('Target', undefined, undefined, (t) => ({ onChange: () => t.setDirty({ change: 'target' }), disabled: () => !t.autoLookAtTarget })),
serialize()
], PerspectiveCamera2.prototype, "target", void 0);
__decorate([
serialize(),
onChange2('refreshAspect'),
uiToggle('Auto Aspect')
], PerspectiveCamera2.prototype, "autoAspect", void 0);
__decorate([
serialize(),
onChange2('refreshAspect'),
uiNumber('Aspect Ratio', (t) => ({ hidden: () => t.autoAspect }))
], PerspectiveCamera2.prototype, "aspect", void 0);
__decorate([
onChange2(PerspectiveCamera2.prototype._nearFarChanged)
], PerspectiveCamera2.prototype, "near", void 0);
__decorate([
onChange2(PerspectiveCamera2.prototype._nearFarChanged)
], PerspectiveCamera2.prototype, "far", void 0);
__decorate([
bindToValue({ obj: 'userData', onChange: 'setDirty' })
], PerspectiveCamera2.prototype, "autoLookAtTarget", void 0);
__decorate([
bindToValue({ obj: 'userData', onChange: 'setDirty' })
], PerspectiveCamera2.prototype, "autoNearFar", void 0);
__decorate([
bindToValue({ obj: 'userData', onChange: 'setDirty' })
], PerspectiveCamera2.prototype, "minNearPlane", void 0);
__decorate([
bindToValue({ obj: 'userData', onChange: 'setDirty' })
], PerspectiveCamera2.prototype, "maxFarPlane", void 0);
__decorate([
bindToValue({ obj: 'userData' })
], PerspectiveCamera2.prototype, "dollyFov", void 0);
__decorate([
onChange((k, v) => {
if (!v)
console.warn('Setting camera invisible is not supported', k, v);
})
], PerspectiveCamera2.prototype, "visible", void 0);
/**
* Empty class with the constructor same as PerspectiveCamera in three.js.
* This can be used to remain compatible with three.js construct signature.
*/
export class PerspectiveCamera0 extends PerspectiveCamera2 {
constructor(fov, aspect, near, far) {
super(undefined, undefined, undefined, fov, aspect || 1);
this.dollyFov = false;
if (near || far) {
this.autoNearFar = false;
if (near) {
this.near = near;
this.minNearPlane = near;
}
if (far) {
this.far = far;
this.maxFarPlane = far;
}
}
}
}
//# sourceMappingURL=PerspectiveCamera2.js.map