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
package/llms-full.txt CHANGED
@@ -15,8 +15,8 @@ editors that drag, snap and undo. Picking, tooltips, hover highlights, HTML labe
15
15
  image-based light are built in, and a view draws only when something changes, so idle pages cost
16
16
  nothing. A dashboard of many small views just works - each tile its own `new View({ canvas })`,
17
17
  with no limit on WebGL contexts to manage (after the first four, views share one). Apps come out short: typically a few dozen lines. For games, custom shaders or
18
- post-processing pipelines, three.js is the better fit: phoria2 has no shader hook (no fresnel or
19
- custom effects), no clearcoat, and no shadow-softness setting.
18
+ post-processing pipelines, three.js is the better fit: phoria2 has no shader hook (no custom
19
+ effects), no refraction, and no shadow-softness setting.
20
20
 
21
21
  ## Install
22
22
 
@@ -114,9 +114,13 @@ const view = new View({ canvas: '#c', orbit: true })
114
114
  | `autoLight` | `true` | light a scene that has no lights (a chart needs none of its own); `false` to leave it dark |
115
115
  | `pixelRatio` | `min(devicePixelRatio, 2)` | |
116
116
  | `context` | `'auto'` | WebGL context: own for the first four views on a page, then one shared context - so a page can hold any number of views |
117
+ | `keyboard` | `false` | `true` or `{ label: (hit) => text }`: use without a pointer, with a screen reader - see Interaction |
118
+ | `description` | none | what the view shows, for screen readers (the canvas's label); made from the scene when `keyboard` is on |
119
+ | `reducedMotion` | `'auto'` | as the system's reduced-motion setting: `frame()` jumps rather than glides, auto-rotation holds, charts change at once. `true` or `false` to choose |
117
120
 
118
121
  Members: `view.scene`, `view.camera` (assign a camera or a preset name to switch), `view.controls`
119
- (the orbit control), `view.renderer`, `view.width`, `view.height`.
122
+ (the orbit control), `view.renderer`, `view.width`, `view.height`, `view.description`, `view.reducedMotion`
123
+ (true or false as it stands - check it for motion of your own, such as a timeline; set `'auto'`, true or false).
120
124
 
121
125
  Methods:
122
126
 
@@ -164,7 +168,7 @@ something changes):
164
168
  ## Scene graph
165
169
 
166
170
  Everything in a scene is a node. Node options (all optional): `name`, `position`, `rotation`
167
- (Euler XYZ radians, `[x, y, z]`), `quaternion`, `scale` (number or `[x, y, z]`), `visible`,
171
+ (Euler XYZ radians, `[x, y, z]`), `quaternion` (`[x, y, z, w]` or `{ x, y, z, w }`), `scale` (number or `[x, y, z]`), `visible`,
168
172
  `pickable` (default true), `userData`, `children`, `onUpdate`, `clip` (a section plane - see
169
173
  Cutaways), plus the interaction options below.
170
174
 
@@ -229,7 +233,11 @@ bars.setColor(3, 'red')
229
233
  `setInstance(i, { position, rotation, quaternion, scale, color })`. An instance's `color` multiplies the
230
234
  material's colour, which is white unless you set one - so the instance colour shows as given. `count` defaults to the number
231
235
  of rows in `data`. For very large data, write `bars.instanceMatrices` (16 numbers per instance,
232
- column-major) directly and call `bars.needsUpdate()`.
236
+ column-major) directly and call `bars.needsUpdate()`. An instance scaled to 0 is hidden: it draws
237
+ nothing, and bounds, framing, shadows and labels leave it out - so a pool can keep spare instances.
238
+ Positions and quaternions also take `{ x, y, z }` and `{ x, y, z, w }`, as physics engines return
239
+ them, so a physics bridge is one line a body:
240
+ `bricks.setInstance(i, { position: body.translation(), quaternion: body.rotation() })`.
233
241
 
234
242
  **Labels** are HTML elements pinned to 3D points (style them with CSS). Options: `text`, `position`,
