phoria2 1.1.0 → 1.3.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 (81) hide show
  1. package/README.md +70 -13
  2. package/dist/bloom.js +4 -4
  3. package/dist/bloom.js.map +1 -1
  4. package/dist/charts.js +538 -508
  5. package/dist/charts.js.map +1 -1
  6. package/dist/core.js +4270 -3652
  7. package/dist/core.js.map +1 -1
  8. package/dist/edit.js +65 -65
  9. package/dist/edit.js.map +1 -1
  10. package/dist/explain.js +6 -2
  11. package/dist/explain.js.map +1 -1
  12. package/dist/gltf.js +116 -116
  13. package/dist/gltf.js.map +1 -1
  14. package/dist/keyboard.js +188 -0
  15. package/dist/keyboard.js.map +1 -0
  16. package/dist/motion.js +35 -35
  17. package/dist/motion.js.map +1 -1
  18. package/dist/particles.js +188 -115
  19. package/dist/particles.js.map +1 -1
  20. package/dist/passes.js +29 -29
  21. package/dist/passes.js.map +1 -1
  22. package/dist/phoria2.js +1 -1
  23. package/dist/picking.js +32 -16
  24. package/dist/picking.js.map +1 -1
  25. package/dist/snapshot.js +1 -1
  26. package/dist/tooltip.js +1 -1
  27. package/dist/types/OrbitControl.d.ts +6 -2
  28. package/dist/types/Raycaster.d.ts +10 -0
  29. package/dist/types/View.d.ts +70 -1
  30. package/dist/types/area.d.ts +18 -0
  31. package/dist/types/charts/Chart.d.ts +7 -0
  32. package/dist/types/explain.d.ts +3 -0
  33. package/dist/types/frame.d.ts +7 -0
  34. package/dist/types/geometry/Geometry.d.ts +24 -2
  35. package/dist/types/index.d.ts +2 -0
  36. package/dist/types/keyboard.d.ts +48 -0
  37. package/dist/types/labels.d.ts +4 -0
  38. package/dist/types/material/Material.d.ts +15 -0
  39. package/dist/types/material/Texture.d.ts +18 -2
  40. package/dist/types/particles/Emitter.d.ts +39 -2
  41. package/dist/types/scene/Node.d.ts +19 -14
  42. package/dist/types/scene/drawables.d.ts +54 -15
  43. package/dist/types/scene/lights.d.ts +16 -2
  44. package/dist/types/shadow.d.ts +2 -1
  45. package/dist/types/types.d.ts +14 -0
  46. package/dist/types/value/Color.d.ts +0 -4
  47. package/dist/types/value/Quat.d.ts +4 -2
  48. package/dist/types/webgl/resources.d.ts +12 -2
  49. package/dist/types/webgl/shaders.d.ts +7 -2
  50. package/llms-full.txt +106 -10
  51. package/package.json +4 -1
  52. package/schema/scene.schema.json +67 -1
  53. package/src/OrbitControl.js +7 -3
  54. package/src/Raycaster.js +58 -0
  55. package/src/View.js +220 -41
  56. package/src/area.js +131 -0
  57. package/src/charts/Chart.js +20 -2
  58. package/src/charts/marks.js +29 -2
  59. package/src/explain.js +6 -2
  60. package/src/frame.js +29 -9
  61. package/src/geometry/Geometry.js +32 -4
  62. package/src/index.js +1 -0
  63. package/src/keyboard.js +298 -0
  64. package/src/labels.js +4 -1
  65. package/src/material/Material.js +9 -2
  66. package/src/material/Texture.js +55 -2
  67. package/src/particles/Emitter.js +121 -10
  68. package/src/scene/Node.js +20 -9
  69. package/src/scene/Scene.js +2 -0
  70. package/src/scene/cameras.js +4 -1
  71. package/src/scene/drawables.js +115 -15
  72. package/src/scene/lights.js +40 -6
  73. package/src/shadow.js +27 -4
  74. package/src/types.js +6 -0
  75. package/src/value/Color.js +21 -0
  76. package/src/value/Quat.js +15 -6
  77. package/src/watch.js +14 -2
  78. package/src/webgl/WebGLRenderer.js +36 -5
  79. package/src/webgl/picking.js +22 -1
  80. package/src/webgl/resources.js +168 -14
  81. package/src/webgl/shaders.js +110 -11
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
+ }
@@ -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
- this.#mark?.tick?.(dt)
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
@@ -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) })
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 @param {FrameLabel[]} out
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
- const { labels, worldMatrix: w } = node
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
- return { ndc: /** @type {[number, number]} */ ([ndc[0], ndc[1]]), depth, visible }
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.
@@ -9,10 +9,16 @@ import { checkOptions, describe, fail } from '../validate.js'
9
9
  * @property {ArrayLike<number>} [uvs2] a second uv per vertex, for maps that need every surface on its own
