phoria2 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -13
- package/dist/bloom.js +1 -1
- package/dist/charts.js +397 -384
- package/dist/charts.js.map +1 -1
- package/dist/core.js +4451 -3568
- package/dist/core.js.map +1 -1
- package/dist/edit.js +67 -67
- package/dist/edit.js.map +1 -1
- package/dist/explain.js +6 -2
- package/dist/explain.js.map +1 -1
- package/dist/gltf.js +211 -166
- package/dist/gltf.js.map +1 -1
- package/dist/motion.js +26 -26
- package/dist/motion.js.map +1 -1
- package/dist/particles.js +49 -49
- package/dist/particles.js.map +1 -1
- package/dist/passes.js +188 -86
- package/dist/passes.js.map +1 -1
- package/dist/phoria2.js +1 -1
- package/dist/picking.js +8 -13
- package/dist/picking.js.map +1 -1
- package/dist/snapshot.js +1 -1
- package/dist/tooltip.js +3 -3
- package/dist/tooltip.js.map +1 -1
- package/dist/types/OrbitControl.d.ts +6 -2
- package/dist/types/Raycaster.d.ts +10 -0
- package/dist/types/View.d.ts +64 -1
- package/dist/types/charts/Chart.d.ts +7 -0
- package/dist/types/explain.d.ts +3 -0
- package/dist/types/frame.d.ts +7 -0
- package/dist/types/geometry/Geometry.d.ts +14 -2
- package/dist/types/keyboard.d.ts +48 -0
- package/dist/types/labels.d.ts +4 -0
- package/dist/types/material/Material.d.ts +125 -0
- package/dist/types/material/Texture.d.ts +37 -5
- package/dist/types/scene/Node.d.ts +19 -14
- package/dist/types/scene/drawables.d.ts +32 -14
- package/dist/types/scene/lights.d.ts +16 -2
- package/dist/types/shadow.d.ts +2 -1
- package/dist/types/types.d.ts +14 -0
- package/dist/types/value/Color.d.ts +0 -4
- package/dist/types/value/Quat.d.ts +4 -2
- package/dist/types/webgl/WebGLRenderer.d.ts +6 -0
- package/dist/types/webgl/passes.d.ts +37 -13
- package/dist/types/webgl/resources.d.ts +24 -2
- package/dist/types/webgl/shaders.d.ts +4 -2
- package/llms-full.txt +153 -15
- package/package.json +4 -1
- package/schema/scene.schema.json +146 -1
- package/src/OrbitControl.js +7 -3
- package/src/Raycaster.js +58 -0
- package/src/View.js +207 -42
- package/src/charts/Chart.js +20 -2
- package/src/explain.js +6 -2
- package/src/frame.js +29 -9
- package/src/geometry/Geometry.js +24 -4
- package/src/gltf/load.js +50 -6
- package/src/keyboard.js +298 -0
- package/src/labels.js +4 -1
- package/src/material/Material.js +104 -19
- package/src/material/Texture.js +116 -11
- package/src/scene/Node.js +20 -9
- package/src/scene/Scene.js +2 -0
- package/src/scene/cameras.js +4 -1
- package/src/scene/drawables.js +48 -12
- package/src/scene/lights.js +40 -6
- package/src/shadow.js +27 -4
- package/src/types.js +6 -0
- package/src/value/Color.js +21 -0
- package/src/value/Quat.js +15 -6
- package/src/watch.js +25 -1
- package/src/webgl/WebGLRenderer.js +51 -12
- package/src/webgl/passes.js +190 -32
- package/src/webgl/picking.js +0 -1
- package/src/webgl/resources.js +89 -8
- package/src/webgl/shaders.js +350 -48
package/src/View.js
CHANGED
|
@@ -27,6 +27,7 @@ import { checkOneOf, checkOptions, checkRange, describe, fail, warnOnce } from '
|
|
|
27
27
|
/** @import { CameraOptions } from './scene/cameras.js' */
|
|
28
28
|
/** @import { ColorInput, Vec3Input } from './types.js' */
|
|
29
29
|
/** @import { Node } from './scene/Node.js' */
|
|
30
|
+
/** @import { Keyboard, KeyboardOptions } from './keyboard.js' */
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* @typedef {'auto' | 'always' | 'manual'} RenderMode
|
|
@@ -53,6 +54,8 @@ import { checkOneOf, checkOptions, checkRange, describe, fail, warnOnce } from '
|
|
|
53
54
|
* @property {(hit: Hit) => TooltipContent} [tooltip] what to show for the thing under the pointer - see tooltip()
|
|
54
55
|
* @property {number} [pixelRatio] default min(devicePixelRatio, 2)
|
|
55
56
|
* @property {boolean} [antialias] (default true)
|
|
57
|
+
* @property {boolean} [outlineAntialias] soft-edged outlines with round corners (default: as antialias); change it
|
|
58
|
+
* any time with view.renderer.outlineAntialias
|
|
56
59
|
* @property {'auto' | 'own' | 'shared'} [context] which WebGL context draws it: 'own' gives the canvas one of
|
|
57
60
|
* its own; 'shared' draws with the page's one shared context and copies the picture over, so a page can hold
|
|
58
61
|
* any number of views (browsers allow about 16 live contexts). 'auto' (default): own for the first four
|
|
@@ -63,13 +66,23 @@ import { checkOneOf, checkOptions, checkRange, describe, fail, warnOnce } from '
|
|
|
63
66
|
* additive points and lines (default off). true for the defaults, or { strength, radius, threshold }
|
|
64
67
|
* @property {boolean} [autoLight] light a scene that has no lights, with one console note (default true)
|
|
65
68
|
* @property {number} [maxDelta] longest frame step in seconds, so a background tab doesn't jump (default 0.1)
|
|
69
|
+
* @property {'auto' | boolean} [reducedMotion] cut motion short, for people it makes unwell: frame() jumps rather
|
|
70
|
+
* than glides, auto-rotation holds, and charts change at once with their flows still. Default 'auto': as the
|
|
71
|
+
* system's setting (prefers-reduced-motion), followed as it changes
|
|
72
|
+
* @property {boolean | KeyboardOptions} [keyboard] use without a pointer: the canvas joins the page's tab order, and
|
|
73
|
+
* the arrow keys step through what can be hovered or clicked - each with the hover tint, the tooltip and hover
|
|
74
|
+
* handlers - with Enter or Space to click and Escape to leave. A screen reader hears each one. { label: (hit) =>
|
|
75
|
+
* text } says what it hears (default: the tooltip's text, the data, or the name). Default false
|
|
76
|
+
* @property {string | null} [description] what the view shows, for screen readers: the canvas's label. With keyboard
|
|
77
|
+
* on and no description, one is made from the scene (charts describe themselves)
|
|
66
78
|
*/
|
|
67
79
|
|
|
68
80
|
/** @typedef {'click' | 'hover'} ViewEvent */
|
|
69
81
|
/** @typedef {(hit: Hit | null, event: PointerEvent | MouseEvent | null) => void} ViewHandler */
|
|
70
82
|
|
|
71
83
|
const OPTIONS = ['canvas', 'scene', 'camera', 'orbit', 'background', 'renderMode', 'framing', 'highlight', 'tooltip', 'pixelRatio',
|
|
72
|
-
'antialias', 'context', 'toneMapping', 'exposure', 'bloom', 'autoLight', 'maxDelta'
|
|
84
|
+
'antialias', 'outlineAntialias', 'context', 'toneMapping', 'exposure', 'bloom', 'autoLight', 'maxDelta', 'reducedMotion',
|
|
85
|
+
'keyboard', 'description']
|
|
73
86
|
const FRAMING_OPTIONS = ['keep', 'aspect']
|
|
74
87
|
const HINTS = {
|
|
75
88
|
renderer: 'the View makes its renderer - read it from view.renderer',
|
|
@@ -88,6 +101,8 @@ const EVENT_HINTS = {
|
|
|
88
101
|
}
|
|
89
102
|
// how far the highlight colour is mixed in
|
|
90
103
|
const HIGHLIGHT_AMOUNT = 0.45
|
|
104
|
+
// what the keyboard's focus is tinted with when nothing says otherwise - focus must always show
|
|
105
|
+
const FOCUS_COLOR = Color.fromInput('#ffcc00')
|
|
91
106
|
// views that get a WebGL context of their own before 'auto' starts sharing one
|
|
92
107
|
const OWN_CONTEXTS = 4
|
|
93
108
|
// above this many things to test (instances, triangles, points, segments), picking goes to the
|
|
@@ -159,6 +174,15 @@ export class View {
|
|
|
159
174
|
#tooltipClass = ''
|
|
160
175
|
#tooltipLoading = false
|
|
161
176
|
/** @type {Color | null} */ #highlight = null
|
|
177
|
+
/** @type {'auto' | boolean} */ #motion = 'auto'
|
|
178
|
+
// keyboard use: its code loads when asked for; the thing it has stepped to shows as hovered
|
|
179
|
+
/** @type {Keyboard | null} */ #keyboard = null
|
|
180
|
+
/** @type {Hit | null} */ #keyFocus = null
|
|
181
|
+
/** @type {string | null} */ #description = null
|
|
182
|
+
// the canvas had no role, so describing it gave it one - taken away again with the description
|
|
183
|
+
#gaveRole = false
|
|
184
|
+
// the system's prefers-reduced-motion, kept up to date
|
|
185
|
+
#systemReduced = false
|
|
162
186
|
/** @type {{ x: number, y: number, clientX: number, clientY: number, event: PointerEvent } | null} */ #pointer = null
|
|
163
187
|
#pickPending = false
|
|
164
188
|
// a hover pick is out on the GPU
|
|
@@ -213,7 +237,7 @@ export class View {
|
|
|
213
237
|
// the renderer checks these too; checked here, the error names the View
|
|
214
238
|
if (options.exposure !== undefined) checkRange('View', 'exposure', options.exposure, 0)
|
|
215
239
|
const shared = context === 'shared' || (context === 'auto' && ownContexts >= OWN_CONTEXTS)
|
|
216
|
-
this.renderer = new WebGLRenderer({ canvas: this.canvas, shared, antialias: options.antialias, toneMapping: options.toneMapping, exposure: options.exposure, bloom: options.bloom })
|
|
240
|
+
this.renderer = new WebGLRenderer({ canvas: this.canvas, shared, antialias: options.antialias, outlineAntialias: options.outlineAntialias, toneMapping: options.toneMapping, exposure: options.exposure, bloom: options.bloom })
|
|
217
241
|
if (!shared) {
|
|
218
242
|
this.#ownContext = true
|
|
219
243
|
ownContexts++
|
|
@@ -225,6 +249,7 @@ export class View {
|
|
|
225
249
|
this.invalidate()
|
|
226
250
|
}
|
|
227
251
|
if (options.autoLight !== undefined) this.autoLight = !!options.autoLight
|
|
252
|
+
if (options.reducedMotion !== undefined) this.#setMotion(options.reducedMotion)
|
|
228
253
|
if (options.maxDelta !== undefined) this.maxDelta = checkRange('View', 'maxDelta', options.maxDelta, 0, Infinity, { above: true })
|
|
229
254
|
if (options.renderMode !== undefined) this.renderMode = checkOneOf('View', 'renderMode', options.renderMode, ['auto', 'always', 'manual'])
|
|
230
255
|
if (options.pixelRatio !== undefined) {
|
|
@@ -245,6 +270,8 @@ export class View {
|
|
|
245
270
|
}
|
|
246
271
|
if (options.highlight !== undefined) this.#setHighlight(options.highlight)
|
|
247
272
|
if (options.tooltip !== undefined) this.tooltip(options.tooltip)
|
|
273
|
+
if (options.description !== undefined) this.description = options.description
|
|
274
|
+
if (options.keyboard !== undefined && options.keyboard !== false) this.#loadKeyboard(options.keyboard)
|
|
248
275
|
this.#labels = new LabelLayer(this.canvas, {
|
|
249
276
|
interactive: (label) => labelHandlers(label, 'click').length > 0 || labelHandlers(label, 'hover').length > 0,
|
|
250
277
|
on: (type, label, e) => this.#labelPointer(type, label, e),
|
|
@@ -322,6 +349,51 @@ export class View {
|
|
|
322
349
|
this.invalidate()
|
|
323
350
|
}
|
|
324
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Is motion cut short - glides, auto-rotation, chart transitions? Set true or false, or 'auto' (the
|
|
354
|
+
* default) to follow the system's setting. Read it for motion of your own, such as a timeline.
|
|
355
|
+
* @returns {boolean}
|
|
356
|
+
*/
|
|
357
|
+
get reducedMotion() { return this.#motion === 'auto' ? this.#systemReduced : this.#motion }
|
|
358
|
+
/** @param {'auto' | boolean} v */
|
|
359
|
+
set reducedMotion(v) { this.#setMotion(v) }
|
|
360
|
+
|
|
361
|
+
/** @param {'auto' | boolean} v */
|
|
362
|
+
#setMotion(v) {
|
|
363
|
+
if (v !== 'auto' && typeof v !== 'boolean') fail('View', `'reducedMotion' is 'auto' (as the system's setting), true or false, got ${describe(v)}`)
|
|
364
|
+
this.#motion = v
|
|
365
|
+
this.invalidate()
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* What the view shows, for screen readers - the canvas's label - or null. With keyboard on and no
|
|
370
|
+
* description, one is made from the scene.
|
|
371
|
+
* @returns {string | null}
|
|
372
|
+
*/
|
|
373
|
+
get description() { return this.#description }
|
|
374
|
+
/** @param {string | null} v */
|
|
375
|
+
set description(v) {
|
|
376
|
+
if (v !== null && typeof v !== 'string') fail('View', `'description' is text for screen readers, or null, got ${describe(v)}`)
|
|
377
|
+
this.#description = v
|
|
378
|
+
if (this.#keyboard) {
|
|
379
|
+
this.#keyboard.setDescription(v)
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
const canvas = this.canvas
|
|
383
|
+
if (v === null) {
|
|
384
|
+
canvas.removeAttribute('aria-label')
|
|
385
|
+
if (this.#gaveRole) canvas.removeAttribute('role')
|
|
386
|
+
this.#gaveRole = false
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
canvas.setAttribute('aria-label', v)
|
|
390
|
+
// a picture, to a screen reader, unless the page has said otherwise
|
|
391
|
+
if (!canvas.hasAttribute('role')) {
|
|
392
|
+
canvas.setAttribute('role', 'img')
|
|
393
|
+
this.#gaveRole = true
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
325
397
|
/** Canvas width in CSS pixels. */
|
|
326
398
|
get width() { return this.#width }
|
|
327
399
|
/** Canvas height in CSS pixels. */
|
|
@@ -395,6 +467,7 @@ export class View {
|
|
|
395
467
|
this.time += dt
|
|
396
468
|
this.#onFrame?.(dt, this.time)
|
|
397
469
|
// the same order as the live loop, so a stepped frame is the frame a user sees
|
|
470
|
+
this.scene.motionReduced = this.reducedMotion
|
|
398
471
|
this.scene.update(dt, this.time)
|
|
399
472
|
this.#updateControls(dt)
|
|
400
473
|
return this.#render(true)
|
|
@@ -409,7 +482,7 @@ export class View {
|
|
|
409
482
|
if (this.#flight) return this.#fly(dt)
|
|
410
483
|
if (!this.controls) return
|
|
411
484
|
const waiting = this.#autoFrame && samePlace(cameraPlace(this.camera), this.#autoFrameFrom)
|
|
412
|
-
this.controls.update(dt)
|
|
485
|
+
this.controls.update(dt, this.reducedMotion)
|
|
413
486
|
if (waiting) this.#autoFrameFrom = cameraPlace(this.camera)
|
|
414
487
|
}
|
|
415
488
|
|
|
@@ -448,8 +521,8 @@ export class View {
|
|
|
448
521
|
// remember what was drawn - including what drawing changed, such as fitting the camera - so
|
|
449
522
|
// the next frame compares against it and doesn't draw the same picture again
|
|
450
523
|
if (this.renderMode === 'auto') this.#watch.changed(this.scene, this.camera)
|
|
451
|
-
// things may have moved under a still pointer
|
|
452
|
-
if (this.#pointer && this.#needsHover()) this.#pickPending = true
|
|
524
|
+
// things may have moved under a still pointer - or the keyboard's focus moved on screen
|
|
525
|
+
if ((this.#pointer || this.#keyFocus) && this.#needsHover()) this.#pickPending = true
|
|
453
526
|
return frame
|
|
454
527
|
}
|
|
455
528
|
|
|
@@ -491,43 +564,26 @@ export class View {
|
|
|
491
564
|
* @param {Node | Box3} [target] (default the scene)
|
|
492
565
|
* @param {{ padding?: number, direction?: CameraPreset | Vec3Input, duration?: number }} [options] padding is
|
|
493
566
|
* the margin around it, as a fraction of its size: 0 is a tight fit, 0.2 (the default) leaves a fifth.
|
|
494
|
-
* duration is in seconds (default 0: straight there); a press or the wheel stops the glide
|
|
567
|
+
* duration is in seconds (default 0: straight there); a press or the wheel stops the glide, and with
|
|
568
|
+
* reducedMotion it goes straight there
|
|
495
569
|
* @returns {this}
|
|
496
570
|
*/
|
|
497
571
|
frame(target = this.scene, { padding = 0.2, direction: towards, duration = 0 } = {}) {
|
|
498
|
-
if (!(padding >= 0)) fail('View', `frame() padding is the margin around, as a fraction of the size (0.2 = 20%), got ${describe(padding)}`)
|
|
499
|
-
if (padding >= 1) warnOnce(this, `frame() padding ${padding} leaves a margin of ${Math.round(padding * 100)}% - it is a fraction of the size (0.2 = 20%)`)
|
|
500
572
|
checkRange('View', 'frame() duration', duration, 0)
|
|
501
573
|
const cam = /** @type {any} */ (this.camera)
|
|
502
574
|
const from = placeOf(cam)
|
|
503
575
|
this.#flight = null
|
|
504
|
-
const
|
|
505
|
-
: typeof towards === 'string' ? presetDirection(/** @type {CameraPreset} */ (towards)) : Vec3.fromInput(towards, 'View').normalize()
|
|
506
|
-
if (towards !== undefined && !(direction.len() > 0)) fail('View', `frame() direction is a preset name or a vector from the target towards the camera, got ${describe(towards)}`)
|
|
507
|
-
if (direction.len() === 0) direction.set(0, 0.3, 1).normalize()
|
|
508
|
-
const points = target instanceof Box3 ? corners(target) : this.#drawnPoints(target, direction)
|
|
576
|
+
const fit = this.#fit('frame', target, padding, towards)
|
|
509
577
|
// an explicit frame replaces the preset camera's fit on the first frame
|
|
510
578
|
this.#autoFrame = false
|
|
511
|
-
if (!
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
if (ortho) {
|
|
516
|
-
cam.height = size
|
|
517
|
-
cam.position.copy(center).add(direction.clone().multiplyScalar(radius * 4))
|
|
518
|
-
cam.far = Math.max(cam.far, radius * 10)
|
|
519
|
-
} else {
|
|
520
|
-
const distance = size
|
|
521
|
-
cam.position.copy(center).add(direction.clone().multiplyScalar(distance))
|
|
522
|
-
// keep the near/far range sensible for the model's size
|
|
523
|
-
cam.near = Math.max(1e-3, distance / 1000)
|
|
524
|
-
cam.far = Math.max(cam.far, distance * 10)
|
|
525
|
-
}
|
|
526
|
-
cam.target = center
|
|
579
|
+
if (!fit) return this
|
|
580
|
+
setPlace(cam, fit.place)
|
|
581
|
+
cam.near = fit.near ?? cam.near
|
|
582
|
+
cam.far = Math.max(cam.far, fit.far)
|
|
527
583
|
// framed for the canvas as it is now
|
|
528
584
|
this.#designed = cam.aspect
|
|
529
585
|
this.#applied = 1
|
|
530
|
-
if (duration > 0) {
|
|
586
|
+
if (duration > 0 && !this.reducedMotion) {
|
|
531
587
|
// the camera starts where it was and glides there, frame by frame
|
|
532
588
|
this.#flight = { from, to: placeOf(cam), time: 0, duration }
|
|
533
589
|
setPlace(cam, from)
|
|
@@ -536,6 +592,56 @@ export class View {
|
|
|
536
592
|
return this
|
|
537
593
|
}
|
|
538
594
|
|
|
595
|
+
/**
|
|
596
|
+
* Where frame() would put the camera, without moving it: its position, target and - for an
|
|
597
|
+
* orthographic camera - height, or null when there is nothing to frame. For a camera of your own
|
|
598
|
+
* that eases after something moving rather than jumping each frame:
|
|
599
|
+
*
|
|
600
|
+
* const aim = view.framing(debris)
|
|
601
|
+
* if (aim) {
|
|
602
|
+
* view.camera.position.lerp(aim.position, 0.1)
|
|
603
|
+
* view.camera.target.lerp(aim.target, 0.1)
|
|
604
|
+
* }
|
|
605
|
+
* @param {Node | Box3} [target] (default the scene)
|
|
606
|
+
* @param {{ padding?: number, direction?: CameraPreset | Vec3Input }} [options] as frame()
|
|
607
|
+
* @returns {{ position: Vec3, target: Vec3, height?: number } | null}
|
|
608
|
+
*/
|
|
609
|
+
framing(target = this.scene, { padding = 0.2, direction } = {}) {
|
|
610
|
+
const fit = this.#fit('framing', target, padding, direction)
|
|
611
|
+
if (!fit) return null
|
|
612
|
+
const { position, target: at, height } = fit.place
|
|
613
|
+
return height === undefined ? { position, target: at } : { position, target: at, height }
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* The camera's place to frame something, worked out without moving it - for frame() and framing().
|
|
618
|
+
* @param {string} method its caller, for errors @param {Node | Box3} target @param {number} padding
|
|
619
|
+
* @param {CameraPreset | Vec3Input | undefined} towards
|
|
620
|
+
* @returns {{ place: Place, near: number | undefined, far: number } | null}
|
|
621
|
+
*/
|
|
622
|
+
#fit(method, target, padding, towards) {
|
|
623
|
+
if (!(padding >= 0)) fail('View', `${method}() padding is the margin around, as a fraction of the size (0.2 = 20%), got ${describe(padding)}`)
|
|
624
|
+
if (padding >= 1) warnOnce(this, `${method}() padding ${padding} leaves a margin of ${Math.round(padding * 100)}% - it is a fraction of the size (0.2 = 20%)`)
|
|
625
|
+
const cam = /** @type {any} */ (this.camera)
|
|
626
|
+
const direction = towards === undefined ? cam.position.clone().sub(cam.target ?? new Vec3()).normalize()
|
|
627
|
+
: typeof towards === 'string' ? presetDirection(/** @type {CameraPreset} */ (towards)) : Vec3.fromInput(towards, 'View').normalize()
|
|
628
|
+
if (towards !== undefined && !(direction.len() > 0)) fail('View', `${method}() direction is a preset name or a vector from the target towards the camera, got ${describe(towards)}`)
|
|
629
|
+
if (direction.len() === 0) direction.set(0, 0.3, 1).normalize()
|
|
630
|
+
const points = target instanceof Box3 ? corners(target) : this.#drawnPoints(target, direction)
|
|
631
|
+
if (!points.length) return null
|
|
632
|
+
const ortho = cam.constructor.type === 'orthographic-camera'
|
|
633
|
+
const { center, size } = fitPoints(points, direction, { aspect: cam.aspect, fov: ortho ? undefined : cam.fov, padding })
|
|
634
|
+
const radius = Math.max(1e-3, ...points.map((p) => Math.hypot(p[0] - center[0], p[1] - center[1], p[2] - center[2])))
|
|
635
|
+
if (ortho) {
|
|
636
|
+
return { place: { position: center.clone().add(direction.clone().multiplyScalar(radius * 4)), target: center, height: size },
|
|
637
|
+
near: undefined, far: radius * 10 }
|
|
638
|
+
}
|
|
639
|
+
// keep the near/far range sensible for the model's size
|
|
640
|
+
const distance = size
|
|
641
|
+
return { place: { position: center.clone().add(direction.clone().multiplyScalar(distance)), target: center },
|
|
642
|
+
near: Math.max(1e-3, distance / 1000), far: distance * 10 }
|
|
643
|
+
}
|
|
644
|
+
|
|
539
645
|
/**
|
|
540
646
|
* One frame of a glide: round the target as it moves, closing the distance evenly, so the picture
|
|
541
647
|
* turns and zooms rather than cutting through what is in view.
|
|
@@ -772,6 +878,39 @@ export class View {
|
|
|
772
878
|
})
|
|
773
879
|
}
|
|
774
880
|
|
|
881
|
+
/** @param {true | KeyboardOptions} options */
|
|
882
|
+
#loadKeyboard(options) {
|
|
883
|
+
if (options !== true && (typeof options !== 'object' || options === null || Array.isArray(options))) {
|
|
884
|
+
fail('View', `'keyboard' is true, false or { label: (hit) => text }, got ${describe(options)}`)
|
|
885
|
+
}
|
|
886
|
+
const o = options === true ? {} : options
|
|
887
|
+
checkOptions('View', o, ['label'], { describe: "use 'label: (hit) => text'", text: "use 'label: (hit) => text'" }, 'keyboard')
|
|
888
|
+
if (o.label !== undefined && typeof o.label !== 'function') fail('View', `keyboard.label is a function (hit) => text, got ${describe(o.label)}`)
|
|
889
|
+
import('./keyboard.js').then(({ Keyboard }) => {
|
|
890
|
+
if (this.#disposed) return
|
|
891
|
+
this.#keyboard = new Keyboard(this, {
|
|
892
|
+
focus: (hit) => {
|
|
893
|
+
this.#keyFocus = hit
|
|
894
|
+
this.#pickPending = true
|
|
895
|
+
this.invalidate()
|
|
896
|
+
},
|
|
897
|
+
press: (hit) => {
|
|
898
|
+
for (const fn of handlersAbove(hit.node, 'click')) fn(hit)
|
|
899
|
+
for (const fn of this.#handlers.click) fn(hit, null)
|
|
900
|
+
},
|
|
901
|
+
tooltip: (hit) => this.#tooltipFor(hit)?.(hit),
|
|
902
|
+
viewWide: () => this.#handlers.hover.size > 0 || this.#handlers.click.size > 0 || !!this.#tooltipContent,
|
|
903
|
+
}, o)
|
|
904
|
+
this.#keyboard.setDescription(this.#description)
|
|
905
|
+
})
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** Where the keyboard's focus shows - its middle, on screen - in place of the pointer. @param {Hit} hit */
|
|
909
|
+
#keyAt(hit) {
|
|
910
|
+
const { x, y } = this.project(hit.point), rect = this.canvas.getBoundingClientRect()
|
|
911
|
+
return { x, y, clientX: rect.left + x, clientY: rect.top + y, event: null }
|
|
912
|
+
}
|
|
913
|
+
|
|
775
914
|
/** The nearest node tooltip above the hit, or the View's. @param {Hit} hit */
|
|
776
915
|
#tooltipFor(hit) {
|
|
777
916
|
for (let n = /** @type {any} */ (hit.node); n; n = n.parent) if (n.tooltip) return n.tooltip
|
|
@@ -820,7 +959,8 @@ export class View {
|
|
|
820
959
|
sharedContext: this.renderer.shared,
|
|
821
960
|
frame, labels: this.#labelStats, bounds: drawn, nodes, autoLight: this.autoLight,
|
|
822
961
|
contextLost: !!this.renderer.gl?.isContextLost?.(),
|
|
823
|
-
interaction: { hover: this.#handlers.hover.size, click: this.#handlers.click.size, tooltip: !!this.#tooltipContent, highlight: !!this.#highlight
|
|
962
|
+
interaction: { hover: this.#handlers.hover.size, click: this.#handlers.click.size, tooltip: !!this.#tooltipContent, highlight: !!this.#highlight,
|
|
963
|
+
keyboard: !!this.#keyboard, reducedMotion: this.reducedMotion, description: this.canvas.getAttribute?.('aria-label') ?? this.#description },
|
|
824
964
|
})
|
|
825
965
|
}
|
|
826
966
|
|
|
@@ -837,6 +977,8 @@ export class View {
|
|
|
837
977
|
this.controls?.dispose()
|
|
838
978
|
this.#labels.dispose()
|
|
839
979
|
this.#tooltip?.dispose()
|
|
980
|
+
this.#keyboard?.dispose()
|
|
981
|
+
if (this.#description !== null && !this.#keyboard) this.description = null
|
|
840
982
|
this.renderer.dispose(this.scene)
|
|
841
983
|
if (this.#ownContext) ownContexts--
|
|
842
984
|
if (this.#ownCanvas) this.canvas.remove()
|
|
@@ -877,6 +1019,7 @@ export class View {
|
|
|
877
1019
|
if (this.#running) {
|
|
878
1020
|
this.time += dt
|
|
879
1021
|
this.#onFrame?.(dt, this.time)
|
|
1022
|
+
this.scene.motionReduced = this.reducedMotion
|
|
880
1023
|
this.scene.update(dt, this.time)
|
|
881
1024
|
}
|
|
882
1025
|
this.#updateControls(dt)
|
|
@@ -902,6 +1045,8 @@ export class View {
|
|
|
902
1045
|
this.#pickPending = false
|
|
903
1046
|
if (!this.#needsHover()) return
|
|
904
1047
|
const p = this.#pointer
|
|
1048
|
+
// what the keyboard stepped to is shown until the pointer moves
|
|
1049
|
+
if (this.#keyFocus) return this.#setHovered(this.#keyFocus, this.#keyAt(this.#keyFocus))
|
|
905
1050
|
if (this.#labelHover) return this.#setHovered(this.#labelHover)
|
|
906
1051
|
if (!p || this.#dragging) return this.#setHovered(null)
|
|
907
1052
|
const frame = this.#gpuFrame()
|
|
@@ -922,9 +1067,11 @@ export class View {
|
|
|
922
1067
|
})
|
|
923
1068
|
}
|
|
924
1069
|
|
|
925
|
-
/**
|
|
926
|
-
|
|
927
|
-
|
|
1070
|
+
/**
|
|
1071
|
+
* @param {Hit | null} hit
|
|
1072
|
+
* @param {{ clientX: number, clientY: number, event: PointerEvent | null } | null} [p] where it is shown - the pointer, or the keyboard's focus
|
|
1073
|
+
*/
|
|
1074
|
+
#setHovered(hit, p = this.#pointer) {
|
|
928
1075
|
const before = this.#hovered
|
|
929
1076
|
// the same thing under the pointer: the same node, instance and row of data - a line's segments
|
|
930
1077
|
// and a surface's rows are told apart by their data, without a plain mesh changing per triangle
|
|
@@ -978,13 +1125,16 @@ export class View {
|
|
|
978
1125
|
/** @param {Hit | null} hit @returns {FrameHighlight | null} */
|
|
979
1126
|
#highlightFor(hit) {
|
|
980
1127
|
if (!hit) return null
|
|
981
|
-
// the nearest colour wins
|
|
1128
|
+
// the nearest colour wins - or the nearest false, which tints nothing - and the nearest
|
|
1129
|
+
// highlightWhole is what is tinted as one
|
|
982
1130
|
let color = null, whole = null
|
|
983
1131
|
for (let n = /** @type {any} */ (hit.node); n; n = n.parent) {
|
|
984
1132
|
color ??= n.highlight
|
|
985
1133
|
whole ??= n.highlightWhole ? n : null
|
|
986
1134
|
}
|
|
987
1135
|
color ??= this.#highlight
|
|
1136
|
+
// the keyboard's focus always shows
|
|
1137
|
+
if (!color && hit === this.#keyFocus) color = FOCUS_COLOR
|
|
988
1138
|
if (!color) return null
|
|
989
1139
|
const node = /** @type {any} */ (hit.node)
|
|
990
1140
|
if (whole) {
|
|
@@ -1001,17 +1151,19 @@ export class View {
|
|
|
1001
1151
|
/** @param {FrameDescription} frame */
|
|
1002
1152
|
#occlude(frame) {
|
|
1003
1153
|
const labels = /** @type {(import('./frame.js').FrameLabel & { occluded?: boolean })[]} */ (frame.labels)
|
|
1004
|
-
|
|
1005
|
-
|
|
1154
|
+
// a label waiting to be zoomed in to won't show either way, so it needs no ray
|
|
1155
|
+
const halfHeight = this.height / 2
|
|
1156
|
+
const testing = labels.filter((l) => l.style.occlude && l.visible && !(l.style.minScale && l.scale * halfHeight < l.style.minScale))
|
|
1157
|
+
if (!testing.length) return
|
|
1158
|
+
// what this frame drew hides what is behind it - the same surfaces the picture shows, each placed
|
|
1159
|
+
// once for all the rays, which ask only whether anything is nearer than their label
|
|
1160
|
+
const solids = Raycaster.solids([...frame.opaque, ...frame.transparent])
|
|
1006
1161
|
const raycaster = new Raycaster()
|
|
1007
|
-
const
|
|
1008
|
-
for (const label of labels) {
|
|
1009
|
-
if (!label.style.occlude || !label.visible) continue
|
|
1162
|
+
for (const label of testing) {
|
|
1010
1163
|
raycaster.setFromCamera(label.ndc, this.camera)
|
|
1011
1164
|
const distance = label.position.distanceTo(raycaster.ray.origin)
|
|
1012
1165
|
// only solid surfaces hide labels, and not the instance or point being labelled
|
|
1013
|
-
|
|
1014
|
-
label.occluded = !!blocker && blocker.distance < distance * 0.995
|
|
1166
|
+
label.occluded = raycaster.blocked(solids, distance * 0.995, label.node, label.index)
|
|
1015
1167
|
}
|
|
1016
1168
|
}
|
|
1017
1169
|
|
|
@@ -1022,6 +1174,8 @@ export class View {
|
|
|
1022
1174
|
return { x: e.clientX - rect.left, y: e.clientY - rect.top, clientX: e.clientX, clientY: e.clientY, event: e }
|
|
1023
1175
|
}
|
|
1024
1176
|
const move = (e) => {
|
|
1177
|
+
// the pointer takes over from the keyboard
|
|
1178
|
+
if (this.#keyFocus) this.#keyboard?.clear()
|
|
1025
1179
|
this.#pointer = at(e)
|
|
1026
1180
|
const press = this.#press
|
|
1027
1181
|
if (press && press.id === e.pointerId && !press.moved && Math.hypot(this.#pointer.x - press.x, this.#pointer.y - press.y) >= DRAG_START) press.moved = true
|
|
@@ -1108,6 +1262,17 @@ export class View {
|
|
|
1108
1262
|
resize.observe(canvas)
|
|
1109
1263
|
this.#cleanup.push(() => resize.disconnect())
|
|
1110
1264
|
}
|
|
1265
|
+
// the system's reduced-motion setting, followed as it changes
|
|
1266
|
+
const motion = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')
|
|
1267
|
+
if (motion) {
|
|
1268
|
+
const follow = () => {
|
|
1269
|
+
this.#systemReduced = motion.matches
|
|
1270
|
+
this.invalidate()
|
|
1271
|
+
}
|
|
1272
|
+
this.#systemReduced = motion.matches
|
|
1273
|
+
motion.addEventListener?.('change', follow)
|
|
1274
|
+
this.#cleanup.push(() => motion.removeEventListener?.('change', follow))
|
|
1275
|
+
}
|
|
1111
1276
|
// an off-screen view (a dashboard tile scrolled away) does no work at all
|
|
1112
1277
|
if (typeof IntersectionObserver !== 'undefined') {
|
|
1113
1278
|
const visible = new IntersectionObserver(([entry]) => {
|
package/src/charts/Chart.js
CHANGED
|
@@ -118,6 +118,22 @@ export class Chart extends Group {
|
|
|
118
118
|
return `Chart (${s.mark}): ${shown} of ${s.rows.length} rows shown${axes.length ? `; ${axes.join(', ')}` : ''}${s.links.length ? `; ${s.links.length} links` : ''}`
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* One sentence about this chart for screen readers - the spec's description, or its title and what
|
|
123
|
+
* it shows: "Sales by month: bar chart of 12 rows by Month, Sales". A View with keyboard: true labels
|
|
124
|
+
* its canvas with this.
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
summary() {
|
|
128
|
+
const s = /** @type {ReturnType<typeof normalise>} */ (this.#spec), o = /** @type {any} */ (s.options)
|
|
129
|
+
if (o.description) return String(o.description)
|
|
130
|
+
// each field once, though it may set more than one channel (z and colour by region)
|
|
131
|
+
const by = [...new Set(['x', 'y', 'z', 'color', 'size'].map((k) => /** @type {any} */ (s.channels)[k]).filter((c) => c?.field).map((c) => c.title))]
|
|
132
|
+
const count = (n, one) => `${n} ${n === 1 ? one : `${one}s`}`
|
|
133
|
+
const what = `${s.mark} chart of ${count(s.rows.length, 'row')}${by.length ? ` by ${by.join(', ')}` : ''}${s.links.length ? ` and ${count(s.links.length, 'link')}` : ''}`
|
|
134
|
+
return o.title ? `${o.title}: ${what}` : what
|
|
135
|
+
}
|
|
136
|
+
|
|
121
137
|
/** @internal @returns {Record<string, unknown>} */
|
|
122
138
|
jsonOptions() {
|
|
123
139
|
return { ...this.#input }
|
|
@@ -305,14 +321,16 @@ export class Chart extends Group {
|
|
|
305
321
|
|
|
306
322
|
/** @param {number} dt */
|
|
307
323
|
#step(dt) {
|
|
308
|
-
|
|
324
|
+
// with motion reduced (the View's reducedMotion), flows stand still and a change shows at once
|
|
325
|
+
const still = /** @type {any} */ (this.root).motionReduced === true
|
|
326
|
+
if (!still) this.#mark?.tick?.(dt)
|
|
309
327
|
const tw = this.#tween
|
|
310
328
|
if (!tw) return
|
|
311
329
|
const o = this.#input.transition ?? {}
|
|
312
330
|
const duration = o.duration ?? 0.5
|
|
313
331
|
const ease = EASES[o.ease ?? 'out']
|
|
314
332
|
if (!ease) fail('chart()', `transition ease '${o.ease}' - expected ${Object.keys(EASES).map((e) => `'${e}'`).join(', ')}`)
|
|
315
|
-
tw.t = Math.min(1, tw.t + dt / duration)
|
|
333
|
+
tw.t = still ? 1 : Math.min(1, tw.t + dt / duration)
|
|
316
334
|
const k = ease(tw.t)
|
|
317
335
|
const state = new Float32Array(tw.from.length)
|
|
318
336
|
for (let i = 0; i < state.length; i++) state[i] = tw.from[i] + (tw.to[i] - tw.from[i]) * k
|
package/src/explain.js
CHANGED
|
@@ -22,7 +22,7 @@ import { Box3 } from './value/Box3.js'
|
|
|
22
22
|
* @property {boolean} autoLight
|
|
23
23
|
* @property {boolean} contextLost
|
|
24
24
|
* @property {boolean} [sharedContext] drawn with the page's shared WebGL context
|
|
25
|
-
* @property {{ hover: number, click: number, tooltip: boolean, highlight: boolean }} interaction
|
|
25
|
+
* @property {{ hover: number, click: number, tooltip: boolean, highlight: boolean, keyboard?: boolean, reducedMotion?: boolean, description?: string | null }} interaction
|
|
26
26
|
* @property {string[]} [notes] lines from nodes that describe themselves, such as charts
|
|
27
27
|
*/
|
|
28
28
|
|
|
@@ -115,7 +115,7 @@ export function explain(info) {
|
|
|
115
115
|
|
|
116
116
|
if (info.labels && info.labels.total) {
|
|
117
117
|
const s = info.labels
|
|
118
|
-
lines.push(`Labels: ${s.shown} shown, ${s.dimmed ? `${s.dimmed} dimmed (covered by labels or geometry), ` : ''}${s.overlapped} hidden by overlap, ${s.occluded} hidden behind geometry, ${s.offscreen} off screen`)
|
|
118
|
+
lines.push(`Labels: ${s.shown} shown, ${s.dimmed ? `${s.dimmed} dimmed (covered by labels or geometry), ` : ''}${s.overlapped} hidden by overlap, ${s.occluded} hidden behind geometry, ${s.offscreen} off screen${s.zoomedOut ? `, ${s.zoomedOut} waiting to be zoomed in to (minScale)` : ''}`)
|
|
119
119
|
if (s.overlapped > s.shown * 3) warnings.push(`${s.overlapped} labels are hidden by overlap - give the important ones a higher priority, label fewer things, or use overlap: 'dim' or 'show'`)
|
|
120
120
|
if (s.offscreen === s.total) warnings.push('every label is off screen - check their positions, or call view.frame()')
|
|
121
121
|
}
|
|
@@ -125,6 +125,10 @@ export function explain(info) {
|
|
|
125
125
|
lines.push(`Interaction: ${[ix.hover && plural(ix.hover, 'hover handler'), ix.click && plural(ix.click, 'click handler'),
|
|
126
126
|
ix.tooltip && 'tooltip', ix.highlight && 'hover highlight'].filter(Boolean).join(', ')}`)
|
|
127
127
|
}
|
|
128
|
+
if (ix.keyboard || ix.reducedMotion || ix.description) {
|
|
129
|
+
lines.push(`Accessibility: ${[ix.keyboard && 'keyboard (arrow keys step through what can be hovered)', ix.reducedMotion && 'reduced motion',
|
|
130
|
+
ix.description && `described as "${ix.description}"`].filter(Boolean).join(', ')}`)
|
|
131
|
+
}
|
|
128
132
|
|
|
129
133
|
if (info.width < 2 || info.height < 2) warnings.unshift(`the canvas is ${info.width} x ${info.height} - give it a size in the page (CSS width and height)`)
|
|
130
134
|
if (info.contextLost) warnings.unshift('the WebGL context is lost - nothing draws until the browser restores it')
|
package/src/frame.js
CHANGED
|
@@ -6,6 +6,7 @@ import { Box3 } from './value/Box3.js'
|
|
|
6
6
|
import { warnOnce } from './validate.js'
|
|
7
7
|
import { fitPointShadow, fitShadow, fitSpotShadow, SPOT_TILES } from './shadow.js'
|
|
8
8
|
import { worldPlane } from './clip.js'
|
|
9
|
+
import { hiddenInstance } from './scene/drawables.js'
|
|
9
10
|
|
|
10
11
|
/** @import { Scene } from './scene/Scene.js' */
|
|
11
12
|
/** @import { Camera } from './scene/cameras.js' */
|
|
@@ -89,6 +90,8 @@ export const LIGHT_LIMITS = { punctual: 4, directional: 2 }
|
|
|
89
90
|
* @property {[number, number]} ndc [x, y] in -1..1
|
|
90
91
|
* @property {number} depth
|
|
91
92
|
* @property {boolean} visible in front of the camera and inside the view
|
|
93
|
+
* @property {number} scale how far zoomed in at the point: half the view's height, in world units, is 1 / scale -
|
|
94
|
+
* the layer turns it into pixels to one world unit, for minScale
|
|
92
95
|
*/
|
|
93
96
|
|
|
94
97
|
/**
|
|
@@ -239,13 +242,13 @@ export function collect(scene, camera) {
|
|
|
239
242
|
else capped.set(item.clip, [item])
|
|
240
243
|
}
|
|
241
244
|
frame.stats.drawn++
|
|
242
|
-
if (node.labels) instanceLabels(node, viewProjection, view, frame.labels)
|
|
245
|
+
if (node.labels) instanceLabels(node, viewProjection, view, projection[5], frame.labels)
|
|
243
246
|
} else {
|
|
244
247
|
frame.stats.culled++
|
|
245
248
|
}
|
|
246
249
|
} else if (node.isLabel) {
|
|
247
250
|
const position = node.worldPosition
|
|
248
|
-
frame.labels.push({ key: node, node, index: null, text: node.text, style: node, position, ...project(position, viewProjection, view) })
|
|
251
|
+
frame.labels.push({ key: node, node, index: null, text: node.text, style: node, position, ...project(position, viewProjection, view, projection[5]) })
|
|
249
252
|
} else if (node.lightKind) {
|
|
250
253
|
addLight(/** @type {Light} */ (node))
|
|
251
254
|
}
|
|
@@ -323,7 +326,7 @@ export function collect(scene, camera) {
|
|
|
323
326
|
if (items.length && sun >= 0) {
|
|
324
327
|
const light = frame.lights[sun]
|
|
325
328
|
const bounds = (/** @type {any} */ n) => n.expandBounds(new Box3())
|
|
326
|
-
const fit = fitShadow(/** @type {Vec3} */ (light.direction), items.map((c) => bounds(c.node)), receivers.map(bounds))
|
|
329
|
+
const fit = fitShadow(/** @type {Vec3} */ (light.direction), items.map((c) => bounds(c.node)), receivers.map(bounds), undefined, /** @type {any} */ (light.node).shadowBounds)
|
|
327
330
|
if (fit) {
|
|
328
331
|
frame.lights.splice(sun, 1)
|
|
329
332
|
frame.lights.unshift(light)
|
|
@@ -392,10 +395,22 @@ let nextId = 1
|
|
|
392
395
|
|
|
393
396
|
/**
|
|
394
397
|
* Labels for every instance or point that has text, from the node's `labels` option.
|
|
395
|
-
* @param {any} node InstancedMesh or Points @param {Mat4} viewProjection @param {Mat4} view
|
|
398
|
+
* @param {any} node InstancedMesh or Points @param {Mat4} viewProjection @param {Mat4} view
|
|
399
|
+
* @param {number} yScale the projection's vertical scale @param {FrameLabel[]} out
|
|
396
400
|
*/
|
|
397
|
-
function instanceLabels(node, viewProjection, view, out) {
|
|
398
|
-
|
|
401
|
+
function instanceLabels(node, viewProjection, view, yScale, out) {
|
|
402
|
+
// several labels per instance: the first keeps the plain key, so a single set's labels don't change identity
|
|
403
|
+
if (Array.isArray(node.labels)) {
|
|
404
|
+
node.labels.forEach((labels, layer) => instanceLabelSet(node, labels, layer, viewProjection, view, yScale, out))
|
|
405
|
+
} else instanceLabelSet(node, node.labels, 0, viewProjection, view, yScale, out)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* @param {any} node @param {any} labels one set of instance labels @param {number} layer its place in the list
|
|
410
|
+
* @param {Mat4} viewProjection @param {Mat4} view @param {number} yScale @param {FrameLabel[]} out
|
|
411
|
+
*/
|
|
412
|
+
function instanceLabelSet(node, labels, layer, viewProjection, view, yScale, out) {
|
|
413
|
+
const { worldMatrix: w } = node
|
|
399
414
|
let id = ids.get(node)
|
|
400
415
|
if (id === undefined) ids.set(node, id = nextId++)
|
|
401
416
|
const instanced = 'instanceMatrices' in node
|
|
@@ -409,6 +424,8 @@ function instanceLabels(node, viewProjection, view, out) {
|
|
|
409
424
|
const data = Array.isArray(node.data) ? node.data : null
|
|
410
425
|
const byRow = typeof labels.priority === 'function'
|
|
411
426
|
for (let i = 0; i < n; i++) {
|
|
427
|
+
// an instance scaled to nothing is hidden, and so is its label
|
|
428
|
+
if (instanced && hiddenInstance(node.instanceMatrices, i)) continue
|
|
412
429
|
const row = data ? data[i] : null
|
|
413
430
|
const text = labels.text(row, i)
|
|
414
431
|
if (text === null || text === undefined || text === '') continue
|
|
@@ -427,7 +444,7 @@ function instanceLabels(node, viewProjection, view, out) {
|
|
|
427
444
|
w[1] * x + w[5] * y + w[9] * z + w[13],
|
|
428
445
|
w[2] * x + w[6] * y + w[10] * z + w[14])
|
|
429
446
|
const style = byRow ? { ...labels, priority: labels.priority(row, i) } : labels
|
|
430
|
-
out.push({ key: `${id}:${i}`, node, index: i, text: String(text), style, position, ...project(position, viewProjection, view) })
|
|
447
|
+
out.push({ key: layer ? `${id}:${i}:${layer}` : `${id}:${i}`, node, index: i, text: String(text), style, position, ...project(position, viewProjection, view, yScale) })
|
|
431
448
|
}
|
|
432
449
|
}
|
|
433
450
|
|
|
@@ -453,10 +470,13 @@ export function toScreen(position, viewProjection) {
|
|
|
453
470
|
* units, for both kinds of camera (an orthographic one's clip w is always 1).
|
|
454
471
|
* @param {Vec3} position @param {Mat4} viewProjection @param {Mat4} view
|
|
455
472
|
*/
|
|
456
|
-
function project(position, viewProjection, view) {
|
|
473
|
+
function project(position, viewProjection, view, yScale) {
|
|
457
474
|
const { ndc, visible } = toScreen(position, viewProjection)
|
|
458
475
|
const depth = -(view[2] * position[0] + view[6] * position[1] + view[10] * position[2] + view[14])
|
|
459
|
-
|
|
476
|
+
// clip w at the point: its depth for a perspective camera, 1 for an orthographic one
|
|
477
|
+
const m = viewProjection
|
|
478
|
+
const w = m[3] * position[0] + m[7] * position[1] + m[11] * position[2] + m[15]
|
|
479
|
+
return { ndc: /** @type {[number, number]} */ ([ndc[0], ndc[1]]), depth, visible, scale: w > 0 ? yScale / w : 0 }
|
|
460
480
|
}
|
|
461
481
|
|
|
462
482
|
// The six planes (a, b, c, d with the normal pointing inwards) of a view-projection matrix.
|