phoria2 1.1.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 (60) hide show
  1. package/README.md +71 -13
  2. package/dist/bloom.js +4 -4
  3. package/dist/bloom.js.map +1 -1
  4. package/dist/charts.js +358 -345
  5. package/dist/charts.js.map +1 -1
  6. package/dist/core.js +3989 -3600
  7. package/dist/core.js.map +1 -1
  8. package/dist/edit.js +43 -43
  9. package/dist/edit.js.map +1 -1
  10. package/dist/explain.js +5 -1
  11. package/dist/explain.js.map +1 -1
  12. package/dist/gltf.js +96 -96
  13. package/dist/gltf.js.map +1 -1
  14. package/dist/motion.js +3 -3
  15. package/dist/motion.js.map +1 -1
  16. package/dist/particles.js +16 -16
  17. package/dist/particles.js.map +1 -1
  18. package/dist/passes.js +26 -26
  19. package/dist/passes.js.map +1 -1
  20. package/dist/phoria2.js +1 -1
  21. package/dist/picking.js +16 -16
  22. package/dist/picking.js.map +1 -1
  23. package/dist/types/OrbitControl.d.ts +6 -2
  24. package/dist/types/Raycaster.d.ts +10 -0
  25. package/dist/types/View.d.ts +59 -1
  26. package/dist/types/charts/Chart.d.ts +7 -0
  27. package/dist/types/explain.d.ts +3 -0
  28. package/dist/types/frame.d.ts +7 -0
  29. package/dist/types/geometry/Geometry.d.ts +8 -2
  30. package/dist/types/keyboard.d.ts +48 -0
  31. package/dist/types/labels.d.ts +4 -0
  32. package/dist/types/scene/Node.d.ts +19 -14
  33. package/dist/types/scene/drawables.d.ts +32 -14
  34. package/dist/types/scene/lights.d.ts +16 -2
  35. package/dist/types/shadow.d.ts +2 -1
  36. package/dist/types/types.d.ts +14 -0
  37. package/dist/types/value/Color.d.ts +0 -4
  38. package/dist/types/value/Quat.d.ts +4 -2
  39. package/llms-full.txt +58 -9
  40. package/package.json +4 -1
  41. package/schema/scene.schema.json +19 -1
  42. package/src/OrbitControl.js +7 -3
  43. package/src/Raycaster.js +58 -0
  44. package/src/View.js +204 -41
  45. package/src/charts/Chart.js +20 -2
  46. package/src/explain.js +6 -2
  47. package/src/frame.js +29 -9
  48. package/src/geometry/Geometry.js +16 -3
  49. package/src/keyboard.js +298 -0
  50. package/src/labels.js +4 -1
  51. package/src/scene/Node.js +20 -9
  52. package/src/scene/Scene.js +2 -0
  53. package/src/scene/cameras.js +4 -1
  54. package/src/scene/drawables.js +48 -12
  55. package/src/scene/lights.js +40 -6
  56. package/src/shadow.js +27 -4
  57. package/src/types.js +6 -0
  58. package/src/value/Color.js +21 -0
  59. package/src/value/Quat.js +15 -6
  60. package/src/watch.js +6 -1
