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.
Files changed (69) hide show
  1. package/README.md +11 -9
  2. package/dist/bloom.js +4 -4
  3. package/dist/bloom.js.map +1 -1
  4. package/dist/charts.js +527 -510
  5. package/dist/charts.js.map +1 -1
  6. package/dist/core.js +1760 -1170
  7. package/dist/core.js.map +1 -1
  8. package/dist/debug.js +297 -0
  9. package/dist/debug.js.map +1 -0
  10. package/dist/edit.js +60 -60
  11. package/dist/edit.js.map +1 -1
  12. package/dist/explain.js +32 -13
  13. package/dist/explain.js.map +1 -1
  14. package/dist/gltf.js +155 -155
  15. package/dist/gltf.js.map +1 -1
  16. package/dist/keyboard.js +188 -0
  17. package/dist/keyboard.js.map +1 -0
  18. package/dist/motion.js +35 -35
  19. package/dist/motion.js.map +1 -1
  20. package/dist/particles.js +187 -114
  21. package/dist/particles.js.map +1 -1
  22. package/dist/passes.js +116 -104
  23. package/dist/passes.js.map +1 -1
  24. package/dist/phoria2.js +1 -1
  25. package/dist/picking.js +95 -62
  26. package/dist/picking.js.map +1 -1
  27. package/dist/snapshot.js +1 -1
  28. package/dist/tooltip.js +1 -1
  29. package/dist/types/View.d.ts +27 -0
  30. package/dist/types/area.d.ts +18 -0
  31. package/dist/types/debug.d.ts +74 -0
  32. package/dist/types/explain.d.ts +4 -0
  33. package/dist/types/frame.d.ts +5 -0
  34. package/dist/types/geometry/Geometry.d.ts +16 -0
  35. package/dist/types/index.d.ts +2 -0
  36. package/dist/types/material/Material.d.ts +67 -0
  37. package/dist/types/material/Texture.d.ts +18 -2
  38. package/dist/types/particles/Emitter.d.ts +39 -2
  39. package/dist/types/scene/drawables.d.ts +23 -2
  40. package/dist/types/watch.d.ts +8 -1
  41. package/dist/types/webgl/hooks.d.ts +48 -0
  42. package/dist/types/webgl/passes.d.ts +4 -0
  43. package/dist/types/webgl/programs.d.ts +15 -5
  44. package/dist/types/webgl/resources.d.ts +12 -2
  45. package/dist/types/webgl/shaders.d.ts +41 -5
  46. package/llms-full.txt +275 -1
  47. package/llms.txt +1 -1
  48. package/package.json +1 -1
  49. package/schema/scene.schema.json +75 -0
  50. package/src/View.js +121 -7
  51. package/src/area.js +131 -0
  52. package/src/charts/marks.js +29 -2
  53. package/src/debug.js +283 -0
  54. package/src/explain.js +19 -0
  55. package/src/frame.js +7 -2
  56. package/src/geometry/Geometry.js +16 -1
  57. package/src/index.js +1 -0
  58. package/src/material/Material.js +130 -4
  59. package/src/material/Texture.js +55 -2
  60. package/src/particles/Emitter.js +121 -10
  61. package/src/scene/drawables.js +89 -8
  62. package/src/watch.js +26 -3
  63. package/src/webgl/WebGLRenderer.js +55 -7
  64. package/src/webgl/hooks.js +121 -0
  65. package/src/webgl/passes.js +17 -2
  66. package/src/webgl/picking.js +45 -3
  67. package/src/webgl/programs.js +20 -9
  68. package/src/webgl/resources.js +169 -15
  69. 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
- const hit = raycaster.intersectElement(node, found.element)
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
+ }
@@ -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
- : new Points({ geometry: emptyPoints(64), material: { size: ctx.spec.channels.size?.value ?? unit * 1.4, vertexColors: true, ...material },
158
- labels: labelsOf(ctx, 'above') })
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) })