phoria2 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +70 -13
  2. package/dist/bloom.js +4 -4
  3. package/dist/bloom.js.map +1 -1
  4. package/dist/charts.js +538 -508
  5. package/dist/charts.js.map +1 -1
  6. package/dist/core.js +4270 -3652
  7. package/dist/core.js.map +1 -1
  8. package/dist/edit.js +65 -65
  9. package/dist/edit.js.map +1 -1
  10. package/dist/explain.js +6 -2
  11. package/dist/explain.js.map +1 -1
  12. package/dist/gltf.js +116 -116
  13. package/dist/gltf.js.map +1 -1
  14. package/dist/keyboard.js +188 -0
  15. package/dist/keyboard.js.map +1 -0
  16. package/dist/motion.js +35 -35
  17. package/dist/motion.js.map +1 -1
  18. package/dist/particles.js +188 -115
  19. package/dist/particles.js.map +1 -1
  20. package/dist/passes.js +29 -29
  21. package/dist/passes.js.map +1 -1
  22. package/dist/phoria2.js +1 -1
  23. package/dist/picking.js +32 -16
  24. package/dist/picking.js.map +1 -1
  25. package/dist/snapshot.js +1 -1
  26. package/dist/tooltip.js +1 -1
  27. package/dist/types/OrbitControl.d.ts +6 -2
  28. package/dist/types/Raycaster.d.ts +10 -0
  29. package/dist/types/View.d.ts +70 -1
  30. package/dist/types/area.d.ts +18 -0
  31. package/dist/types/charts/Chart.d.ts +7 -0
  32. package/dist/types/explain.d.ts +3 -0
  33. package/dist/types/frame.d.ts +7 -0
  34. package/dist/types/geometry/Geometry.d.ts +24 -2
  35. package/dist/types/index.d.ts +2 -0
  36. package/dist/types/keyboard.d.ts +48 -0
  37. package/dist/types/labels.d.ts +4 -0
  38. package/dist/types/material/Material.d.ts +15 -0
  39. package/dist/types/material/Texture.d.ts +18 -2
  40. package/dist/types/particles/Emitter.d.ts +39 -2
  41. package/dist/types/scene/Node.d.ts +19 -14
  42. package/dist/types/scene/drawables.d.ts +54 -15
  43. package/dist/types/scene/lights.d.ts +16 -2
  44. package/dist/types/shadow.d.ts +2 -1
  45. package/dist/types/types.d.ts +14 -0
  46. package/dist/types/value/Color.d.ts +0 -4
  47. package/dist/types/value/Quat.d.ts +4 -2
  48. package/dist/types/webgl/resources.d.ts +12 -2
  49. package/dist/types/webgl/shaders.d.ts +7 -2
  50. package/llms-full.txt +106 -10
  51. package/package.json +4 -1
  52. package/schema/scene.schema.json +67 -1
  53. package/src/OrbitControl.js +7 -3
  54. package/src/Raycaster.js +58 -0
  55. package/src/View.js +220 -41
  56. package/src/area.js +131 -0
  57. package/src/charts/Chart.js +20 -2
  58. package/src/charts/marks.js +29 -2
  59. package/src/explain.js +6 -2
  60. package/src/frame.js +29 -9
  61. package/src/geometry/Geometry.js +32 -4
  62. package/src/index.js +1 -0
  63. package/src/keyboard.js +298 -0
  64. package/src/labels.js +4 -1
  65. package/src/material/Material.js +9 -2
  66. package/src/material/Texture.js +55 -2
  67. package/src/particles/Emitter.js +121 -10
  68. package/src/scene/Node.js +20 -9
  69. package/src/scene/Scene.js +2 -0
  70. package/src/scene/cameras.js +4 -1
  71. package/src/scene/drawables.js +115 -15
  72. package/src/scene/lights.js +40 -6
  73. package/src/shadow.js +27 -4
  74. package/src/types.js +6 -0
  75. package/src/value/Color.js +21 -0
  76. package/src/value/Quat.js +15 -6
  77. package/src/watch.js +14 -2
  78. package/src/webgl/WebGLRenderer.js +36 -5
  79. package/src/webgl/picking.js +22 -1
  80. package/src/webgl/resources.js +168 -14
  81. package/src/webgl/shaders.js +110 -11
