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/area.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Picking an area of the canvas: everything whose place on screen falls inside a box or a lasso drawn
|
|
2
|
+
// over it - each point, instance and line segment on its own, and a mesh by its middle. Positions
|
|
3
|
+
// are tested, not surfaces, so what is hidden behind other things is found too: selecting a region
|
|
4
|
+
// of a scatter takes the whole region, front to back.
|
|
5
|
+
import * as mat4 from './math/mat4.js'
|
|
6
|
+
import { hiddenInstance } from './scene/drawables.js'
|
|
7
|
+
import { Vec3 } from './value/Vec3.js'
|
|
8
|
+
import { describe, fail } from './validate.js'
|
|
9
|
+
|
|
10
|
+
/** @import { Hit } from './Raycaster.js' */
|
|
11
|
+
/** @import { Node } from './scene/Node.js' */
|
|
12
|
+
/** @import { Camera } from './scene/cameras.js' */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A box on the canvas, CSS pixels from its top left - width and height may be negative, as a drag
|
|
16
|
+
* up or left makes them - or a lasso: the points of a polygon, [x, y] each.
|
|
17
|
+
* @typedef {{ x: number, y: number, width: number, height: number } | [number, number][]} PickArea
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const instanceWorld = new Float32Array(16)
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The polygon an area is, and its bounding box, for the inside test.
|
|
24
|
+
* @param {PickArea} area
|
|
25
|
+
* @returns {{ points: number[], left: number, top: number, right: number, bottom: number }}
|
|
26
|
+
*/
|
|
27
|
+
function outline(area) {
|
|
28
|
+
/** @type {number[]} */
|
|
29
|
+
let points
|
|
30
|
+
if (Array.isArray(area)) {
|
|
31
|
+
if (area.length < 3 || !area.every((p) => Array.isArray(p) && p.length === 2 && p.every(Number.isFinite))) {
|
|
32
|
+
fail('View', `pickArea() takes a box { x, y, width, height } or a lasso of three or more [x, y] points, got ${describe(area)}`)
|
|
33
|
+
}
|
|
34
|
+
points = area.flat()
|
|
35
|
+
} else {
|
|
36
|
+
const { x, y, width, height } = area ?? /** @type {any} */ ({})
|
|
37
|
+
if (![x, y, width, height].every(Number.isFinite)) {
|
|
38
|
+
fail('View', `pickArea() takes a box { x, y, width, height } or a lasso of three or more [x, y] points, got ${describe(area)}`)
|
|
39
|
+
}
|
|
40
|
+
points = [x, y, x + width, y, x + width, y + height, x, y + height]
|
|
41
|
+
}
|
|
42
|
+
let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity
|
|
43
|
+
for (let i = 0; i < points.length; i += 2) {
|
|
44
|
+
left = Math.min(left, points[i])
|
|
45
|
+
right = Math.max(right, points[i])
|
|
46
|
+
top = Math.min(top, points[i + 1])
|
|
47
|
+
bottom = Math.max(bottom, points[i + 1])
|
|
48
|
+
}
|
|
49
|
+
return { points, left, top, right, bottom }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Is (x, y) inside the polygon? Even-odd crossings. @param {number[]} p @param {number} x @param {number} y */
|
|
53
|
+
function inside(p, x, y) {
|
|
54
|
+
let into = false
|
|
55
|
+
for (let i = 0, j = p.length - 2; i < p.length; j = i, i += 2) {
|
|
56
|
+
const yi = p[i + 1], yj = p[j + 1]
|
|
57
|
+
if ((yi > y) !== (yj > y) && x < p[i] + (p[j] - p[i]) * (y - yi) / (yj - yi)) into = !into
|
|
58
|
+
}
|
|
59
|
+
return into
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Everything in the scene the camera shows, pickable, whose place falls inside the area - nearest
|
|
64
|
+
* the camera first.
|
|
65
|
+
* @param {Node} scene @param {Camera} camera @param {Float32Array} viewProjection
|
|
66
|
+
* @param {number} width @param {number} height the canvas, CSS pixels
|
|
67
|
+
* @param {PickArea} area
|
|
68
|
+
* @returns {Hit[]}
|
|
69
|
+
*/
|
|
70
|
+
export function pickArea(scene, camera, viewProjection, width, height, area) {
|
|
71
|
+
const shape = outline(area)
|
|
72
|
+
const eye = camera.worldPosition
|
|
73
|
+
const m = viewProjection
|
|
74
|
+
/** @type {Hit[]} */
|
|
75
|
+
const hits = []
|
|
76
|
+
/** World position (x, y, z) inside the area? @param {number} x @param {number} y @param {number} z */
|
|
77
|
+
const within = (x, y, z) => {
|
|
78
|
+
const w = m[3] * x + m[7] * y + m[11] * z + m[15]
|
|
79
|
+
if (w <= 0) return false
|
|
80
|
+
const nz = (m[2] * x + m[6] * y + m[10] * z + m[14]) / w
|
|
81
|
+
if (nz < -1 || nz > 1) return false
|
|
82
|
+
const sx = ((m[0] * x + m[4] * y + m[8] * z + m[12]) / w * 0.5 + 0.5) * width
|
|
83
|
+
const sy = (0.5 - (m[1] * x + m[5] * y + m[9] * z + m[13]) / w * 0.5) * height
|
|
84
|
+
return sx >= shape.left && sx <= shape.right && sy >= shape.top && sy <= shape.bottom && inside(shape.points, sx, sy)
|
|
85
|
+
}
|
|
86
|
+
/** @param {any} node @param {number} x @param {number} y @param {number} z @param {number | null} face @param {number | null} instance */
|
|
87
|
+
const hit = (node, x, y, z, face, instance) => {
|
|
88
|
+
const point = new Vec3(x, y, z)
|
|
89
|
+
hits.push({ node, point, distance: point.distanceTo(eye), normal: null, face, instance, uv: null, datum: node.datumAt(instance ?? face) })
|
|
90
|
+
}
|
|
91
|
+
const p = new Vec3()
|
|
92
|
+
|
|
93
|
+
/** @param {Node} node */
|
|
94
|
+
const visit = (node) => {
|
|
95
|
+
if (!node.shownTo(camera) || !node.pickable) return
|
|
96
|
+
const n = /** @type {any} */ (node)
|
|
97
|
+
if (n.isDrawable && n.geometry.vertexCount) {
|
|
98
|
+
const g = n.geometry, world = n.worldMatrix
|
|
99
|
+
if (n.drawMode === 'points') {
|
|
100
|
+
const pos = g.positions
|
|
101
|
+
for (let i = 0; i < g.drawnVertices; i++) {
|
|
102
|
+
p.set(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]).applyMatrix4(world)
|
|
103
|
+
if (within(p[0], p[1], p[2])) hit(n, p[0], p[1], p[2], i, null)
|
|
104
|
+
}
|
|
105
|
+
} else if (n.drawMode === 'lines') {
|
|
106
|
+
// a segment by its middle
|
|
107
|
+
const pos = g.positions, idx = g.indices
|
|
108
|
+
for (let s = 0; s < Math.floor(g.drawnCount / 2); s++) {
|
|
109
|
+
const a = idx ? idx[s * 2] : s * 2, b = idx ? idx[s * 2 + 1] : s * 2 + 1
|
|
110
|
+
p.set((pos[a * 3] + pos[b * 3]) / 2, (pos[a * 3 + 1] + pos[b * 3 + 1]) / 2, (pos[a * 3 + 2] + pos[b * 3 + 2]) / 2).applyMatrix4(world)
|
|
111
|
+
if (within(p[0], p[1], p[2])) hit(n, p[0], p[1], p[2], s, null)
|
|
112
|
+
}
|
|
113
|
+
} else if (n.instanceMatrices) {
|
|
114
|
+
// each instance by the middle of its copy of the geometry; hidden ones aren't there
|
|
115
|
+
const centre = g.bounds.getCenter()
|
|
116
|
+
for (let i = 0; i < n.count; i++) {
|
|
117
|
+
if (hiddenInstance(n.instanceMatrices, i)) continue
|
|
118
|
+
mat4.multiply(instanceWorld, world, n.instanceMatrices.subarray(i * 16, i * 16 + 16))
|
|
119
|
+
p.copy(centre).applyMatrix4(instanceWorld)
|
|
120
|
+
if (within(p[0], p[1], p[2])) hit(n, p[0], p[1], p[2], null, i)
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
p.copy(g.bounds.getCenter()).applyMatrix4(world)
|
|
124
|
+
if (within(p[0], p[1], p[2])) hit(n, p[0], p[1], p[2], null, null)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const child of node.children) visit(child)
|
|
128
|
+
}
|
|
129
|
+
visit(scene)
|
|
130
|
+
return hits.sort((a, b) => a.distance - b.distance)
|
|
131
|
+
}
|
package/src/charts/Chart.js
CHANGED
|
@@ -118,6 +118,22 @@ export class Chart extends Group {
|
|
|
118
118
|
return `Chart (${s.mark}): ${shown} of ${s.rows.length} rows shown${axes.length ? `; ${axes.join(', ')}` : ''}${s.links.length ? `; ${s.links.length} links` : ''}`
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* One sentence about this chart for screen readers - the spec's description, or its title and what
|
|
123
|
+
* it shows: "Sales by month: bar chart of 12 rows by Month, Sales". A View with keyboard: true labels
|
|
124
|
+
* its canvas with this.
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
summary() {
|
|
128
|
+
const s = /** @type {ReturnType<typeof normalise>} */ (this.#spec), o = /** @type {any} */ (s.options)
|
|
129
|
+
if (o.description) return String(o.description)
|
|
130
|
+
// each field once, though it may set more than one channel (z and colour by region)
|
|
131
|
+
const by = [...new Set(['x', 'y', 'z', 'color', 'size'].map((k) => /** @type {any} */ (s.channels)[k]).filter((c) => c?.field).map((c) => c.title))]
|
|
132
|
+
const count = (n, one) => `${n} ${n === 1 ? one : `${one}s`}`
|
|
133
|
+
const what = `${s.mark} chart of ${count(s.rows.length, 'row')}${by.length ? ` by ${by.join(', ')}` : ''}${s.links.length ? ` and ${count(s.links.length, 'link')}` : ''}`
|
|
134
|
+
return o.title ? `${o.title}: ${what}` : what
|
|
135
|
+
}
|
|
136
|
+
|
|
121
137
|
/** @internal @returns {Record<string, unknown>} */
|
|
122
138
|
jsonOptions() {
|
|
123
139
|
return { ...this.#input }
|
|
@@ -305,14 +321,16 @@ export class Chart extends Group {
|
|
|
305
321
|
|
|
306
322
|
/** @param {number} dt */
|
|
307
323
|
#step(dt) {
|
|
308
|
-
|
|
324
|
+
// with motion reduced (the View's reducedMotion), flows stand still and a change shows at once
|
|
325
|
+
const still = /** @type {any} */ (this.root).motionReduced === true
|
|
326
|
+
if (!still) this.#mark?.tick?.(dt)
|
|
309
327
|
const tw = this.#tween
|
|
310
328
|
if (!tw) return
|
|
311
329
|
const o = this.#input.transition ?? {}
|
|
312
330
|
const duration = o.duration ?? 0.5
|
|
313
331
|
const ease = EASES[o.ease ?? 'out']
|
|
314
332
|
if (!ease) fail('chart()', `transition ease '${o.ease}' - expected ${Object.keys(EASES).map((e) => `'${e}'`).join(', ')}`)
|
|
315
|
-
tw.t = Math.min(1, tw.t + dt / duration)
|
|
333
|
+
tw.t = still ? 1 : Math.min(1, tw.t + dt / duration)
|
|
316
334
|
const k = ease(tw.t)
|
|
317
335
|
const state = new Float32Array(tw.from.length)
|
|
318
336
|
for (let i = 0; i < state.length; i++) state[i] = tw.from[i] + (tw.to[i] - tw.from[i]) * k
|
package/src/charts/marks.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// lay out and draw, never animate.
|
|
4
4
|
import { Geometry } from '../geometry/Geometry.js'
|
|
5
5
|
import { box as boxGeometry, icosphere } from '../geometry/generators.js'
|
|
6
|
+
import { Texture } from '../material/Texture.js'
|
|
6
7
|
import { InstancedMesh, Lines, Mesh, Points } from '../scene/drawables.js'
|
|
7
8
|
import { fail } from '../validate.js'
|
|
8
9
|
import { distinct } from './scales.js'
|
|
@@ -154,8 +155,10 @@ export function pointMark(ctx) {
|
|
|
154
155
|
/** @type {any} */
|
|
155
156
|
let node = sized
|
|
156
157
|
? new InstancedMesh({ geometry: icosphere({ radius: 1, detail: 1 }), material, count: 64, labels: labelsOf(ctx, 'above') })
|
|
157
|
-
|
|
158
|
-
|
|
158
|
+
// dots with a darker rim, so each one reads where they crowd together - drawn furthest first, so
|
|
159
|
+
// each blends over the ones behind it
|
|
160
|
+
: new Points({ geometry: emptyPoints(64), material: { size: ctx.spec.channels.size?.value ?? unit * 1.4, vertexColors: true, map: dot(), depthWrite: false, ...material },
|
|
161
|
+
sort: true, labels: labelsOf(ctx, 'above') })
|
|
159
162
|
if (sized) node.count = 0
|
|
160
163
|
const mark = {
|
|
161
164
|
nodes: [node],
|
|
@@ -205,6 +208,30 @@ export function pointMark(ctx) {
|
|
|
205
208
|
return mark
|
|
206
209
|
}
|
|
207
210
|
|
|
211
|
+
/** @type {Texture | null} */
|
|
212
|
+
let dotTexture = null
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A dot: a white disc whose outer part darkens to a rim, with a soft edge - pixels of its own, so
|
|
216
|
+
* charts need no canvas to make it. Shared by every scatter.
|
|
217
|
+
*/
|
|
218
|
+
function dot() {
|
|
219
|
+
if (dotTexture) return dotTexture
|
|
220
|
+
const size = 64, data = new Uint8Array(size * size * 4), r = size / 2
|
|
221
|
+
for (let y = 0; y < size; y++) {
|
|
222
|
+
for (let x = 0; x < size; x++) {
|
|
223
|
+
const d = Math.hypot(x + 0.5 - r, y + 0.5 - r) / r
|
|
224
|
+
// the colour full to 0.78 of the radius, then down to 0.62 at the edge; alpha soft over the last pixel and a half
|
|
225
|
+
const shade = d < 0.78 ? 1 : 1 - (d - 0.78) / 0.22 * 0.38
|
|
226
|
+
const alpha = Math.min(1, Math.max(0, (1 - d) * r / 1.5))
|
|
227
|
+
const o = (y * size + x) * 4
|
|
228
|
+
data[o] = data[o + 1] = data[o + 2] = Math.round(shade * 255)
|
|
229
|
+
data[o + 3] = Math.round(alpha * 255)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return (dotTexture = new Texture({ image: { data, width: size, height: size } }))
|
|
233
|
+
}
|
|
234
|
+
|
|
208
235
|
/** @param {number} n */
|
|
209
236
|
function emptyPoints(n) {
|
|
210
237
|
const g = new Geometry({ positions: new Float32Array(n * 3), colors: new Float32Array(n * 3) })
|
package/src/explain.js
CHANGED
|
@@ -22,7 +22,7 @@ import { Box3 } from './value/Box3.js'
|
|
|
22
22
|
* @property {boolean} autoLight
|
|
23
23
|
* @property {boolean} contextLost
|
|
24
24
|
* @property {boolean} [sharedContext] drawn with the page's shared WebGL context
|
|
25
|
-
* @property {{ hover: number, click: number, tooltip: boolean, highlight: boolean }} interaction
|
|
25
|
+
* @property {{ hover: number, click: number, tooltip: boolean, highlight: boolean, keyboard?: boolean, reducedMotion?: boolean, description?: string | null }} interaction
|
|
26
26
|
* @property {string[]} [notes] lines from nodes that describe themselves, such as charts
|
|
27
27
|
*/
|
|
28
28
|
|
|
@@ -115,7 +115,7 @@ export function explain(info) {
|
|
|
115
115
|
|
|
116
116
|
if (info.labels && info.labels.total) {
|
|
117
117
|
const s = info.labels
|
|
118
|
-
lines.push(`Labels: ${s.shown} shown, ${s.dimmed ? `${s.dimmed} dimmed (covered by labels or geometry), ` : ''}${s.overlapped} hidden by overlap, ${s.occluded} hidden behind geometry, ${s.offscreen} off screen`)
|
|
118
|
+
lines.push(`Labels: ${s.shown} shown, ${s.dimmed ? `${s.dimmed} dimmed (covered by labels or geometry), ` : ''}${s.overlapped} hidden by overlap, ${s.occluded} hidden behind geometry, ${s.offscreen} off screen${s.zoomedOut ? `, ${s.zoomedOut} waiting to be zoomed in to (minScale)` : ''}`)
|
|
119
119
|
if (s.overlapped > s.shown * 3) warnings.push(`${s.overlapped} labels are hidden by overlap - give the important ones a higher priority, label fewer things, or use overlap: 'dim' or 'show'`)
|
|
120
120
|
if (s.offscreen === s.total) warnings.push('every label is off screen - check their positions, or call view.frame()')
|
|
121
121
|
}
|
|
@@ -125,6 +125,10 @@ export function explain(info) {
|
|
|
125
125
|
lines.push(`Interaction: ${[ix.hover && plural(ix.hover, 'hover handler'), ix.click && plural(ix.click, 'click handler'),
|
|
126
126
|
ix.tooltip && 'tooltip', ix.highlight && 'hover highlight'].filter(Boolean).join(', ')}`)
|
|
127
127
|
}
|
|
128
|
+
if (ix.keyboard || ix.reducedMotion || ix.description) {
|
|
129
|
+
lines.push(`Accessibility: ${[ix.keyboard && 'keyboard (arrow keys step through what can be hovered)', ix.reducedMotion && 'reduced motion',
|
|
130
|
+
ix.description && `described as "${ix.description}"`].filter(Boolean).join(', ')}`)
|
|
131
|
+
}
|
|
128
132
|
|
|
129
133
|
if (info.width < 2 || info.height < 2) warnings.unshift(`the canvas is ${info.width} x ${info.height} - give it a size in the page (CSS width and height)`)
|
|
130
134
|
if (info.contextLost) warnings.unshift('the WebGL context is lost - nothing draws until the browser restores it')
|
package/src/frame.js
CHANGED
|
@@ -6,6 +6,7 @@ import { Box3 } from './value/Box3.js'
|
|
|
6
6
|
import { warnOnce } from './validate.js'
|
|
7
7
|
import { fitPointShadow, fitShadow, fitSpotShadow, SPOT_TILES } from './shadow.js'
|
|
8
8
|
import { worldPlane } from './clip.js'
|
|
9
|
+
import { hiddenInstance } from './scene/drawables.js'
|
|
9
10
|
|
|
10
11
|
/** @import { Scene } from './scene/Scene.js' */
|
|
11
12
|
/** @import { Camera } from './scene/cameras.js' */
|
|
@@ -89,6 +90,8 @@ export const LIGHT_LIMITS = { punctual: 4, directional: 2 }
|
|
|
89
90
|
* @property {[number, number]} ndc [x, y] in -1..1
|
|
90
91
|
* @property {number} depth
|
|
91
92
|
* @property {boolean} visible in front of the camera and inside the view
|
|
93
|
+
* @property {number} scale how far zoomed in at the point: half the view's height, in world units, is 1 / scale -
|
|
94
|
+
* the layer turns it into pixels to one world unit, for minScale
|
|
92
95
|
*/
|
|
93
96
|
|
|
94
97
|
/**
|
|
@@ -239,13 +242,13 @@ export function collect(scene, camera) {
|
|
|
239
242
|
else capped.set(item.clip, [item])
|
|
240
243
|
}
|
|
241
244
|
frame.stats.drawn++
|
|
242
|
-
if (node.labels) instanceLabels(node, viewProjection, view, frame.labels)
|
|
245
|
+
if (node.labels) instanceLabels(node, viewProjection, view, projection[5], frame.labels)
|
|
243
246
|
} else {
|
|
244
247
|
frame.stats.culled++
|
|
245
248
|
}
|
|
246
249
|
} else if (node.isLabel) {
|
|
247
250
|
const position = node.worldPosition
|
|
248
|
-
frame.labels.push({ key: node, node, index: null, text: node.text, style: node, position, ...project(position, viewProjection, view) })
|
|
251
|
+
frame.labels.push({ key: node, node, index: null, text: node.text, style: node, position, ...project(position, viewProjection, view, projection[5]) })
|
|
249
252
|
} else if (node.lightKind) {
|
|
250
253
|
addLight(/** @type {Light} */ (node))
|
|
251
254
|
}
|
|
@@ -323,7 +326,7 @@ export function collect(scene, camera) {
|
|
|
323
326
|
if (items.length && sun >= 0) {
|
|
324
327
|
const light = frame.lights[sun]
|
|
325
328
|
const bounds = (/** @type {any} */ n) => n.expandBounds(new Box3())
|
|
326
|
-
const fit = fitShadow(/** @type {Vec3} */ (light.direction), items.map((c) => bounds(c.node)), receivers.map(bounds))
|
|
329
|
+
const fit = fitShadow(/** @type {Vec3} */ (light.direction), items.map((c) => bounds(c.node)), receivers.map(bounds), undefined, /** @type {any} */ (light.node).shadowBounds)
|
|
327
330
|
if (fit) {
|
|
328
331
|
frame.lights.splice(sun, 1)
|
|
329
332
|
frame.lights.unshift(light)
|
|
@@ -392,10 +395,22 @@ let nextId = 1
|
|
|
392
395
|
|
|
393
396
|
/**
|
|
394
397
|
* Labels for every instance or point that has text, from the node's `labels` option.
|
|
395
|
-
* @param {any} node InstancedMesh or Points @param {Mat4} viewProjection @param {Mat4} view
|
|
398
|
+
* @param {any} node InstancedMesh or Points @param {Mat4} viewProjection @param {Mat4} view
|
|
399
|
+
* @param {number} yScale the projection's vertical scale @param {FrameLabel[]} out
|
|
396
400
|
*/
|
|
397
|
-
function instanceLabels(node, viewProjection, view, out) {
|
|
398
|
-
|
|
401
|
+
function instanceLabels(node, viewProjection, view, yScale, out) {
|
|
402
|
+
// several labels per instance: the first keeps the plain key, so a single set's labels don't change identity
|
|
403
|
+
if (Array.isArray(node.labels)) {
|
|
404
|
+
node.labels.forEach((labels, layer) => instanceLabelSet(node, labels, layer, viewProjection, view, yScale, out))
|
|
405
|
+
} else instanceLabelSet(node, node.labels, 0, viewProjection, view, yScale, out)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* @param {any} node @param {any} labels one set of instance labels @param {number} layer its place in the list
|
|
410
|
+
* @param {Mat4} viewProjection @param {Mat4} view @param {number} yScale @param {FrameLabel[]} out
|
|
411
|
+
*/
|
|
412
|
+
function instanceLabelSet(node, labels, layer, viewProjection, view, yScale, out) {
|
|
413
|
+
const { worldMatrix: w } = node
|
|
399
414
|
let id = ids.get(node)
|
|
400
415
|
if (id === undefined) ids.set(node, id = nextId++)
|
|
401
416
|
const instanced = 'instanceMatrices' in node
|
|
@@ -409,6 +424,8 @@ function instanceLabels(node, viewProjection, view, out) {
|
|
|
409
424
|
const data = Array.isArray(node.data) ? node.data : null
|
|
410
425
|
const byRow = typeof labels.priority === 'function'
|
|
411
426
|
for (let i = 0; i < n; i++) {
|
|
427
|
+
// an instance scaled to nothing is hidden, and so is its label
|
|
428
|
+
if (instanced && hiddenInstance(node.instanceMatrices, i)) continue
|
|
412
429
|
const row = data ? data[i] : null
|
|
413
430
|
const text = labels.text(row, i)
|
|
414
431
|
if (text === null || text === undefined || text === '') continue
|
|
@@ -427,7 +444,7 @@ function instanceLabels(node, viewProjection, view, out) {
|
|
|
427
444
|
w[1] * x + w[5] * y + w[9] * z + w[13],
|
|
428
445
|
w[2] * x + w[6] * y + w[10] * z + w[14])
|
|
429
446
|
const style = byRow ? { ...labels, priority: labels.priority(row, i) } : labels
|
|
430
|
-
out.push({ key: `${id}:${i}`, node, index: i, text: String(text), style, position, ...project(position, viewProjection, view) })
|
|
447
|
+
out.push({ key: layer ? `${id}:${i}:${layer}` : `${id}:${i}`, node, index: i, text: String(text), style, position, ...project(position, viewProjection, view, yScale) })
|
|
431
448
|
}
|
|
432
449
|
}
|
|
433
450
|
|
|
@@ -453,10 +470,13 @@ export function toScreen(position, viewProjection) {
|
|
|
453
470
|
* units, for both kinds of camera (an orthographic one's clip w is always 1).
|
|
454
471
|
* @param {Vec3} position @param {Mat4} viewProjection @param {Mat4} view
|
|
455
472
|
*/
|
|
456
|
-
function project(position, viewProjection, view) {
|
|
473
|
+
function project(position, viewProjection, view, yScale) {
|
|
457
474
|
const { ndc, visible } = toScreen(position, viewProjection)
|
|
458
475
|
const depth = -(view[2] * position[0] + view[6] * position[1] + view[10] * position[2] + view[14])
|
|
459
|
-
|
|
476
|
+
// clip w at the point: its depth for a perspective camera, 1 for an orthographic one
|
|
477
|
+
const m = viewProjection
|
|
478
|
+
const w = m[3] * position[0] + m[7] * position[1] + m[11] * position[2] + m[15]
|
|
479
|
+
return { ndc: /** @type {[number, number]} */ ([ndc[0], ndc[1]]), depth, visible, scale: w > 0 ? yScale / w : 0 }
|
|
460
480
|
}
|
|
461
481
|
|
|
462
482
|
// The six planes (a, b, c, d with the normal pointing inwards) of a view-projection matrix.
|
package/src/geometry/Geometry.js
CHANGED
|
@@ -9,10 +9,16 @@ import { checkOptions, describe, fail } from '../validate.js'
|
|
|
9
9
|
* @property {ArrayLike<number>} [uvs2] a second uv per vertex, for maps that need every surface on its own
|
|
10
10
|
* patch of texture - baked occlusion and light maps (a texture's uvSet: 1 reads it)
|
|
11
11
|
* @property {ArrayLike<number>} [colors] rgb per vertex, sRGB 0-1 like every colour input
|
|
12
|
+
* @property {ArrayLike<number>} [sizes] points: one per vertex, a multiple of the material's size
|
|
13
|
+
* @property {ArrayLike<number>} [opacities] points: one per vertex, 0-1, multiplying the material's opacity
|
|
14
|
+
* @property {ArrayLike<number>} [angles] points: one per vertex, radians anticlockwise on screen - turns
|
|
15
|
+
* each point's texture
|
|
12
16
|
* @property {ArrayLike<number>} [indices] vertex indices; triangles for meshes, pairs for lines
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
|
-
const OPTIONS = ['positions', 'normals', 'uvs', 'uvs2', 'colors', 'indices']
|
|
19
|
+
const OPTIONS = ['positions', 'normals', 'uvs', 'uvs2', 'colors', 'sizes', 'opacities', 'angles', 'indices']
|
|
20
|
+
// the per-point arrays: Points with any of them draw each point as its own square, sized, faded and turned
|
|
21
|
+
const POINT_ARRAYS = /** @type {const} */ (['sizes', 'opacities', 'angles'])
|
|
16
22
|
|
|
17
23
|
/**
|
|
18
24
|
* Vertex data: flat typed arrays plus an optional index. Generators such as box() and sphere()
|
|
@@ -25,6 +31,9 @@ export class Geometry {
|
|
|
25
31
|
/** @type {Float32Array | null} */ uvs = null
|
|
26
32
|
/** A second UV set, for baked occlusion and light maps. @type {Float32Array | null} */ uvs2 = null
|
|
27
33
|
/** @type {Float32Array | null} */ colors = null
|
|
34
|
+
/** Points: each one's size, as a multiple of the material's. @type {Float32Array | null} */ sizes = null
|
|
35
|
+
/** Points: each one's opacity, 0-1, times the material's. @type {Float32Array | null} */ opacities = null
|
|
36
|
+
/** Points: each one's turn on screen, in radians anticlockwise. @type {Float32Array | null} */ angles = null
|
|
28
37
|
/** @type {Uint16Array | Uint32Array | null} */ indices = null
|
|
29
38
|
|
|
30
39
|
/** @internal Bumped by needsUpdate() so renderers know to re-upload. */
|
|
@@ -45,6 +54,8 @@ export class Geometry {
|
|
|
45
54
|
|
|
46
55
|
/** @type {Float32Array | null} */
|
|
47
56
|
#normals = null
|
|
57
|
+
/** The normals were worked out on first read, rather than given or asked for: they follow the shape. */
|
|
58
|
+
#autoNormals = false
|
|
48
59
|
/** @type {Box3 | null} */
|
|
49
60
|
#bounds = null
|
|
50
61
|
/** @type {Set<() => void>} */
|
|
@@ -60,6 +71,7 @@ export class Geometry {
|
|
|
60
71
|
if (options.uvs) this.uvs = toFloat32('uvs', options.uvs, 2, n)
|
|
61
72
|
if (options.uvs2) this.uvs2 = toFloat32('uvs2', options.uvs2, 2, n)
|
|
62
73
|
if (options.colors) this.colors = toFloat32('colors', options.colors, 3, n)
|
|
74
|
+
for (const name of POINT_ARRAYS) if (options[name]) this[name] = toFloat32(name, options[name], 1, n)
|
|
63
75
|
if (options.indices) {
|
|
64
76
|
// -1: no indices at all refer to no vertex, so an empty geometry can say so
|
|
65
77
|
let max = -1
|
|
@@ -75,16 +87,22 @@ export class Geometry {
|
|
|
75
87
|
|
|
76
88
|
/**
|
|
77
89
|
* xyz per vertex. Computed (smooth) from the triangles on first read when not supplied, so
|
|
78
|
-
* points and lines - whose geometry has no triangles - never pay for them
|
|
90
|
+
* points and lines - whose geometry has no triangles - never pay for them - and computed again after
|
|
91
|
+
* needsUpdate(), so they follow positions edited in place. Normals that were supplied, assigned or
|
|
92
|
+
* made by calling computeNormals() are kept as they are.
|
|
79
93
|
* @type {Float32Array}
|
|
80
94
|
*/
|
|
81
95
|
get normals() {
|
|
82
|
-
if (!this.#normals)
|
|
96
|
+
if (!this.#normals) {
|
|
97
|
+
this.computeNormals()
|
|
98
|
+
this.#autoNormals = true
|
|
99
|
+
}
|
|
83
100
|
return /** @type {Float32Array} */ (this.#normals)
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
set normals(v) {
|
|
87
104
|
this.#normals = v
|
|
105
|
+
this.#autoNormals = false
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
/** True once normals exist, supplied or computed. */
|
|
@@ -121,9 +139,14 @@ export class Geometry {
|
|
|
121
139
|
return this.#bounds ??= new Box3().expandByPositions(this.positions)
|
|
122
140
|
}
|
|
123
141
|
|
|
124
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* Call after editing the arrays in place. It is no longer what its generator made, so toJSON() saves
|
|
144
|
+
* the arrays; normals that were worked out on first read are worked out again on the next.
|
|
145
|
+
* @returns {this}
|
|
146
|
+
*/
|
|
125
147
|
needsUpdate() {
|
|
126
148
|
this.source = null
|
|
149
|
+
if (this.#autoNormals) this.#normals = null
|
|
127
150
|
return this.#changed()
|
|
128
151
|
}
|
|
129
152
|
|
|
@@ -178,6 +201,9 @@ export class Geometry {
|
|
|
178
201
|
uvs: this.uvs ?? undefined,
|
|
179
202
|
uvs2: this.uvs2 ?? undefined,
|
|
180
203
|
colors: this.colors ?? undefined,
|
|
204
|
+
sizes: this.sizes ?? undefined,
|
|
205
|
+
opacities: this.opacities ?? undefined,
|
|
206
|
+
angles: this.angles ?? undefined,
|
|
181
207
|
indices: this.indices ?? undefined,
|
|
182
208
|
})
|
|
183
209
|
g.source = this.source && { ...this.source }
|
|
@@ -205,6 +231,7 @@ export class Geometry {
|
|
|
205
231
|
if (this.uvs) json.uvs = Array.from(this.uvs)
|
|
206
232
|
if (this.uvs2) json.uvs2 = Array.from(this.uvs2)
|
|
207
233
|
if (this.colors) json.colors = Array.from(this.colors)
|
|
234
|
+
for (const name of POINT_ARRAYS) if (this[name]) json[name] = Array.from(/** @type {Float32Array} */ (this[name]))
|
|
208
235
|
if (this.indices) json.indices = Array.from(this.indices)
|
|
209
236
|
return json
|
|
210
237
|
}
|
|
@@ -229,6 +256,7 @@ export class Geometry {
|
|
|
229
256
|
this.uvs = expand(this.uvs, 2)
|
|
230
257
|
this.uvs2 = expand(this.uvs2, 2)
|
|
231
258
|
this.colors = expand(this.colors, 3)
|
|
259
|
+
for (const name of POINT_ARRAYS) this[name] = expand(this[name], 1)
|
|
232
260
|
this.indices = null
|
|
233
261
|
this.source = null
|
|
234
262
|
}
|
package/src/index.js
CHANGED
|
@@ -50,6 +50,7 @@ export { PhoriaError } from './validate.js'
|
|
|
50
50
|
/** @typedef {import('./presets.js').CameraPreset} CameraPreset */
|
|
51
51
|
/** @typedef {import('./tooltip.js').TooltipContent} TooltipContent */
|
|
52
52
|
/** @typedef {import('./Raycaster.js').Hit} Hit */
|
|
53
|
+
/** @typedef {import('./area.js').PickArea} PickArea */
|
|
53
54
|
/** @typedef {import('./scene/drawables.js').LabelPlacement} LabelPlacement */
|
|
54
55
|
/** @typedef {import('./scene/drawables.js').LabelPlace} LabelPlace */
|
|
55
56
|
/** @typedef {import('./scene/drawables.js').InstanceLabels} InstanceLabels */
|