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.
Files changed (76) hide show
  1. package/README.md +71 -13
  2. package/dist/bloom.js +1 -1
  3. package/dist/charts.js +397 -384
  4. package/dist/charts.js.map +1 -1
  5. package/dist/core.js +4451 -3568
  6. package/dist/core.js.map +1 -1
  7. package/dist/edit.js +67 -67
  8. package/dist/edit.js.map +1 -1
  9. package/dist/explain.js +6 -2
  10. package/dist/explain.js.map +1 -1
  11. package/dist/gltf.js +211 -166
  12. package/dist/gltf.js.map +1 -1
  13. package/dist/motion.js +26 -26
  14. package/dist/motion.js.map +1 -1
  15. package/dist/particles.js +49 -49
  16. package/dist/particles.js.map +1 -1
  17. package/dist/passes.js +188 -86
  18. package/dist/passes.js.map +1 -1
  19. package/dist/phoria2.js +1 -1
  20. package/dist/picking.js +8 -13
  21. package/dist/picking.js.map +1 -1
  22. package/dist/snapshot.js +1 -1
  23. package/dist/tooltip.js +3 -3
  24. package/dist/tooltip.js.map +1 -1
  25. package/dist/types/OrbitControl.d.ts +6 -2
  26. package/dist/types/Raycaster.d.ts +10 -0
  27. package/dist/types/View.d.ts +64 -1
  28. package/dist/types/charts/Chart.d.ts +7 -0
  29. package/dist/types/explain.d.ts +3 -0
  30. package/dist/types/frame.d.ts +7 -0
  31. package/dist/types/geometry/Geometry.d.ts +14 -2
  32. package/dist/types/keyboard.d.ts +48 -0
  33. package/dist/types/labels.d.ts +4 -0
  34. package/dist/types/material/Material.d.ts +125 -0
  35. package/dist/types/material/Texture.d.ts +37 -5
  36. package/dist/types/scene/Node.d.ts +19 -14
  37. package/dist/types/scene/drawables.d.ts +32 -14
  38. package/dist/types/scene/lights.d.ts +16 -2
  39. package/dist/types/shadow.d.ts +2 -1
  40. package/dist/types/types.d.ts +14 -0
  41. package/dist/types/value/Color.d.ts +0 -4
  42. package/dist/types/value/Quat.d.ts +4 -2
  43. package/dist/types/webgl/WebGLRenderer.d.ts +6 -0
  44. package/dist/types/webgl/passes.d.ts +37 -13
  45. package/dist/types/webgl/resources.d.ts +24 -2
  46. package/dist/types/webgl/shaders.d.ts +4 -2
  47. package/llms-full.txt +153 -15
  48. package/package.json +4 -1
  49. package/schema/scene.schema.json +146 -1
  50. package/src/OrbitControl.js +7 -3
  51. package/src/Raycaster.js +58 -0
  52. package/src/View.js +207 -42
  53. package/src/charts/Chart.js +20 -2
  54. package/src/explain.js +6 -2
  55. package/src/frame.js +29 -9
  56. package/src/geometry/Geometry.js +24 -4
  57. package/src/gltf/load.js +50 -6
  58. package/src/keyboard.js +298 -0
  59. package/src/labels.js +4 -1
  60. package/src/material/Material.js +104 -19
  61. package/src/material/Texture.js +116 -11
  62. package/src/scene/Node.js +20 -9
  63. package/src/scene/Scene.js +2 -0
  64. package/src/scene/cameras.js +4 -1
  65. package/src/scene/drawables.js +48 -12
  66. package/src/scene/lights.js +40 -6
  67. package/src/shadow.js +27 -4
  68. package/src/types.js +6 -0
  69. package/src/value/Color.js +21 -0
  70. package/src/value/Quat.js +15 -6
  71. package/src/watch.js +25 -1
  72. package/src/webgl/WebGLRenderer.js +51 -12
  73. package/src/webgl/passes.js +190 -32
  74. package/src/webgl/picking.js +0 -1
  75. package/src/webgl/resources.js +89 -8
  76. package/src/webgl/shaders.js +350 -48
@@ -6,11 +6,13 @@ import { checkOptions, describe, fail } from '../validate.js'
6
6
  * @property {ArrayLike<number>} positions xyz per vertex
7
7
  * @property {ArrayLike<number>} [normals] xyz per vertex; computed (smooth) when missing