235
243
  `className`, `place` (`'center'` | `'above'` | `'below'` | `'left'` | `'right'`), `offset` ([x, y]
@@ -237,7 +245,8 @@ pixels), `priority` (higher wins when labels overlap), `overlap` (`'hide'` | `'d
237
245
  a label does when covered), `occlude` (true: counts as covered when geometry is in front), `leader`
238
246
  (true: a line from the point to the label, with a dot on the point - for callouts), `edge` (`'left'` |
239
247
  `'right'`: set the label at that edge of the view, level with its point - callouts in a column, with
240
- `leader: true`; `offset` then moves it in and up or down).
248
+ `leader: true`; `offset` then moves it in and up or down), `minScale` (show it only when zoomed in this
249
+ far - below).
241
250
 
242
251
  A label answers the pointer when it has handlers: a Label with its own `onClick` or `onHover` is
243
252
  clicked and hovered like a mesh (`new Label({ text: 'Mantle', onClick: () => show('mantle') })` - a
@@ -250,6 +259,21 @@ clicking that bar, with the same hit. Other labels let the pointer through to th
250
259
  new InstancedMesh({ geometry: box(1), data: rows, labels: { text: (row) => row.name, place: 'above', priority: (row) => row.alert ? 2 : 0 } })
251
260
  ```
252
261
 
262
+ **Labels by zoom level**: `minScale` on any label shows it only when the view is zoomed in at least that
263
+ far - that many CSS pixels to one world unit, at the label's point (perspective or orthographic alike).
264
+ Until then it takes no part in the layout, so it can't push other labels out. `labels` can also be a
265
+ list, several labels per instance - the short one always, the details as you come closer:
266
+
267
+ ```js
268
+ new InstancedMesh({ geometry: box(1), data: elements, labels: [
269
+ { text: (e) => e.symbol, place: 'center', className: 'symbol' },
270
+ { text: (e) => e.name, place: 'below', className: 'name', minScale: 45 },
271
+ ] })
272
+ ```
273
+
274
+ Pick `minScale` by looking: at the zoom where the details should appear, a world unit spans that many
275
+ pixels (a 1-unit tile drawn 60 px wide is 60). `view.explain()` counts the labels still waiting.
276
+
253
277
  ## Geometry
254
278
 
255
279
  Generators return a `Geometry`; each takes one options object (a number is the main size):
@@ -285,8 +309,11 @@ extrude({
285
309
  })
286
310
  ```
287
311
 
288
- Your own: `new Geometry({ positions, normals?, uvs?, colors?, indices? })` - flat arrays, xyz per
289
- vertex; `colors` are rgb 0-1 per vertex (use `material: { vertexColors: true }`).
312
+ Your own: `new Geometry({ positions, normals?, uvs?, uvs2?, colors?, indices? })` - flat arrays, xyz per
313
+ vertex; `colors` are rgb 0-1 per vertex (use `material: { vertexColors: true }`); `uvs2` is a second UV
314
+ set, for baked occlusion and light maps (below). To animate one - cloth, a wave, a morph - write
315
+ `geometry.positions` in place and call `geometry.needsUpdate()` each frame; normals you didn't give are
316
+ worked out again to follow the shape.
290
317
 
291
318
  ## Materials
292
319
 
@@ -296,7 +323,14 @@ One material type, given as a plain object:
296
323
  { color: 'white' } // lit, physically based
297
324
  { color: '#c33', roughness: 0.2, metalness: 1 } // metal
298
325
  { color: 'orange', unlit: true } // flat colour, ignores lights
299
- { color: '#8cf', opacity: 0.4 } // transparent
326
+ { color: '#8cf', opacity: 0.4 } // transparent: fades the whole surface
327
+ { transmission: 1, roughness: 0.05 } // clear glass
328
+ { color: '#a3121f', metalness: 0.6, clearcoat: 1 } // car paint: a glossy coat over it
329
+ { color: '#1f2d6e', roughness: 0.9, sheen: 1 } // velvet: cloth's bright rim
330
+ { color: '#ccc', metalness: 1, anisotropy: 0.8 } // brushed metal
331
+ { color: '#f2e4c4', translucency: 0.8 } // paper: lit from behind too
332
+ { matcap: 'clay.png' } // the look from a picture of a lit sphere
333
+ { color: '#e4572e', toon: 3 } // cartoon: three flat tones
300
334
  { emissive: '#ff0', emissiveIntensity: 3 } // glows
301
335
  { map: 'wood.jpg', normalMap: 'wood_n.png' } // textures from urls
302
336
  ```
@@ -313,7 +347,8 @@ Properties: `color`, `opacity`, `roughness` (0.5), `metalness` (0), `emissive`,
313
347
  (`'normal'`, `'additive'` - adds light, for glows and sparks - or `'multiply'` - darkens what is behind, as a colour
314
348
  filter does: cyan over magenta is blue), `alphaTest` (cut-outs: drop pixels with less alpha - leaves, fences),
315
349
  `map`, `normalMap`/`normalScale`, `bumpMap`/`bumpScale`, `roughnessMap`, `metalnessMap`, `aoMap`,
316
- `emissiveMap`; `size`/`sizeAttenuation` for Points, `width` for Lines. Change later through
350
+ `emissiveMap`, `lightMap`/`lightMapIntensity`, `environmentIntensity` (1), `transmission`, `clearcoat`/`clearcoatRoughness`, `sheen`/`sheenColor`/`sheenRoughness`, `ior`/
351
+ `specularIntensity`/`specularColor`, `anisotropy`/`anisotropyRotation`, `translucency`/`translucencyColor`, `matcap`, `toon` (below); `size`/`sizeAttenuation` for Points, `width` for Lines. Change later through
317
352
  `mesh.material.color.set('#0f0')`, `mesh.material.opacity = 0.5`, or a whole new one: `mesh.material = { color: 'red' }`
318
353
  (options become a Material, as in the constructor; `mesh.geometry = box(2)` likewise).
319
354
 
@@ -324,6 +359,83 @@ into the distance. A cut-out (`alphaTest`) casts the shadow of what is left of i
324
359
  data map (`normalMap`, `bumpMap`, `roughnessMap`, `metalnessMap`, `aoMap`) is read as data, not colour,
325
360
  unless its `colorSpace` was set on purpose - `normalMap: await loadTexture('n.png')` just works.
326
361
 
362
+ **Canvas and video textures**: a texture's `image` can be a canvas - draw on it, then call
363
+ `texture.needsUpdate()` - or a `<video>`, whose frames upload by themselves while it plays, with
364
+ nothing to call. `new Texture({ url: 'promo.mp4' })` (or .webm, .ogv, .mov, .m4v) makes the video
365
+ for you, muted and looping so browsers let it start by itself; `texture.image` is that `<video>` -
366
+ pause, seek or unmute it as any other. A browser that blocks autoplay leaves it on its first frame
367
+ until `texture.image.play()` from a click. Video textures make no mipmaps (they would be remade
368
+ every frame) unless `mipmaps: true`. Put one on an unlit plane for a screen:
369
+ `{ map: new Texture({ url: 'promo.mp4', wrap: 'clamp' }), unlit: true }`.
370
+
371
+ **Placing a texture**: `repeat` ([u, v] times across), `offset`, and `rotation` (radians, about
372
+ `center`: [0, 0], the corner, by default - [0.5, 0.5] for the middle) - applied in that order, as glTF's
373
+ `KHR_texture_transform` does, and each map keeps its own: a fabric's colour can repeat 7 times while
374
+ its normal map repeats twice. Animate them to scroll or spin a pattern.
375
+
376
+ **Baked occlusion and light maps**: occlusion and lighting baked in Blender, Unreal or Unity need
377
+ every surface on its own patch of texture, so they come on a second UV set while the first tiles
378
+ colour and detail. Give the geometry `uvs2` and the texture `uvSet: 1` - glTF models do both
379
+ for you (`TEXCOORD_1`, `texCoord: 1`). Read through the first set, baked occlusion lands in the
380
+ wrong places as dark smudges. `lightMap` is light baked into a texture, added as light from all
381
+ round (`lightMapIntensity`, 1) - the soft, cheap lighting of architectural scenes.
382
+
383
+ **Environment strength per material**: `environmentIntensity` (1) scales how much of the
384
+ scene's environment - its light and its reflections - one material takes: lower for a matte floor in
385
+ a shiny showroom, higher to make one product catch the light, without changing the scene's.
386
+
387
+ **Glass**: `transmission` (0-1) lets light through as glass does. Unlike `opacity`, which fades
388
+ everything - reflections too, so faded things look washed out, as dimmed chart bars should - glass
389
+ keeps its reflections whole, and shows less of what is behind it towards its edges, where it
390
+ reflects nearly everything. Its colour shows in proportion to `1 - transmission`: `transmission: 0.6`
391
+ with a colour is tinted glass. Keep `roughness` low for clear glass, higher for frosted. There is
392
+ no refraction - what is behind isn't bent. It shines with an `environment` to reflect
393
+ (`'studio'`, or better an `.hdr`).
394
+
395
+ **Clearcoat**: `clearcoat` (0-1) puts a clear glossy layer over the surface - car paint, lacquered
396
+ wood, varnish - with its own sharp reflection over the base's softer one; `clearcoatRoughness` (0,
397
+ a mirror) makes it satin. Normal and bump maps show under a smooth coat.
398
+
399
+ **Sheen**: `sheen` (0-1) is the soft bright rim of cloth at grazing angles - velvet, felt,
400
+ upholstery - which plain roughness can't give: without it fabric looks like matte plastic.
401
+ `sheenColor` (white) tints it, often lighter than the cloth or another colour entirely;
402
+ `sheenRoughness` (0.5) runs from a thin bright rim (0) to a broad soft glow (1).
403
+
404
+ **Reflectance**: `ior` (1.5) sets how strongly a non-metal reflects - water 1.33, glass and plastic
405
+ 1.5, diamond 2.4 - and glass with `transmission` reflects more the higher it is.
406
+ `specularIntensity` (1) scales a non-metal's reflections, 0 for none (chalk, clay, unglazed
407
+ pottery); `specularColor` tints them face on. Metals take their reflections from `color`, so
408
+ these leave them alone.
409
+
410
+ **Brushed metal**: `anisotropy` (0-1) - the material's, not a texture's filtering - draws highlights and reflections out along the brushing -
411
+ brushed aluminium, steel appliances, the bottom of a pan. The brushing runs along the UVs' u (round
412
+ a sphere or cylinder, say; round the up axis on a mesh with no UVs); `anisotropyRotation`
413
+ (radians) turns it - `Math.PI / 2` runs it along v. Use it with `metalness: 1` and a middling
414
+ `roughness` (0.2-0.4).
415
+
416
+ **Translucency**: `translucency` (0-1) lets light through thin surfaces, so they are lit from
417
+ behind as well as in front - leaves against the sun, paper lanterns and lampshades with a light
418
+ inside, curtains. What passes through is taken from what is scattered back, so at 1 a surface is lit
419
+ only through it. `translucencyColor` (white) tints the light that comes through. It is light
420
+ through a thin sheet, not light scattering inside a solid: a lantern glows, marble stays marble.
421
+
422
+ **Matcap**: `matcap` is a picture of a lit sphere - clay, wax, chrome, a painted ball - that gives the
423
+ whole look: each point takes the colour from where a sphere facing the same way would show it. It is
424
+ the clay look of sculpting tools and CAD viewers, good for reading a model's shape, and costs one
425
+ texture lookup - no lights, shadows or environment, which it ignores. Its shine stays put as the camera
426
+ turns, since the light is in the picture, so it looks out of place beside lit things. `color` and
427
+ `map` multiply it, so one grey matcap serves any colour; normal and bump maps and `flatShading`
428
+ still shape it. Any matcap image works, or draw one on a canvas (`new Texture({ image: canvas })`).
429
+
430
+ **Toon**: `toon: 3` shades in that many flat tones (2 or more; 0, the default, is smooth), with one
431
+ hard-edged highlight - a cartoon. Lights and shadows work as usual; the shaded tone is the scene's
432
+ ambient and hemisphere light, so add some for a lighter shade. Metals keep their colour, with a
433
+ highlight of it. Pair it with `outline` on the nodes for inked edges.
434
+
435
+ glTF models bring glass, clearcoat, sheen, reflectance, brushed metal and translucency from `KHR_materials_transmission`, `KHR_materials_clearcoat`,
436
+ `KHR_materials_sheen`, `KHR_materials_ior`, `KHR_materials_specular`, `KHR_materials_anisotropy`
437
+ and `KHR_materials_diffuse_transmission` (by their factors; their textures aren't read).
438
+
327
439
  **Models without UVs** (scans, CAD exports) can still take maps: `triplanar: 40` projects them
328
440
  from the mesh's three axes instead of using UVs, repeating 40 times per unit of the mesh's own space.
329
441
  The maps stay on the mesh as it moves; normal maps follow the surface; a map's `repeat` and `offset` apply
@@ -339,7 +451,7 @@ on top, as with UVs (animate `offset` to scroll a pattern). 0 (the default) uses
339
451
  |---|---|
340
452
  | `AmbientLight` | `color`, `intensity` |
341
453
  | `HemisphereLight` | `sky`, `ground`, `intensity` |
342
- | `DirectionalLight` | `color`, `intensity`, `direction` (the way it travels, e.g. `[-1, -2, -1]`), `castShadow` |
454
+ | `DirectionalLight` | `color`, `intensity`, `direction` (the way it travels, e.g. `[-1, -2, -1]`), `castShadow`, `shadowBounds` |
343
455
  | `PointLight` | `color`, `intensity`, `position`, `range` - inverse-square falloff, so intensity ~ distance squared (30 lights things ~5 m away); `castShadow` |
344
456
  | `SpotLight` | point light options plus `direction`, `angle`, `penumbra`, `castShadow` |
345
457
 
@@ -357,7 +469,9 @@ meshes opt in with `castShadow` and `receiveShadow`. No shadow camera to set up.
357
469
  lamp - anything whose bounds hold the light - doesn't block it, so a lamp's shade can cast shadows of
358
470
  its own. A point light casts all round, and everything that casts blocks it, around it too: a candle in
359
471
  a lantern or a carved pumpkin lights the room only through the openings. Give a bulb or flame mesh
360
- drawn at the light `castShadow: false`.
472
+ drawn at the light `castShadow: false`. The directional shadow is fitted to whatever casts, every
473
+ frame; when things fly far off, give the light `shadowBounds: { min: [x, y, z], max: [x, y, z] }` -
474
+ the world area that gets shadows - so the map stays sharp there.
361
475
 
362
476
  ```js
363
477
  scene.add(new PointLight({ position: [0, 0.1, 0], intensity: 0.5, range: 4, castShadow: true })) // a candle in a jack-o'-lantern
@@ -383,6 +497,9 @@ and `target` yourself; the orbit always turns about wherever the target is.
383
497
  press or the wheel takes over.
384
498
  - **Back to the start**: `view.frame(undefined, { direction: 'perspective', duration: 0.6 })`
385
499
  (or the preset the view started with).
500
+ - **Where framing would go**: `view.framing(target, { padding, direction })` returns
501
+ `{ position, target, height? }` without moving the camera - for a camera that eases after
502
+ something that keeps changing size (`camera.position.lerp(aim.position, 0.1)`, the same for target).
386
503
  - **Follow something that moves** - move the camera and its target by as much as it moved, so the
387
504
  view keeps its angle and the user can still orbit round it:
388
505
 
@@ -438,7 +555,8 @@ view.on('hover', (hit) => show(hit?.datum)) // fires when the th
438
555
  children. A node's `highlight` is the colour for its subtree; it tints the mesh, instance or point
439
556
  under the pointer. Add `highlightWhole: true` to tint the whole subtree as one thing (a piece of
440
557
  furniture, a machine) whichever part is hovered. Without the View's `highlight`, only nodes with
441
- their own highlight light up - leave it off to keep floors and walls plain.
558
+ their own highlight light up - leave it off to keep floors and walls plain, or keep it and give
559
+ those `highlight: false` (`null` means "the View's").
442
560
  - `onHover` and `onClick` are the app's own handler for a node; to add more without replacing it
443
561
  (an add-on, a second concern), `node.on('hover' | 'click', fn)` - it returns a function that stops
444
562
  listening. Both run. A drag has one handler, `onDrag`: one thing moves a node at a time.
@@ -458,8 +576,27 @@ chair.onDrag = (drag) => {
458
576
  ```
459
577
 
460
578
  - **Outline**: `outline: '#ffcc00'` or `{ color, width }` (pixels, up to 8; default 2) on a node draws
461
- a line round its silhouette, over everything - for what is selected. Set it any time; `null` removes it.
579
+ a line round its silhouette, over everything - for what is selected, or as an inked edge for a
580
+ drawn look. Set it any time; `null` removes it. Outlines are antialiased - soft edges and round
581
+ corners - as the canvas is; `outlineAntialias: false` on the View (or `view.renderer.outlineAntialias =
582
+ false`, any time) draws them in hard pixel steps instead.
462
583
  - `view.controls.cancel()` lets go of the orbit gesture in progress, for apps that take over a press.
584
+ - **Keyboard and screen readers**: `keyboard: true` on the View makes the canvas a stop in the page's
585
+ tab order. The arrow keys (and Home, End, PageUp, PageDown) step through what can be hovered or
586
+ clicked, in scene order and each mesh's instances in order: each instance, point, surface grid
587
+ point (a mesh with a row per vertex) or row of data under a node with its own `tooltip`, `onClick`,
588
+ `onHover`, `onDrag` or `highlight` - and, when the View has a tooltip or handlers itself, each one
589
+ with data. A mesh with no rows is a stop when it has a name or a handler of its own. A
590
+ `highlightWhole` subtree is one stop. Lines with a row per segment (a network's links) are a stop
591
+ per segment, after everything else. The one stepped to is shown as hovering would show it - tint (`#ffcc00` when no highlight
592
+ is set), tooltip, hover handlers - and Enter or Space clicks it (the handler's event is null);
593
+ Escape leaves it, and moving the pointer takes over. A screen reader hears each one: the tooltip's
594
+ text, else its data, else its name, with "3 of 12" - or `keyboard: { label: (hit) => text }`. The
595
+ canvas is labelled with `description`, or from the scene: each chart's `summary()` ("Sales: bar
596
+ chart of 12 rows by month, sales"), else "3D view of 12 items".
597
+ - **Reduced motion**: `reducedMotion` follows the system's setting by default. When on, `frame()`
598
+ with a `duration` jumps, `autoRotate` holds, and chart transitions and flowing links settle at
599
+ once. Motion of your own (a `Timeline`, `onUpdate` spin) can check `view.reducedMotion`.
463
600
 
464
601
  ## Charts: `phoria2/charts`
465
602
 
@@ -511,7 +648,8 @@ A chart is a node with axes, ticks, grid walls, a legend and tooltips built in.
511
648
  in it as a wrapping row, so give it no fixed height). Categories get swatches that click to hide
512
649
  and show; a number colour gets a key - a ramp, highest at the top, with values marked up its side,
513
650
  `ticks` as on an axis),
514
- `theme: 'dark' | 'light'`, `title`, `transition: false | { duration, ease }`,
651
+ `theme: 'dark' | 'light'`, `title`, `description` (what a screen reader hears for it; else `chart.summary()`
652
+ makes one from the title and channels), `transition: false | { duration, ease }`,
515
653
  `overlap: 'dim' | 'hide' | 'show'` (labels), `highlight` (a colour, false, or for networks
516
654
  `'neighbours'` - the default, lighting the node's links and linked nodes - or `'node'`, the node alone), and node options (`position`, ...).
517
655
  - **Surfaces**: one row per grid point - `x` and `z` are the grid (numbers or categories), `y` the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phoria2",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Small, friendly 3D for the web - a WebGL 2 renderer with charts, labels, picking, particles and glTF",
5
5
  "keywords": [
6
6
  "3d",
@@ -85,10 +85,13 @@
85
85
  "plugin": "node scripts/plugin.mjs"
86
86
  },
87
87
  "devDependencies": {
88
+ "@dimforge/rapier3d-compat": "^0.21.0",
88
89
  "@eslint/js": "^10.0.1",
89
90
  "eslint": "^10.3.0",
90
91
  "gl-matrix": "^3.4.4",
91
92
  "globals": "^17.6.0",
93
+ "manifold-3d": "^3.5.4",
94
+ "recast-navigation": "^0.44.0",
92
95
  "three": "0.186.1",
93
96
  "typescript": "^7.0.2",
94
97
  "vite": "^8.0.12",
@@ -202,6 +202,11 @@
202
202
  "type": "null"
203
203
  }
204
204
  ]
205
+ },
206
+ "minScale": {
207
+ "type": "number",
208
+ "description": "Show the label only when zoomed in this far: CSS pixels to one world unit at its point (0, always).",
209
+ "minimum": 0
205
210
  }
