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
@@ -1,7 +1,8 @@
1
1
  // A particle system as one Points node: particles are born at the emitter's origin, move under
2
2
  // velocity, gravity and drag, fade through a list of colours and die. The simulation runs in the
3
3
  // emitter's own update handler, so it follows the scene clock (frame-rate independent) and its
4
- // arrays go to the GPU as one buffer refill per frame.
4
+ // arrays go to the GPU as one buffer refill per frame. Particles that grow, fade or turn give the
5
+ // geometry per-point sizes, opacities or angles, and are drawn as squares.
5
6
  import * as mat4 from '../math/mat4.js'
6
7
  import { Geometry } from '../geometry/Geometry.js'
7
8
  import { Material } from '../material/Material.js'
@@ -48,17 +49,32 @@ import { checkNumber, describe, fail } from '../validate.js'
48
49
  * frustumCulled?: boolean,
49
50
  * renderOrder?: number,
50
51
  * onParticle?: ((p: Particle) => boolean | void) | null,
51
- * }} EmitterOptions
52
+ * sort?: boolean,
53
+ * } & LookOptions} EmitterOptions
52
54
  * max: most live particles at once (default 1000). rate: particles born per second (default 100).
53
55
  * gravity: acceleration, e.g. [0, -9.8, 0] (default none). drag: share of velocity lost per second
54
56
  * (default 0). follow: a node whose position becomes the origin each frame - a trail that stays
55
57
  * behind it. onParticle: called for every live particle each frame after it moves; return false to
56
- * remove it (a particle hitting something).
58
+ * remove it (a particle hitting something). sort: draw furthest first, for soft particles with
59
+ * blending 'normal' (smoke) that would otherwise layer wrongly.
60
+ */
61
+
62
+ /**
63
+ * How particles look over their lives, beyond colour.
64
+ * @typedef {object} LookOptions
65
+ * @property {number | number[]} [size] a multiple of the material's size, or several each particle goes
66
+ * through over its life - [0.5, 3] for smoke that billows out (default 1)
67
+ * @property {number} [sizeSpread] each particle's size varies by up to this share either way, 0-1 (default 0)
68
+ * @property {number | number[]} [opacity] 0-1, or several each particle goes through - [0, 1, 0] fades in
69
+ * and out (default 1)
70
+ * @property {number | [number, number]} [spin] radians per second: a number turns each particle up to that
71
+ * fast either way, [min, max] a range. Spinning particles start at a random angle (default 0)
57
72
  */
58
73
 
59
74
  const SPAWN_OPTIONS = ['origin', 'originSpread', 'velocity', 'velocitySpread', 'lifetime', 'color']
