-
Notifications
You must be signed in to change notification settings - Fork 28
/
PerspectiveCamera2.ts
630 lines (542 loc) · 23 KB
/
PerspectiveCamera2.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
import {Camera, Event, IUniform, Object3D, PerspectiveCamera, Vector3} from 'three'
import {generateUiConfig, uiInput, UiObjectConfig, uiSlider, uiToggle, uiVector} from 'uiconfig.js'
import {onChange, onChange2, onChange3, serialize} from 'ts-browser-helpers'
import type {ICamera, ICameraEvent, ICameraUserData, TCameraControlsMode} from '../ICamera'
import {ICameraSetDirtyOptions} from '../ICamera'
import type {ICameraControls, TControlsCtor} from './ICameraControls'
import {OrbitControls3} from '../../three/controls/OrbitControls3'
import {IObject3D} from '../IObject'
import {ThreeSerialization} from '../../utils'
import {iCameraCommons} from '../object/iCameraCommons'
import {bindToValue} from '../../three/utils/decorators'
import {makeICameraCommonUiConfig} from '../object/IObjectUi'
import {CameraView, ICameraView} from './CameraView'
// todo: maybe change domElement to some wrapper/base class of viewer
export class PerspectiveCamera2 extends PerspectiveCamera implements ICamera {
assetType = 'camera' as const
get controls(): ICameraControls | undefined {
return this._controls
}
@uiInput('Name') name: string
@serialize('camControls')
private _controls?: ICameraControls
private _currentControlsMode: TCameraControlsMode = ''
@onChange2(PerspectiveCamera2.prototype.refreshCameraControls)
controlsMode: TCameraControlsMode
/**
* It should be the canvas actually
* @private
*/
private _canvas?: HTMLCanvasElement
get isMainCamera(): boolean {
return this.userData ? this.userData.__isMainCamera || false : false
}
@serialize()
userData: ICameraUserData = {}
@onChange3(PerspectiveCamera2.prototype.setDirty)
@uiSlider('Field Of View', [1, 180], 0.001)
@serialize() fov: number
@onChange3(PerspectiveCamera2.prototype.setDirty)
@serialize() focus: number
@onChange3(PerspectiveCamera2.prototype.setDirty)
@uiSlider('FoV Zoom', [0.001, 10], 0.001)
@serialize() zoom: number
@uiVector('Position', undefined, undefined, (that:PerspectiveCamera2)=>({onChange: ()=>that.setDirty()}))
@serialize() readonly position: Vector3
/**
* 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
*/
@uiVector('Target', undefined, undefined, (that:PerspectiveCamera2)=>({onChange: ()=>that.setDirty()}))
@serialize() readonly target: Vector3 = new Vector3(0, 0, 0)
/**
* Automatically manage aspect ratio based on window/canvas size.
* Defaults to `true` if {@link domElement}(canvas) is set.
*/
@serialize()
@onChange2(PerspectiveCamera2.prototype.refreshAspect)
@uiToggle('Auto Aspect')
autoAspect: boolean
/**
* 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}
*/
@onChange2(PerspectiveCamera2.prototype._nearFarChanged)
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}
*/
@onChange2(PerspectiveCamera2.prototype._nearFarChanged)
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
*/
@bindToValue({obj: 'userData', onChange: 'setDirty'})
autoLookAtTarget = false // bound to userData so that it's saved in the glb.
/**
* Automatically manage near and far clipping planes based on scene size.
*/
@bindToValue({obj: 'userData', onChange: 'setDirty'})
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
*/
@bindToValue({obj: 'userData', onChange: 'setDirty'})
minNearPlane = 0.5
/**
* Maximum far clipping plane allowed. (Distance from camera)
* Used in RootScene when {@link autoNearFar} is true.
*/
@bindToValue({obj: 'userData', onChange: 'setDirty'})
maxFarPlane = 1000
constructor(controlsMode?: TCameraControlsMode, domElement?: HTMLCanvasElement, autoAspect?: boolean, fov?: number, aspect?: number) {
super(fov, aspect)
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
}
// @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)
// }
// }
private _interactionsDisabledBy = new Set<string>()
/**
* 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(): boolean {
return this._interactionsDisabledBy.size === 0
}
setInteractions(enabled: boolean, by: string): void {
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?: ICameraSetDirtyOptions|Event): void {
if (!this._positionWorld) return // class not initialized
if (options?.key === 'fov' || options?.key === 'zoom') this.updateProjectionMatrix()
this.getWorldPosition(this._positionWorld)
iCameraCommons.setDirty.call(this, options)
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): void {
if (this.autoAspect) {
if (!this._canvas) console.error('cannot calculate aspect ratio without canvas/container')
else {
this.aspect = this._canvas.clientWidth / this._canvas.clientHeight
this.updateProjectionMatrix?.()
}
}
if (setDirty) this.setDirty()
// console.log('refreshAspect', this._options.aspect)
}
protected _nearFarChanged() {
if (this.view === undefined) return // not initialized yet
this.updateProjectionMatrix?.()
}
refreshUi = iCameraCommons.refreshUi
refreshTarget = iCameraCommons.refreshTarget
activateMain = iCameraCommons.activateMain
deactivateMain = iCameraCommons.deactivateMain
// endregion
// region controls
// todo: move orbit to a plugin maybe? so that its not forced
private _controlsCtors = new Map<string, TControlsCtor>([['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
}]])
setControlsCtor(key: string, ctor: TControlsCtor, replace = false): void {
if (!replace && this._controlsCtors.has(key)) {
console.error(key + ' already exists.')
return
}
this._controlsCtors.set(key, ctor)
}
removeControlsCtor(key: string): void {
this._controlsCtors.delete(key)
}
private _controlsChanged = ()=>{
if (this._controls && this._controls.target) this.refreshTarget(undefined, false)
this.setDirty({change: 'controls'})
}
private _initCameraControls() {
const mode = this.controlsMode
this._controls = this._controlsCtors.get(mode)?.(this, this._canvas) ?? undefined
if (!this._controls && mode !== '') console.error('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
}
private _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): void {
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?: any, baseOnly = false): any {
if (baseOnly) return super.toJSON(meta)
// todo add camOptions for backwards compatibility?
return ThreeSerialization.Serialize(this, meta, true)
}
fromJSON(data: any, meta?: any): this | null {
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<T extends ICameraView = CameraView>(worldSpace = true, _view?: T) {
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 as T
}
setView(view: ICameraView) {
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: Camera|ICamera, distanceFromTarget?: number, 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: Partial<ICameraEvent>) {
this.dispatchEvent({type: 'setView', ...eventOptions, camera: this, bubbleToParent: true})
}
// endregion
// region utils/others
// for shader prop updater
private _positionWorld = new Vector3()
/**
* See also cameraHelpers.glsl
* @param material
*/
updateShaderProperties(material: {defines: Record<string, string | number | undefined>; uniforms: {[p: string]: IUniform}}): this {
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(): void {
this._disposeCameraControls()
// todo: anything else?
// iObjectCommons.dispose and dispatch event dispose is called automatically because of updateObject3d
}
// endregion
// region ui
private _camUi: UiObjectConfig[] = [
...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'],
},
()=>({ // because _controlsCtors can change
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),
]
uiConfig: UiObjectConfig = {
type: 'folder',
label: ()=>this.name || 'Camera',
children: [
...this._camUi,
// todo hack for zoom in and out for now.
()=>(this._controls as OrbitControls3)?.zoomIn ? {
type: 'button',
label: 'Zoom in',
value: ()=> (this._controls as OrbitControls3)?.zoomIn(1),
} : {},
()=>(this._controls as OrbitControls3)?.zoomOut ? {
type: 'button',
label: 'Zoom out',
value: ()=> (this._controls as OrbitControls3)?.zoomOut(1),
} : {},
()=>this._controls?.uiConfig,
],
}
// endregion
// region deprecated/old
@onChange((k: string, v: boolean)=>{
if (!v) console.warn('Setting camera invisible is not supported', k, v)
})
visible: boolean
get isActiveCamera(): boolean {
return this.isMainCamera
}
/**
* @deprecated use `<T>camera.controls` instead
*/
getControls<T extends ICameraControls>(): T|undefined {
return this._controls as any as T
}
/**
* @deprecated use `this` instead
*/
get cameraObject(): this {
return this
}
/**
* @deprecated use `this` instead
*/
get modelObject(): this {
return this
}
/**
* @deprecated - use setDirty directly
* @param setDirty
*/
targetUpdated(setDirty = true): void {
if (setDirty) this.setDirty()
}
// setCameraOptions<T extends Partial<IPerspectiveCameraOptions | IOrthographicCameraOptions>>(value: T, setDirty = true): void {
// const ops: any = {...value}
//
// this._refreshCameraOptions(false)
// this.refreshCameraControls(false)
// if (setDirty) this.setDirty()
// }
// not to be used
// private _changeType(setDirty = true) {
// // let cam = this._camera.modelObject
//
// // change of type, not supported now.
// // if (this._options.type !== cam.type) {
// // const cam2 = this._options.type === 'PerspectiveCamera' ? new PerspectiveCamera() : new OrthographicCamera()
// // cam2.name = this._camera.name
// // cam2.near = this._camera.modelObject.near
// // cam2.far = this._camera.modelObject.far
// // cam2.zoom = this._camera.modelObject.zoom
// // cam2.scale.copy(this._camera.modelObject.scale)
// //
// // const isActive = this._isMainCamera
// // if (isActive) this.deactivateMain()
// // this._camera = this._setCameraObject(cam2)
// // cam = this._camera.modelObject
// // if (isActive) this.activateMain()
// // this._camera.modelObject.updateProjectionMatrix()
// // }
//
// // this._nearFarChanged() // this updates projection matrix todo: move to setDirty
//
// if (setDirty) this.setDirty()
// }
// private _cameraObjectUpdate = (e: any)=>{
// this.setDirty(e)
// }
// private _setCameraObject(cam: OrthographicCamera | PerspectiveCamera) {
// if (this._camera) this._camera.removeEventListener('objectUpdate', this._cameraObjectUpdate)
// this._camera = setupIModel(cam as any)
// this._camera.addEventListener('objectUpdate', this._cameraObjectUpdate)
// return this._camera
// }
// for ortho
// private _frustumSize: number | undefined = undefined
//
// get frustumSize(): number | undefined {
// return this._frustumSize
// }
//
// set frustumSize(value: number | undefined) {
// this._frustumSize = value
// if (value !== undefined) {
// cam.top = value / 2
// cam.bottom = -value / 2
// cam.left = aspect * value / 2
// cam.right = -aspect * value / 2
// }
// this.setDirty()
// }
// endregion
// region inherited type fixes
// re-declaring from IObject3D because: https://github.com/microsoft/TypeScript/issues/16936
traverse: (callback: (object: IObject3D) => void) => void
traverseVisible: (callback: (object: IObject3D) => void) => void
traverseAncestors: (callback: (object: IObject3D) => void) => void
getObjectById: <T extends IObject3D = IObject3D>(id: number) => T | undefined
getObjectByName: <T extends IObject3D = IObject3D>(name: string) => T | undefined
getObjectByProperty: <T extends IObject3D = IObject3D>(name: string, value: string) => T | undefined
copy: (source: ICamera|Camera|IObject3D, recursive?: boolean, distanceFromTarget?: number, worldSpace?: boolean) => this
clone: (recursive?: boolean) => this
add: (...object: IObject3D[]) => this
remove: (...object: IObject3D[]) => this
dispatchEvent: (event: ICameraEvent) => void
parent: IObject3D | null
children: IObject3D[]
// endregion
}
/**
* 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?: number, aspect?: number, near?: number, far?: number) {
super(undefined, undefined, undefined, fov, aspect || 1)
if (near || far) {
this.autoNearFar = false
if (near) {
this.near = near
this.minNearPlane = near
}
if (far) {
this.far = far
this.maxFarPlane = far
}
}
}
}