206
211
  },
207
212
  "unevaluatedProperties": false
@@ -327,6 +332,16 @@
327
332
  },
328
333
  "castShadow": {
329
334
  "type": "boolean"
335
+ },
336
+ "shadowBounds": {
337
+ "type": ["object", "null"],
338
+ "description": "The part of the world, in world units, that has shadows; null (the default) follows what casts.",
339
+ "required": ["min", "max"],
340
+ "properties": {
341
+ "min": { "$ref": "#/$defs/vec3" },
342
+ "max": { "$ref": "#/$defs/vec3" }
343
+ },
344
+ "additionalProperties": false
330
345
  }
331
346
  },
332
347
  "unevaluatedProperties": false
@@ -462,13 +477,16 @@
462
477
  "description": "Anything of your own."
463
478
  },
464
479
  "highlight": {
465
- "description": "Hover tint for this subtree, or null for none.",
480
+ "description": "Hover tint for this subtree; false for none, null to use the View's.",
466
481
  "anyOf": [
467
482
  {
468
483
  "$ref": "#/$defs/color"
469
484
  },
470
485
  {
471
486
  "type": "null"
487
+ },
488
+ {
489
+ "const": false
472
490
  }
473
491
  ]
474
492
  },
@@ -703,6 +721,23 @@
703
721
  "minItems": 2,
