phoria2 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +71 -13
  2. package/dist/bloom.js +1 -1
  3. package/dist/charts.js +397 -384
  4. package/dist/charts.js.map +1 -1
  5. package/dist/core.js +4451 -3568
  6. package/dist/core.js.map +1 -1
  7. package/dist/edit.js +67 -67
  8. package/dist/edit.js.map +1 -1
  9. package/dist/explain.js +6 -2
  10. package/dist/explain.js.map +1 -1
  11. package/dist/gltf.js +211 -166
  12. package/dist/gltf.js.map +1 -1
  13. package/dist/motion.js +26 -26
  14. package/dist/motion.js.map +1 -1
  15. package/dist/particles.js +49 -49
  16. package/dist/particles.js.map +1 -1
  17. package/dist/passes.js +188 -86
  18. package/dist/passes.js.map +1 -1
  19. package/dist/phoria2.js +1 -1
  20. package/dist/picking.js +8 -13
  21. package/dist/picking.js.map +1 -1
  22. package/dist/snapshot.js +1 -1
  23. package/dist/tooltip.js +3 -3
  24. package/dist/tooltip.js.map +1 -1
  25. package/dist/types/OrbitControl.d.ts +6 -2
  26. package/dist/types/Raycaster.d.ts +10 -0
  27. package/dist/types/View.d.ts +64 -1
  28. package/dist/types/charts/Chart.d.ts +7 -0
  29. package/dist/types/explain.d.ts +3 -0
  30. package/dist/types/frame.d.ts +7 -0
  31. package/dist/types/geometry/Geometry.d.ts +14 -2
  32. package/dist/types/keyboard.d.ts +48 -0
  33. package/dist/types/labels.d.ts +4 -0
  34. package/dist/types/material/Material.d.ts +125 -0
  35. package/dist/types/material/Texture.d.ts +37 -5
  36. package/dist/types/scene/Node.d.ts +19 -14
  37. package/dist/types/scene/drawables.d.ts +32 -14
  38. package/dist/types/scene/lights.d.ts +16 -2
  39. package/dist/types/shadow.d.ts +2 -1
  40. package/dist/types/types.d.ts +14 -0
  41. package/dist/types/value/Color.d.ts +0 -4
  42. package/dist/types/value/Quat.d.ts +4 -2
  43. package/dist/types/webgl/WebGLRenderer.d.ts +6 -0
  44. package/dist/types/webgl/passes.d.ts +37 -13
  45. package/dist/types/webgl/resources.d.ts +24 -2
  46. package/dist/types/webgl/shaders.d.ts +4 -2
  47. package/llms-full.txt +153 -15
  48. package/package.json +4 -1
  49. package/schema/scene.schema.json +146 -1
  50. package/src/OrbitControl.js +7 -3
  51. package/src/Raycaster.js +58 -0
  52. package/src/View.js +207 -42
  53. package/src/charts/Chart.js +20 -2
  54. package/src/explain.js +6 -2
  55. package/src/frame.js +29 -9
  56. package/src/geometry/Geometry.js +24 -4
  57. package/src/gltf/load.js +50 -6
  58. package/src/keyboard.js +298 -0
  59. package/src/labels.js +4 -1
  60. package/src/material/Material.js +104 -19
  61. package/src/material/Texture.js +116 -11
  62. package/src/scene/Node.js +20 -9
  63. package/src/scene/Scene.js +2 -0
  64. package/src/scene/cameras.js +4 -1
  65. package/src/scene/drawables.js +48 -12
  66. package/src/scene/lights.js +40 -6
  67. package/src/shadow.js +27 -4
  68. package/src/types.js +6 -0
  69. package/src/value/Color.js +21 -0
  70. package/src/value/Quat.js +15 -6
  71. package/src/watch.js +25 -1
  72. package/src/webgl/WebGLRenderer.js +51 -12
  73. package/src/webgl/passes.js +190 -32
  74. package/src/webgl/picking.js +0 -1
  75. package/src/webgl/resources.js +89 -8
  76. package/src/webgl/shaders.js +350 -48