60
- const OPTIONS = [...Points.OPTIONS, ...SPAWN_OPTIONS,
61
- 'max', 'rate', 'gravity', 'drag', 'follow', 'onParticle']
75
+ const LOOK_OPTIONS = ['size', 'sizeSpread', 'opacity', 'spin']
76
+ const BEHAVIOUR_OPTIONS = ['rate', 'gravity', 'drag', 'follow', 'onParticle', ...LOOK_OPTIONS]
77
+ const OPTIONS = [...Points.OPTIONS, ...SPAWN_OPTIONS, 'max', ...BEHAVIOUR_OPTIONS]
62
78
  const HINTS = {
63
79
  count: "use 'max' for the most particles alive at once, 'rate' for how many are born per second",
64
80
  lifetimeRnd: "give 'lifetime' a [min, max] range",
@@ -111,6 +127,13 @@ export class Emitter extends Points {
111
127
  /** @type {Map<string, number>} */ #paletteIds = new Map()
112
128
  /** @type {Uint8Array} */ #palette
113
129
  #spawnPalette = 0
130
+ // the looks: stops over each particle's life, and each one's own size factor and turn rate
131
+ /** @type {number[]} */ #size = [1]
132
+ #sizeSpread = 0
133
+ /** @type {number[]} */ #opacity = [1]
134
+ /** @type {[number, number]} */ #spin = [0, 0]
135
+ /** @type {Float32Array | null} */ #sizeFactors = null
136
+ /** @type {Float32Array | null} */ #spins = null
114
137
  /** Last frame's origin, so a moving source leaves an even trail rather than clumps. */
115
138
  #lastOrigin = /** @type {Vec3 | null} */ (null)
116
139
  #particle = /** @type {Particle} */ ({ position: new Vec3(), velocity: new Vec3(), age: 0, lifetime: 0 })
@@ -144,12 +167,12 @@ export class Emitter extends Points {
144
167
  /**
145
168
  * Change several settings at once - a preset, say. Vectors are copied into the emitter's own,
146
169
  * so anything bound to `emitter.gravity` and friends stays bound. Live particles carry on.
147
- * @param {SpawnOptions & { rate?: number, gravity?: Vec3Input, drag?: number, follow?: Node | null,
170
+ * @param {SpawnOptions & LookOptions & { rate?: number, gravity?: Vec3Input, drag?: number, follow?: Node | null,
148
171
  * onParticle?: ((p: Particle) => boolean | void) | null }} options
149
172
  * @returns {this}
150
173
  */
151
174
  set(options) {
152
- const known = [...SPAWN_OPTIONS, 'rate', 'gravity', 'drag', 'follow', 'onParticle']
175
+ const known = [...SPAWN_OPTIONS, ...BEHAVIOUR_OPTIONS]
153
176
  for (const key of Object.keys(options ?? {})) {
154
177
  if (!known.includes(key)) fail('Emitter', `set() doesn't take '${key}' - it takes ${known.join(', ')}`)
155
178
  }
@@ -169,6 +192,22 @@ export class Emitter extends Points {
169
192
  /** @returns {[number, number, number][]} */
170
193
  get color() { return /** @type {[number, number, number][]} */ (this.#palettes[0].map((s) => [...s])) }
171
194
 
195
+ /** Each particle's size over its life, as multiples of the material's. @param {number | number[]} v */
196
+ set size(v) { this.#setLooks({ size: v }) }
197
+ /** @returns {number[]} */
198
+ get size() { return [...this.#size] }
199
+ /** How much each particle's size varies, 0-1 either way. @param {number} v */
200
+ set sizeSpread(v) { this.#setLooks({ sizeSpread: v }) }
201
+ get sizeSpread() { return this.#sizeSpread }
202
+ /** Each particle's opacity over its life. @param {number | number[]} v */
203
+ set opacity(v) { this.#setLooks({ opacity: v }) }
204
+ /** @returns {number[]} */
205
+ get opacity() { return [...this.#opacity] }
206
+ /** How fast particles turn, radians per second, as a [min, max] range. @param {number | [number, number]} v */
207
+ set spin(v) { this.#setLooks({ spin: v }) }
208
+ /** @returns {[number, number]} */
209
+ get spin() { return [...this.#spin] }
210
+
172
211
  /**
173
212
  * Birth `count` particles at once - explosions, splashes. Options override the emitter's own
174
213
  * spawn settings for this burst only.
@@ -211,6 +250,7 @@ export class Emitter extends Points {
211
250
  const v = this.#velocities, ages = this.#ages, lives = this.#lifetimes
212
251
  const g = this.gravity, keep = Math.exp(-this.drag * dt)
213
252
  const hook = this.onParticle, particle = this.#particle
253
+ const angles = this.geometry.angles, spins = this.#spins
214
254
 
215
255
  for (let i = 0; i < this.#alive;) {
216
256
  const age = ages[i] += dt
@@ -223,6 +263,7 @@ export class Emitter extends Points {
223
263
  v[i3 + k] = (v[i3 + k] + g[k] * dt) * keep
224
264
  p[i3 + k] += v[i3 + k] * dt
225
265
  }
266
+ if (angles && spins) angles[i] += spins[i] * dt
226
267
  this.#paint(i, age / lives[i])
227
268
  if (hook) {
228
269
  particle.position.set(p[i3], p[i3 + 1], p[i3 + 2])
@@ -256,7 +297,7 @@ export class Emitter extends Points {
256
297
  const rest = super.cloneOptions()
257
298
  delete rest.geometry
258
299
  return { ...rest, ...this.#saveSpawn(), max: this.#max, rate: this.rate, gravity: this.gravity.clone(),
259
- drag: this.drag, follow: this.follow, onParticle: this.onParticle, color: this.color }
300
+ drag: this.drag, follow: this.follow, onParticle: this.onParticle, color: this.color, ...this.#looks() }
260
301
  }
261
302
 
262
303
  /** @internal @returns {Record<string, unknown>} */
@@ -266,7 +307,8 @@ export class Emitter extends Points {
266
307
  return { ...rest, max: this.#max, rate: this.rate, gravity: this.gravity.toArray(), drag: this.drag,
267
308
  origin: this.origin.toArray(), originSpread: this.originSpread, velocity: this.velocity.toArray(),
268
309
  velocitySpread: typeof this.velocitySpread === 'number' ? this.velocitySpread : this.velocitySpread.toArray(),
269
- lifetime: [...this.lifetime], color: this.#palettes[0].map((s) => new Color().setSRGB(s[0], s[1], s[2]).toJSON()) }
310
+ lifetime: [...this.lifetime], color: this.#palettes[0].map((s) => new Color().setSRGB(s[0], s[1], s[2]).toJSON()),
311
+ ...this.#looks() }
270
312
  }
271
313
 
272
314
  // birth one particle somewhere between a and b, already `head` seconds into its life
@@ -287,6 +329,12 @@ export class Emitter extends Points {
287
329
  this.#lifetimes[i] = min + Math.random() * (max - min)
288
330
  this.#ages[i] = head
289
331
  this.#palette[i] = this.#spawnPalette
332
+ if (this.#sizeFactors) this.#sizeFactors[i] = 1 + (Math.random() * 2 - 1) * this.#sizeSpread
333
+ const angles = this.geometry.angles, spins = this.#spins
334
+ if (angles && spins) {
335
+ spins[i] = this.#spin[0] + Math.random() * (this.#spin[1] - this.#spin[0])
336
+ angles[i] = Math.random() * Math.PI * 2 + spins[i] * head
337
+ }
290
338
  this.#paint(i, head / this.#lifetimes[i])
291
339
  }
292
340
 
@@ -302,10 +350,16 @@ export class Emitter extends Points {
302
350
  this.#ages[i] = this.#ages[last]
303
351
  this.#lifetimes[i] = this.#lifetimes[last]
304
352
  this.#palette[i] = this.#palette[last]
353
+ for (const a of [this.geometry.sizes, this.geometry.opacities, this.geometry.angles, this.#sizeFactors, this.#spins]) {
354
+ if (a) a[i] = a[last]
355
+ }
305
356
  }
306
357
 
307
358
  /** @param {number} i @param {number} t 0-1 through its life */
308
359
  #paint(i, t) {
360
+ const { sizes, opacities } = this.geometry
361
+ if (sizes) sizes[i] = along(this.#size, t) * (this.#sizeFactors ? this.#sizeFactors[i] : 1)
362
+ if (opacities) opacities[i] = along(this.#opacity, t)
309
363
  const c = /** @type {Float32Array} */ (this.geometry.colors), stops = this.#palettes[this.#palette[i]]
310
364
  if (stops.length === 1) {
311
365
  c.set(stops[0], i * 3)
@@ -349,8 +403,9 @@ export class Emitter extends Points {
349
403
  if (o.color !== undefined) this.#palettes[0] = colorStops(o.color)
350
404
  }
351
405
 
352
- /** @param {{ rate?: number, gravity?: Vec3Input, drag?: number, follow?: Node | null, onParticle?: ((p: Particle) => boolean | void) | null }} o */
406
+ /** @param {LookOptions & { rate?: number, gravity?: Vec3Input, drag?: number, follow?: Node | null, onParticle?: ((p: Particle) => boolean | void) | null }} o */
353
407
  #setBehaviour(o) {
408
+ this.#setLooks(o)
354
409
  const owner = 'Emitter'
355
410
  if (o.rate !== undefined) this.rate = Math.max(0, checkNumber(owner, 'rate', o.rate))
356
411
  if (o.gravity !== undefined) this.gravity.copy(o.gravity, owner)
@@ -359,6 +414,31 @@ export class Emitter extends Points {
359
414
  if (o.onParticle !== undefined) this.onParticle = o.onParticle
360
415
  }
361
416
 
417
+ // Sizes, opacities and angles go on the geometry when first wanted (the points are then drawn as
418
+ // squares) and stay; live particles get the plain values until they are next painted.
419
+ /** @param {LookOptions} o */
420
+ #setLooks(o) {
421
+ const owner = 'Emitter', g = this.geometry, max = this.#max
422
+ if (o.size !== undefined) this.#size = lookStops('size', o.size, Infinity)
423
+ if (o.sizeSpread !== undefined) this.#sizeSpread = Math.min(Math.max(checkNumber(owner, 'sizeSpread', o.sizeSpread), 0), 1)
424
+ if (o.opacity !== undefined) this.#opacity = lookStops('opacity', o.opacity, 1)
425
+ if (o.spin !== undefined) this.#spin = spinRange(o.spin)
426
+ const plain = (/** @type {number[]} */ stops) => stops.length === 1 && stops[0] === 1
427
+ if (!g.sizes && (!plain(this.#size) || this.#sizeSpread)) g.sizes = new Float32Array(max).fill(1)
428
+ if (!this.#sizeFactors && this.#sizeSpread) this.#sizeFactors = new Float32Array(max).fill(1)
429
+ if (!g.opacities && !plain(this.#opacity)) g.opacities = new Float32Array(max).fill(1)
430
+ if (!g.angles && (this.#spin[0] || this.#spin[1])) {
431
+ g.angles = new Float32Array(max)
432
+ this.#spins = new Float32Array(max)
433
+ }
434
+ }
435
+
436
+ /** The looks, as options. @returns {LookOptions} */
437
+ #looks() {
438
+ return { size: unstop(this.#size), sizeSpread: this.#sizeSpread, opacity: unstop(this.#opacity),
439
+ spin: /** @type {[number, number]} */ ([...this.#spin]) }
440
+ }
441
+
362
442
  /** The spawn settings other than colour, as options. @returns {SpawnOptions} */
363
443
  #saveSpawn() {
364
444
  return { origin: this.origin.clone(), originSpread: this.originSpread, velocity: this.velocity.clone(),
@@ -397,6 +477,37 @@ function lifetimeRange(v) {
397
477
  return [Math.min(range[0], range[1]), Math.max(range[0], range[1])]
398
478
  }
399
479
 
480
+ /** @param {string} name @param {number | number[]} v @param {number} most @returns {number[]} */
481
+ function lookStops(name, v, most) {
482
+ const list = Array.isArray(v) ? v : [v]
483
+ if (!list.length || !list.every((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)) {
484
+ fail('Emitter', `'${name}' must be a number of 0 or more, or a list of them a particle goes through, got ${describe(v)}`)
485
+ }
486
+ return list.map((n) => Math.min(n, most))
487
+ }
488
+
489
+ /** One stop as a number, several as a list. @param {number[]} stops @returns {number | number[]} */
490
+ function unstop(stops) {
491
+ return stops.length === 1 ? stops[0] : [...stops]
492
+ }
493
+
494
+ /** The value t (0-1) of the way through evenly spaced stops. @param {number[]} stops @param {number} t */
495
+ function along(stops, t) {
496
+ if (stops.length === 1) return stops[0]
497
+ const x = Math.min(Math.max(t, 0), 1) * (stops.length - 1)
498
+ const s = Math.min(Math.floor(x), stops.length - 2)
499
+ return stops[s] + (stops[s + 1] - stops[s]) * (x - s)
500
+ }
501
+
502
+ /** @param {number | [number, number]} v @returns {[number, number]} */
503
+ function spinRange(v) {
504
+ const range = typeof v === 'number' ? [-Math.abs(v), Math.abs(v)] : v
505
+ if (!Array.isArray(range) || range.length !== 2 || !range.every((n) => typeof n === 'number' && Number.isFinite(n))) {
506
+ fail('Emitter', `'spin' must be radians per second, or a [min, max] range, got ${describe(v)}`)
507
+ }
508
+ return [Math.min(range[0], range[1]), Math.max(range[0], range[1])]
509
+ }
510
+
400
511
  /** @param {ColorInput | ColorInput[]} v @returns {number[][]} */
401
512
  function colorStops(v) {
402
513
  // a single [r, g, b] array is one colour, not three
package/src/scene/Node.js CHANGED
@@ -10,7 +10,7 @@ import { Vec3 } from '../value/Vec3.js'
10
10
  import { checkOptions, describe, fail } from '../validate.js'
11
11
  import { Clip } from '../clip.js'
12
12
 
13
- /** @import { ColorInput, Vec3Input } from '../types.js' */
13
+ /** @import { ColorInput, QuatInput, Vec3Input } from '../types.js' */
14
14
  /** @import { Hit } from '../Raycaster.js' */
15
15
  /** @import { Camera } from './cameras.js' */
16
16
  /** @import { ClipOptions } from '../clip.js' */
@@ -44,7 +44,7 @@ import { Clip } from '../clip.js'
44
44
  * @property {string} [name]
45
45
  * @property {Vec3Input} [position]
46
46
  * @property {Vec3Input | Euler} [rotation] Euler angles in radians, XYZ order
47
- * @property {ArrayLike<number>} [quaternion] [x, y, z, w] - instead of rotation
47
+ * @property {QuatInput} [quaternion] [x, y, z, w] or { x, y, z, w } - instead of rotation
48
48
  * @property {Vec3Input | number} [scale] a number scales uniformly
49
49
  * @property {boolean} [visible]
50
50
  * @property {boolean} [pickable] include in view.pick() (default true)
@@ -54,7 +54,8 @@ import { Clip } from '../clip.js'
54
54
  * @property {UpdateHandler | UpdateHandler[]} [onUpdate]
55
55
  * @property {((hit: Hit) => any) | null} [tooltip] tooltip for anything picked in this subtree; nearer nodes win, and
56
56
  * all win over the View's
57
- * @property {ColorInput | null} [highlight] hover tint for this subtree, over the View's
57
+ * @property {ColorInput | null | false} [highlight] hover tint for this subtree, over the View's; false for none - a
58
+ * floor or wall left plain under a View that tints everything else (null: the View's)
58
59
  * @property {boolean} [highlightWhole] hovering any part of this subtree tints all of it, as one thing - a
59
60
  * piece of furniture, a machine - rather than just the part under the pointer (default false)
60
61
  * @property {((hit: Hit | null) => void) | null} [onHover] called as the thing under the pointer in this subtree changes
@@ -136,7 +137,7 @@ export class Node {
136
137
  onDrag = null
137
138
  /** @type {Outline | null} */
138
139
  #outline = null
139
- /** @type {Color | null} */
140
+ /** @type {Color | null | false} */
140
141
  #highlight = null
141
142
  /** Hovering any part of this subtree tints all of it, not just the part under the pointer. */
142
143
  highlightWhole = false
@@ -164,7 +165,7 @@ export class Node {
164
165
  set rotation(v) { this.#rotation.copy(/** @type {any} */ (v), this.constructor.name) }
165
166
  /** Kept in sync with rotation. Assigning copies, normalised. @returns {Quat} */
166
167
  get quaternion() { return this.#quaternion }
167
- /** @param {ArrayLike<number>} v */
168
+ /** @param {QuatInput} v */
168
169
  set quaternion(v) { this.#quaternion.copy(v, this.constructor.name).normalize() }
169
170
 
170
171
  /** @param {NodeOptions} [options] */
@@ -191,7 +192,7 @@ export class Node {
191
192
  if (typeof fn !== 'function') fail(owner, `'${key}' must be a function, got ${describe(fn)}`)
192
193
  this[key] = fn
193
194
  }
194
- if (options.highlight != null) this.#highlight = Color.fromInput(options.highlight, owner)
195
+ if (options.highlight != null) this.#setHighlight(options.highlight, owner)
195
196
  if (options.highlightWhole !== undefined) {
196
197
  if (typeof options.highlightWhole !== 'boolean') fail(owner, `'highlightWhole' must be true or false, got ${describe(options.highlightWhole)}`)
197
198
  this.highlightWhole = options.highlightWhole
@@ -201,14 +202,23 @@ export class Node {
201
202
  if (options.children) for (const child of options.children) this.add(child)
202
203
  }
203
204
 
204
- /** Hover tint for this subtree, over the View's; set any colour, or null. @returns {Color | null} */
205
+ /**
206
+ * Hover tint for this subtree, over the View's: a colour, false for none, or null to use the View's.
207
+ * @returns {Color | null | false}
208
+ */
205
209
  get highlight() {
206
210
  return this.#highlight
207
211
  }
208
212
 
209
- /** @param {ColorInput | null} value */
213
+ /** @param {ColorInput | null | false} value */
210
214
  set highlight(value) {
211
- this.#highlight = value == null ? null : Color.fromInput(value, this.constructor.name)
215
+ this.#setHighlight(value, this.constructor.name)
216
+ }
217
+
218
+ /** @param {ColorInput | null | false | true} value @param {string} owner */
219
+ #setHighlight(value, owner) {
220
+ if (value === true) fail(owner, "'highlight' is a colour, false for no tint, or null to use the View's - got true")
221
+ this.#highlight = value == null ? null : value === false ? false : Color.fromInput(value, owner)
212
222
  }
213
223
 
214
224
  /**
@@ -612,6 +622,7 @@ export class Node {
612
622
  if (!this.pickable) json.pickable = false
613
623
  if (!this.matrixAutoUpdate) json.matrixAutoUpdate = false
614
624
  if (this.highlight) json.highlight = this.highlight.getStyle()
625
+ else if (this.highlight === false) json.highlight = false
615
626
  if (this.highlightWhole) json.highlightWhole = true
616
627
  if (this.#clip) json.clip = this.#clip.toJSON()
617
628
  if (this.#outline) json.outline = { color: this.#outline.color.getStyle(), width: this.#outline.width }
@@ -44,6 +44,8 @@ export class Scene extends Node {
44
44
  environmentIntensity = 1
45
45
  /** 0-1: how blurred a panorama background is drawn - a soft backdrop that doesn't compete. */
46
46
  backgroundBlur = 0
47
+ /** @internal Set by the View drawing it, from its reducedMotion: charts' transitions and flows stand still. */
48
+ motionReduced = false
47
49
  /** @type {Fog | null} */
48
50
  #fog = null
49
51
 
@@ -20,7 +20,10 @@ const tmp3 = new Float32Array(9)
20
20
  export function aimAt(node, target) {
21
21
  if (node.position.equals(target, 1e-9)) return
22
22
  const dir = target.clone().sub(node.position).normalize()
23
- const up = Math.abs(dir[1]) > 0.9999 ? [0, 0, -1] : UP
23
+ // world up keeps the picture upright, and turns it smoothly with the view right up to straight up
24
+ // or down - the orbit stops a thousandth of a radian short. Only within a hair of exactly vertical
25
+ // (the 'top' preset's view), where up would no longer say which way the screen faces, is north up
26
+ const up = Math.hypot(dir[0], dir[2]) < 5e-4 ? [0, 0, -1] : UP
24
27
  mat4.targetTo(tmp, node.position, target, up)
25
28
  const q = quat.normalize(new Float32Array(4), quat.fromMat3(new Float32Array(4), mat3.fromMat4(tmp3, tmp)))
26
29
  node.quaternion.copy(q)
@@ -12,7 +12,7 @@ import { NODE_OPTIONS, Node } from './Node.js'
12
12
  /** @import { Material, MaterialOptions } from '../material/Material.js' */
13
13
  /** @import { GeometryOptions } from '../geometry/Geometry.js' */
14
14
  /** @import { NodeOptions } from './Node.js' */
15
- /** @import { ColorInput, Vec3Input } from '../types.js' */
15
+ /** @import { ColorInput, QuatInput, Vec3Input } from '../types.js' */
16
16
 
17
17
  /**
18
18
  * @typedef {NodeOptions & {
@@ -45,11 +45,14 @@ import { NODE_OPTIONS, Node } from './Node.js'
45
45
  * @property {'left' | 'right' | null} [edge] set the label at the view's left or right edge, level with its
46
46
  * point - callouts in a column down the side of a diagram, with leader: true. offset then moves it in from
47
47
  * the edge and up or down (default null: by its point)
48
+ * @property {number} [minScale] show the label only when zoomed in this far: at least this many CSS pixels to
49
+ * one world unit, at the label's point - details that appear as you come closer (default 0: always)
48
50
  */
49
51
  /** @typedef {'center' | 'above' | 'below' | 'left' | 'right'} LabelPlace */
50
52
 
51
53
  /**
52
- * One label per instance or point, from its row of data.
54
+ * One label per instance or point, from its row of data - or a list of these, for several each (a symbol
55
+ * always, its details when zoomed in).
53
56
  * @typedef {Omit<LabelPlacement, 'priority'> & {
54
57
  * text: (datum: any, index: number) => string | number | null | undefined,
55
58
  * priority?: number | ((datum: any, index: number) => number),
@@ -61,7 +64,7 @@ import { NODE_OPTIONS, Node } from './Node.js'
61
64
  */
62
65
 
63
66
  export const LABEL_PLACES = /** @type {LabelPlace[]} */ (['center', 'above', 'below', 'left', 'right'])
64
- const PLACEMENT_OPTIONS = ['place', 'offset', 'className', 'priority', 'overlap', 'occlude', 'leader', 'edge']
67
+ const PLACEMENT_OPTIONS = ['place', 'offset', 'className', 'priority', 'overlap', 'occlude', 'leader', 'edge', 'minScale']
65
68
  const LABEL_HINTS = {
66
69
  line: 'use leader: true - a line from the point to the label',
67
70
  callout: "use leader: true, with edge: 'left' | 'right' to line the labels up down the side",
@@ -70,6 +73,8 @@ const LABEL_HINTS = {
70
73
  pointer: 'use leader: true - a line from the point to the label',
71
74
  anchor: "use place: 'center' | 'above' | 'below' | 'left' | 'right' (where the label goes)",
72
75
  zIndex: "use 'priority': higher stays when labels overlap",
76
+ minZoom: "use 'minScale': CSS pixels to one world unit at the label's point",
77
+ lod: "use 'minScale': the label shows when zoomed in at least this far (CSS pixels to one world unit)",
73
78
  collision: "use overlap: 'hide' | 'dim' | 'show'",
74
79
  }
75
80
  // labels per instance or point take one more: they can't be moved anywhere but by offset
@@ -94,12 +99,26 @@ function placement(owner, o) {
94
99
  occlude: !!o.occlude,
95
100
  leader: !!o.leader,
96
101
  edge: o.edge == null ? null : /** @type {'left' | 'right'} */ (checkOneOf(owner, 'edge', o.edge, ['left', 'right'])),
102
+ minScale: o.minScale === undefined ? 0 : minScale(owner, o.minScale),
97
103
  }
98
104
  }
99
105
 
100
- /** @param {string} owner @param {unknown} v @returns {(InstanceLabels & Required<Omit<LabelPlacement, 'priority'>> & { priority: number | ((datum: any, index: number) => number) }) | null} */
106
+ /** @param {string} owner @param {unknown} v @returns {number} */
107
+ function minScale(owner, v) {
108
+ const n = checkNumber(owner, 'minScale', v)
109
+ if (!(n >= 0)) fail(owner, `'minScale' is CSS pixels to one world unit, 0 or more, got ${n}`)
110
+ return n
111
+ }
112
+
113
+ /** @typedef {InstanceLabels & Required<Omit<LabelPlacement, 'priority'>> & { priority: number | ((datum: any, index: number) => number) }} CheckedLabels */
114
+
115
+ /** @param {string} owner @param {unknown} v @returns {CheckedLabels | CheckedLabels[] | null} */
101
116
  function instanceLabels(owner, v) {
102
117
  if (v == null) return null
118
+ if (Array.isArray(v)) {
119
+ if (!v.length) fail(owner, "'labels' as a list needs at least one { text: (datum, index) => string, ... }")
120
+ return v.map((one) => /** @type {CheckedLabels} */ (instanceLabels(owner, one)))
121
+ }
103
122
  if (typeof v !== 'object' || typeof (/** @type {any} */ (v)).text !== 'function') {
104
123
  fail(owner, `'labels' must be { text: (datum, index) => string, ... }, got ${describe(v)}`)
105
124
  }
@@ -253,7 +272,7 @@ export class Mesh extends Drawable {
253
272
  * @typedef {object} InstanceOptions
254
273
  * @property {Vec3Input} [position]
255
274
  * @property {Vec3Input | Euler} [rotation] Euler angles in radians, XYZ order
256
- * @property {ArrayLike<number>} [quaternion]
275
+ * @property {QuatInput} [quaternion] [x, y, z, w] or { x, y, z, w } - as physics engines give them
257
276
  * @property {Vec3Input | number} [scale]
258
277
  * @property {ColorInput} [color] multiplies the material colour
259
278
  */
@@ -261,11 +280,23 @@ export class Mesh extends Drawable {
261
280
  const INSTANCE_OPTIONS = new Set(['position', 'rotation', 'quaternion', 'scale', 'color'])
262
281
  // reused by setInstance, so updating every instance of a large mesh each frame allocates nothing
263
282
  const scratch = { position: new Vec3(), scale: new Vec3(), quaternion: new Quat(), rotation: new Euler() }
283
+ const SCRATCH_COLOR = new Color()
264
284
  const scratchMatrix = new Float32Array(16)
265
285
 
286
+ /**
287
+ * True when an instance is scaled to nothing - hidden: it draws nothing, takes no room in its mesh's
288
+ * bounds and shows no label.
289
+ * @internal @param {ArrayLike<number>} m instance matrices @param {number} i
290
+ */
291
+ export function hiddenInstance(m, i) {
292
+ const o = i * 16
293
+ return !m[o] && !m[o + 1] && !m[o + 2] && !m[o + 4] && !m[o + 5] && !m[o + 6] && !m[o + 8] && !m[o + 9] && !m[o + 10]
294
+ }
295
+
266
296
  /**
267
297
  * Many copies of one geometry and material, drawn together. Set each copy with setInstance().
268
- * Picking reports which instance was hit.
298
+ * Picking reports which instance was hit. An instance scaled to 0 is hidden - it draws nothing, and
299
+ * framing, culling, shadows and labels leave it out - so a pool of instances can keep spares.
269
300
  */
270
301
  export class InstancedMesh extends Mesh {
271
302
  static type = 'instanced-mesh'
@@ -279,12 +310,12 @@ export class InstancedMesh extends Mesh {
279
310
  instanceColors = null
280
311
  /** @internal Bumped when instance data changes, so renderers re-upload. */
281
312
  version = 0
282
- /** A label per instance, from its row of data. @type {ReturnType<typeof instanceLabels>} */
313
+ /** A label per instance from its row of data - or a list of them, several per instance. @type {ReturnType<typeof instanceLabels>} */
283
314
  labels = null
284
315
 
285
316
  /**
286
317
  * @param {DrawableOptions & { count?: number, instances?: InstanceOptions[], instanceMatrices?: ArrayLike<number>,
287
- * instanceColors?: ArrayLike<number>, labels?: InstanceLabels | null }} options count defaults to the number
318
+ * instanceColors?: ArrayLike<number>, labels?: InstanceLabels | InstanceLabels[] | null }} options count defaults to the number
288
319
  * of instances or rows of data; instanceMatrices/instanceColors are raw data, as toJSON() writes
289
320
  */
290
321
  constructor(options) {
@@ -375,7 +406,7 @@ export class InstancedMesh extends Mesh {
375
406
  /** @param {number} i @param {ColorInput} color */
376
407
  #setColor(i, color) {
377
408
  if (!this.instanceColors) this.instanceColors = new Float32Array(this.instanceMatrices.length / 16 * 3).fill(1)
378
- this.instanceColors.set(Color.fromInput(color, 'InstancedMesh'), i * 3)
409
+ this.instanceColors.set(SCRATCH_COLOR.set(color, 'InstancedMesh'), i * 3)
379
410
  }
380
411
 
381
412
  /** @internal @param {Box3} box */
@@ -401,6 +432,7 @@ export class InstancedMesh extends Mesh {
401
432
  const ex = (local.max[0] - local.min[0]) / 2, ey = (local.max[1] - local.min[1]) / 2, ez = (local.max[2] - local.min[2]) / 2
402
433
  const w = this.worldMatrix, im = this.instanceMatrices, m = scratchMatrix
403
434
  for (let i = 0; i < this.count; i++) {
435
+ if (hiddenInstance(im, i)) continue
404
436
  mat4.multiply(m, w, im.subarray(i * 16, i * 16 + 16))
405
437
  const x = m[0] * cx + m[4] * cy + m[8] * cz + m[12]
406
438
  const y = m[1] * cx + m[5] * cy + m[9] * cz + m[13]
@@ -434,20 +466,44 @@ export class InstancedMesh extends Mesh {
434
466
  /** Each vertex drawn as a square point sprite: star fields, particles, scatter plots. */
435
467
  export class Points extends Drawable {
436
468
  static type = 'points'
437
- static get OPTIONS() { return [...DRAWABLE_OPTIONS, 'labels'] }
469
+ static get OPTIONS() { return [...DRAWABLE_OPTIONS, 'labels', 'sort'] }
438
470
 
439
- /** A label per point, from its row of data. @type {ReturnType<typeof instanceLabels>} */
471
+ /** A label per point from its row of data - or a list of them, several per point. @type {ReturnType<typeof instanceLabels>} */
440
472
  labels = null
473
+ /**
474
+ * Draw the points back to front, furthest first, each frame - for soft points that cover what is
475
+ * behind them (smoke, with blending 'normal'), which then layer the right way round. Drawn as squares.
476
+ */
477
+ sort = false
441
478
 
442
- /** @param {DrawableOptions & { labels?: InstanceLabels | null }} options */
479
+ /** @param {DrawableOptions & { labels?: InstanceLabels | InstanceLabels[] | null, sort?: boolean }} options */
443
480
  constructor(options) {
444
481
  super(options)
445
482
  this.labels = instanceLabels(this.constructor.name, options?.labels)
483
+ if (options?.sort !== undefined) {
484
+ if (typeof options.sort !== 'boolean') fail(this.constructor.name, `'sort' is true or false, got ${describe(options.sort)}`)
485
+ this.sort = options.sort
486
+ }
487
+ }
488
+
489
+ /**
490
+ * @internal Drawn as squares, one a point, rather than as GL points: when the geometry gives each
491
+ * point its own size, opacity or angle, or the points are sorted. Squares can be any size and stay
492
+ * whole at the edge of the view.
493
+ */
494
+ get drawsSprites() {
495
+ const g = this.geometry
496
+ return this.sort || !!(g.sizes || g.opacities || g.angles)
446
497
  }
447
498
 
448
499
  /** @internal @returns {Record<string, unknown>} */
449
500
  cloneOptions() {
450
- return { ...super.cloneOptions(), labels: this.labels }
501
+ return { ...super.cloneOptions(), labels: this.labels, sort: this.sort }
502
+ }
503
+
504
+ /** @internal @returns {Record<string, unknown>} */
505
+ jsonOptions() {
506
+ return { ...super.jsonOptions(), ...(this.sort ? { sort: true } : {}) }
451
507
  }
452
508
 
453
509
  /** @returns {'points'} */
@@ -456,12 +512,52 @@ export class Points extends Drawable {
456
512
  }
457
513
  }
458
514
 
515
+ const ARROWS = ['none', 'start', 'end', 'both']
516
+
517
+ /**
518
+ * @typedef {DrawableOptions & { arrows?: 'none' | 'start' | 'end' | 'both', arrowSize?: number }} LinesOptions
519
+ */
520
+
459
521
  /**
460
522
  * Line segments: indices (or consecutive vertices) in pairs. Width is in pixels and works on
461
- * every platform. edges(geometry) makes a wireframe of any geometry.
523
+ * every platform. edges(geometry) makes a wireframe of any geometry. Segments each starting where
524
+ * the one before ended make a chain - a polyline - which dashes run on along and arrowheads finish.
462
525
  */
463
526
  export class Lines extends Drawable {
464
527
  static type = 'lines'
528
+ static get OPTIONS() { return [...DRAWABLE_OPTIONS, 'arrows', 'arrowSize'] }
529
+
530
+ /** Arrowheads at the ends of each chain: at its 'start', its 'end', 'both' or 'none'. @type {'none' | 'start' | 'end' | 'both'} */
531
+ arrows = 'none'
532
+ /** Arrowhead length in pixels; 0 sizes them from the material's width. */
533
+ arrowSize = 0
534
+
535
+ /** @param {LinesOptions} options */
536
+ constructor(options) {
537
+ super(options)
538
+ if (options?.arrows !== undefined) this.arrows = /** @type {any} */ (checkOneOf('Lines', 'arrows', options.arrows, ARROWS))
539
+ if (options?.arrowSize !== undefined) this.arrowSize = Math.max(0, checkNumber('Lines', 'arrowSize', options.arrowSize))
540
+ }
541
+
542
+ /** @internal Which ends get arrowheads, for the shader: 1 the start, 2 the end. */
543
+ get arrowBits() {
544
+ return ARROWS.indexOf(this.arrows)
545
+ }
546
+
547
+ /** @internal An arrowhead's length in pixels, before the pixel ratio. @param {number} width */
548
+ arrowPixels(width) {
549
+ return this.arrowSize || 6 + width * 3
550
+ }
551
+
552
+ /** @internal @returns {Record<string, unknown>} */
553
+ cloneOptions() {
554
+ return { ...super.cloneOptions(), arrows: this.arrows, arrowSize: this.arrowSize }
555
+ }
556
+
557
+ /** @internal @returns {Record<string, unknown>} */
558
+ jsonOptions() {
559
+ return { ...super.jsonOptions(), ...(this.arrows !== 'none' ? { arrows: this.arrows } : {}), ...(this.arrowSize ? { arrowSize: this.arrowSize } : {}) }
560
+ }
465
561
 
466
562
  /** @returns {'lines'} */
467
563
  get drawMode() {
@@ -505,6 +601,8 @@ export class Label extends Node {
505
601
  leader = false
506
602
  /** Set at the view's left or right edge, level with its point, or null for by its point. @type {'left' | 'right' | null} */
507
603
  edge = null
604
+ /** Shown only when zoomed in this far: CSS pixels to one world unit at its point; 0 for always. */
605
+ minScale = 0
508
606
 
509
607
  /** @param {LabelOptions} options */
510
608
  constructor(options) {
@@ -529,7 +627,8 @@ export class Label extends Node {
529
627
  /** @internal @returns {Record<string, unknown>} */
530
628
  cloneOptions() {
531
629
  return { ...super.cloneOptions(), text: this.text, className: this.className, place: this.place,
532
- offset: [...this.offset], priority: this.priority, overlap: this.overlap, occlude: this.occlude, leader: this.leader, edge: this.edge }
630
+ offset: [...this.offset], priority: this.priority, overlap: this.overlap, occlude: this.occlude, leader: this.leader, edge: this.edge,
631
+ minScale: this.minScale }
533
632
  }
534
633
 
535
634
  /** @internal @returns {Record<string, unknown>} */
@@ -544,6 +643,7 @@ export class Label extends Node {
544
643
  if (this.occlude) json.occlude = true
545
644
  if (this.leader) json.leader = true
546
645
  if (this.edge) json.edge = this.edge
646
+ if (this.minScale) json.minScale = this.minScale
547
647
  return json
548
648
  }
549
649
  }