704
722
  "maxItems": 2
705
723
  },
724
+ "rotation": {
725
+ "type": "number",
726
+ "description": "Turns the image on the surface, in radians, about center."
727
+ },
728
+ "center": {
729
+ "type": "array",
730
+ "description": "The point rotation turns about, in repeated UVs ([0, 0], the corner, by default).",
731
+ "items": {
732
+ "type": "number"
733
+ },
734
+ "minItems": 2,
735
+ "maxItems": 2
736
+ },
737
+ "uvSet": {
738
+ "enum": [0, 1],
739
+ "description": "Which UV set to read: 0 the first, 1 the second (uvs2) - baked occlusion and light maps usually come on the second."
740
+ },
706
741
  "filter": {
707
742
  "enum": ["linear", "nearest"]
708
743
  },
@@ -801,6 +836,28 @@
801
836
  }
802
837
  ]
803
838
  },
839
+ "lightMap": {
840
+ "description": "Light baked into a texture, added as light from all round - usually on the second UV set.",
841
+ "anyOf": [
842
+ {
843
+ "$ref": "#/$defs/texture"
844
+ },
845
+ {
846
+ "type": "null"
847
+ }
848
+ ]
849
+ },
850
+ "matcap": {
851
+ "description": "A picture of a lit sphere that gives the whole look - clay, wax, chrome - picked by which way the surface faces the camera; lights are ignored.",
852
+ "anyOf": [
853
+ {
854
+ "$ref": "#/$defs/texture"
855
+ },
856
+ {
857
+ "type": "null"
858
+ }
859
+ ]
860
+ },
804
861
  "unlit": {
805
862
  "type": "boolean",
806
863
  "description": "Ignore lights: draws exactly its colour."
@@ -873,6 +930,15 @@
873
930
  "minimum": 0,
874
931
  "maximum": 1
875
932
  },