@@ -30,8 +30,36 @@ import { Texture } from './Texture.js'
30
30
  * @property {MapInput | null} [aoMap] ambient occlusion in its red channel; darkens environment and ambient light
31
31
  * @property {number} [aoMapIntensity] 0-1 (default 1)
32
32
  * @property {MapInput | null} [emissiveMap] multiplies the emissive colour
33
+ * @property {MapInput | null} [lightMap] light baked into a texture, added as light from all round - usually on
34
+ * the second UV set (uvSet: 1)
35
+ * @property {number} [lightMapIntensity] (default 1)
36
+ * @property {number} [environmentIntensity] this material's share of the environment's light and reflections:
37
+ * lower for a matte floor in a shiny showroom, higher to make one thing catch the light (default 1)
33
38
  * @property {ColorInput} [emissive] glow colour (default black)
34
39
  * @property {number} [emissiveIntensity] (default 1)
40
+ * @property {number} [transmission] 0-1: how much light passes through, as through glass (default 0). Unlike
41
+ * opacity, which fades the whole surface, glass keeps its reflections, and shows less of what is behind it
42
+ * towards its edges. Its colour shows in proportion to 1 - transmission
43
+ * @property {number} [clearcoat] 0-1: a clear glossy layer over the surface - car paint, lacquer, varnish (default 0)
44
+ * @property {number} [clearcoatRoughness] 0-1: how glossy the coat is, 0 a mirror (default 0)
45
+ * @property {number} [sheen] 0-1: the soft bright rim of cloth at grazing angles - velvet, felt, upholstery (default 0)
46
+ * @property {ColorInput} [sheenColor] the sheen's colour (default white)
47
+ * @property {number} [sheenRoughness] 0-1: a thin bright rim at 0, a broad soft glow at 1 (default 0.5)
48
+ * @property {number} [ior] index of refraction, 1 or more: how strongly a non-metal reflects - water 1.33, glass and
49
+ * plastic 1.5 (the default), diamond 2.4
50
+ * @property {number} [specularIntensity] 0-1: scales a non-metal's reflections, 0 for none - chalk, clay (default 1)
51
+ * @property {ColorInput} [specularColor] tints a non-metal's reflections face on (default white)
52
+ * @property {number} [anisotropy] 0-1: brushed metal - highlights drawn out along the brushing, which runs along
53
+ * the UVs' u (around the mesh where it has no UVs) (default 0)
54
+ * @property {number} [anisotropyRotation] turns the brushing from the UVs' u, in radians (default 0)
55
+ * @property {number} [translucency] 0-1: light through thin surfaces, lighting them from behind - leaves, paper,
56
+ * lampshades, curtains (default 0)
57
+ * @property {ColorInput} [translucencyColor] tints the light that comes through (default white)
58
+ * @property {MapInput | null} [matcap] a picture of a lit sphere that gives the whole look - clay, wax, chrome,
59
+ * a painted ball - picked by which way the surface faces the camera. Lights, shadows and the environment are
60
+ * ignored; the colour and colour map multiply it
61
+ * @property {number} [toon] cartoon shading in this many flat tones (2 or more) with a hard-edged highlight;
62
+ * 0 (the default) is smooth
35
63
  * @property {boolean} [unlit] flat colour that ignores lights (default false)
36
64
  * @property {boolean} [flatShading] faceted look from face normals (default false)
37
65
  * @property {boolean} [doubleSided] draw back faces too (default false)
@@ -46,18 +74,26 @@ import { Texture } from './Texture.js'
46
74
  * @property {number} [width] Lines: width in pixels (default 1)
47
75
  */
48
76
 
49
- const MAPS = ['map', 'roughnessMap', 'metalnessMap', 'normalMap', 'bumpMap', 'aoMap', 'emissiveMap']
77
+ // the matcap last: the first map's repeat and offset are every map's, and a matcap has none
78
+ const MAPS = ['map', 'roughnessMap', 'metalnessMap', 'normalMap', 'bumpMap', 'aoMap', 'emissiveMap', 'lightMap', 'matcap']
50
79
  // data maps hold numbers, not colours
51
80
  const LINEAR_MAPS = new Set(['roughnessMap', 'metalnessMap', 'normalMap', 'bumpMap', 'aoMap'])
