threepipe
Version:
A 3D viewer framework built on top of three.js in TypeScript with a focus on quality rendering, modularity and extensibility.
534 lines • 21.9 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, 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 } from '../object/IObjectUi';
import { CameraView } from './CameraView';
// todo: maybe change domElement to some wrapper/base class of viewer
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 trye 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();
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 controls = new OrbitControls3(object, domElement ? !domElement.ownerDocument ? domElement.documentElement : domElement : document.body);
// this._controls.enabled = false
// this._controls.listenToKeyEvents(window as any) // optional // todo: this breaks keyboard events in UI like cursor left/right, make option for this
// 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: '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),
];
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) {
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
if (!options?.key || options?.key === 'fov' || options?.key === 'zoom')
this.updateProjectionMatrix();
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('PerspectiveCamera2: 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.updateProjectionMatrix?.();
}
}
if (setDirty)
this.setDirty();
// console.log('refreshAspect', this._options.aspect)
}
_nearFarChanged() {
if (this.view === undefined)
return; // not initialized yet
this.updateProjectionMatrix?.();
}
setControlsCtor(key, ctor, replace = false) {
if (!replace && this._controlsCtors.has(key)) {
console.error(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) { // 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);
// todo add camOptions for backwards compatibility?
return ThreeSerialization.Serialize(this, meta, true);
}
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.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 === 'PerspectiveCamera' ? '1' : '0';
// material.defines.ORTHOGRAPHIC_CAMERA = this.type === 'OrthographicCamera' ? '1' : '0' // todo
return this;
}
dispose() {
this._disposeCameraControls();
// todo: anything else?
// iObjectCommons.dispose and dispatch event dispose is called automatically because of updateObject3d
}
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),
uiSlider('FoV Zoom', [0.001, 10], 0.001),
serialize()
], PerspectiveCamera2.prototype, "zoom", void 0);
__decorate([
uiVector('Position', undefined, undefined, (that) => ({ onChange: () => that.setDirty() })),
serialize()
], PerspectiveCamera2.prototype, "position", void 0);
__decorate([
uiVector('Target', undefined, undefined, (that) => ({ onChange: () => that.setDirty() })),
serialize()
], PerspectiveCamera2.prototype, "target", void 0);
__decorate([
serialize(),
onChange2(PerspectiveCamera2.prototype.refreshAspect),
uiToggle('Auto Aspect')
], PerspectiveCamera2.prototype, "autoAspect", 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