933
+ "lightMapIntensity": {
934
+ "type": "number",
935
+ "minimum": 0
936
+ },
937
+ "environmentIntensity": {
938
+ "type": "number",
939
+ "description": "This material's share of the environment's light and reflections (1).",
940
+ "minimum": 0
941
+ },
876
942
  "triplanar": {
877
943
  "type": "number",
878
944
  "description": "Repeats per unit, projecting maps from three axes instead of using UVs; 0 uses the UVs.",
@@ -881,6 +947,78 @@
881
947
  "bumpScale": {
882
948
  "type": "number"
883
949
  },
950
+ "transmission": {
951
+ "type": "number",
952
+ "description": "How much light passes through, as through glass: reflections stay whole, and less of what is behind shows towards the edges. Unlike opacity, which fades the whole surface.",
953
+ "minimum": 0,
954
+ "maximum": 1
955
+ },
956
+ "clearcoat": {
957
+ "type": "number",
958
+ "description": "A clear glossy layer over the surface - car paint, lacquer, varnish.",
959
+ "minimum": 0,
960
+ "maximum": 1
961
+ },
962
+ "clearcoatRoughness": {
963
+ "type": "number",
964
+ "description": "How glossy the coat is, 0 a mirror.",
965
+ "minimum": 0,
966
+ "maximum": 1
967
+ },
968
+ "sheen": {
969
+ "type": "number",
970
+ "description": "The soft bright rim of cloth at grazing angles - velvet, felt, upholstery.",
971
+ "minimum": 0,
972
+ "maximum": 1
973
+ },
974
+ "sheenColor": {
975
+ "$ref": "#/$defs/color"
976
+ },
977
+ "sheenRoughness": {
978
+ "type": "number",
979
+ "description": "A thin bright rim at 0, a broad soft glow at 1.",
980
+ "minimum": 0,
981
+ "maximum": 1
982
+ },
983
+ "ior": {
984
+ "type": "number",
985
+ "description": "Index of refraction: how strongly a non-metal reflects - water 1.33, glass and plastic 1.5 (the default), diamond 2.4.",
986
+ "minimum": 1
987
+ },
988
+ "specularIntensity": {
989
+ "type": "number",
990
+ "description": "Scales a non-metal's reflections, 0 for none.",
991
+ "minimum": 0,
992
+ "maximum": 1
993
+ },
994
+ "specularColor": {
995
+ "$ref": "#/$defs/color"
996
+ },
997
+ "anisotropy": {
998
+ "type": "number",
999
+ "description": "Brushed metal: highlights drawn out along the brushing, which runs along the UVs' u.",
1000
+ "minimum": 0,
1001
+ "maximum": 1
1002
+ },
1003
+ "anisotropyRotation": {
1004
+ "type": "number",
1005
+ "description": "Turns the brushing from the UVs' u, in radians."
1006
+ },
1007
+ "translucency": {
1008
+ "type": "number",
1009
+ "description": "Light through thin surfaces, lighting them from behind - leaves, paper, lampshades.",
1010
+ "minimum": 0,
1011
+ "maximum": 1
1012
+ },
1013
+ "translucencyColor": {
1014
+ "$ref": "#/$defs/color"
1015
+ },
1016
+ "toon": {
1017
+ "type": "number",
1018
+ "description": "Cartoon shading in this many flat tones (2 or more), with a hard-edged highlight; 0 is smooth.",
1019
+ "minimum": 0,
1020
+ "maximum": 16
1021
+ },
884
1022
  "blending": {
885
1023
  "enum": ["normal", "additive", "multiply"]
886
1024
  }
@@ -1129,6 +1267,13 @@
1129
1267
  "type": "number"
1130
1268
  }