8
8
  * @property {ArrayLike<number>} [uvs] uv per vertex - glTF convention, (0, 0) is the image's top left
9
+ * @property {ArrayLike<number>} [uvs2] a second uv per vertex, for maps that need every surface on its own
10
+ * patch of texture - baked occlusion and light maps (a texture's uvSet: 1 reads it)
9
11
  * @property {ArrayLike<number>} [colors] rgb per vertex, sRGB 0-1 like every colour input
10
12
  * @property {ArrayLike<number>} [indices] vertex indices; triangles for meshes, pairs for lines
11
13
  */
12
14
 
13
- const OPTIONS = ['positions', 'normals', 'uvs', 'colors', 'indices']
15
+ const OPTIONS = ['positions', 'normals', 'uvs', 'uvs2', 'colors', 'indices']
14
16
 
15
17
  /**
16
18
  * Vertex data: flat typed arrays plus an optional index. Generators such as box() and sphere()
@@ -21,6 +23,7 @@ export class Geometry {
21
23
  static OPTIONS = OPTIONS
22
24
  /** @type {Float32Array} */ positions
23
25
  /** @type {Float32Array | null} */ uvs = null
26
+ /** A second UV set, for baked occlusion and light maps. @type {Float32Array | null} */ uvs2 = null
24
27
  /** @type {Float32Array | null} */ colors = null
25
28
  /** @type {Uint16Array | Uint32Array | null} */ indices = null
26
29
 
@@ -42,6 +45,8 @@ export class Geometry {
42
45
 
43
46
  /** @type {Float32Array | null} */
44
47
  #normals = null
48
+ /** The normals were worked out on first read, rather than given or asked for: they follow the shape. */
49
+ #autoNormals = false
45
50
  /** @type {Box3 | null} */
46
51
  #bounds = null
47
52
  /** @type {Set<() => void>} */
@@ -55,6 +60,7 @@ export class Geometry {
55
60
  const n = this.vertexCount
56
61
  if (options.normals) this.#normals = toFloat32('normals', options.normals, 3, n)
57
62
  if (options.uvs) this.uvs = toFloat32('uvs', options.uvs, 2, n)
63
+ if (options.uvs2) this.uvs2 = toFloat32('uvs2', options.uvs2, 2, n)
58
64
  if (options.colors) this.colors = toFloat32('colors', options.colors, 3, n)
59
65
  if (options.indices) {
60
66
  // -1: no indices at all refer to no vertex, so an empty geometry can say so
@@ -71,16 +77,22 @@ export class Geometry {
71
77
 
72
78
  /**
73
79
  * xyz per vertex. Computed (smooth) from the triangles on first read when not supplied, so
74
- * points and lines - whose geometry has no triangles - never pay for them.
80
+ * points and lines - whose geometry has no triangles - never pay for them - and computed again after
81
+ * needsUpdate(), so they follow positions edited in place. Normals that were supplied, assigned or
82
+ * made by calling computeNormals() are kept as they are.
75
83
  * @type {Float32Array}
76
84
  */
77
85
  get normals() {
78
- if (!this.#normals) this.computeNormals()
86
+ if (!this.#normals) {
87
+ this.computeNormals()
88
+ this.#autoNormals = true
89
+ }
79
90
  return /** @type {Float32Array} */ (this.#normals)
80
91
  }
81
92
 
82
93
  set normals(v) {
83
94
  this.#normals = v
95
+ this.#autoNormals = false
84
96
  }
85
97
 
86
98
  /** True once normals exist, supplied or computed. */
@@ -117,9 +129,14 @@ export class Geometry {
117
129
  return this.#bounds ??= new Box3().expandByPositions(this.positions)
118
130
  }
119
131
 
120
- /** Call after editing the arrays in place. It is no longer what its generator made, so toJSON() saves the arrays. @returns {this} */
132
+ /**
133
+ * Call after editing the arrays in place. It is no longer what its generator made, so toJSON() saves
134
+ * the arrays; normals that were worked out on first read are worked out again on the next.
135
+ * @returns {this}
136
+ */
121
137
  needsUpdate() {
122
138
  this.source = null
139
+ if (this.#autoNormals) this.#normals = null
123
140
  return this.#changed()
124
141
  }
125
142
 
@@ -172,6 +189,7 @@ export class Geometry {
172
189
  positions: this.positions,
173
190
  normals: this.#normals ?? undefined,
174
191
  uvs: this.uvs ?? undefined,
192
+ uvs2: this.uvs2 ?? undefined,
175
193
  colors: this.colors ?? undefined,
176
194
  indices: this.indices ?? undefined,
177
195
  })
@@ -198,6 +216,7 @@ export class Geometry {
198
216
  const json = { positions: Array.from(this.positions) }
199
217
  if (this.#normals) json.normals = Array.from(this.#normals)
200
218
  if (this.uvs) json.uvs = Array.from(this.uvs)
219
+ if (this.uvs2) json.uvs2 = Array.from(this.uvs2)
201
220
  if (this.colors) json.colors = Array.from(this.colors)
202
221
  if (this.indices) json.indices = Array.from(this.indices)
203
222
  return json
@@ -221,6 +240,7 @@ export class Geometry {
221
240
  }
222
241
  this.positions = /** @type {Float32Array} */ (expand(this.positions, 3))
223
242
  this.uvs = expand(this.uvs, 2)
243
+ this.uvs2 = expand(this.uvs2, 2)
224
244
  this.colors = expand(this.colors, 3)
225
245
  this.indices = null
226
246
  this.source = null
package/src/gltf/load.js CHANGED
@@ -25,7 +25,8 @@ import { Animation } from './Animation.js'
25
25
  */
26
26
 
27
27
  const SUPPORTED = new Set(['KHR_materials_emissive_strength', 'KHR_texture_transform', 'KHR_lights_punctual',
28
- 'KHR_materials_unlit', 'KHR_mesh_quantization'])
28
+ 'KHR_materials_unlit', 'KHR_mesh_quantization', 'KHR_materials_transmission', 'KHR_materials_clearcoat', 'KHR_materials_sheen',
29
+ 'KHR_materials_ior', 'KHR_materials_specular', 'KHR_materials_anisotropy', 'KHR_materials_diffuse_transmission'])
29
30
  const COMPRESSION = new Set(['KHR_draco_mesh_compression', 'EXT_meshopt_compression', 'KHR_texture_basisu'])
30
31
 
31
32
  const COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 }
@@ -266,6 +267,7 @@ class Builder {
266
267
  const options = { positions: this.#accessor(a.POSITION) }
267
268
  if (a.NORMAL !== undefined) options.normals = this.#accessor(a.NORMAL)
268
269
  if (a.TEXCOORD_0 !== undefined) options.uvs = this.#accessor(a.TEXCOORD_0)
270
+ if (a.TEXCOORD_1 !== undefined) options.uvs2 = this.#accessor(a.TEXCOORD_1)
269
271
  if (a.COLOR_0 !== undefined) options.colors = srgbColors(/** @type {Float32Array} */ (this.#accessor(a.COLOR_0)), COMPONENTS[this.json.accessors[a.COLOR_0].type])
270
272
  if (p.indices !== undefined) options.indices = this.#accessor(p.indices, true)
271
273
  const geometry = new Geometry(options)
@@ -364,6 +366,43 @@ class Builder {
364
366
  }
365
367
  material.emissiveIntensity = m.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? 1
366
368
  if (m.extensions?.KHR_materials_unlit) material.unlit = true
369
+ // glass, clear coat, cloth, reflectance, brushed metal and translucency, by their factors - their
370
+ // own textures aren't read
371
+ const clamp = (/** @type {number} */ v) => Math.min(Math.max(v, 0), 1)
372
+ const x = m.extensions ?? {}
373
+ const transmission = x.KHR_materials_transmission, coat = x.KHR_materials_clearcoat, sheen = x.KHR_materials_sheen
374
+ if (transmission) material.transmission = clamp(transmission.transmissionFactor ?? 0)
375
+ if (coat) {
376
+ material.clearcoat = clamp(coat.clearcoatFactor ?? 0)
377
+ material.clearcoatRoughness = clamp(coat.clearcoatRoughnessFactor ?? 0)
378
+ }
379
+ if (sheen) {
380
+ const c = sheen.sheenColorFactor ?? [0, 0, 0]
381
+ // glTF's sheen is its colour alone: black is none
382
+ if (Math.max(c[0], c[1], c[2]) > 0) {
383
+ material.sheen = 1
384
+ material.sheenColor.setLinear(c[0], c[1], c[2])
385
+ }
386
+ material.sheenRoughness = clamp(sheen.sheenRoughnessFactor ?? 0)
387
+ }
388
+ if (x.KHR_materials_ior?.ior >= 1) material.ior = x.KHR_materials_ior.ior
389
+ const specular = x.KHR_materials_specular
390
+ if (specular) {
391
+ material.specularIntensity = clamp(specular.specularFactor ?? 1)
392
+ const c = specular.specularColorFactor ?? [1, 1, 1]
393
+ material.specularColor.setLinear(c[0], c[1], c[2])
394
+ }
395
+ const brushed = x.KHR_materials_anisotropy
396
+ if (brushed) {
397
+ material.anisotropy = clamp(brushed.anisotropyStrength ?? 0)
398
+ material.anisotropyRotation = brushed.anisotropyRotation ?? 0
399
+ }
400
+ const translucent = x.KHR_materials_diffuse_transmission
401
+ if (translucent) {
402
+ material.translucency = clamp(translucent.diffuseTransmissionFactor ?? 0)
403
+ const c = translucent.diffuseTransmissionColorFactor ?? [1, 1, 1]
404
+ material.translucencyColor.setLinear(c[0], c[1], c[2])
405
+ }
367
406
  if (m.alphaMode === 'BLEND') {
368
407
  material.opacity = base[3]
369
408
  material.transparent = true
@@ -390,10 +429,12 @@ class Builder {
390
429
  */
391
430
  #texture(info, colorSpace) {
392
431
  const transform = info.extensions?.KHR_texture_transform
393
- const texCoord = transform?.texCoord ?? info.texCoord ?? 0
394
- if (texCoord !== 0) warnOnce(this.model, 'textures on a second UV set (TEXCOORD_1) are drawn with the first')
395
- if (transform?.rotation) warnOnce(this.model, "rotated texture transforms (KHR_texture_transform rotation) aren't supported - drawn unrotated")
396
- const key = JSON.stringify([info.index, colorSpace, transform?.offset, transform?.scale])
432
+ let texCoord = transform?.texCoord ?? info.texCoord ?? 0
433
+ if (texCoord > 1) {
434
+ warnOnce(this.model, `textures on a third UV set or later (TEXCOORD_${texCoord}) are drawn with the first`)
435
+ texCoord = 0
436
+ }
437
+ const key = JSON.stringify([info.index, colorSpace, transform?.offset, transform?.scale, transform?.rotation, texCoord])
397
438
  const cached = this.#textures.get(key)
398
439
  if (cached) return cached
399
440
 
@@ -420,6 +461,8 @@ class Builder {
420
461
  flipY: false,
421
462
  repeat: transform?.scale ?? [1, 1],
422
463
  offset: transform?.offset ?? [0, 0],
464
+ rotation: transform?.rotation ?? 0,
465
+ uvSet: texCoord,
423
466
  })
424
467
  this.#textures.set(key, texture)
425
468
  return texture
@@ -538,5 +581,6 @@ function lineList(g, mode) {
538
581
  function withIndices(g, indices, triangles) {
539
582
  // only normals the file gave - reading g.normals would work them out from the strip's own indices,
540
583
  // taken as a triangle list; the new geometry works them out from its real triangles instead
541
- return new Geometry({ positions: g.positions, normals: triangles && g.hasNormals ? g.normals : undefined, uvs: g.uvs ?? undefined, colors: g.colors ?? undefined, indices })
584
+ return new Geometry({ positions: g.positions, normals: triangles && g.hasNormals ? g.normals : undefined, uvs: g.uvs ?? undefined, uvs2: g.uvs2 ?? undefined,
585
+ colors: g.colors ?? undefined, indices })
542
586
  }
@@ -0,0 +1,298 @@
1
+ // Keyboard use of a View, for people who can't use a pointer and for screen readers: the canvas
2
+ // becomes a stop in the page's tab order, and the arrow keys step through what you can act on - each
3
+ // instance, point or node with a tooltip, a handler or data - as hovering would: the hover tint, the
4
+ // tooltip and hover handlers, with Enter or Space as a click. A live region says what each one is.
5
+ // Loaded when a View is given keyboard: true, so apps without it don't carry it.
6
+ import { hiddenInstance } from './scene/drawables.js'
7
+ import { Vec3 } from './value/Vec3.js'
8
+
9
+ /** @import { Hit } from './Raycaster.js' */
10
+ /** @import { TooltipContent } from './tooltip.js' */
11
+ /** @import { Node } from './scene/Node.js' */
12
+ /** @import { View } from './View.js' */
13
+
14
+ /**
15
+ * @typedef {object} KeyboardOptions
16
+ * @property {(hit: Hit) => string} [label] what a screen reader says for each thing stepped to (default: the
17
+ * tooltip's text, then its data, then the node's name)
18
+ */
19
+
20
+ /**
21
+ * What the View lends the keyboard: the hover it shows, the click it runs, and what it says about a hit.
22
+ * @typedef {object} KeyboardHooks
23
+ * @property {(hit: Hit | null) => void} focus show this as hovered, or nothing
24
+ * @property {(hit: Hit) => void} press click it
25
+ * @property {(hit: Hit) => TooltipContent} tooltip the tooltip's content for it, if there is a tooltip
26
+ * @property {() => boolean} viewWide does the View itself answer hovers or clicks (a tooltip, handlers)?
27
+ */
28
+
29
+ /** @typedef {{ node: Node, index: number | null, whole: Node | null }} Item */
30
+
31
+ const STYLE = `:where(.phoria-sr-only) { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
32
+ clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }`
33
+ // how far PageUp and PageDown jump
34
+ const PAGE = 10
35
+ let ids = 0
36
+
37
+ export class Keyboard {
38
+ /** @type {View} */ #view
39
+ /** @type {KeyboardHooks} */ #hooks
40
+ /** @type {KeyboardOptions} */ #options
41
+ /** The item stepped to, or null. @type {Item | null} */ #at = null
42
+ /** @type {HTMLDivElement} */ #say
43
+ /** @type {HTMLDivElement} */ #help
44
+ /** Attributes the canvas had before, put back on dispose. @type {[string, string | null][]} */ #before
45
+ #description = ''
46
+ #auto = true
47
+ /** @type {(() => void)[]} */ #cleanup = []
48
+
49
+ /** @param {View} view @param {KeyboardHooks} hooks @param {KeyboardOptions} options */
50
+ constructor(view, hooks, options) {
51
+ this.#view = view
52
+ this.#hooks = hooks
53
+ this.#options = options
54
+ if (!document.getElementById('phoria-sr-style')) {
55
+ const style = document.createElement('style')
56
+ style.id = 'phoria-sr-style'
57
+ style.textContent = STYLE
58
+ document.head.prepend(style)
59
+ }
60
+ const canvas = view.canvas, id = `phoria-keys-${++ids}`
61
+ // what a screen reader says as you step: politely, after what it's saying already
62
+ this.#say = document.createElement('div')
63
+ this.#say.className = 'phoria-sr-only'
64
+ this.#say.setAttribute('aria-live', 'polite')
65
+ this.#say.setAttribute('aria-atomic', 'true')
66
+ // how to use it, read once the canvas has focus
67
+ this.#help = document.createElement('div')
68
+ this.#help.className = 'phoria-sr-only'
69
+ this.#help.id = id
70
+ canvas.after(this.#say, this.#help)
71
+ this.#before = ['tabindex', 'role', 'aria-roledescription', 'aria-label', 'aria-describedby'].map((a) => [a, canvas.getAttribute(a)])
72
+ if (!canvas.hasAttribute('tabindex')) canvas.tabIndex = 0
73
+ canvas.setAttribute('role', 'application')
74
+ canvas.setAttribute('aria-roledescription', '3D view')
75
+ canvas.setAttribute('aria-describedby', id)
76
+
77
+ const keydown = (/** @type {KeyboardEvent} */ e) => this.#key(e)
78
+ const blur = () => this.clear()
79
+ const focus = () => this.#describe()
80
+ canvas.addEventListener('keydown', keydown)
81
+ canvas.addEventListener('blur', blur)
82
+ canvas.addEventListener('focus', focus)
83
+ this.#cleanup.push(() => {
84
+ canvas.removeEventListener('keydown', keydown)
85
+ canvas.removeEventListener('blur', blur)
86
+ canvas.removeEventListener('focus', focus)
87
+ })
88
+ this.#describe()
89
+ }
90
+
91
+ /**
92
+ * The canvas's label for screen readers: the View's description, or - when it has none - one made
93
+ * from the scene: what its charts show, and how many things there are to step through.
94
+ * @param {string | null} text
95
+ */
96
+ setDescription(text) {
97
+ this.#auto = text === null
98
+ this.#description = text ?? ''
99
+ this.#describe()
100
+ }
101
+
102
+ #describe() {
103
+ const count = this.#items().length
104
+ const label = this.#auto ? autoDescription(this.#view.scene, count) : this.#description
105
+ this.#view.canvas.setAttribute('aria-label', label)
106
+ this.#help.textContent = count
107
+ ? `Use the arrow keys to move between ${count === 1 ? 'the one item' : `${count} items`}, Enter to select, Escape to leave.`
108
+ : 'Nothing here can be selected.'
109
+ }
110
+
111
+ /** Step off whatever was stepped to: the pointer has taken over, or focus has gone. */
112
+ clear() {
113
+ if (!this.#at) return
114
+ this.#at = null
115
+ this.#hooks.focus(null)
116
+ }
117
+
118
+ /** @param {KeyboardEvent} e */
119
+ #key(e) {
120
+ if (e.altKey || e.ctrlKey || e.metaKey) return
121
+ const items = this.#items()
122
+ let i = this.#at ? items.findIndex((it) => same(it, /** @type {Item} */ (this.#at))) : -1
123
+ switch (e.key) {
124
+ case 'ArrowRight': case 'ArrowDown': i = i < 0 ? 0 : Math.min(items.length - 1, i + 1); break
125
+ case 'ArrowLeft': case 'ArrowUp': i = i < 0 ? items.length - 1 : Math.max(0, i - 1); break
126
+ case 'PageDown': i = Math.min(items.length - 1, Math.max(0, i) + PAGE); break
127
+ case 'PageUp': i = Math.max(0, i - PAGE); break
128
+ case 'Home': i = 0; break
129
+ case 'End': i = items.length - 1; break
130
+ case 'Enter': case ' ':
131
+ if (!this.#at || i < 0) return
132
+ e.preventDefault()
133
+ this.#hooks.press(hitOf(items[i]))
134
+ return
135
+ case 'Escape':
136
+ if (!this.#at) return
137
+ e.preventDefault()
138
+ this.clear()
139
+ this.#say.textContent = ''
140
+ return
141
+ default: return
142
+ }
143
+ // the arrows and paging keys are the view's while it has focus, not the page's to scroll
144
+ e.preventDefault()
145
+ if (!items.length) {
146
+ this.#say.textContent = 'Nothing here can be selected.'
147
+ return
148
+ }
149
+ this.#at = items[i]
150
+ const hit = hitOf(items[i])
151
+ this.#hooks.focus(hit)
152
+ this.#say.textContent = `${this.#options.label?.(hit) ?? this.#textOf(hit)}. ${i + 1} of ${items.length}`
153
+ }
154
+
155
+ /** What to say about a hit: the tooltip's text, its data, or its node's name. @param {Hit} hit @returns {string} */
156
+ #textOf(hit) {
157
+ const tip = textOfContent(this.#hooks.tooltip(hit))
158
+ if (tip) return tip
159
+ const datum = hit.datum
160
+ if (datum !== null && datum !== undefined) {
161
+ if (typeof datum !== 'object') return String(datum)
162
+ const parts = Object.entries(datum).filter(([, v]) => v === null || typeof v !== 'object').slice(0, 4).map(([k, v]) => `${k}: ${format(v)}`)
163
+ if (parts.length) return parts.join(', ')
164
+ }
165
+ for (let n = /** @type {Node | null} */ (hit.node); n; n = n.parent) if (n.name) return n.name
166
+ return 'Item'
167
+ }
168
+
169
+ /**
170
+ * What can be stepped to, in scene order and each mesh's instances in order: everything visible and
171
+ * pickable in a subtree with its own tooltip, handlers, drag or tint - and, when the View answers
172
+ * hovers or clicks itself, each thing with data or a name. A highlightWhole subtree is one item, and
173
+ * a surface - a mesh with a row of data per vertex - is a stop per vertex, as picking gives its rows.
174
+ * Lines with a row per segment - a network's links - are a stop per segment, after everything else:
175
+ * the things first, then what joins them. Something with no row and no name of its own is a stop
176
+ * only when it has a handler or tooltip itself, not one it shares with the subtree it's in.
177
+ * @returns {Item[]}
178
+ */
179
+ #items() {
180
+ /** @type {Item[]} */
181
+ const items = [], links = []
182
+ const viewWide = this.#hooks.viewWide()
183
+ /** @type {Set<Node>} */
184
+ const wholes = new Set()
185
+ /** @param {any} n @param {boolean} own @param {Node | null} whole */
186
+ const walk = (n, own, whole) => {
187
+ if (!n.visible || n.pickable === false) return
188
+ const direct = !!(n.tooltip || n.onDrag || n.highlight || n.handlersFor('click').length || n.handlersFor('hover').length)
189
+ own ||= direct
190
+ whole = n.highlightWhole ? n : whole
191
+ if (n.isDrawable) {
192
+ if (whole) {
193
+ if ((own || viewWide) && !wholes.has(whole)) {
194
+ wholes.add(whole)
195
+ items.push({ node: n, index: null, whole })
196
+ }
197
+ } else if ('instanceMatrices' in n) {
198
+ for (let i = 0; i < n.count; i++) {
199
+ if (!hiddenInstance(n.instanceMatrices, i) && (own || (viewWide && n.datumAt(i) !== null))) items.push({ node: n, index: i, whole: null })
200
+ }
201
+ } else if ((n.drawMode === 'points' && Array.isArray(n.data)) || perVertex(n)) {
202
+ for (let i = 0; i < n.data.length; i++) if (own || viewWide) items.push({ node: n, index: i, whole: null })
203
+ } else if (n.drawMode === 'lines' && Array.isArray(n.data)) {
204
+ for (let i = 0; i < n.data.length; i++) if (own || viewWide) links.push({ node: n, index: i, whole: null })
205
+ } else if (direct || ((own || viewWide) && (n.name || n.datumAt(null) !== null))) items.push({ node: n, index: null, whole: null })
206
+ }
207
+ for (const c of n.children) walk(c, own, whole)
208
+ }
209
+ walk(this.#view.scene, false, null)
210
+ return items.concat(links)
211
+ }
212
+
213
+ dispose() {
214
+ for (const fn of this.#cleanup) fn()
215
+ const canvas = this.#view.canvas
216
+ for (const [name, value] of this.#before) {
217
+ if (value === null) canvas.removeAttribute(name)
218
+ else canvas.setAttribute(name, value)
219
+ }
220
+ this.#say.remove()
221
+ this.#help.remove()
222
+ }
223
+ }
224
+
225
+ /** @param {Item} a @param {Item} b */
226
+ const same = (a, b) => a.node === b.node && a.index === b.index
227
+
228
+ /** A surface: a mesh of triangles with a row of data for each vertex. @param {any} n */
229
+ const perVertex = (n) => n.drawMode === 'triangles' && !('instanceMatrices' in n) && Array.isArray(n.data) && n.data.length === n.geometry.vertexCount
230
+
231
+ /**
232
+ * A hit on an item, as the pointer would make over its middle.
233
+ * @param {Item} item @returns {Hit}
234
+ */
235
+ function hitOf({ node, index, whole }) {
236
+ const d = /** @type {any} */ (node)
237
+ const points = index !== null && d.drawMode === 'points', vertex = index !== null && perVertex(d)
238
+ const segment = index !== null && d.drawMode === 'lines'
239
+ /** @type {Vec3} */
240
+ let point
241
+ if (whole) point = whole.bounds.getCenter()
242
+ else if (index !== null && 'instanceMatrices' in d) {
243
+ // the middle of the geometry, through the instance's matrix and then the mesh's
244
+ node.updateWorld()
245
+ const local = d.geometry.bounds.getCenter()
246
+ point = through(d.worldMatrix, through(d.instanceMatrices.subarray(index * 16, index * 16 + 16), local))
247
+ } else if (points || vertex) {
248
+ const p = d.geometry.positions
249
+ node.updateWorld()
250
+ point = through(d.worldMatrix, new Vec3(p[index * 3], p[index * 3 + 1], p[index * 3 + 2]))
251
+ } else if (segment) {
252
+ // the middle of the segment: its two ends, by index when the lines have them
253
+ const p = d.geometry.positions, idx = d.geometry.indices
254
+ const a = idx ? idx[index * 2] : index * 2, b = idx ? idx[index * 2 + 1] : index * 2 + 1
255
+ node.updateWorld()
256
+ point = through(d.worldMatrix, new Vec3((p[a * 3] + p[b * 3]) / 2, (p[a * 3 + 1] + p[b * 3 + 1]) / 2, (p[a * 3 + 2] + p[b * 3 + 2]) / 2))
257
+ } else point = node.bounds.getCenter()
258
+ // a surface's vertex is no face or instance - its row is the datum, as a pointer over it finds
259
+ return { node, point, distance: 0, normal: null, face: points || segment ? index : null, instance: index !== null && !points && !vertex && !segment ? index : null,
260
+ uv: null, datum: vertex ? d.data[index] ?? null : d.datumAt?.(index) ?? null }
261
+ }
262
+
263
+ /** A point through a 4x4 matrix (column-major). @param {ArrayLike<number>} m @param {Vec3} p @returns {Vec3} */
264
+ function through(m, p) {
265
+ const [x, y, z] = p
266
+ return new Vec3(m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14])
267
+ }
268
+
269
+ /** The text of a tooltip's content, for a screen reader. @param {TooltipContent} content @returns {string} */
270
+ function textOfContent(content) {
271
+ if (content === null || content === undefined || content === false || content === '') return ''
272
+ if (typeof content === 'string' || typeof content === 'number') return String(content).replace(/\s*\n\s*/g, ', ')
273
+ if (typeof globalThis.Node !== 'undefined' && content instanceof globalThis.Node) return (content.textContent ?? '').trim()
274
+ if (typeof content === 'object') {
275
+ const c = /** @type {{ title?: string, rows?: Record<string, unknown> | Array<[string, unknown]> }} */ (content)
276
+ const rows = c.rows ? (Array.isArray(c.rows) ? c.rows : Object.entries(c.rows)).map(([k, v]) => `${k}: ${format(v)}`) : []
277
+ return [c.title, ...rows].filter((s) => s !== undefined && s !== '').join(', ')
278
+ }
279
+ return ''
280
+ }
281
+
282
+ /** @param {unknown} v */
283
+ const format = (v) => (typeof v === 'number' ? v.toLocaleString() : String(v))
284
+
285
+ /**
286
+ * A label for the canvas made from the scene: each chart's summary, or how many things there are.
287
+ * @param {Node} scene @param {number} count @returns {string}
288
+ */
289
+ function autoDescription(scene, count) {
290
+ /** @type {string[]} */
291
+ const parts = []
292
+ scene.traverse((n) => {
293
+ const summary = /** @type {any} */ (n).summary
294
+ if (n !== scene && n.visible && typeof summary === 'function') parts.push(summary.call(n))
295
+ })
296
+ if (!parts.length) parts.push(count ? `3D view of ${count === 1 ? 'one item' : `${count} items`}` : '3D view')
297
+ return parts.join('. ')
298
+ }
package/src/labels.js CHANGED
@@ -14,6 +14,7 @@ import { alignColumns, layoutLabels, leaderEnd, placeBox, stackColumns } from '.
14
14
  * @property {number} dimmed drawn faint because a higher-priority label or geometry was in front (overlap: 'dim')
15
15
  * @property {number} occluded hidden because geometry was in front (overlap: 'hide')
16
16
  * @property {number} offscreen behind the camera or outside the view
17
+ * @property {number} zoomedOut hidden because the view isn't zoomed in to their minScale
17
18
  */
18
19
 
19
20
  /** @typedef {{ el: HTMLDivElement, text: string, className: string, width: number, height: number, showing: boolean, leader: SVGGElement | null, color: string, rule: number, label: FrameLabel | null, interactive: boolean }} Entry */
@@ -67,7 +68,7 @@ export class LabelLayer {
67
68
  */
68
69
  update(labels, overlays = []) {
69
70
  /** @type {LabelStats} */
70
- const stats = { total: labels.length, shown: 0, overlapped: 0, dimmed: 0, occluded: 0, offscreen: 0 }
71
+ const stats = { total: labels.length, shown: 0, overlapped: 0, dimmed: 0, occluded: 0, offscreen: 0, zoomedOut: 0 }
71
72
  this.shown = []
72
73
  this.leaders = []
73
74
  if (!labels.length && !this.#elements.size && !overlays.length && !this.#overlays.size && !this.#root) return stats
@@ -83,6 +84,8 @@ export class LabelLayer {
83
84
  for (const label of labels) {
84
85
  seen.add(label.key)
85
86
  if (!label.visible) stats.offscreen++
87
+ // details for closer up: until then they take no part, so they can't push other labels out
88
+ else if (label.style.minScale && label.scale * rect.height / 2 < label.style.minScale) stats.zoomedOut++
86
89
  else if (label.occluded && label.style.overlap === 'hide') stats.occluded++
87
90
  else active.push({ label, entry: this.#entry(label, root) })
88
91
  }