phoria2 1.2.0 → 1.4.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 +11 -9
- package/dist/bloom.js +4 -4
- package/dist/bloom.js.map +1 -1
- package/dist/charts.js +527 -510
- package/dist/charts.js.map +1 -1
- package/dist/core.js +1760 -1170
- package/dist/core.js.map +1 -1
- package/dist/debug.js +297 -0
- package/dist/debug.js.map +1 -0
- package/dist/edit.js +60 -60
- package/dist/edit.js.map +1 -1
- package/dist/explain.js +32 -13
- package/dist/explain.js.map +1 -1
- package/dist/gltf.js +155 -155
- package/dist/gltf.js.map +1 -1
- package/dist/keyboard.js +188 -0
- package/dist/keyboard.js.map +1 -0
- package/dist/motion.js +35 -35
- package/dist/motion.js.map +1 -1
- package/dist/particles.js +187 -114
- package/dist/particles.js.map +1 -1
- package/dist/passes.js +116 -104
- package/dist/passes.js.map +1 -1
- package/dist/phoria2.js +1 -1
- package/dist/picking.js +95 -62
- package/dist/picking.js.map +1 -1
- package/dist/snapshot.js +1 -1
- package/dist/tooltip.js +1 -1
- package/dist/types/View.d.ts +27 -0
- package/dist/types/area.d.ts +18 -0
- package/dist/types/debug.d.ts +74 -0
- package/dist/types/explain.d.ts +4 -0
- package/dist/types/frame.d.ts +5 -0
- package/dist/types/geometry/Geometry.d.ts +16 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/material/Material.d.ts +67 -0
- package/dist/types/material/Texture.d.ts +18 -2
- package/dist/types/particles/Emitter.d.ts +39 -2
- package/dist/types/scene/drawables.d.ts +23 -2
- package/dist/types/watch.d.ts +8 -1
- package/dist/types/webgl/hooks.d.ts +48 -0
- package/dist/types/webgl/passes.d.ts +4 -0
- package/dist/types/webgl/programs.d.ts +15 -5
- package/dist/types/webgl/resources.d.ts +12 -2
- package/dist/types/webgl/shaders.d.ts +41 -5
- package/llms-full.txt +275 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/schema/scene.schema.json +75 -0
- package/src/View.js +121 -7
- package/src/area.js +131 -0
- package/src/charts/marks.js +29 -2
- package/src/debug.js +283 -0
- package/src/explain.js +19 -0
- package/src/frame.js +7 -2
- package/src/geometry/Geometry.js +16 -1
- package/src/index.js +1 -0
- package/src/material/Material.js +130 -4
- package/src/material/Texture.js +55 -2
- package/src/particles/Emitter.js +121 -10
- package/src/scene/drawables.js +89 -8
- package/src/watch.js +26 -3
- package/src/webgl/WebGLRenderer.js +55 -7
- package/src/webgl/hooks.js +121 -0
- package/src/webgl/passes.js +17 -2
- package/src/webgl/picking.js +45 -3
- package/src/webgl/programs.js +20 -9
- package/src/webgl/resources.js +169 -15
- package/src/webgl/shaders.js +545 -38
package/src/View.js
CHANGED
|
@@ -4,6 +4,7 @@ import { DRAG_START, OrbitControl } from './OrbitControl.js'
|
|
|
4
4
|
import { cameraPreset, fitPoints, framingScale, presetDirection } from './presets.js'
|
|
5
5
|
import { EASES } from './ease.js'
|
|
6
6
|
import { Raycaster } from './Raycaster.js'
|
|
7
|
+
import { pickArea } from './area.js'
|
|
7
8
|
import { ChangeWatch } from './watch.js'
|
|
8
9
|
import { Box3 } from './value/Box3.js'
|
|
9
10
|
import { Color } from './value/Color.js'
|
|
@@ -28,6 +29,7 @@ import { checkOneOf, checkOptions, checkRange, describe, fail, warnOnce } from '
|
|
|
28
29
|
/** @import { ColorInput, Vec3Input } from './types.js' */
|
|
29
30
|
/** @import { Node } from './scene/Node.js' */
|
|
30
31
|
/** @import { Keyboard, KeyboardOptions } from './keyboard.js' */
|
|
32
|
+
/** @import { DebugDraw, DebugOptions } from './debug.js' */
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
35
|
* @typedef {'auto' | 'always' | 'manual'} RenderMode
|
|
@@ -75,6 +77,8 @@ import { checkOneOf, checkOptions, checkRange, describe, fail, warnOnce } from '
|
|
|
75
77
|
* text } says what it hears (default: the tooltip's text, the data, or the name). Default false
|
|
76
78
|
* @property {string | null} [description] what the view shows, for screen readers: the canvas's label. With keyboard
|
|
77
79
|
* on and no description, one is made from the scene (charts describe themselves)
|
|
80
|
+
* @property {boolean | DebugOptions} [debug] pictures for checking a scene: lights, shadow cameras, bounds,
|
|
81
|
+
* normals, axes, the orbit target and a stats readout. true shows all but normals. Default false
|
|
78
82
|
*/
|
|
79
83
|
|
|
80
84
|
/** @typedef {'click' | 'hover'} ViewEvent */
|
|
@@ -82,7 +86,12 @@ import { checkOneOf, checkOptions, checkRange, describe, fail, warnOnce } from '
|
|
|
82
86
|
|
|
83
87
|
const OPTIONS = ['canvas', 'scene', 'camera', 'orbit', 'background', 'renderMode', 'framing', 'highlight', 'tooltip', 'pixelRatio',
|
|
84
88
|
'antialias', 'outlineAntialias', 'context', 'toneMapping', 'exposure', 'bloom', 'autoLight', 'maxDelta', 'reducedMotion',
|
|
85
|
-
'keyboard', 'description']
|
|
89
|
+
'keyboard', 'description', 'debug']
|
|
90
|
+
// what debug: true shows - everything but normals, which bristle on a big model
|
|
91
|
+
/** @type {Required<DebugOptions>} */
|
|
92
|
+
const DEBUG_ALL = { lights: true, shadows: true, bounds: true, normals: false, axes: true, camera: true, stats: 'top-left' }
|
|
93
|
+
const DEBUG_OPTIONS = Object.keys(DEBUG_ALL)
|
|
94
|
+
const CORNERS = ['top-left', 'top-right', 'bottom-left', 'bottom-right']
|
|
86
95
|
const FRAMING_OPTIONS = ['keep', 'aspect']
|
|
87
96
|
const HINTS = {
|
|
88
97
|
renderer: 'the View makes its renderer - read it from view.renderer',
|
|
@@ -179,6 +188,12 @@ export class View {
|
|
|
179
188
|
/** @type {Keyboard | null} */ #keyboard = null
|
|
180
189
|
/** @type {Hit | null} */ #keyFocus = null
|
|
181
190
|
/** @type {string | null} */ #description = null
|
|
191
|
+
// debug drawing: its code loads when turned on; the helpers are added to each frame, then taken out
|
|
192
|
+
/** @type {Required<DebugOptions> | null} */ #debug = null
|
|
193
|
+
/** @type {DebugDraw | null} */ #debugDraw = null
|
|
194
|
+
#debugLoading = false
|
|
195
|
+
#drawnAt = 0
|
|
196
|
+
#frameMs = 0
|
|
182
197
|
// the canvas had no role, so describing it gave it one - taken away again with the description
|
|
183
198
|
#gaveRole = false
|
|
184
199
|
// the system's prefers-reduced-motion, kept up to date
|
|
@@ -276,6 +291,7 @@ export class View {
|
|
|
276
291
|
interactive: (label) => labelHandlers(label, 'click').length > 0 || labelHandlers(label, 'hover').length > 0,
|
|
277
292
|
on: (type, label, e) => this.#labelPointer(type, label, e),
|
|
278
293
|
})
|
|
294
|
+
if (options.debug !== undefined) this.debug = options.debug
|
|
279
295
|
|
|
280
296
|
if (options.framing !== undefined) {
|
|
281
297
|
// 'both' is short for { keep: 'both' }
|
|
@@ -365,6 +381,32 @@ export class View {
|
|
|
365
381
|
this.invalidate()
|
|
366
382
|
}
|
|
367
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Debug drawing, or false: what it shows, as DebugOptions. Assign true for everything but normals,
|
|
386
|
+
* options for some, or false. Its code loads the first time it's turned on.
|
|
387
|
+
*
|
|
388
|
+
* view.debug = { lights: true, shadows: true, bounds: 'hovered', stats: 'bottom-right' }
|
|
389
|
+
* @returns {boolean | DebugOptions} the options in force, every one given, or false
|
|
390
|
+
*/
|
|
391
|
+
get debug() { return this.#debug ?? false }
|
|
392
|
+
/** @param {boolean | DebugOptions} v */
|
|
393
|
+
set debug(v) {
|
|
394
|
+
this.#debug = debugOptions(v)
|
|
395
|
+
if (this.#debug && !this.#debugDraw && !this.#debugLoading) {
|
|
396
|
+
this.#debugLoading = true
|
|
397
|
+
import('./debug.js').then(({ DebugDraw }) => {
|
|
398
|
+
this.#debugLoading = false
|
|
399
|
+
if (this.#disposed) return
|
|
400
|
+
this.#debugDraw = new DebugDraw(this.#labels.root)
|
|
401
|
+
this.invalidate()
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
// hovered helpers need the pointer followed
|
|
405
|
+
if (this.#debug && (this.#debug.bounds === 'hovered' || this.#debug.normals === 'hovered')) this.#pickPending = true
|
|
406
|
+
if (!this.#debug) this.#debugDraw?.stats(false, /** @type {any} */ ({}), 0, 0)
|
|
407
|
+
this.invalidate()
|
|
408
|
+
}
|
|
409
|
+
|
|
368
410
|
/**
|
|
369
411
|
* What the view shows, for screen readers - the canvas's label - or null. With keyboard on and no
|
|
370
412
|
* description, one is made from the scene.
|
|
@@ -510,9 +552,28 @@ export class View {
|
|
|
510
552
|
this.scene.updateWorldTree()
|
|
511
553
|
const frame = collect(this.scene, this.camera)
|
|
512
554
|
if (this.autoLight && frame.lights.length === 0) this.#addAutoLights(frame)
|
|
555
|
+
frame.time = this.time
|
|
513
556
|
frame.highlight = this.#highlightFor(this.#hovered)
|
|
514
557
|
this.#occlude(frame)
|
|
558
|
+
// debug helpers go on the end of the frame for drawing, and come off again after: never picked,
|
|
559
|
+
// explained or kept in lastFrame
|
|
560
|
+
const debug = this.#debug && this.#debugDraw
|
|
561
|
+
? this.#debugDraw.add(frame, this.#debug, { camera: this.camera, hovered: this.#hovered, bounds: this.scene.boundsFrom(this.camera) })
|
|
562
|
+
: null
|
|
563
|
+
const started = performance.now()
|
|
515
564
|
this.renderer.renderFrame(frame, { wait })
|
|
565
|
+
if (debug) {
|
|
566
|
+
frame.opaque.length -= debug.opaque
|
|
567
|
+
frame.transparent.length -= debug.transparent
|
|
568
|
+
}
|
|
569
|
+
if (this.#debugDraw) {
|
|
570
|
+
const now = performance.now()
|
|
571
|
+
// time between frames drawn one after another, smoothed; after a pause, none
|
|
572
|
+
const gap = this.#drawnAt ? now - this.#drawnAt : 0
|
|
573
|
+
this.#frameMs = gap > 0 && gap < 250 ? this.#frameMs * 0.9 + gap * 0.1 : 0
|
|
574
|
+
this.#drawnAt = now
|
|
575
|
+
this.#debugDraw.stats(this.#debug?.stats ?? false, this.renderer.info, this.#frameMs, now - started)
|
|
576
|
+
}
|
|
516
577
|
this.#labelStats = this.#labels.update(frame.labels, frame.overlays)
|
|
517
578
|
// a node with its own tooltip needs the tooltip's code
|
|
518
579
|
if (frame.tooltips && !this.#tooltip) this.#loadTooltip()
|
|
@@ -520,7 +581,7 @@ export class View {
|
|
|
520
581
|
this.#dirty = false
|
|
521
582
|
// remember what was drawn - including what drawing changed, such as fitting the camera - so
|
|
522
583
|
// the next frame compares against it and doesn't draw the same picture again
|
|
523
|
-
if (this.renderMode === 'auto') this.#watch.changed(this.scene, this.camera)
|
|
584
|
+
if (this.renderMode === 'auto') this.#watch.changed(this.scene, this.camera, this.time)
|
|
524
585
|
// things may have moved under a still pointer - or the keyboard's focus moved on screen
|
|
525
586
|
if ((this.#pointer || this.#keyFocus) && this.#needsHover()) this.#pickPending = true
|
|
526
587
|
return frame
|
|
@@ -731,7 +792,9 @@ export class View {
|
|
|
731
792
|
const frame = this.lastFrame
|
|
732
793
|
if (!frame) return null
|
|
733
794
|
let work = 0
|
|
734
|
-
for (const { node, geometry, instances } of [...frame.opaque, ...frame.transparent]) {
|
|
795
|
+
for (const { node, geometry, instances, material } of [...frame.opaque, ...frame.transparent]) {
|
|
796
|
+
// a shader hook moves vertices where only the GPU knows: picked there, however small the scene
|
|
797
|
+
if (material.shader?.vertex) return frame
|
|
735
798
|
const mode = /** @type {any} */ (node).drawMode
|
|
736
799
|
work += instances ? instances.count : mode === 'points' ? geometry.drawnVertices : mode === 'lines' ? geometry.drawnCount / 2 : geometry.drawnCount / 3
|
|
737
800
|
}
|
|
@@ -747,7 +810,8 @@ export class View {
|
|
|
747
810
|
#exact(found, frame) {
|
|
748
811
|
const node = /** @type {any} */ (found.item.node)
|
|
749
812
|
const raycaster = new Raycaster().setFromCamera(found.ndc, this.camera)
|
|
750
|
-
|
|
813
|
+
// moved by a shader hook, the CPU's copy isn't where it was drawn: the GPU's depth is the hit
|
|
814
|
+
const hit = node.material.shader?.vertex ? null : raycaster.intersectElement(node, found.element)
|
|
751
815
|
if (hit) return hit
|
|
752
816
|
// the ray grazed an edge the GPU drew: place the hit at the depth the GPU found
|
|
753
817
|
const inverse = new Mat4().copy(frame.camera.viewProjection).invert()
|
|
@@ -803,6 +867,21 @@ export class View {
|
|
|
803
867
|
return new Mat4().multiplyMatrices(camera.projectionMatrix, camera.viewMatrix)
|
|
804
868
|
}
|
|
805
869
|
|
|
870
|
+
/**
|
|
871
|
+
* Everything inside an area of the canvas, nearest first: a box `{ x, y, width, height }` or a
|
|
872
|
+
* lasso of `[x, y]` points, CSS pixels from the canvas's top left. Each point, instance and line
|
|
873
|
+
* segment is its own hit, found by where it is (a mesh by its middle) - hidden behind others or
|
|
874
|
+
* not - so a box dragged over a scatter selects that region of it.
|
|
875
|
+
*
|
|
876
|
+
* const picked = view.pickArea({ x: 40, y: 30, width: 200, height: 120 }).map((h) => h.datum)
|
|
877
|
+
* @param {import('./area.js').PickArea} area
|
|
878
|
+
* @returns {Hit[]}
|
|
879
|
+
*/
|
|
880
|
+
pickArea(area) {
|
|
881
|
+
this.scene.updateWorldTree()
|
|
882
|
+
return pickArea(this.scene, this.camera, this.#viewProjection(), this.#width, this.#height, area)
|
|
883
|
+
}
|
|
884
|
+
|
|
806
885
|
/** Every hit under a canvas position, nearest first. @param {number | MouseEvent} x @param {number} [y] @returns {Hit[]} */
|
|
807
886
|
pickAll(x, y) {
|
|
808
887
|
if (typeof x === 'object') {
|
|
@@ -961,6 +1040,7 @@ export class View {
|
|
|
961
1040
|
contextLost: !!this.renderer.gl?.isContextLost?.(),
|
|
962
1041
|
interaction: { hover: this.#handlers.hover.size, click: this.#handlers.click.size, tooltip: !!this.#tooltipContent, highlight: !!this.#highlight,
|
|
963
1042
|
keyboard: !!this.#keyboard, reducedMotion: this.reducedMotion, description: this.canvas.getAttribute?.('aria-label') ?? this.#description },
|
|
1043
|
+
debug: this.#debug,
|
|
964
1044
|
})
|
|
965
1045
|
}
|
|
966
1046
|
|
|
@@ -978,6 +1058,7 @@ export class View {
|
|
|
978
1058
|
this.#labels.dispose()
|
|
979
1059
|
this.#tooltip?.dispose()
|
|
980
1060
|
this.#keyboard?.dispose()
|
|
1061
|
+
this.#debugDraw?.dispose()
|
|
981
1062
|
if (this.#description !== null && !this.#keyboard) this.description = null
|
|
982
1063
|
this.renderer.dispose(this.scene)
|
|
983
1064
|
if (this.#ownContext) ownContexts--
|
|
@@ -1025,7 +1106,7 @@ export class View {
|
|
|
1025
1106
|
this.#updateControls(dt)
|
|
1026
1107
|
if (this.#pickPending) this.#updateHover()
|
|
1027
1108
|
const draw = this.#dirty || this.renderMode === 'always' ||
|
|
1028
|
-
(this.renderMode === 'auto' && this.#watch.changed(this.scene, this.camera))
|
|
1109
|
+
(this.renderMode === 'auto' && this.#watch.changed(this.scene, this.camera, this.time))
|
|
1029
1110
|
if (draw) this.render()
|
|
1030
1111
|
} finally {
|
|
1031
1112
|
// the next frame comes even if this one threw - the error still reaches the console, but one
|
|
@@ -1036,7 +1117,12 @@ export class View {
|
|
|
1036
1117
|
|
|
1037
1118
|
#needsHover() {
|
|
1038
1119
|
return this.#handlers.hover.size > 0 || !!this.#tooltipContent || !!this.#highlight || this.#handlers.click.size > 0 ||
|
|
1039
|
-
!!this.lastFrame?.interactive
|
|
1120
|
+
!!this.lastFrame?.interactive || this.#debugHovers()
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** Does debug draw something for what is under the pointer? */
|
|
1124
|
+
#debugHovers() {
|
|
1125
|
+
return !!this.#debug && (this.#debug.bounds === 'hovered' || this.#debug.normals === 'hovered')
|
|
1040
1126
|
}
|
|
1041
1127
|
|
|
1042
1128
|
// pick under the pointer and tell everyone when the thing under it changes - on the GPU without
|
|
@@ -1081,7 +1167,7 @@ export class View {
|
|
|
1081
1167
|
if (p && hit && this.#tooltip) this.#tooltip.show(hit, p.clientX, p.clientY)
|
|
1082
1168
|
else this.#tooltip?.hide()
|
|
1083
1169
|
if (same) return
|
|
1084
|
-
if (this.#highlightFor(before) || this.#highlightFor(hit)) this.invalidate()
|
|
1170
|
+
if (this.#highlightFor(before) || this.#highlightFor(hit) || this.#debugHovers()) this.invalidate()
|
|
1085
1171
|
if (this.#handlers.click.size || this.lastFrame?.interactive) {
|
|
1086
1172
|
this.canvas.style.cursor = hit && nearestAbove(hit.node, 'onDrag') ? 'grab' : hit && this.#clickable(hit) ? 'pointer' : ''
|
|
1087
1173
|
}
|
|
@@ -1474,3 +1560,31 @@ function resolveCanvas(canvas) {
|
|
|
1474
1560
|
if (!(canvas instanceof HTMLCanvasElement)) fail('View', `'canvas' must be a <canvas> element or a selector, got ${describe(canvas)}`)
|
|
1475
1561
|
return canvas
|
|
1476
1562
|
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* Debug options checked: true for everything but normals, false or null for none.
|
|
1566
|
+
* @param {boolean | DebugOptions | null} v
|
|
1567
|
+
* @returns {Required<DebugOptions> | null}
|
|
1568
|
+
*/
|
|
1569
|
+
function debugOptions(v) {
|
|
1570
|
+
if (v === false || v === null) return null
|
|
1571
|
+
if (v === true) return { ...DEBUG_ALL }
|
|
1572
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) fail('View', `'debug' is true, false or { ${DEBUG_OPTIONS.join(', ')} }, got ${describe(v)}`)
|
|
1573
|
+
checkOptions('View', v, DEBUG_OPTIONS, { helpers: "list what to draw: { lights: true, bounds: true, ... }", fps: "use 'stats: true'", wireframe: "use new Lines({ geometry: edges(geometry) }) for a wireframe" }, 'debug')
|
|
1574
|
+
/** @type {any} */
|
|
1575
|
+
const o = {}
|
|
1576
|
+
for (const key of DEBUG_OPTIONS) {
|
|
1577
|
+
const value = /** @type {any} */ (v)[key]
|
|
1578
|
+
if (value === undefined) o[key] = false
|
|
1579
|
+
else if (key === 'bounds' || key === 'normals') o[key] = value === 'hovered' ? 'hovered' : checkBool(key, value, "true, false or 'hovered'")
|
|
1580
|
+
else if (key === 'stats') o[key] = value === true ? 'top-left' : value === false ? false : checkOneOf('View', 'debug.stats', value, CORNERS)
|
|
1581
|
+
else o[key] = checkBool(key, value, 'true or false')
|
|
1582
|
+
}
|
|
1583
|
+
return o
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/** @param {string} key @param {unknown} v @param {string} expected */
|
|
1587
|
+
function checkBool(key, v, expected) {
|
|
1588
|
+
if (typeof v !== 'boolean') fail('View', `debug.${key} is ${expected}, got ${describe(v)}`)
|
|
1589
|
+
return v
|
|
1590
|
+
}
|
package/src/area.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Picking an area of the canvas: everything whose place on screen falls inside a box or a lasso drawn
|
|
2
|
+
// over it - each point, instance and line segment on its own, and a mesh by its middle. Positions
|
|
3
|
+
// are tested, not surfaces, so what is hidden behind other things is found too: selecting a region
|
|
4
|
+
// of a scatter takes the whole region, front to back.
|
|
5
|
+
import * as mat4 from './math/mat4.js'
|
|
6
|
+
import { hiddenInstance } from './scene/drawables.js'
|
|
7
|
+
import { Vec3 } from './value/Vec3.js'
|
|
8
|
+
import { describe, fail } from './validate.js'
|
|
9
|
+
|
|
10
|
+
/** @import { Hit } from './Raycaster.js' */
|
|
11
|
+
/** @import { Node } from './scene/Node.js' */
|
|
12
|
+
/** @import { Camera } from './scene/cameras.js' */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A box on the canvas, CSS pixels from its top left - width and height may be negative, as a drag
|
|
16
|
+
* up or left makes them - or a lasso: the points of a polygon, [x, y] each.
|
|
17
|
+
* @typedef {{ x: number, y: number, width: number, height: number } | [number, number][]} PickArea
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const instanceWorld = new Float32Array(16)
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The polygon an area is, and its bounding box, for the inside test.
|
|
24
|
+
* @param {PickArea} area
|
|
25
|
+
* @returns {{ points: number[], left: number, top: number, right: number, bottom: number }}
|
|
26
|
+
*/
|
|
27
|
+
function outline(area) {
|
|
28
|
+
/** @type {number[]} */
|
|
29
|
+
let points
|
|
30
|
+
if (Array.isArray(area)) {
|
|
31
|
+
if (area.length < 3 || !area.every((p) => Array.isArray(p) && p.length === 2 && p.every(Number.isFinite))) {
|
|
32
|
+
fail('View', `pickArea() takes a box { x, y, width, height } or a lasso of three or more [x, y] points, got ${describe(area)}`)
|
|
33
|
+
}
|
|
34
|
+
points = area.flat()
|
|
35
|
+
} else {
|
|
36
|
+
const { x, y, width, height } = area ?? /** @type {any} */ ({})
|
|
37
|
+
if (![x, y, width, height].every(Number.isFinite)) {
|
|
38
|
+
fail('View', `pickArea() takes a box { x, y, width, height } or a lasso of three or more [x, y] points, got ${describe(area)}`)
|
|
39
|
+
}
|
|
40
|
+
points = [x, y, x + width, y, x + width, y + height, x, y + height]
|
|
41
|
+
}
|
|
42
|
+
let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity
|
|
43
|
+
for (let i = 0; i < points.length; i += 2) {
|
|
44
|
+
left = Math.min(left, points[i])
|
|
45
|
+
right = Math.max(right, points[i])
|
|
46
|
+
top = Math.min(top, points[i + 1])
|
|
47
|
+
bottom = Math.max(bottom, points[i + 1])
|
|
48
|
+
}
|
|
49
|
+
return { points, left, top, right, bottom }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Is (x, y) inside the polygon? Even-odd crossings. @param {number[]} p @param {number} x @param {number} y */
|
|
53
|
+
function inside(p, x, y) {
|
|
54
|
+
let into = false
|
|
55
|
+
for (let i = 0, j = p.length - 2; i < p.length; j = i, i += 2) {
|
|
56
|
+
const yi = p[i + 1], yj = p[j + 1]
|
|
57
|
+
if ((yi > y) !== (yj > y) && x < p[i] + (p[j] - p[i]) * (y - yi) / (yj - yi)) into = !into
|
|
58
|
+
}
|
|
59
|
+
return into
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Everything in the scene the camera shows, pickable, whose place falls inside the area - nearest
|
|
64
|
+
* the camera first.
|
|
65
|
+
* @param {Node} scene @param {Camera} camera @param {Float32Array} viewProjection
|
|
66
|
+
* @param {number} width @param {number} height the canvas, CSS pixels
|
|
67
|
+
* @param {PickArea} area
|
|
68
|
+
* @returns {Hit[]}
|
|
69
|
+
*/
|
|
70
|
+
export function pickArea(scene, camera, viewProjection, width, height, area) {
|
|
71
|
+
const shape = outline(area)
|
|
72
|
+
const eye = camera.worldPosition
|
|
73
|
+
const m = viewProjection
|
|
74
|
+
/** @type {Hit[]} */
|
|
75
|
+
const hits = []
|
|
76
|
+
/** World position (x, y, z) inside the area? @param {number} x @param {number} y @param {number} z */
|
|
77
|
+
const within = (x, y, z) => {
|
|
78
|
+
const w = m[3] * x + m[7] * y + m[11] * z + m[15]
|
|
79
|
+
if (w <= 0) return false
|
|
80
|
+
const nz = (m[2] * x + m[6] * y + m[10] * z + m[14]) / w
|
|
81
|
+
if (nz < -1 || nz > 1) return false
|
|
82
|
+
const sx = ((m[0] * x + m[4] * y + m[8] * z + m[12]) / w * 0.5 + 0.5) * width
|
|
83
|
+
const sy = (0.5 - (m[1] * x + m[5] * y + m[9] * z + m[13]) / w * 0.5) * height
|
|
84
|
+
return sx >= shape.left && sx <= shape.right && sy >= shape.top && sy <= shape.bottom && inside(shape.points, sx, sy)
|
|
85
|
+
}
|
|
86
|
+
/** @param {any} node @param {number} x @param {number} y @param {number} z @param {number | null} face @param {number | null} instance */
|
|
87
|
+
const hit = (node, x, y, z, face, instance) => {
|
|
88
|
+
const point = new Vec3(x, y, z)
|
|
89
|
+
hits.push({ node, point, distance: point.distanceTo(eye), normal: null, face, instance, uv: null, datum: node.datumAt(instance ?? face) })
|
|
90
|
+
}
|
|
91
|
+
const p = new Vec3()
|
|
92
|
+
|
|
93
|
+
/** @param {Node} node */
|
|
94
|
+
const visit = (node) => {
|
|
95
|
+
if (!node.shownTo(camera) || !node.pickable) return
|
|
96
|
+
const n = /** @type {any} */ (node)
|
|
97
|
+
if (n.isDrawable && n.geometry.vertexCount) {
|
|
98
|
+
const g = n.geometry, world = n.worldMatrix
|
|
99
|
+
if (n.drawMode === 'points') {
|
|
100
|
+
const pos = g.positions
|
|
101
|
+
for (let i = 0; i < g.drawnVertices; i++) {
|
|
102
|
+
p.set(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]).applyMatrix4(world)
|
|
103
|
+
if (within(p[0], p[1], p[2])) hit(n, p[0], p[1], p[2], i, null)
|
|
104
|
+
}
|
|
105
|
+
} else if (n.drawMode === 'lines') {
|
|
106
|
+
// a segment by its middle
|
|
107
|
+
const pos = g.positions, idx = g.indices
|
|
108
|
+
for (let s = 0; s < Math.floor(g.drawnCount / 2); s++) {
|
|
109
|
+
const a = idx ? idx[s * 2] : s * 2, b = idx ? idx[s * 2 + 1] : s * 2 + 1
|
|
110
|
+
p.set((pos[a * 3] + pos[b * 3]) / 2, (pos[a * 3 + 1] + pos[b * 3 + 1]) / 2, (pos[a * 3 + 2] + pos[b * 3 + 2]) / 2).applyMatrix4(world)
|
|
111
|
+
if (within(p[0], p[1], p[2])) hit(n, p[0], p[1], p[2], s, null)
|
|
112
|
+
}
|
|
113
|
+
} else if (n.instanceMatrices) {
|
|
114
|
+
// each instance by the middle of its copy of the geometry; hidden ones aren't there
|
|
115
|
+
const centre = g.bounds.getCenter()
|
|
116
|
+
for (let i = 0; i < n.count; i++) {
|
|
117
|
+
if (hiddenInstance(n.instanceMatrices, i)) continue
|
|
118
|
+
mat4.multiply(instanceWorld, world, n.instanceMatrices.subarray(i * 16, i * 16 + 16))
|
|
119
|
+
p.copy(centre).applyMatrix4(instanceWorld)
|
|
120
|
+
if (within(p[0], p[1], p[2])) hit(n, p[0], p[1], p[2], null, i)
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
p.copy(g.bounds.getCenter()).applyMatrix4(world)
|
|
124
|
+
if (within(p[0], p[1], p[2])) hit(n, p[0], p[1], p[2], null, null)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const child of node.children) visit(child)
|
|
128
|
+
}
|
|
129
|
+
visit(scene)
|
|
130
|
+
return hits.sort((a, b) => a.distance - b.distance)
|
|
131
|
+
}
|
package/src/charts/marks.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// lay out and draw, never animate.
|
|
4
4
|
import { Geometry } from '../geometry/Geometry.js'
|
|
5
5
|
import { box as boxGeometry, icosphere } from '../geometry/generators.js'
|
|
6
|
+
import { Texture } from '../material/Texture.js'
|
|
6
7
|
import { InstancedMesh, Lines, Mesh, Points } from '../scene/drawables.js'
|
|
7
8
|
import { fail } from '../validate.js'
|
|
8
9
|
import { distinct } from './scales.js'
|
|
@@ -154,8 +155,10 @@ export function pointMark(ctx) {
|
|
|
154
155
|
/** @type {any} */
|
|
155
156
|
let node = sized
|
|
156
157
|
? new InstancedMesh({ geometry: icosphere({ radius: 1, detail: 1 }), material, count: 64, labels: labelsOf(ctx, 'above') })
|
|
157
|
-
|
|
158
|
-
|
|
158
|
+
// dots with a darker rim, so each one reads where they crowd together - drawn furthest first, so
|
|
159
|
+
// each blends over the ones behind it
|
|
160
|
+
: new Points({ geometry: emptyPoints(64), material: { size: ctx.spec.channels.size?.value ?? unit * 1.4, vertexColors: true, map: dot(), depthWrite: false, ...material },
|
|
161
|
+
sort: true, labels: labelsOf(ctx, 'above') })
|
|
159
162
|
if (sized) node.count = 0
|
|
160
163
|
const mark = {
|
|
161
164
|
nodes: [node],
|
|
@@ -205,6 +208,30 @@ export function pointMark(ctx) {
|
|
|
205
208
|
return mark
|
|
206
209
|
}
|
|
207
210
|
|
|
211
|
+
/** @type {Texture | null} */
|
|
212
|
+
let dotTexture = null
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A dot: a white disc whose outer part darkens to a rim, with a soft edge - pixels of its own, so
|
|
216
|
+
* charts need no canvas to make it. Shared by every scatter.
|
|
217
|
+
*/
|
|
218
|
+
function dot() {
|
|
219
|
+
if (dotTexture) return dotTexture
|
|
220
|
+
const size = 64, data = new Uint8Array(size * size * 4), r = size / 2
|
|
221
|
+
for (let y = 0; y < size; y++) {
|
|
222
|
+
for (let x = 0; x < size; x++) {
|
|
223
|
+
const d = Math.hypot(x + 0.5 - r, y + 0.5 - r) / r
|
|
224
|
+
// the colour full to 0.78 of the radius, then down to 0.62 at the edge; alpha soft over the last pixel and a half
|
|
225
|
+
const shade = d < 0.78 ? 1 : 1 - (d - 0.78) / 0.22 * 0.38
|
|
226
|
+
const alpha = Math.min(1, Math.max(0, (1 - d) * r / 1.5))
|
|
227
|
+
const o = (y * size + x) * 4
|
|
228
|
+
data[o] = data[o + 1] = data[o + 2] = Math.round(shade * 255)
|
|
229
|
+
data[o + 3] = Math.round(alpha * 255)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return (dotTexture = new Texture({ image: { data, width: size, height: size } }))
|
|
233
|
+
}
|
|
234
|
+
|
|
208
235
|
/** @param {number} n */
|
|
209
236
|
function emptyPoints(n) {
|
|
210
237
|
const g = new Geometry({ positions: new Float32Array(n * 3), colors: new Float32Array(n * 3) })
|