1131
1269
  },
1270
+ "uvs2": {
1271
+ "type": "array",
1272
+ "description": "A second UV set, for baked occlusion and light maps.",
1273
+ "items": {
1274
+ "type": "number"
1275
+ }
1276
+ },
1132
1277
  "colors": {
1133
1278
  "type": "array",
1134
1279
  "items": {
@@ -255,8 +255,12 @@ export class OrbitControl {
255
255
  this.target.add(move)
256
256
  }
257
257
 
258
- /** Apply inertia and auto-rotate; the View calls this each frame. @param {number} dt seconds */
259
- update(dt) {
258
+ /**
259
+ * Apply inertia and auto-rotate; the View calls this each frame.
260
+ * @param {number} dt seconds
261
+ * @param {boolean} [still] hold auto-rotation - the View passes its reducedMotion
262
+ */
263
+ update(dt, still = false) {
260
264
  if (!this.enabled) return
261
265
  if (this.#pointers.size === 0) {
262
266
  const v = this.#velocity
@@ -267,7 +271,7 @@ export class OrbitControl {
267
271
  v.yaw *= keep
268
272
  v.pitch *= keep
269
273
  }
270
- if (this.autoRotate) this.rotate(-(this.autoRotate === true ? 0.5 : this.autoRotate) * dt, 0)
274
+ if (this.autoRotate && !still) this.rotate(-(this.autoRotate === true ? 0.5 : this.autoRotate) * dt, 0)
271
275
  }
272
276
  }
273
277
 
package/src/Raycaster.js CHANGED
@@ -10,6 +10,7 @@ import { BVH_MIN_TRIANGLES, bvhOf, walk } from './geometry/bvh.js'
10
10
  /** @import { InstancedMesh, Lines, Mesh, Points } from './scene/drawables.js' */
11
11
 
12
12
  /** The section plane cutting what is being tested: world plane, and whether its cut faces are filled. @typedef {{ plane: Float32Array, cap: boolean }} Cut */
13
+ /** @internal A surface ready for many rays: a mesh or one instance, with its world matrix and bounding sphere. @typedef {{ node: any, world: Float32Array, instance: number | null, cut: Cut | null, x: number, y: number, z: number, radius: number }} Solid */
13
14
 
14
15
  // Nodes are told apart by markers (drawMode, instanceMatrices) rather than instanceof, so picking
15
16
  // doesn't pull every node class into an app's bundle.
@@ -119,6 +120,63 @@ export class Raycaster {
119
120
  return hits.filter((h) => h.distance >= this.near && h.distance <= this.far).sort((x, y) => x.distance - y.distance)
120
121
  }
121
122
 
123
+ /** @internal
124
+ * The solid surfaces of a frame's draw list, ready for many rays: each mesh and each instance with its
125
+ * world matrix and bounding sphere worked out once, rather than once per ray - for label occlusion,
126
+ * which casts a ray per label.
127
+ * @param {{ node: Node, worldMatrix: Float32Array, instances: { count: number, instanceMatrices: Float32Array } | null, clip: { plane: Float32Array, cap: unknown } | null }[]} items
128
+ * @returns {Solid[]}
129
+ */
130
+ static solids(items) {
131
+ /** @type {Solid[]} */
132
+ const out = []
133
+ for (const item of items) {
134
+ const n = /** @type {any} */ (item.node)
135
+ if (n.drawMode !== 'triangles' || !n.geometry.vertexCount) continue
136
+ const box = n.geometry.bounds, center = box.getCenter(), radius = box.getRadius()
137
+ const cut = item.clip ? { plane: item.clip.plane, cap: !!item.clip.cap } : null
138
+ /** @param {Float32Array} world @param {number | null} instance */
139
+ const add = (world, instance) => {
140
+ const c = center.clone().applyMatrix4(world)
141
+ const scale = Math.max(Math.hypot(world[0], world[1], world[2]), Math.hypot(world[4], world[5], world[6]), Math.hypot(world[8], world[9], world[10]))
142
+ out.push({ node: n, world, instance, cut, x: c[0], y: c[1], z: c[2], radius: radius * scale })
143
+ }
144
+ if (item.instances) {
145
+ for (let i = 0; i < item.instances.count; i++) {
146
+ add(mat4.multiply(new Float32Array(16), item.worldMatrix, item.instances.instanceMatrices.subarray(i * 16, i * 16 + 16)), i)
147
+ }
148
+ } else add(Float32Array.from(item.worldMatrix), null)
149
+ }
150
+ return out
151
+ }
152
+
153
+ /** @internal
154
+ * Whether the ray meets any of these solids nearer than `distance` - the first one found settles it,
155
+ * so nothing is sorted. One node, or one instance of it, can be left out: what a label is on.
156
+ * @param {Solid[]} solids from Raycaster.solids() @param {number} distance
157
+ * @param {Node | null} [skip] @param {number | null} [skipInstance] null for the whole node
158
+ * @returns {boolean}
159
+ */
160
+ blocked(solids, distance, skip = null, skipInstance = null) {
161
+ const o = this.ray.origin, d = this.ray.direction
162
+ /** @type {Hit[]} */
163
+ const hits = []
164
+ for (const s of solids) {
165
+ if (s.node === skip && (skipInstance === null || s.instance === skipInstance)) continue
166
+ // its bounding sphere first: missed, or wholly beyond the distance or behind the ray, it can't block
167
+ const ox = s.x - o[0], oy = s.y - o[1], oz = s.z - o[2]
168
+ const along = ox * d[0] + oy * d[1] + oz * d[2]
169
+ const off = ox * ox + oy * oy + oz * oz - along * along, r2 = s.radius * s.radius
170
+ if (off > r2) continue
171
+ const half = Math.sqrt(r2 - off)
172
+ if (along - half > distance || along + half < 0) continue
173
+ hits.length = 0
174
+ this.#mesh(s.node, s.world, s.instance, hits, s.cut)
175
+ if (hits.length && hits[0].distance < distance) return true
176
+ }
177
+ return false
178
+ }
179
+
122
180
  /** @internal
123
181
  * Where the ray meets one instance, point or segment of a drawable - the CPU half of GPU picking,
124
182
  * which finds what is under the pointer and leaves the exact point, face and uv to this.