@@ -0,0 +1,298 @@
1
+ // Keyboard use of a View, for people who can't use a pointer and for screen readers: the canvas
2
+ // becomes a stop in the page's tab order, and the arrow keys step through what you can act on - each
3
+ // instance, point or node with a tooltip, a handler or data - as hovering would: the hover tint, the
4
+ // tooltip and hover handlers, with Enter or Space as a click. A live region says what each one is.
5
+ // Loaded when a View is given keyboard: true, so apps without it don't carry it.
6
+ import { hiddenInstance } from './scene/drawables.js'
7
+ import { Vec3 } from './value/Vec3.js'
8
+
9
+ /** @import { Hit } from './Raycaster.js' */
10
+ /** @import { TooltipContent } from './tooltip.js' */
11
+ /** @import { Node } from './scene/Node.js' */
12
+ /** @import { View } from './View.js' */
13
+
14
+ /**
15
+ * @typedef {object} KeyboardOptions
16
+ * @property {(hit: Hit) => string} [label] what a screen reader says for each thing stepped to (default: the
17
+ * tooltip's text, then its data, then the node's name)
18
+ */
19
+
20
+ /**
21
+ * What the View lends the keyboard: the hover it shows, the click it runs, and what it says about a hit.
22
+ * @typedef {object} KeyboardHooks
23
+ * @property {(hit: Hit | null) => void} focus show this as hovered, or nothing
24
+ * @property {(hit: Hit) => void} press click it
25
+ * @property {(hit: Hit) => TooltipContent} tooltip the tooltip's content for it, if there is a tooltip
26
+ * @property {() => boolean} viewWide does the View itself answer hovers or clicks (a tooltip, handlers)?
27
+ */
28
+
29
+ /** @typedef {{ node: Node, index: number | null, whole: Node | null }} Item */
30
+
31
+ const STYLE = `:where(.phoria-sr-only) { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
32
+ clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }`
33
+ // how far PageUp and PageDown jump
34
+ const PAGE = 10
35
+ let ids = 0
36
+
37
+ export class Keyboard {
38
+ /** @type {View} */ #view
39
+ /** @type {KeyboardHooks} */ #hooks
40
+ /** @type {KeyboardOptions} */ #options
41
+ /** The item stepped to, or null. @type {Item | null} */ #at = null
42
+ /** @type {HTMLDivElement} */ #say
43
+ /** @type {HTMLDivElement} */ #help
44
+ /** Attributes the canvas had before, put back on dispose. @type {[string, string | null][]} */ #before
45
+ #description = ''
46
+ #auto = true
47
+ /** @type {(() => void)[]} */ #cleanup = []
48
+
49
+ /** @param {View} view @param {KeyboardHooks} hooks @param {KeyboardOptions} options */
50
+ constructor(view, hooks, options) {
51
+ this.#view = view
52
+ this.#hooks = hooks
53
+ this.#options = options
54
+ if (!document.getElementById('phoria-sr-style')) {
55
+ const style = document.createElement('style')
56
+ style.id = 'phoria-sr-style'
57
+ style.textContent = STYLE
58
+ document.head.prepend(style)
59
+ }
60
+ const canvas = view.canvas, id = `phoria-keys-${++ids}`
61
+ // what a screen reader says as you step: politely, after what it's saying already
62
+ this.#say = document.createElement('div')
63
+ this.#say.className = 'phoria-sr-only'
64
+ this.#say.setAttribute('aria-live', 'polite')
65
+ this.#say.setAttribute('aria-atomic', 'true')
66
+ // how to use it, read once the canvas has focus
67
+ this.#help = document.createElement('div')
68
+ this.#help.className = 'phoria-sr-only'
69
+ this.#help.id = id
70
+ canvas.after(this.#say, this.#help)
71
+ this.#before = ['tabindex', 'role', 'aria-roledescription', 'aria-label', 'aria-describedby'].map((a) => [a, canvas.getAttribute(a)])
72
+ if (!canvas.hasAttribute('tabindex')) canvas.tabIndex = 0
73
+ canvas.setAttribute('role', 'application')
74
+ canvas.setAttribute('aria-roledescription', '3D view')
75
+ canvas.setAttribute('aria-describedby', id)
76
+
77
+ const keydown = (/** @type {KeyboardEvent} */ e) => this.#key(e)
78
+ const blur = () => this.clear()
79
+ const focus = () => this.#describe()
80
+ canvas.addEventListener('keydown', keydown)
81
+ canvas.addEventListener('blur', blur)
82
+ canvas.addEventListener('focus', focus)
83
+ this.#cleanup.push(() => {
84
+ canvas.removeEventListener('keydown', keydown)
85
+ canvas.removeEventListener('blur', blur)
86
+ canvas.removeEventListener('focus', focus)
87
+ })
88
+ this.#describe()
89
+ }
90
+
91
+ /**
92
+ * The canvas's label for screen readers: the View's description, or - when it has none - one made
93
+ * from the scene: what its charts show, and how many things there are to step through.
94
+ * @param {string | null} text
95
+ */
96
+ setDescription(text) {
97
+ this.#auto = text === null
98
+ this.#description = text ?? ''
99
+ this.#describe()
100
+ }
101
+
102
+ #describe() {
103
+ const count = this.#items().length
104
+ const label = this.#auto ? autoDescription(this.#view.scene, count) : this.#description
105
+ this.#view.canvas.setAttribute('aria-label', label)
106
+ this.#help.textContent = count
107
+ ? `Use the arrow keys to move between ${count === 1 ? 'the one item' : `${count} items`}, Enter to select, Escape to leave.`
108
+ : 'Nothing here can be selected.'
109
+ }
110
+
111
+ /** Step off whatever was stepped to: the pointer has taken over, or focus has gone. */
112
+ clear() {
113
+ if (!this.#at) return
114
+ this.#at = null
115
+ this.#hooks.focus(null)
116
+ }
117
+
118
+ /** @param {KeyboardEvent} e */
119
+ #key(e) {
120
+ if (e.altKey || e.ctrlKey || e.metaKey) return
121
+ const items = this.#items()
122
+ let i = this.#at ? items.findIndex((it) => same(it, /** @type {Item} */ (this.#at))) : -1
123
+ switch (e.key) {
124
+ case 'ArrowRight': case 'ArrowDown': i = i < 0 ? 0 : Math.min(items.length - 1, i + 1); break
125
+ case 'ArrowLeft': case 'ArrowUp': i = i < 0 ? items.length - 1 : Math.max(0, i - 1); break
126
+ case 'PageDown': i = Math.min(items.length - 1, Math.max(0, i) + PAGE); break
127
+ case 'PageUp': i = Math.max(0, i - PAGE); break
128
+ case 'Home': i = 0; break
129
+ case 'End': i = items.length - 1; break
130
+ case 'Enter': case ' ':
131
+ if (!this.#at || i < 0) return
132
+ e.preventDefault()
133
+ this.#hooks.press(hitOf(items[i]))
134
+ return
135
+ case 'Escape':
136
+ if (!this.#at) return
137
+ e.preventDefault()
138
+ this.clear()
139
+ this.#say.textContent = ''
140
+ return
141
+ default: return
142
+ }
143
+ // the arrows and paging keys are the view's while it has focus, not the page's to scroll
144
+ e.preventDefault()
145
+ if (!items.length) {
146
+ this.#say.textContent = 'Nothing here can be selected.'
147
+ return
148
+ }
149
+ this.#at = items[i]
150
+ const hit = hitOf(items[i])
151
+ this.#hooks.focus(hit)
152
+ this.#say.textContent = `${this.#options.label?.(hit) ?? this.#textOf(hit)}. ${i + 1} of ${items.length}`
153
+ }
154
+
155
+ /** What to say about a hit: the tooltip's text, its data, or its node's name. @param {Hit} hit @returns {string} */
156
+ #textOf(hit) {
157
+ const tip = textOfContent(this.#hooks.tooltip(hit))
158
+ if (tip) return tip
159
+ const datum = hit.datum
160
+ if (datum !== null && datum !== undefined) {
161
+ if (typeof datum !== 'object') return String(datum)
162
+ const parts = Object.entries(datum).filter(([, v]) => v === null || typeof v !== 'object').slice(0, 4).map(([k, v]) => `${k}: ${format(v)}`)
163
+ if (parts.length) return parts.join(', ')
164
+ }
165
+ for (let n = /** @type {Node | null} */ (hit.node); n; n = n.parent) if (n.name) return n.name
166
+ return 'Item'
167
+ }
168
+
169
+ /**
170
+ * What can be stepped to, in scene order and each mesh's instances in order: everything visible and
171
+ * pickable in a subtree with its own tooltip, handlers, drag or tint - and, when the View answers
172
+ * hovers or clicks itself, each thing with data or a name. A highlightWhole subtree is one item, and
173
+ * a surface - a mesh with a row of data per vertex - is a stop per vertex, as picking gives its rows.
174
+ * Lines with a row per segment - a network's links - are a stop per segment, after everything else:
175
+ * the things first, then what joins them. Something with no row and no name of its own is a stop
176
+ * only when it has a handler or tooltip itself, not one it shares with the subtree it's in.
177
+ * @returns {Item[]}
178
+ */
179
+ #items() {
180
+ /** @type {Item[]} */
181
+ const items = [], links = []
182
+ const viewWide = this.#hooks.viewWide()
183
+ /** @type {Set<Node>} */
184
+ const wholes = new Set()
185
+ /** @param {any} n @param {boolean} own @param {Node | null} whole */
186
+ const walk = (n, own, whole) => {
187
+ if (!n.visible || n.pickable === false) return
188
+ const direct = !!(n.tooltip || n.onDrag || n.highlight || n.handlersFor('click').length || n.handlersFor('hover').length)
189
+ own ||= direct
190
+ whole = n.highlightWhole ? n : whole
191
+ if (n.isDrawable) {
192
+ if (whole) {
193
+ if ((own || viewWide) && !wholes.has(whole)) {
194
+ wholes.add(whole)
195
+ items.push({ node: n, index: null, whole })
196
+ }
197
+ } else if ('instanceMatrices' in n) {
198
+ for (let i = 0; i < n.count; i++) {
199
+ if (!hiddenInstance(n.instanceMatrices, i) && (own || (viewWide && n.datumAt(i) !== null))) items.push({ node: n, index: i, whole: null })
200
+ }
201
+ } else if ((n.drawMode === 'points' && Array.isArray(n.data)) || perVertex(n)) {
202
+ for (let i = 0; i < n.data.length; i++) if (own || viewWide) items.push({ node: n, index: i, whole: null })
203
+ } else if (n.drawMode === 'lines' && Array.isArray(n.data)) {
204
+ for (let i = 0; i < n.data.length; i++) if (own || viewWide) links.push({ node: n, index: i, whole: null })
205
+ } else if (direct || ((own || viewWide) && (n.name || n.datumAt(null) !== null))) items.push({ node: n, index: null, whole: null })
206
+ }
207
+ for (const c of n.children) walk(c, own, whole)
208
+ }
209
+ walk(this.#view.scene, false, null)
210
+ return items.concat(links)
211
+ }
212
+
213
+ dispose() {
214
+ for (const fn of this.#cleanup) fn()
215
+ const canvas = this.#view.canvas
216
+ for (const [name, value] of this.#before) {
217
+ if (value === null) canvas.removeAttribute(name)
218
+ else canvas.setAttribute(name, value)
219
+ }
220
+ this.#say.remove()
221
+ this.#help.remove()
222
+ }
223
+ }
224
+
225
+ /** @param {Item} a @param {Item} b */
226
+ const same = (a, b) => a.node === b.node && a.index === b.index
227
+
228
+ /** A surface: a mesh of triangles with a row of data for each vertex. @param {any} n */
229
+ const perVertex = (n) => n.drawMode === 'triangles' && !('instanceMatrices' in n) && Array.isArray(n.data) && n.data.length === n.geometry.vertexCount
230
+
231
+ /**
232
+ * A hit on an item, as the pointer would make over its middle.
233
+ * @param {Item} item @returns {Hit}
234
+ */
235
+ function hitOf({ node, index, whole }) {
236
+ const d = /** @type {any} */ (node)
237
+ const points = index !== null && d.drawMode === 'points', vertex = index !== null && perVertex(d)
238
+ const segment = index !== null && d.drawMode === 'lines'
239
+ /** @type {Vec3} */
240
+ let point
241
+ if (whole) point = whole.bounds.getCenter()
242
+ else if (index !== null && 'instanceMatrices' in d) {
243
+ // the middle of the geometry, through the instance's matrix and then the mesh's
244
+ node.updateWorld()
245
+ const local = d.geometry.bounds.getCenter()
246
+ point = through(d.worldMatrix, through(d.instanceMatrices.subarray(index * 16, index * 16 + 16), local))
247
+ } else if (points || vertex) {
248
+ const p = d.geometry.positions
249
+ node.updateWorld()
250
+ point = through(d.worldMatrix, new Vec3(p[index * 3], p[index * 3 + 1], p[index * 3 + 2]))
251
+ } else if (segment) {
252
+ // the middle of the segment: its two ends, by index when the lines have them
253
+ const p = d.geometry.positions, idx = d.geometry.indices
254
+ const a = idx ? idx[index * 2] : index * 2, b = idx ? idx[index * 2 + 1] : index * 2 + 1
255
+ node.updateWorld()
256
+ point = through(d.worldMatrix, new Vec3((p[a * 3] + p[b * 3]) / 2, (p[a * 3 + 1] + p[b * 3 + 1]) / 2, (p[a * 3 + 2] + p[b * 3 + 2]) / 2))
257
+ } else point = node.bounds.getCenter()
258
+ // a surface's vertex is no face or instance - its row is the datum, as a pointer over it finds
259
+ return { node, point, distance: 0, normal: null, face: points || segment ? index : null, instance: index !== null && !points && !vertex && !segment ? index : null,
260
+ uv: null, datum: vertex ? d.data[index] ?? null : d.datumAt?.(index) ?? null }
261
+ }
262
+
263
+ /** A point through a 4x4 matrix (column-major). @param {ArrayLike<number>} m @param {Vec3} p @returns {Vec3} */
264
+ function through(m, p) {
265
+ const [x, y, z] = p
266
+ return new Vec3(m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14])
267
+ }
268
+
269
+ /** The text of a tooltip's content, for a screen reader. @param {TooltipContent} content @returns {string} */
270
+ function textOfContent(content) {
271
+ if (content === null || content === undefined || content === false || content === '') return ''
272
+ if (typeof content === 'string' || typeof content === 'number') return String(content).replace(/\s*\n\s*/g, ', ')
273
+ if (typeof globalThis.Node !== 'undefined' && content instanceof globalThis.Node) return (content.textContent ?? '').trim()
274
+ if (typeof content === 'object') {
275
+ const c = /** @type {{ title?: string, rows?: Record<string, unknown> | Array<[string, unknown]> }} */ (content)
276
+ const rows = c.rows ? (Array.isArray(c.rows) ? c.rows : Object.entries(c.rows)).map(([k, v]) => `${k}: ${format(v)}`) : []
277
+ return [c.title, ...rows].filter((s) => s !== undefined && s !== '').join(', ')
278
+ }
279
+ return ''
280
+ }
281
+
282
+ /** @param {unknown} v */
283
+ const format = (v) => (typeof v === 'number' ? v.toLocaleString() : String(v))
284
+
285
+ /**
286
+ * A label for the canvas made from the scene: each chart's summary, or how many things there are.
287
+ * @param {Node} scene @param {number} count @returns {string}
288
+ */
289
+ function autoDescription(scene, count) {
290
+ /** @type {string[]} */
291
+ const parts = []
292
+ scene.traverse((n) => {
293
+ const summary = /** @type {any} */ (n).summary
294
+ if (n !== scene && n.visible && typeof summary === 'function') parts.push(summary.call(n))
295
+ })
296
+ if (!parts.length) parts.push(count ? `3D view of ${count === 1 ? 'one item' : `${count} items`}` : '3D view')
297
+ return parts.join('. ')
298
+ }
package/src/labels.js CHANGED
@@ -14,6 +14,7 @@ import { alignColumns, layoutLabels, leaderEnd, placeBox, stackColumns } from '.
14
14
  * @property {number} dimmed drawn faint because a higher-priority label or geometry was in front (overlap: 'dim')
15
15
  * @property {number} occluded hidden because geometry was in front (overlap: 'hide')
16
16
  * @property {number} offscreen behind the camera or outside the view
17
+ * @property {number} zoomedOut hidden because the view isn't zoomed in to their minScale
17
18
  */
18
19
 
19
20
  /** @typedef {{ el: HTMLDivElement, text: string, className: string, width: number, height: number, showing: boolean, leader: SVGGElement | null, color: string, rule: number, label: FrameLabel | null, interactive: boolean }} Entry */
@@ -67,7 +68,7 @@ export class LabelLayer {
67
68
  */
68
69
  update(labels, overlays = []) {
69
70
  /** @type {LabelStats} */
70
- const stats = { total: labels.length, shown: 0, overlapped: 0, dimmed: 0, occluded: 0, offscreen: 0 }
71
+ const stats = { total: labels.length, shown: 0, overlapped: 0, dimmed: 0, occluded: 0, offscreen: 0, zoomedOut: 0 }
71
72
  this.shown = []
72
73
  this.leaders = []
73
74
  if (!labels.length && !this.#elements.size && !overlays.length && !this.#overlays.size && !this.#root) return stats
@@ -83,6 +84,8 @@ export class LabelLayer {
83
84
  for (const label of labels) {
84
85
  seen.add(label.key)
85
86
  if (!label.visible) stats.offscreen++
87
+ // details for closer up: until then they take no part, so they can't push other labels out
88
+ else if (label.style.minScale && label.scale * rect.height / 2 < label.style.minScale) stats.zoomedOut++
86
89
  else if (label.occluded && label.style.overlap === 'hide') stats.occluded++
87
90
  else active.push({ label, entry: this.#entry(label, root) })
88
91
  }
@@ -72,6 +72,9 @@ import { Texture } from './Texture.js'
72
72
  * @property {number} [size] Points: diameter in world units, or pixels when sizeAttenuation is off (default 1)
73
73
  * @property {boolean} [sizeAttenuation] Points: sized in the world, so smaller with distance (default true)
74
74
  * @property {number} [width] Lines: width in pixels (default 1)
75
+ * @property {number} [dash] Lines: dashed, with dashes this long in the model's units; 0 is solid (default 0)
76
+ * @property {number} [gap] Lines: the gaps between dashes; 0 is as long as the dashes (default 0)
77
+ * @property {number} [dashOffset] Lines: moves the dashes along the lines - animate it for flow (default 0)
75
78
  */
76
79
 
77
80
  // the matcap last: the first map's repeat and offset are every map's, and a matcap has none
@@ -83,7 +86,8 @@ const NUMBERS = { opacity: [0, 1], roughness: [0, 1], metalness: [0, 1], emissiv
83
86
  alphaTest: [0, 1], normalScale: [-Infinity, Infinity], aoMapIntensity: [0, 1], triplanar: [0, Infinity], bumpScale: [-Infinity, Infinity],
84
87
  transmission: [0, 1], clearcoat: [0, 1], clearcoatRoughness: [0, 1], sheen: [0, 1], sheenRoughness: [0, 1], ior: [1, Infinity],
85
88
  specularIntensity: [0, 1], anisotropy: [0, 1], anisotropyRotation: [-Infinity, Infinity], translucency: [0, 1], toon: [0, 16],
86
- lightMapIntensity: [0, Infinity], environmentIntensity: [0, Infinity] }
89
+ lightMapIntensity: [0, Infinity], environmentIntensity: [0, Infinity], dash: [0, Infinity], gap: [0, Infinity],
90
+ dashOffset: [-Infinity, Infinity] }
87
91
  // colours, and their defaults
88
92
  const COLORS = { color: 0xffffff, emissive: 0x000000, sheenColor: 0xffffff, specularColor: 0xffffff, translucencyColor: 0xffffff }
89
93
  const DEFAULTS = {
@@ -91,7 +95,7 @@ const DEFAULTS = {
91
95
  transmission: 0, clearcoat: 0, clearcoatRoughness: 0, sheen: 0, sheenRoughness: 0.5, ior: 1.5, specularIntensity: 1, anisotropy: 0,
92
96
  anisotropyRotation: 0, translucency: 0, toon: 0, lightMapIntensity: 1, environmentIntensity: 1, unlit: false, flatShading: false,
93
97
  doubleSided: false, vertexColors: false, blending: 'normal', depthWrite: true, depthTest: true, fog: true,
94
- size: 1, sizeAttenuation: true, width: 1,
98
+ size: 1, sizeAttenuation: true, width: 1, dash: 0, gap: 0, dashOffset: 0,
95
99
  }
96
100
  const OPTIONS = ['name', ...Object.keys(COLORS), ...MAPS, ...FLAGS, ...Object.keys(NUMBERS), 'blending']
97
101
 
@@ -161,6 +165,9 @@ export class Material {
161
165
  /** @type {number} */ size
162
166
  /** @type {boolean} */ sizeAttenuation
163
167
  /** @type {number} */ width
168
+ /** Lines: dash length in the model's units; 0 is solid. @type {number} */ dash
169
+ /** Lines: gap length; 0 is the dash's. @type {number} */ gap
170
+ /** Lines: how far the dashes have moved along. @type {number} */ dashOffset
164
171
 
165
172
  /** @type {Record<string, Color>} */
166
173
  #colors = Object.fromEntries(Object.entries(COLORS).map(([key, hex]) => [key, new Color(hex)]))
@@ -43,9 +43,18 @@ const HINTS = {
43
43
  scale: "use 'repeat' - [u, v] times across",
44
44
  }
45
45
 
46
+ /**
47
+ * Part of an image, in its pixels from the top left, as needsUpdate() takes it.
48
+ * @typedef {{ x: number, y: number, width: number, height: number }} TextureRegion
49
+ */
50
+
51
+ // how many updates a texture remembers the region of, for a renderer catching up on several - a
52
+ // frame of paint can make a hundred or so
53
+ const CHANGES_KEPT = 256
54
+
46
55
  /**
47
56
  * An image for a material map. A video's frames upload by themselves as it plays; after drawing on
48
- * a canvas, call `needsUpdate()`.
57
+ * a canvas, call `needsUpdate()` - with the rectangle drawn in, to send only that.
49
58
  */
50
59
  export class Texture {
51
60
  /** The options it takes, and hints for names people reach for instead. */
@@ -77,6 +86,8 @@ export class Texture {
77
86
  flipY = false
78
87
  /** @internal Bumped by needsUpdate() so renderers re-upload. */
79
88
  version = 0
89
+ /** What each recent update changed - its region, or null for all of it. @type {{ version: number, region: TextureRegion | null }[]} */
90
+ #changes = []
80
91
  /** Resolves when the image is available (immediately when one was given). @type {Promise<Texture>} */
81
92
  ready
82
93
 
@@ -180,11 +191,53 @@ export class Texture {
180
191
  /** @internal Whether the app chose the colour space, rather than leaving the default. @returns {boolean} */
181
192
  get colorSpaceSet() { return this.#colorSpaceSet }
182
193
 
183
- needsUpdate() {
194
+ /**
195
+ * Upload the image again, after drawing on it (a canvas) or changing its pixels. Give the
196
+ * rectangle that changed - in the image's pixels, from its top left - and only that is sent: for a
197
+ * big canvas drawn on a little at a time, far less to send each frame.
198
+ *
199
+ * ctx.fillRect(x, y, 20, 20)
200
+ * texture.needsUpdate({ x, y, width: 20, height: 20 })
201
+ * @param {TextureRegion} [region]
202
+ * @returns {this}
203
+ */
204
+ needsUpdate(region) {
205
+ /** @type {TextureRegion | null} */
206
+ let changed = null
207
+ if (region !== undefined) {
208
+ const { x, y, width, height } = region ?? /** @type {any} */ ({})
209
+ if (![x, y, width, height].every((v) => typeof v === 'number' && Number.isFinite(v)) || width < 0 || height < 0) {
210
+ fail('Texture', `needsUpdate() takes the region changed, { x, y, width, height } in the image's pixels, got ${describe(region)}`)
211
+ }
212
+ // whole pixels, covering every one the region touches
213
+ const x0 = Math.floor(x), y0 = Math.floor(y)
214
+ changed = { x: x0, y: y0, width: Math.ceil(x + width) - x0, height: Math.ceil(y + height) - y0 }
215
+ }
184
216
  this.version++
217
+ this.#changes.push({ version: this.version, region: changed })
218
+ if (this.#changes.length > CHANGES_KEPT) this.#changes.shift()
185
219
  return this
186
220
  }
187
221
 
222
+ /**
223
+ * @internal What changed since a version a renderer uploaded: each update's region since, or null
224
+ * when that's all of it - an update without a region, or one too long ago to know.
225
+ * @param {number} version
226
+ * @returns {TextureRegion[] | null}
227
+ */
228
+ changedSince(version) {
229
+ const first = this.#changes[0]
230
+ if (!first || first.version > version + 1) return null
231
+ /** @type {TextureRegion[]} */
232
+ const regions = []
233
+ for (const { version: v, region } of this.#changes) {
234
+ if (v <= version) continue
235
+ if (!region) return null
236
+ regions.push(region)
237
+ }
238
+ return regions.length ? regions : null
239
+ }
240
+
188
241
  /** Release the GPU copy. @returns {void} */
189
242
  dispose() {
190
243
  for (const fn of this.#onDispose) fn()