52
81
  const FLAGS = ['unlit', 'flatShading', 'doubleSided', 'vertexColors', 'depthWrite', 'depthTest', 'sizeAttenuation', 'transparent', 'fog']
53
82
  const NUMBERS = { opacity: [0, 1], roughness: [0, 1], metalness: [0, 1], emissiveIntensity: [0, Infinity], size: [0, Infinity], width: [0, Infinity],
54
- alphaTest: [0, 1], normalScale: [-Infinity, Infinity], aoMapIntensity: [0, 1], triplanar: [0, Infinity], bumpScale: [-Infinity, Infinity] }
83
+ alphaTest: [0, 1], normalScale: [-Infinity, Infinity], aoMapIntensity: [0, 1], triplanar: [0, Infinity], bumpScale: [-Infinity, Infinity],
84
+ transmission: [0, 1], clearcoat: [0, 1], clearcoatRoughness: [0, 1], sheen: [0, 1], sheenRoughness: [0, 1], ior: [1, Infinity],
85
+ specularIntensity: [0, 1], anisotropy: [0, 1], anisotropyRotation: [-Infinity, Infinity], translucency: [0, 1], toon: [0, 16],
86
+ lightMapIntensity: [0, Infinity], environmentIntensity: [0, Infinity] }
87
+ // colours, and their defaults
88
+ const COLORS = { color: 0xffffff, emissive: 0x000000, sheenColor: 0xffffff, specularColor: 0xffffff, translucencyColor: 0xffffff }
55
89
  const DEFAULTS = {
56
- name: '', opacity: 1, alphaTest: 0, roughness: 0.5, metalness: 0, normalScale: 1, bumpScale: 1, aoMapIntensity: 1, triplanar: 0, emissiveIntensity: 1, unlit: false, flatShading: false,
90
+ name: '', opacity: 1, alphaTest: 0, roughness: 0.5, metalness: 0, normalScale: 1, bumpScale: 1, aoMapIntensity: 1, triplanar: 0, emissiveIntensity: 1,
91
+ transmission: 0, clearcoat: 0, clearcoatRoughness: 0, sheen: 0, sheenRoughness: 0.5, ior: 1.5, specularIntensity: 1, anisotropy: 0,
92
+ anisotropyRotation: 0, translucency: 0, toon: 0, lightMapIntensity: 1, environmentIntensity: 1, unlit: false, flatShading: false,
57
93
  doubleSided: false, vertexColors: false, blending: 'normal', depthWrite: true, depthTest: true, fog: true,
58
94
  size: 1, sizeAttenuation: true, width: 1,
59
95
  }
60
- const OPTIONS = ['name', 'color', 'emissive', ...MAPS, ...FLAGS, ...Object.keys(NUMBERS), 'blending']
96
+ const OPTIONS = ['name', ...Object.keys(COLORS), ...MAPS, ...FLAGS, ...Object.keys(NUMBERS), 'blending']
61
97
 
62
98
  // what agents (and three.js habits) will reach for
