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.
- package/README.md +70 -13
- package/dist/bloom.js +4 -4
- package/dist/bloom.js.map +1 -1
- package/dist/charts.js +538 -508
- package/dist/charts.js.map +1 -1
- package/dist/core.js +4270 -3652
- package/dist/core.js.map +1 -1
- package/dist/edit.js +65 -65
- package/dist/edit.js.map +1 -1
- package/dist/explain.js +6 -2
- package/dist/explain.js.map +1 -1
- package/dist/gltf.js +116 -116
- package/dist/gltf.js.map +1 -1
- package/dist/keyboard.js +188 -0
- package/dist/keyboard.js.map +1 -0
- package/dist/motion.js +35 -35
- package/dist/motion.js.map +1 -1
- package/dist/particles.js +188 -115
- package/dist/particles.js.map +1 -1
- package/dist/passes.js +29 -29
- package/dist/passes.js.map +1 -1
- package/dist/phoria2.js +1 -1
- package/dist/picking.js +32 -16
- package/dist/picking.js.map +1 -1
- package/dist/snapshot.js +1 -1
- package/dist/tooltip.js +1 -1
- package/dist/types/OrbitControl.d.ts +6 -2
- package/dist/types/Raycaster.d.ts +10 -0
- package/dist/types/View.d.ts +70 -1
- package/dist/types/area.d.ts +18 -0
- package/dist/types/charts/Chart.d.ts +7 -0
- package/dist/types/explain.d.ts +3 -0
- package/dist/types/frame.d.ts +7 -0
- package/dist/types/geometry/Geometry.d.ts +24 -2
- package/dist/types/index.d.ts +2 -0
- package/dist/types/keyboard.d.ts +48 -0
- package/dist/types/labels.d.ts +4 -0
- package/dist/types/material/Material.d.ts +15 -0
- package/dist/types/material/Texture.d.ts +18 -2
- package/dist/types/particles/Emitter.d.ts +39 -2
- package/dist/types/scene/Node.d.ts +19 -14
- package/dist/types/scene/drawables.d.ts +54 -15
- package/dist/types/scene/lights.d.ts +16 -2
- package/dist/types/shadow.d.ts +2 -1
- package/dist/types/types.d.ts +14 -0
- package/dist/types/value/Color.d.ts +0 -4
- package/dist/types/value/Quat.d.ts +4 -2
- package/dist/types/webgl/resources.d.ts +12 -2
- package/dist/types/webgl/shaders.d.ts +7 -2
- package/llms-full.txt +106 -10
- package/package.json +4 -1
- package/schema/scene.schema.json +67 -1
- package/src/OrbitControl.js +7 -3
- package/src/Raycaster.js +58 -0
- package/src/View.js +220 -41
- package/src/area.js +131 -0
- package/src/charts/Chart.js +20 -2
- package/src/charts/marks.js +29 -2
- package/src/explain.js +6 -2
- package/src/frame.js +29 -9
- package/src/geometry/Geometry.js +32 -4
- package/src/index.js +1 -0
- package/src/keyboard.js +298 -0
- package/src/labels.js +4 -1
- package/src/material/Material.js +9 -2
- package/src/material/Texture.js +55 -2
- package/src/particles/Emitter.js +121 -10
- package/src/scene/Node.js +20 -9
- package/src/scene/Scene.js +2 -0
- package/src/scene/cameras.js +4 -1
- package/src/scene/drawables.js +115 -15
- package/src/scene/lights.js +40 -6
- package/src/shadow.js +27 -4
- package/src/types.js +6 -0
- package/src/value/Color.js +21 -0
- package/src/value/Quat.js +15 -6
- package/src/watch.js +14 -2
- package/src/webgl/WebGLRenderer.js +36 -5
- package/src/webgl/picking.js +22 -1
- package/src/webgl/resources.js +168 -14
- package/src/webgl/shaders.js +110 -11
package/src/keyboard.js
ADDED
|
@@ -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
|
}
|
package/src/material/Material.js
CHANGED
|
@@ -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)]))
|
package/src/material/Texture.js
CHANGED
|
@@ -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
|
-
|
|
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()
|