@@ -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]
@@ -436,10 +468,10 @@ export class Points extends Drawable {
436
468
  static type = 'points'
437
469
  static get OPTIONS() { return [...DRAWABLE_OPTIONS, 'labels'] }
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
441
473
 
442
- /** @param {DrawableOptions & { labels?: InstanceLabels | null }} options */
474
+ /** @param {DrawableOptions & { labels?: InstanceLabels | InstanceLabels[] | null }} options */
443
475
  constructor(options) {
444
476
  super(options)
445
477
  this.labels = instanceLabels(this.constructor.name, options?.labels)
@@ -505,6 +537,8 @@ export class Label extends Node {
505
537
  leader = false
506
538
  /** Set at the view's left or right edge, level with its point, or null for by its point. @type {'left' | 'right' | null} */
507
539
  edge = null
540
+ /** Shown only when zoomed in this far: CSS pixels to one world unit at its point; 0 for always. */
541
+ minScale = 0
508
542
 
509
543
  /** @param {LabelOptions} options */
510
544
  constructor(options) {
@@ -529,7 +563,8 @@ export class Label extends Node {
529
563
  /** @internal @returns {Record<string, unknown>} */
530
564
  cloneOptions() {
531
565
  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 }
566
+ offset: [...this.offset], priority: this.priority, overlap: this.overlap, occlude: this.occlude, leader: this.leader, edge: this.edge,
567
+ minScale: this.minScale }
533
568
  }
534
569
 
535
570
  /** @internal @returns {Record<string, unknown>} */
@@ -544,6 +579,7 @@ export class Label extends Node {
544
579
  if (this.occlude) json.occlude = true
545
580
  if (this.leader) json.leader = true
546
581
  if (this.edge) json.edge = this.edge
582
+ if (this.minScale) json.minScale = this.minScale
547
583
  return json
548
584
  }
549
585
  }
@@ -1,10 +1,11 @@
1
1
  import { Color } from '../value/Color.js'
2
2
  import { Vec3 } from '../value/Vec3.js'
3
- import { checkNumber, fail } from '../validate.js'
3
+ import { Box3 } from '../value/Box3.js'
4
+ import { checkNumber, describe, fail } from '../validate.js'
4
5
  import { NODE_OPTIONS, Node, inParentSpace, setPoint } from './Node.js'
5
6
 
6
7
  /** @import { NodeOptions } from './Node.js' */
7
- /** @import { ColorInput, Vec3Input } from '../types.js' */
8
+ /** @import { Box3Input, ColorInput, Vec3Input } from '../types.js' */
8
9
 
9
10
  /** @typedef {NodeOptions & { color?: ColorInput, intensity?: number }} LightOptions */
10
11
  /** @typedef {'ambient' | 'hemisphere' | 'directional' | 'point' | 'spot'} LightKind */
@@ -189,24 +190,57 @@ export class DirectionalLight extends AimedLight {
189
190
  static type = 'directional-light'
190
191
  /** @internal @type {LightKind} */
191
192
  static kind = 'directional'
192
- static get OPTIONS() { return [...LIGHT_OPTIONS, 'direction', 'target', 'castShadow'] }
193
+ static get OPTIONS() { return [...LIGHT_OPTIONS, 'direction', 'target', 'castShadow', 'shadowBounds'] }
193
194
 
194
195
  castShadow = false
196
+ /** @type {Box3 | null} */
197
+ #shadowBounds = null
195
198
 
196
- /** @param {AimedLightOptions & { castShadow?: boolean }} [options] */
199
+ /** @param {AimedLightOptions & { castShadow?: boolean, shadowBounds?: Box3Input | null }} [options] */
197
200
  constructor(options = {}) {
198
201
  super(options)
199
202
  if (options.castShadow !== undefined) this.castShadow = !!options.castShadow
203
+ if (options.shadowBounds !== undefined) this.#setShadowBounds(options.shadowBounds)
204
+ }
205
+
206
+ /**
207
+ * The part of the world, in world units, that has shadows - or null (the default) to follow
208
+ * whatever casts them. The shadow map is fitted to what casts every frame, so one caster flung far
209
+ * off spreads its texels over everything and every shadow blurs; set the area where things happen
210
+ * and its shadows stay sharp. Casters outside it still shadow it, from wherever they stand.
211
+ * Assigning copies.
212
+ * @returns {Box3 | null}
213
+ */
214
+ get shadowBounds() { return this.#shadowBounds }
215
+
216
+ /** @param {Box3Input | null} v */
217
+ set shadowBounds(v) { this.#setShadowBounds(v) }
218
+
219
+ /** @param {Box3Input | null} v */
220
+ #setShadowBounds(v) {
221
+ if (v === null) {
222
+ this.#shadowBounds = null
223
+ return
224
+ }
225
+ const b = /** @type {any} */ (v)
226
+ if (!b || typeof b !== 'object' || !b.min || !b.max) {
227
+ fail(this.constructor.name, `'shadowBounds' is a box - { min: [x, y, z], max: [x, y, z] } or a Box3 - or null, got ${describe(v)}`)
228
+ }
229
+ const box = new Box3(Vec3.fromInput(b.min, this.constructor.name), Vec3.fromInput(b.max, this.constructor.name))
230
+ if (box.isEmpty()) fail(this.constructor.name, `'shadowBounds' min must not be above max on any axis, got ${describe(v)}`)
231
+ this.#shadowBounds = box
200
232
  }
201
233
 
202
234
  /** @internal @returns {Record<string, unknown>} */
203
235
  cloneOptions() {
204
- return { ...super.cloneOptions(), castShadow: this.castShadow }
236
+ return { ...super.cloneOptions(), castShadow: this.castShadow, shadowBounds: this.#shadowBounds }
205
237
  }
206
238
 
207
239
  /** @internal @returns {Record<string, unknown>} */
208
240
  jsonOptions() {
209
- return { ...super.jsonOptions(), ...(this.castShadow ? { castShadow: true } : {}) }
241
+ const b = this.#shadowBounds
242
+ return { ...super.jsonOptions(), ...(this.castShadow ? { castShadow: true } : {}),
243
+ ...(b ? { shadowBounds: { min: Array.from(b.min), max: Array.from(b.max) } } : {}) }
210
244
  }
211
245
  }
212
246
 
package/src/shadow.js CHANGED
@@ -23,9 +23,10 @@ export const SHADOW_MAP_SIZE = 2048
23
23
  * @param {Box3[]} casters world bounds
24
24
  * @param {Box3[]} receivers world bounds
25
25
  * @param {number} [size] map size in texels
26
+ * @param {Box3 | null} [area] the light's shadowBounds: the map covers this across, whatever casts
26
27
  * @returns {ShadowFit | null}
27
28
  */
28
- export function fitShadow(direction, casters, receivers, size = SHADOW_MAP_SIZE) {
29
+ export function fitShadow(direction, casters, receivers, size = SHADOW_MAP_SIZE, area = null) {
29
30
  const boxes = casters.filter((b) => !b.isEmpty())
30
31
  if (!boxes.length) return null
31
32
  const d = normalized(direction)
@@ -50,7 +51,10 @@ export function fitShadow(direction, casters, receivers, size = SHADOW_MAP_SIZE)
50
51
  if (z > z1) z1 = z
51
52
  }
52
53
  }
53
- for (const b of boxes) corners(b, true)
54
+ // across the light's view: the area it was given, or else the casters; in depth, everything that
55
+ // casts - so a caster outside the area, between it and the light, still shadows it
56
+ if (area) corners(area, true)
57
+ for (const b of boxes) corners(b, !area)
54
58
  // receivers only stretch the depth (view z is negative in front, so the far end is the minimum)
55
59
  for (const b of receivers) if (!b.isEmpty()) corners(b, false)
56
60
 
@@ -58,10 +62,29 @@ export function fitShadow(direction, casters, receivers, size = SHADOW_MAP_SIZE)
58
62
  const margin = Math.max(x1 - x0, y1 - y0, 1e-3) * 0.02
59
63
  x0 -= margin; x1 += margin; y0 -= margin; y1 += margin
60
64
  const depthMargin = Math.max(z1 - z0, 1e-3) * 0.01
61
- const projection = mat4.ortho(mat4.create(), x0, x1, y0, y1, -z1 - depthMargin, -z0 + depthMargin)
65
+ z0 -= depthMargin; z1 += depthMargin
66
+
67
+ // steady while things move: the map grows and shrinks in small steps rather than with every change
68
+ // in the casters' extent, and its edges sit on the texel grid, so a shadow that isn't moving falls
69
+ // on the same texels every frame, instead of shimmering as the fit slides about under it
70
+ const w = steps(x1 - x0), h = steps(y1 - y0)
71
+ const tx = w / size, ty = h / size
72
+ x0 = Math.floor((x0 + x1 - w) / 2 / tx) * tx
73
+ y0 = Math.floor((y0 + y1 - h) / 2 / ty) * ty
74
+ // the depth range too, which sets what each stored depth means
75
+ const dz = steps(z1 - z0) / 64
76
+ z0 = Math.floor(z0 / dz) * dz
77
+ z1 = Math.ceil(z1 / dz) * dz
78
+
79
+ const projection = mat4.ortho(mat4.create(), x0, x0 + w, y0, y0 + h, -z1, -z0)
62
80
  const viewProjection = new Mat4()
63
81
  mat4.multiply(viewProjection, projection, view)
64
- return { viewProjection, texelSize: Math.max(x1 - x0, y1 - y0) / size }
82
+ return { viewProjection, texelSize: Math.max(w, h) / size }
83
+ }
84
+
85
+ /** A length rounded up to the next of a ladder of sizes, sixteen to each doubling. @param {number} v */
86
+ function steps(v) {
87
+ return 2 ** (Math.ceil(Math.log2(Math.max(v, 1e-3)) * 16) / 16)
65
88
  }
66
89
 
67
90
  /** Spot shadows' map: four tiles of half its size, one a light. */
package/src/types.js CHANGED
@@ -9,6 +9,12 @@
9
9
 
10
10
  /** A vector as accepted on input: [x, y, z], { x, y, z } or a Vec3. @typedef {Vec3Like | XYZ} Vec3Input */
11
11
 
12
+ /** A quaternion as an object literal - as physics engines such as Rapier return them. @typedef {{ x: number, y: number, z: number, w: number }} XYZW */
13
+
14
+ /** A rotation quaternion as accepted on input: [x, y, z, w], { x, y, z, w } or a Quat. @typedef {ArrayLike<number> | XYZW} QuatInput */
15
+
16
+ /** A box as accepted on input: a Box3, or its lowest and highest corners. @typedef {import('./value/Box3.js').Box3 | { min: Vec3Input, max: Vec3Input }} Box3Input */
17
+
12
18
  /**
13
19
  * A colour as accepted on input, in sRGB: '#ff8800', '#f80', 0xff8800, 'rgb(255 136 0)',
14
20
  * 'hsl(32 100% 50%)', a CSS colour name, or [r, g, b] with components 0-1.
@@ -16,6 +16,11 @@ const HELP = "use '#rrggbb', a CSS name, 'rgb(...)', 'hsl(...)', 0xrrggbb or [r,
16
16
  * An RGB colour. Input is sRGB, as everywhere in phoria2; the components are stored **linear**,
17
17
  * ready for lighting maths. `toJSON()` gives sRGB '#rrggbb' back.
18
18
  */
19
+ // colour strings already worked out, as linear rgb - emptied when full, as a palette never fills it
20
+ /** @type {Map<string, number[]>} */
21
+ const PARSED = new Map()
22
+ const PARSED_MAX = 1024
23
+
19
24
  export class Color extends Float32Array {
20
25
  static get [Symbol.species]() { return Float32Array }
21
26
 
@@ -154,6 +159,22 @@ export class Color extends Float32Array {
154
159
 
155
160
  /** @param {string} s @param {string} owner */
156
161
  #setString(s, owner) {
162
+ // the same few strings come again and again - an instance's colour set every frame - so each is
163
+ // worked out once
164
+ const known = PARSED.get(s)
165
+ if (known) {
166
+ this[0] = known[0]
167
+ this[1] = known[1]
168
+ this[2] = known[2]
169
+ return
170
+ }
171
+ this.#parse(s, owner)
172
+ if (PARSED.size >= PARSED_MAX) PARSED.clear()
173
+ PARSED.set(s, [this[0], this[1], this[2]])
174
+ }
175
+
176
+ /** @param {string} s @param {string} owner */
177
+ #parse(s, owner) {
157
178
  const lower = s.toLowerCase()
158
179
  if (lower.startsWith('#')) {
159
180
  let hex = lower.slice(1)
package/src/value/Quat.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as quat from '../math/quat.js'
2
2
  import { describe, fail } from '../validate.js'
3
3
 
4
+ /** @import { QuatInput } from '../types.js' */
5
+
4
6
  /**
5
7
  * A rotation quaternion [x, y, z, w]: a Float32Array(4) with accessors and chainable methods.
6
8
  * A node's quaternion and its Euler `rotation` keep each other in sync through these setters;
@@ -61,13 +63,20 @@ export class Quat extends Float32Array {
61
63
  return this.changed()
62
64
  }
63
65
 
64
- /** @param {ArrayLike<number>} q [x, y, z, w] @param {string} [owner] @returns {this} */
66
+ /** @param {QuatInput} q [x, y, z, w], or { x, y, z, w } as physics engines give them @param {string} [owner] @returns {this} */
65
67
  copy(q, owner = 'Quat') {
66
- if (!q || typeof q !== 'object' || !(q.length >= 4)) fail(owner, `expected a quaternion [x, y, z, w], got ${describe(q)}`)
67
- this[0] = q[0]
68
- this[1] = q[1]
69
- this[2] = q[2]
70
- this[3] = q[3]
68
+ const o = /** @type {any} */ (q)
69
+ if (o && typeof o === 'object' && o.length >= 4) {
70
+ this[0] = o[0]
71
+ this[1] = o[1]
72
+ this[2] = o[2]
73
+ this[3] = o[3]
74
+ } else if (o && typeof o === 'object' && 'x' in o && 'y' in o && 'z' in o && 'w' in o) {
75
+ this[0] = o.x
76
+ this[1] = o.y
77
+ this[2] = o.z
78
+ this[3] = o.w
79
+ } else fail(owner, `expected a quaternion [x, y, z, w] or { x, y, z, w }, got ${describe(q)}`)
71
80
  return this.changed()
72
81
  }
73
82
 
package/src/watch.js CHANGED
@@ -123,7 +123,7 @@ export class ChangeWatch {
123
123
  this.#push((node.handlersFor('click').length ? 1 : 0) | (node.handlersFor('hover').length ? 2 : 0) | (node.tooltip ? 4 : 0) | (node.onDrag ? 8 : 0) | (node.highlightWhole ? 16 : 0))
124
124
  // what hovering tints it with - a different colour shows straight away on what is hovered
125
125
  if (node.highlight) this.#pushAll(node.highlight)
126
- else this.#push(-4)
126
+ else this.#push(node.highlight === false ? -5 : -4)
127
127
  const outline = node.outline
128
128
  if (outline) {
129
129
  this.#pushAll(outline.color)
@@ -208,6 +208,7 @@ export class ChangeWatch {
208
208
  this.#push(label.priority)
209
209
  this.#push(label.occlude ? 1 : 0)
210
210
  this.#push(label.leader ? 1 : 0)
211
+ this.#push(label.minScale)
211
212
  }
212
213
 
213
214
  /** @param {any} light */
@@ -218,6 +219,10 @@ export class ChangeWatch {
218
219
  if (light.direction) this.#pushAll(light.direction)
219
220
  if (light.target) this.#pushAll(light.target)
220
221
  if (light.castShadow) this.#push(1)
222
+ if (light.shadowBounds) {
223
+ this.#pushAll(light.shadowBounds.min)
224
+ this.#pushAll(light.shadowBounds.max)
225
+ }
221
226
  if ('range' in light) this.#push(light.range)
222
227
  if ('angle' in light) {
223
228
  this.#push(light.angle)