63
99
  const HINTS = {
@@ -65,7 +101,17 @@ const HINTS = {
65
101
  side: "use 'doubleSided: true'",
66
102
  wireframe: 'use new Lines({ geometry: edges(geometry) }) for a wireframe',
67
103
  shininess: "use 'roughness' (0 = mirror, 1 = matte)",
68
- specular: "use 'roughness' and 'metalness'",
104
+ envMapIntensity: "use 'environmentIntensity'",
105
+ reflectivityScale: "use 'environmentIntensity'",
106
+ matcapMap: "use 'matcap'",
107
+ gradientMap: "use 'toon' - the number of flat tones",
108
+ cel: "use 'toon' - the number of flat tones",
109
+ celShading: "use 'toon' - the number of flat tones",
110
+ specular: "use 'specularIntensity' and 'specularColor' for a non-metal's reflections, or 'roughness' and 'metalness'",
111
+ reflectivity: "use 'ior' (1.5 by default) or 'specularIntensity'",
112
+ anisotropyStrength: "use 'anisotropy'",
113
+ diffuseTransmission: "use 'translucency'",
114
+ subsurface: "use 'translucency' for light through thin surfaces",
69
115
  colour: "use 'color'",
70
116
  emissiveColor: "use 'emissive'",
71
117
  alpha: "use 'opacity'",
@@ -91,6 +137,19 @@ export class Material {
91
137
  /** @type {number} */ roughness
92
138
  /** @type {number} */ metalness
93
139
  /** @type {number} */ emissiveIntensity
140
+ /** How much light passes through, as through glass: 0-1. @type {number} */ transmission
141
+ /** A clear glossy layer over the surface: 0-1. @type {number} */ clearcoat
142
+ /** How glossy the coat is, 0 a mirror. @type {number} */ clearcoatRoughness
143
+ /** The soft bright rim of cloth: 0-1. @type {number} */ sheen
144
+ /** A thin bright rim at 0, a broad soft glow at 1. @type {number} */ sheenRoughness
145
+ /** Index of refraction: how strongly a non-metal reflects (1.5). @type {number} */ ior
146
+ /** Scales a non-metal's reflections, 0-1. @type {number} */ specularIntensity
147
+ /** Brushed metal: 0-1. @type {number} */ anisotropy
148
+ /** Turns the brushing from the UVs' u, in radians. @type {number} */ anisotropyRotation
149
+ /** Light through thin surfaces: 0-1. @type {number} */ translucency
150
+ /** Cartoon shading in this many flat tones; 0 is smooth. @type {number} */ toon
151
+ /** @type {number} */ lightMapIntensity
152
+ /** This material's share of the environment's light and reflections. @type {number} */ environmentIntensity
94
153
  /** @type {boolean} */ unlit
95
154
  /** @type {boolean} */ flatShading
96
155
  /** @type {boolean} */ doubleSided
@@ -103,8 +162,8 @@ export class Material {
103
162
  /** @type {boolean} */ sizeAttenuation
104
163
  /** @type {number} */ width
105
164
 
106
- #color = new Color(0xffffff)
107
- #emissive = new Color(0x000000)
165
+ /** @type {Record<string, Color>} */
166
+ #colors = Object.fromEntries(Object.entries(COLORS).map(([key, hex]) => [key, new Color(hex)]))
108
167
  #transparent = false
109
168
  /** @type {Record<string, Texture | null>} */
110
169
  #maps = Object.fromEntries(MAPS.map((m) => [m, null]))
@@ -117,8 +176,7 @@ export class Material {
117
176
  Object.assign(this, DEFAULTS)
118
177
  for (const [key, value] of /** @type {[string, any][]} */ (Object.entries(options))) {
119
178
  if (value === undefined) continue
120
- if (key === 'color') this.color = value
121
- else if (key === 'emissive') this.emissive = value
179
+ if (key in COLORS) this.#colors[key].set(value, 'Material')
122
180
  else if (MAPS.includes(key)) this.setMap(key, value)
123
181
  else if (FLAGS.includes(key)) this[key] = bool(key, value)
124
182
  else if (key in NUMBERS) this[key] = range(key, value, NUMBERS[key])
@@ -141,14 +199,29 @@ export class Material {
141
199
  }
142
200
 
143
201
  /** Base colour; assign any colour input. @returns {Color} */
144
- get color() { return this.#color }
202
+ get color() { return this.#colors.color }
145
203
  /** @param {ColorInput} v */
146
- set color(v) { this.#color.set(v, 'Material') }
204
+ set color(v) { this.#colors.color.set(v, 'Material') }
147
205
 
148
206
  /** Glow colour; assign any colour input. @returns {Color} */
149
- get emissive() { return this.#emissive }
207
+ get emissive() { return this.#colors.emissive }
150
208
  /** @param {ColorInput} v */
151
- set emissive(v) { this.#emissive.set(v, 'Material') }
209
+ set emissive(v) { this.#colors.emissive.set(v, 'Material') }
210
+
211
+ /** The sheen's colour; assign any colour input. @returns {Color} */
212
+ get sheenColor() { return this.#colors.sheenColor }
213
+ /** @param {ColorInput} v */
214
+ set sheenColor(v) { this.#colors.sheenColor.set(v, 'Material') }
215
+
216
+ /** Tints a non-metal's reflections face on; assign any colour input. @returns {Color} */
217
+ get specularColor() { return this.#colors.specularColor }
218
+ /** @param {ColorInput} v */
219
+ set specularColor(v) { this.#colors.specularColor.set(v, 'Material') }
220
+
221
+ /** Tints the light that comes through a translucent surface; assign any colour input. @returns {Color} */
222
+ get translucencyColor() { return this.#colors.translucencyColor }
223
+ /** @param {ColorInput} v */
224
+ set translucencyColor(v) { this.#colors.translucencyColor.set(v, 'Material') }
152
225
 
153
226
  /** @returns {Texture | null} */
154
227
  get map() { return this.#maps.map }
@@ -178,9 +251,17 @@ export class Material {
178
251
  get emissiveMap() { return this.#maps.emissiveMap }
179
252
  /** @param {MapInput | null} v */
180
253
  set emissiveMap(v) { this.setMap('emissiveMap', v) }
254
+ /** Light baked into a texture. @returns {Texture | null} */
255
+ get lightMap() { return this.#maps.lightMap }
256
+ /** @param {MapInput | null} v */
257
+ set lightMap(v) { this.setMap('lightMap', v) }
258
+ /** A picture of a lit sphere that gives the whole look. @returns {Texture | null} */
259
+ get matcap() { return this.#maps.matcap }
260
+ /** @param {MapInput | null} v */
261
+ set matcap(v) { this.setMap('matcap', v) }
181
262
 
182
263
  /** @internal
183
- * @param {string} slot one of map, roughnessMap, metalnessMap, normalMap, aoMap, emissiveMap
264
+ * @param {string} slot one of map, roughnessMap, metalnessMap, normalMap, bumpMap, aoMap, emissiveMap, lightMap, matcap
184
265
  * @param {MapInput | null} value
185
266
  */
186
267
  setMap(slot, value) {
@@ -213,7 +294,7 @@ export class Material {
213
294
 
214
295
  /** Rendered in the transparent pass: an opacity below 1, additive or multiply blending, or set for maps with soft alpha. @returns {boolean} */
215
296
  get transparent() {
216
- return this.#transparent || this.opacity < 1 || this.blending !== 'normal'
297
+ return this.#transparent || this.opacity < 1 || this.transmission > 0 || this.blending !== 'normal'
217
298
  }
218
299
  /** @param {boolean} v */
219
300
  set transparent(v) { this.#transparent = !!v }
@@ -226,6 +307,12 @@ export class Material {
226
307
  const parts = [this.unlit ? 'unlit' : 'lit']
227
308
  for (const [slot] of this.textures) parts.push(slot)
228
309
  if (this.alphaTest > 0) parts.push('cutout')
310
+ if (this.transmission > 0) parts.push('glass')
311
+ if (this.clearcoat > 0) parts.push('clearcoat')
312
+ if (this.sheen > 0) parts.push('sheen')
313
+ if (this.anisotropy > 0) parts.push('brushed')
314
+ if (this.translucency > 0) parts.push('translucent')
315
+ if (this.toon > 0) parts.push('toon')
229
316
  if (this.flatShading) parts.push('flat')
230
317
  if (this.vertexColors) parts.push('vcolor')
231
318
  return parts.join('+')
@@ -240,8 +327,7 @@ export class Material {
240
327
  clone() {
241
328
  const copy = new Material(this.toJSON(true))
242
329
  // the colours exactly: a description rounds them to 8-bit sRGB and can't hold values past 1
243
- copy.color.set(this.#color)
244
- copy.emissive.set(this.#emissive)
330
+ for (const [key, c] of Object.entries(this.#colors)) copy.#colors[key].set(c)
245
331
  return copy
246
332
  }
247
333
 
@@ -268,8 +354,7 @@ export class Material {
268
354
  if (this[key] !== value) json[key] = this[key]
269
355
  }
270
356
  if (this.#transparent) json.transparent = true
271
- if (this.#color.getHex() !== 0xffffff) json.color = this.#color.getStyle()
272
- if (this.#emissive.getHex() !== 0) json.emissive = this.#emissive.getStyle()
357
+ for (const [key, c] of Object.entries(this.#colors)) if (c.getHex() !== COLORS[key]) json[key] = c.getStyle()
273
358
  for (const [slot, t] of this.textures) {
274
359
  const desc = keepTextures ? t : t.toJSON()
275
360
  if (desc) json[slot] = desc
@@ -10,36 +10,49 @@ import { checkNumber, checkOneOf, checkOptions, describe, fail } from '../valida
10
10
  * @typedef {object} TextureOptions
11
11
  * @property {TextureImage} [image] the pixels; omit and give a url to load them
12
12
  * @property {string} [url] image to load (starts loading immediately in a browser); a .hdr file loads as
13
- * linear HDR radiance, for scene.environment
13
+ * linear HDR radiance, for scene.environment; a video (.mp4, .webm, .ogv, .mov, .m4v) plays muted and on a
14
+ * loop, and texture.image is its <video> to pause, seek or unmute
14
15
  * @property {'srgb' | 'linear'} [colorSpace] 'srgb' for colour maps (default), 'linear' for data
15
16
  * maps such as normal and roughness maps
16
17
  * @property {'repeat' | 'clamp' | 'mirror'} [wrap] (default 'repeat')
17
18
  * @property {number[]} [repeat] [u, v] (default [1, 1])
18
19
  * @property {number[]} [offset] [u, v] (default [0, 0])
20
+ * @property {number} [rotation] turns the image on the surface, in radians, about `center` (default 0)
21
+ * @property {number[]} [center] [u, v] the point rotation turns about, in repeated UVs (default [0, 0], the
22
+ * corner, as glTF has it; [0.5, 0.5] for the middle of an unrepeated image)
23
+ * @property {0 | 1} [uvSet] which UV set to read: 0 the first, 1 the second (Geometry uvs2) - baked occlusion
24
+ * and light maps usually come on the second (default 0)
19
25
  * @property {'linear' | 'nearest'} [filter] (default 'linear')
20
- * @property {boolean} [mipmaps] (default true)
26
+ * @property {boolean} [mipmaps] (default true; false for a video, which would make them every frame)
21
27
  * @property {number} [anisotropy] samples along a surface seen at a glancing angle - floors and roads
22
28
  * stay sharp into the distance. Up to what the GPU allows; 1 is off (default 8)
23
29
  * @property {boolean} [flipY] flip on upload; false for glTF-style UVs (default false)
24
30
  */
25
31
 
26
- const OPTIONS = ['image', 'url', 'colorSpace', 'wrap', 'repeat', 'offset', 'filter', 'mipmaps', 'anisotropy', 'flipY']
27
- const DEFAULTS = { colorSpace: 'srgb', wrap: 'repeat', repeat: [1, 1], offset: [0, 0], filter: 'linear', mipmaps: true, anisotropy: 8, flipY: false }
32
+ const OPTIONS = ['image', 'url', 'colorSpace', 'wrap', 'repeat', 'offset', 'rotation', 'center', 'uvSet', 'filter', 'mipmaps', 'anisotropy', 'flipY']
33
+ const DEFAULTS = { colorSpace: 'srgb', wrap: 'repeat', repeat: [1, 1], offset: [0, 0], rotation: 0, center: [0, 0], uvSet: 0, filter: 'linear',
34
+ mipmaps: true, anisotropy: 8, flipY: false }
28
35
 
29
36
  const HINTS = {
30
37
  src: "use 'url' for the image address",
31
38
  encoding: "use colorSpace: 'srgb' | 'linear'",
32
39
  wrapS: "use wrap: 'repeat' | 'clamp' | 'mirror' (applies to both directions)",
33
40
  maxAnisotropy: "use 'anisotropy' - samples along glancing angles, 1 for off",
41
+ channel: "use 'uvSet' - 0 for the first UV set, 1 for the second",
42
+ texCoord: "use 'uvSet' - 0 for the first UV set, 1 for the second",
43
+ scale: "use 'repeat' - [u, v] times across",
34
44
  }
35
45
 
36
- /** An image for a material map. Canvas and video textures: set `needsUpdate()` after drawing. */
46
+ /**
47
+ * An image for a material map. A video's frames upload by themselves as it plays; after drawing on
48
+ * a canvas, call `needsUpdate()`.
49
+ */
37
50
  export class Texture {
38
51
  /** The options it takes, and hints for names people reach for instead. */
39
52
  static OPTIONS = OPTIONS
40
53
  static HINTS = HINTS
41
- /** @type {TextureImage | null} null until loaded */
42
- image = null
54
+ /** @type {TextureImage | null} */
55
+ #image = null
43
56
  /** @type {string | null} */
44
57
  url = null
45
58
  /** @type {'srgb' | 'linear'} */
@@ -50,6 +63,12 @@ export class Texture {
50
63
  wrap = 'repeat'
51
64
  repeat = [1, 1]
52
65
  offset = [0, 0]
66
+ /** Turns the image on the surface, in radians, about `center`. */
67
+ rotation = 0
68
+ /** [u, v] the point rotation turns about, in repeated UVs. */
69
+ center = [0, 0]
70
+ /** Which UV set it reads: 0 the first, 1 the second (Geometry uvs2). @type {0 | 1} */
71
+ uvSet = 0
53
72
  /** @type {'linear' | 'nearest'} */
54
73
  filter = 'linear'
55
74
  mipmaps = true
@@ -75,7 +94,12 @@ export class Texture {
75
94
  this.filter = checkOneOf('Texture', 'filter', o.filter, ['linear', 'nearest'])
76
95
  this.repeat = pair('repeat', o.repeat)
77
96
  this.offset = pair('offset', o.offset)
78
- this.mipmaps = !!o.mipmaps
97
+ this.rotation = checkNumber('Texture', 'rotation', o.rotation)
98
+ this.center = pair('center', o.center)
99
+ if (o.uvSet !== 0 && o.uvSet !== 1) fail('Texture', `'uvSet' is 0 (the first UV set) or 1 (the second), got ${describe(o.uvSet)}`)
100
+ this.uvSet = o.uvSet
101
+ // a video would make mipmaps every frame: off unless asked for
102
+ this.mipmaps = options.mipmaps !== undefined ? !!options.mipmaps : !(isVideo(o.image) || (!o.image && VIDEO.test(o.url ?? '')))
79
103
  this.anisotropy = checkNumber('Texture', 'anisotropy', o.anisotropy)
80
104
  if (!(this.anisotropy >= 1)) fail('Texture', `'anisotropy' is how many samples along a glancing angle - 1 (off) or more, got ${this.anisotropy}`)
81
105
  this.flipY = !!o.flipY
@@ -83,7 +107,8 @@ export class Texture {
83
107
 
84
108
  if (o.image) {
85
109
  this.image = o.image
86
- this.ready = Promise.resolve(this)
110
+ this.ready = isVideo(o.image) ? loaded(/** @type {HTMLVideoElement} */ (o.image)).then(() => this) : Promise.resolve(this)
111
+ this.ready.catch(() => {})
87
112
  } else if (this.url) {
88
113
  this.ready = this.#load(this.url)
89
114
  // a failed load is reported by whoever awaits `ready`; don't also raise an unhandled rejection
@@ -94,6 +119,57 @@ export class Texture {
94
119
  }
95
120
 
96
121
  /** Call after redrawing a canvas or advancing a video. @returns {this} */
122
+ /**
123
+ * The pixels: null until loaded. A <video>'s frames upload by themselves while it plays.
124
+ * @returns {TextureImage | null}
125
+ */
126
+ get image() { return this.#image }
127
+ /** @param {TextureImage | null} v */
128
+ set image(v) {
129
+ this.#image = v
130
+ this.needsUpdate()
131
+ if (isVideo(v)) this.#follow(/** @type {HTMLVideoElement} */ (v))
132
+ }
133
+
134
+ // Each new frame of a playing video marks the texture changed, so views redraw with it: the
135
+ // browser's own per-frame callback where there is one, otherwise a check every animation frame
136
+ // while it plays. Following stops when the image is replaced.
137
+ /** @param {HTMLVideoElement} video */
138
+ #follow(video) {
139
+ if (typeof video.requestVideoFrameCallback === 'function') {
140
+ const frame = () => {
141
+ if (this.#image !== video) return
142
+ this.needsUpdate()
143
+ video.requestVideoFrameCallback(frame)
144
+ }
145
+ video.requestVideoFrameCallback(frame)
146
+ return
147
+ }
148
+ let last = -1, running = false
149
+ const tick = () => {
150
+ if (this.#image !== video || video.paused || video.ended) {
151
+ running = false
152
+ return
153
+ }
154
+ if (video.currentTime !== last) {
155
+ last = video.currentTime
156
+ this.needsUpdate()
157
+ }
158
+ requestAnimationFrame(tick)
159
+ }
160
+ const start = () => {
161
+ if (running || this.#image !== video) return
162
+ running = true
163
+ requestAnimationFrame(tick)
164
+ }
165
+ video.addEventListener('play', start)
166
+ // a seek while paused shows a new frame too
167
+ video.addEventListener('seeked', () => {
168
+ if (this.#image === video) this.needsUpdate()
169
+ })
170
+ if (!video.paused) start()
171
+ }
172
+
97
173
  /** 'srgb' for colour (decoded to linear by the GPU), 'linear' for data such as normals and roughness. Call needsUpdate() after changing it. @returns {'srgb' | 'linear'} */
98
174
  get colorSpace() { return this.#colorSpace }
99
175
  set colorSpace(v) {
@@ -139,12 +215,26 @@ export class Texture {
139
215
 
140
216
  /** @param {string} url @returns {Promise<Texture>} */
141
217
  async #load(url) {
218
+ if (VIDEO.test(url)) {
219
+ if (typeof document === 'undefined') throw new Error(`Texture: can't load '${url}' - no video support in this environment`)
220
+ const video = document.createElement('video')
221
+ // muted, so browsers let it play by itself; inline, so phones don't take it full screen
222
+ Object.assign(video, { crossOrigin: 'anonymous', muted: true, loop: true, playsInline: true, autoplay: true, src: url })
223
+ try {
224
+ await loaded(video)
225
+ } catch {
226
+ throw new Error(`Texture: failed to load '${url}'`)
227
+ }
228
+ // a browser that won't autoplay leaves it paused on its first frame: video.play() on a click starts it
229
+ video.play()?.catch(() => {})
230
+ this.image = video
231
+ return this
232
+ }
142
233
  if (/\.hdr($|[?#])/i.test(url)) {
143
234
  const [{ parseHDR }, response] = await Promise.all([import('../hdr.js'), fetch(url)])
144
235
  if (!response.ok) throw new Error(`Texture: failed to load '${url}' (${response.status})`)
145
236
  this.image = parseHDR(await response.arrayBuffer())
146
237
  this.colorSpace = 'linear'
147
- this.needsUpdate()
148
238
  return this
149
239
  }
150
240
  if (typeof Image === 'undefined') {
@@ -159,11 +249,26 @@ export class Texture {
159
249
  throw new Error(`Texture: failed to load '${url}'`)
160
250
  }
161
251
  this.image = img
162
- this.needsUpdate()
163
252
  return this
164
253
  }
165
254
  }
166
255
 
256
+ const VIDEO = /\.(mp4|webm|ogv|mov|m4v)($|[?#])/i
257
+
258
+ /** @internal A <video>, whose frames change as it plays. @param {unknown} image @returns {boolean} */
259
+ export function isVideo(image) {
260
+ return typeof HTMLVideoElement !== 'undefined' && image instanceof HTMLVideoElement
261
+ }
262
+
263
+ /** Resolves once a video has a frame to show. @param {HTMLVideoElement} video @returns {Promise<void>} */
264
+ function loaded(video) {
265
+ if (video.readyState >= 2) return Promise.resolve()
266
+ return new Promise((resolve, reject) => {
267
+ video.addEventListener('loadeddata', () => resolve(), { once: true })
268
+ video.addEventListener('error', () => reject(new Error('Texture: the video failed to load')), { once: true })
269
+ })
270
+ }
271
+
167
272
  /**
168
273
  * Load an image as a texture; resolves once it has decoded.
169
274
  * @param {string} url
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)