10
10
  * patch of texture - baked occlusion and light maps (a texture's uvSet: 1 reads it)
11
11
  * @property {ArrayLike<number>} [colors] rgb per vertex, sRGB 0-1 like every colour input
12
+ * @property {ArrayLike<number>} [sizes] points: one per vertex, a multiple of the material's size
13
+ * @property {ArrayLike<number>} [opacities] points: one per vertex, 0-1, multiplying the material's opacity
14
+ * @property {ArrayLike<number>} [angles] points: one per vertex, radians anticlockwise on screen - turns
15
+ * each point's texture
12
16
  * @property {ArrayLike<number>} [indices] vertex indices; triangles for meshes, pairs for lines
13
17
  */
14
18
 
15
- const OPTIONS = ['positions', 'normals', 'uvs', 'uvs2', 'colors', 'indices']
19
+ const OPTIONS = ['positions', 'normals', 'uvs', 'uvs2', 'colors', 'sizes', 'opacities', 'angles', 'indices']
20
+ // the per-point arrays: Points with any of them draw each point as its own square, sized, faded and turned
21
+ const POINT_ARRAYS = /** @type {const} */ (['sizes', 'opacities', 'angles'])
16
22
 
17
23
  /**
18
24
  * Vertex data: flat typed arrays plus an optional index. Generators such as box() and sphere()
@@ -25,6 +31,9 @@ export class Geometry {
25
31
  /** @type {Float32Array | null} */ uvs = null
26
32
  /** A second UV set, for baked occlusion and light maps. @type {Float32Array | null} */ uvs2 = null
27
33
  /** @type {Float32Array | null} */ colors = null
34
+ /** Points: each one's size, as a multiple of the material's. @type {Float32Array | null} */ sizes = null
35
+ /** Points: each one's opacity, 0-1, times the material's. @type {Float32Array | null} */ opacities = null
36
+ /** Points: each one's turn on screen, in radians anticlockwise. @type {Float32Array | null} */ angles = null
28
37
  /** @type {Uint16Array | Uint32Array | null} */ indices = null
29
38
 
30
39
  /** @internal Bumped by needsUpdate() so renderers know to re-upload. */
@@ -45,6 +54,8 @@ export class Geometry {
45
54
 
46
55
  /** @type {Float32Array | null} */
47
56
  #normals = null
57
+ /** The normals were worked out on first read, rather than given or asked for: they follow the shape. */
58
+ #autoNormals = false
48
59
  /** @type {Box3 | null} */
49
60
  #bounds = null
50
61
  /** @type {Set<() => void>} */
@@ -60,6 +71,7 @@ export class Geometry {
60
71
  if (options.uvs) this.uvs = toFloat32('uvs', options.uvs, 2, n)
61
72
  if (options.uvs2) this.uvs2 = toFloat32('uvs2', options.uvs2, 2, n)
62
73
  if (options.colors) this.colors = toFloat32('colors', options.colors, 3, n)
74
+ for (const name of POINT_ARRAYS) if (options[name]) this[name] = toFloat32(name, options[name], 1, n)
63
75
  if (options.indices) {
64
76
  // -1: no indices at all refer to no vertex, so an empty geometry can say so
65
77
  let max = -1
@@ -75,16 +87,22 @@ export class Geometry {
75
87
 
76
88
  /**
77
89
  * xyz per vertex. Computed (smooth) from the triangles on first read when not supplied, so
78
- * points and lines - whose geometry has no triangles - never pay for them.
90
+ * points and lines - whose geometry has no triangles - never pay for them - and computed again after
91
+ * needsUpdate(), so they follow positions edited in place. Normals that were supplied, assigned or
92
+ * made by calling computeNormals() are kept as they are.
79
93
  * @type {Float32Array}
80
94
  */
81
95
  get normals() {
82
- if (!this.#normals) this.computeNormals()
96
+ if (!this.#normals) {
97
+ this.computeNormals()
98
+ this.#autoNormals = true
99
+ }
83
100
  return /** @type {Float32Array} */ (this.#normals)
84
101
  }
85
102
 
86
103
  set normals(v) {
87
104
  this.#normals = v
105
+ this.#autoNormals = false
88
106
  }
89
107
 
90
108
  /** True once normals exist, supplied or computed. */
@@ -121,9 +139,14 @@ export class Geometry {
121
139
  return this.#bounds ??= new Box3().expandByPositions(this.positions)
122
140
  }
123
141
 
124
- /** Call after editing the arrays in place. It is no longer what its generator made, so toJSON() saves the arrays. @returns {this} */
142
+ /**
143
+ * Call after editing the arrays in place. It is no longer what its generator made, so toJSON() saves
144
+ * the arrays; normals that were worked out on first read are worked out again on the next.
145
+ * @returns {this}
146
+ */
125
147
  needsUpdate() {
126
148
  this.source = null
149
+ if (this.#autoNormals) this.#normals = null
127
150
  return this.#changed()
128
151
  }
129
152
 
@@ -178,6 +201,9 @@ export class Geometry {
178
201
  uvs: this.uvs ?? undefined,
179
202
  uvs2: this.uvs2 ?? undefined,
180
203
  colors: this.colors ?? undefined,
204
+ sizes: this.sizes ?? undefined,
205
+ opacities: this.opacities ?? undefined,
206
+ angles: this.angles ?? undefined,
181
207
  indices: this.indices ?? undefined,
182
208
  })
183
209
  g.source = this.source && { ...this.source }
@@ -205,6 +231,7 @@ export class Geometry {
205
231
  if (this.uvs) json.uvs = Array.from(this.uvs)
206
232
  if (this.uvs2) json.uvs2 = Array.from(this.uvs2)
207
233
  if (this.colors) json.colors = Array.from(this.colors)
234
+ for (const name of POINT_ARRAYS) if (this[name]) json[name] = Array.from(/** @type {Float32Array} */ (this[name]))
208
235
  if (this.indices) json.indices = Array.from(this.indices)
209
236
  return json
210
237
  }
@@ -229,6 +256,7 @@ export class Geometry {
229
256
  this.uvs = expand(this.uvs, 2)
230
257
  this.uvs2 = expand(this.uvs2, 2)
231
258
  this.colors = expand(this.colors, 3)
259
+ for (const name of POINT_ARRAYS) this[name] = expand(this[name], 1)
232
260
  this.indices = null
233
261
  this.source = null
234
262
  }
package/src/index.js CHANGED
@@ -50,6 +50,7 @@ export { PhoriaError } from './validate.js'
50
50
  /** @typedef {import('./presets.js').CameraPreset} CameraPreset */
51
51
  /** @typedef {import('./tooltip.js').TooltipContent} TooltipContent */
52
52
  /** @typedef {import('./Raycaster.js').Hit} Hit */
53
+ /** @typedef {import('./area.js').PickArea} PickArea */
53
54
  /** @typedef {import('./scene/drawables.js').LabelPlacement} LabelPlacement */
54
55
  /** @typedef {import('./scene/drawables.js').LabelPlace} LabelPlace */
55
56
  /** @typedef {import('./scene/drawables.js').InstanceLabels} InstanceLabels */