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
@@ -1,10 +1,11 @@
1
1
  import { Color } from '../value/Color.js'
2
2
  import { Vec3 } from '../value/Vec3.js'
3
- import { checkNumber, fail } from '../validate.js'
3
+ import { Box3 } from '../value/Box3.js'
4
+ import { checkNumber, describe, fail } from '../validate.js'
4
5
  import { NODE_OPTIONS, Node, inParentSpace, setPoint } from './Node.js'
5
6
 
6
7
  /** @import { NodeOptions } from './Node.js' */
7
- /** @import { ColorInput, Vec3Input } from '../types.js' */
8
+ /** @import { Box3Input, ColorInput, Vec3Input } from '../types.js' */
8
9
 
9
10
  /** @typedef {NodeOptions & { color?: ColorInput, intensity?: number }} LightOptions */
10
11
  /** @typedef {'ambient' | 'hemisphere' | 'directional' | 'point' | 'spot'} LightKind */
@@ -189,24 +190,57 @@ export class DirectionalLight extends AimedLight {
189
190
  static type = 'directional-light'
190
191
  /** @internal @type {LightKind} */
191
192
  static kind = 'directional'
192
- static get OPTIONS() { return [...LIGHT_OPTIONS, 'direction', 'target', 'castShadow'] }
193
+ static get OPTIONS() { return [...LIGHT_OPTIONS, 'direction', 'target', 'castShadow', 'shadowBounds'] }
193
194
 
194
195
  castShadow = false
196
+ /** @type {Box3 | null} */
197
+ #shadowBounds = null
195
198
 
196
- /** @param {AimedLightOptions & { castShadow?: boolean }} [options] */
199
+ /** @param {AimedLightOptions & { castShadow?: boolean, shadowBounds?: Box3Input | null }} [options] */
197
200
  constructor(options = {}) {
198
201
  super(options)
199
202
  if (options.castShadow !== undefined) this.castShadow = !!options.castShadow
203
+ if (options.shadowBounds !== undefined) this.#setShadowBounds(options.shadowBounds)
204
+ }
205
+
206
+ /**
207
+ * The part of the world, in world units, that has shadows - or null (the default) to follow
208
+ * whatever casts them. The shadow map is fitted to what casts every frame, so one caster flung far
209
+ * off spreads its texels over everything and every shadow blurs; set the area where things happen
210
+ * and its shadows stay sharp. Casters outside it still shadow it, from wherever they stand.
211
+ * Assigning copies.
212
+ * @returns {Box3 | null}
213
+ */
214
+ get shadowBounds() { return this.#shadowBounds }
215
+
216
+ /** @param {Box3Input | null} v */
217
+ set shadowBounds(v) { this.#setShadowBounds(v) }
218
+
219
+ /** @param {Box3Input | null} v */
220
+ #setShadowBounds(v) {
221
+ if (v === null) {
222
+ this.#shadowBounds = null
223
+ return
224
+ }
225
+ const b = /** @type {any} */ (v)
226
+ if (!b || typeof b !== 'object' || !b.min || !b.max) {
227
+ fail(this.constructor.name, `'shadowBounds' is a box - { min: [x, y, z], max: [x, y, z] } or a Box3 - or null, got ${describe(v)}`)
228
+ }
229
+ const box = new Box3(Vec3.fromInput(b.min, this.constructor.name), Vec3.fromInput(b.max, this.constructor.name))
230
+ if (box.isEmpty()) fail(this.constructor.name, `'shadowBounds' min must not be above max on any axis, got ${describe(v)}`)
231
+ this.#shadowBounds = box
200
232
  }
201
233
 
202
234
  /** @internal @returns {Record<string, unknown>} */
203
235
  cloneOptions() {
204
- return { ...super.cloneOptions(), castShadow: this.castShadow }
236
+ return { ...super.cloneOptions(), castShadow: this.castShadow, shadowBounds: this.#shadowBounds }
205
237
  }
206
238
 
207
239
  /** @internal @returns {Record<string, unknown>} */
208
240
  jsonOptions() {
209
- return { ...super.jsonOptions(), ...(this.castShadow ? { castShadow: true } : {}) }
241
+ const b = this.#shadowBounds
242
+ return { ...super.jsonOptions(), ...(this.castShadow ? { castShadow: true } : {}),
243
+ ...(b ? { shadowBounds: { min: Array.from(b.min), max: Array.from(b.max) } } : {}) }
210
244
  }
211
245
  }
212
246
 
package/src/shadow.js CHANGED
@@ -23,9 +23,10 @@ export const SHADOW_MAP_SIZE = 2048
23
23
  * @param {Box3[]} casters world bounds
24
24
  * @param {Box3[]} receivers world bounds
25
25
  * @param {number} [size] map size in texels
26
+ * @param {Box3 | null} [area] the light's shadowBounds: the map covers this across, whatever casts
26
27
  * @returns {ShadowFit | null}
27
28
  */
28
- export function fitShadow(direction, casters, receivers, size = SHADOW_MAP_SIZE) {
29
+ export function fitShadow(direction, casters, receivers, size = SHADOW_MAP_SIZE, area = null) {
29
30
  const boxes = casters.filter((b) => !b.isEmpty())
30
31
  if (!boxes.length) return null
31
32
  const d = normalized(direction)
@@ -50,7 +51,10 @@ export function fitShadow(direction, casters, receivers, size = SHADOW_MAP_SIZE)
50
51
  if (z > z1) z1 = z
51
52
  }
52
53
  }
53
- for (const b of boxes) corners(b, true)
54
+ // across the light's view: the area it was given, or else the casters; in depth, everything that
55
+ // casts - so a caster outside the area, between it and the light, still shadows it
56
+ if (area) corners(area, true)
57
+ for (const b of boxes) corners(b, !area)
54
58
  // receivers only stretch the depth (view z is negative in front, so the far end is the minimum)
55
59
  for (const b of receivers) if (!b.isEmpty()) corners(b, false)
56
60
 
@@ -58,10 +62,29 @@ export function fitShadow(direction, casters, receivers, size = SHADOW_MAP_SIZE)
58
62
  const margin = Math.max(x1 - x0, y1 - y0, 1e-3) * 0.02
59
63
  x0 -= margin; x1 += margin; y0 -= margin; y1 += margin
60
64
  const depthMargin = Math.max(z1 - z0, 1e-3) * 0.01
61
- const projection = mat4.ortho(mat4.create(), x0, x1, y0, y1, -z1 - depthMargin, -z0 + depthMargin)
65
+ z0 -= depthMargin; z1 += depthMargin
66
+
67
+ // steady while things move: the map grows and shrinks in small steps rather than with every change
68
+ // in the casters' extent, and its edges sit on the texel grid, so a shadow that isn't moving falls
69
+ // on the same texels every frame, instead of shimmering as the fit slides about under it
70
+ const w = steps(x1 - x0), h = steps(y1 - y0)
71
+ const tx = w / size, ty = h / size
72
+ x0 = Math.floor((x0 + x1 - w) / 2 / tx) * tx
73
+ y0 = Math.floor((y0 + y1 - h) / 2 / ty) * ty
74
+ // the depth range too, which sets what each stored depth means
75
+ const dz = steps(z1 - z0) / 64
76
+ z0 = Math.floor(z0 / dz) * dz
77
+ z1 = Math.ceil(z1 / dz) * dz
78
+
79
+ const projection = mat4.ortho(mat4.create(), x0, x0 + w, y0, y0 + h, -z1, -z0)
62
80
  const viewProjection = new Mat4()
63
81
  mat4.multiply(viewProjection, projection, view)
64
- return { viewProjection, texelSize: Math.max(x1 - x0, y1 - y0) / size }
82
+ return { viewProjection, texelSize: Math.max(w, h) / size }
83
+ }
84
+
85
+ /** A length rounded up to the next of a ladder of sizes, sixteen to each doubling. @param {number} v */
86
+ function steps(v) {
87
+ return 2 ** (Math.ceil(Math.log2(Math.max(v, 1e-3)) * 16) / 16)
65
88
  }
66
89
 
67
90
  /** Spot shadows' map: four tiles of half its size, one a light. */
package/src/types.js CHANGED
@@ -9,6 +9,12 @@
9
9
 
10
10
  /** A vector as accepted on input: [x, y, z], { x, y, z } or a Vec3. @typedef {Vec3Like | XYZ} Vec3Input */
11
11
 
12
+ /** A quaternion as an object literal - as physics engines such as Rapier return them. @typedef {{ x: number, y: number, z: number, w: number }} XYZW */
13
+
14
+ /** A rotation quaternion as accepted on input: [x, y, z, w], { x, y, z, w } or a Quat. @typedef {ArrayLike<number> | XYZW} QuatInput */
15
+
16
+ /** A box as accepted on input: a Box3, or its lowest and highest corners. @typedef {import('./value/Box3.js').Box3 | { min: Vec3Input, max: Vec3Input }} Box3Input */
17
+
12
18
  /**
13
19
  * A colour as accepted on input, in sRGB: '#ff8800', '#f80', 0xff8800, 'rgb(255 136 0)',
14
20
  * 'hsl(32 100% 50%)', a CSS colour name, or [r, g, b] with components 0-1.
@@ -16,6 +16,11 @@ const HELP = "use '#rrggbb', a CSS name, 'rgb(...)', 'hsl(...)', 0xrrggbb or [r,
16
16
  * An RGB colour. Input is sRGB, as everywhere in phoria2; the components are stored **linear**,
17
17
  * ready for lighting maths. `toJSON()` gives sRGB '#rrggbb' back.
18
18
  */
19
+ // colour strings already worked out, as linear rgb - emptied when full, as a palette never fills it
20
+ /** @type {Map<string, number[]>} */
21
+ const PARSED = new Map()
22
+ const PARSED_MAX = 1024
23
+
19
24
  export class Color extends Float32Array {
20
25
  static get [Symbol.species]() { return Float32Array }
21
26
 
@@ -154,6 +159,22 @@ export class Color extends Float32Array {
154
159
 
155
160
  /** @param {string} s @param {string} owner */
156
161
  #setString(s, owner) {
162
+ // the same few strings come again and again - an instance's colour set every frame - so each is
163
+ // worked out once
164
+ const known = PARSED.get(s)
165
+ if (known) {
166
+ this[0] = known[0]
167
+ this[1] = known[1]
168
+ this[2] = known[2]
169
+ return
170
+ }
171
+ this.#parse(s, owner)
172
+ if (PARSED.size >= PARSED_MAX) PARSED.clear()
173
+ PARSED.set(s, [this[0], this[1], this[2]])
174
+ }
175
+
176
+ /** @param {string} s @param {string} owner */
177
+ #parse(s, owner) {
157
178
  const lower = s.toLowerCase()
158
179
  if (lower.startsWith('#')) {
159
180
  let hex = lower.slice(1)
package/src/value/Quat.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as quat from '../math/quat.js'
2
2
  import { describe, fail } from '../validate.js'
3
3
 
4
+ /** @import { QuatInput } from '../types.js' */
5
+
4
6
  /**
5
7
  * A rotation quaternion [x, y, z, w]: a Float32Array(4) with accessors and chainable methods.
6
8
  * A node's quaternion and its Euler `rotation` keep each other in sync through these setters;
@@ -61,13 +63,20 @@ export class Quat extends Float32Array {
61
63
  return this.changed()
62
64
  }
63
65
 
64
- /** @param {ArrayLike<number>} q [x, y, z, w] @param {string} [owner] @returns {this} */
66
+ /** @param {QuatInput} q [x, y, z, w], or { x, y, z, w } as physics engines give them @param {string} [owner] @returns {this} */
65
67
  copy(q, owner = 'Quat') {
66
- if (!q || typeof q !== 'object' || !(q.length >= 4)) fail(owner, `expected a quaternion [x, y, z, w], got ${describe(q)}`)
67
- this[0] = q[0]
68
- this[1] = q[1]
69
- this[2] = q[2]
70
- this[3] = q[3]
68
+ const o = /** @type {any} */ (q)
69
+ if (o && typeof o === 'object' && o.length >= 4) {
70
+ this[0] = o[0]
71
+ this[1] = o[1]
72
+ this[2] = o[2]
73
+ this[3] = o[3]
74
+ } else if (o && typeof o === 'object' && 'x' in o && 'y' in o && 'z' in o && 'w' in o) {
75
+ this[0] = o.x
76
+ this[1] = o.y
77
+ this[2] = o.z
78
+ this[3] = o.w
79
+ } else fail(owner, `expected a quaternion [x, y, z, w] or { x, y, z, w }, got ${describe(q)}`)
71
80
  return this.changed()
72
81
  }
73
82
 
package/src/watch.js CHANGED
@@ -123,7 +123,7 @@ export class ChangeWatch {
123
123
  this.#push((node.handlersFor('click').length ? 1 : 0) | (node.handlersFor('hover').length ? 2 : 0) | (node.tooltip ? 4 : 0) | (node.onDrag ? 8 : 0) | (node.highlightWhole ? 16 : 0))
124
124
  // what hovering tints it with - a different colour shows straight away on what is hovered
125
125
  if (node.highlight) this.#pushAll(node.highlight)
126
- else this.#push(-4)
126
+ else this.#push(node.highlight === false ? -5 : -4)
127
127
  const outline = node.outline
128
128
  if (outline) {
129
129
  this.#pushAll(outline.color)
@@ -147,7 +147,11 @@ export class ChangeWatch {
147
147
  this.#push(g.version)
148
148
  this.#push(g.drawCount ?? -1)
149
149
  this.#push(node.renderOrder)
150
- this.#push((node.castShadow ? 1 : 0) | (node.receiveShadow ? 2 : 0))
150
+ this.#push((node.castShadow ? 1 : 0) | (node.receiveShadow ? 2 : 0) | (node.sort ? 4 : 0))
151
+ if ('arrowBits' in node) {
152
+ this.#push(node.arrowBits)
153
+ this.#push(node.arrowSize)
154
+ }
151
155
  // per-instance labels come from these, so swapping either redraws
152
156
  this.#push(node.labels ? idOf(node.labels) : 0)
153
157
  this.#push(node.data && typeof node.data === 'object' ? idOf(node.data) : 0)
@@ -164,6 +168,9 @@ export class ChangeWatch {
164
168
  this.#push(m.metalness)
165
169
  this.#push(m.size)
166
170
  this.#push(m.width)
171
+ this.#push(m.dash)
172
+ this.#push(m.gap)
173
+ this.#push(m.dashOffset)
167
174
  this.#push(m.alphaTest)
168
175
  this.#push(m.normalScale)
169
176
  this.#push(m.aoMapIntensity)
@@ -208,6 +215,7 @@ export class ChangeWatch {
208
215
  this.#push(label.priority)
209
216
  this.#push(label.occlude ? 1 : 0)
210
217
  this.#push(label.leader ? 1 : 0)
218
+ this.#push(label.minScale)
211
219
  }
212
220
 
213
221
  /** @param {any} light */
@@ -218,6 +226,10 @@ export class ChangeWatch {
218
226
  if (light.direction) this.#pushAll(light.direction)
219
227
  if (light.target) this.#pushAll(light.target)
220
228
  if (light.castShadow) this.#push(1)
229
+ if (light.shadowBounds) {
230
+ this.#pushAll(light.shadowBounds.min)
231
+ this.#pushAll(light.shadowBounds.max)
232
+ }
221
233
  if ('range' in light) this.#push(light.range)
222
234
  if ('angle' in light) {
223
235
  this.#push(light.angle)
@@ -117,6 +117,8 @@ export class WebGLRenderer {
117
117
  /** This frame's prefiltered environment, when it is an HDR image. @type {Environment | null} */ #environment = null
118
118
  /** This frame's shadows - directional, spot, point - for the meshes that receive them. @type {import('./passes.js').ShadowMap[]} */ #shadows = []
119
119
  /** @type {Float32Array} */ #normalMatrix = new Float32Array(9)
120
+ /** Model-view for sorting points back to front. */
121
+ #modelView = new Float32Array(16)
120
122
  /** @type {Record<string, any>} */ #lights = {}
121
123
  /** This frame waits for shaders to compile rather than leaving things out. */
122
124
  #wait = false
@@ -407,6 +409,7 @@ export class WebGLRenderer {
407
409
 
408
410
  const clip = kind === 'mesh' ? item.clip : null
409
411
  const defines = this.#defines(kind, material, geometry.colors !== null, !!instances, geometry.uvs2 !== null)
412
+ if (kind === 'points' && /** @type {any} */ (node).drawsSprites) defines.push('SPRITES')
410
413
  for (const shadow of shadows) defines.push(shadow.define)
411
414
  const fog = material.fog ? frame.fog : null
412
415
  if (fog) {
@@ -531,9 +534,18 @@ export class WebGLRenderer {
531
534
  if (kind === 'lines') {
532
535
  gl.disable(gl.CULL_FACE)
533
536
  program.set('uWidth', material.width * this.pixelRatio)
537
+ const arrows = /** @type {any} */ (node).arrowBits ?? 0
538
+ program.set('uArrows', arrows)
539
+ program.set('uArrowSize', /** @type {any} */ (node).arrowPixels?.(material.width) * this.pixelRatio || 0)
540
+ if (material.dash > 0) {
541
+ program.set('uDash', material.dash)
542
+ program.set('uGap', material.gap || material.dash)
543
+ program.set('uDashOffset', material.dashOffset)
544
+ }
534
545
  const buffers = this.#gpu.resources.lines(geometry)
535
546
  gl.bindVertexArray(buffers.vao)
536
- gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, Math.min(buffers.count, Math.floor(geometry.drawnCount / 2)))
547
+ // a quad a segment, and two arrowheads where there are any
548
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, arrows ? 12 : 6, Math.min(buffers.count, Math.floor(geometry.drawnCount / 2)))
537
549
  this.info.drawCalls++
538
550
  return
539
551
  }
@@ -541,9 +553,27 @@ export class WebGLRenderer {
541
553
  if (kind === 'points') {
542
554
  gl.disable(gl.CULL_FACE)
543
555
  program.set('uSize', material.sizeAttenuation ? material.size : material.size * this.pixelRatio)
544
- const buffers = this.#gpu.resources.mesh(geometry, null, false)
545
- gl.bindVertexArray(buffers.vao)
546
- gl.drawArrays(gl.POINTS, 0, geometry.drawnVertices)
556
+ // round points are solid, but their edges are soft: blended over what is behind even in the
557
+ // opaque pass, or the edge fades to black - a dark ring round every dot
558
+ const round = !transparent && !material.map
559
+ if (round) {
560
+ gl.enable(gl.BLEND)
561
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
562
+ }
563
+ if (/** @type {any} */ (node).drawsSprites) {
564
+ // each point a square, any size and turned - drawn back to front when the points ask, for
565
+ // smoke and other soft things that hide what is behind them
566
+ program.set('uResolution', [this.canvas.width, this.canvas.height])
567
+ const modelView = /** @type {any} */ (node).sort ? mat4.multiply(this.#modelView, frame.camera.view, worldMatrix) : null
568
+ const buffers = this.#gpu.resources.sprites(geometry, modelView)
569
+ gl.bindVertexArray(buffers.vao)
570
+ gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, buffers.count)
571
+ } else {
572
+ const buffers = this.#gpu.resources.mesh(geometry, null, false)
573
+ gl.bindVertexArray(buffers.vao)
574
+ gl.drawArrays(gl.POINTS, 0, geometry.drawnVertices)
575
+ }
576
+ if (round) gl.disable(gl.BLEND)
547
577
  this.info.drawCalls++
548
578
  return
549
579
  }
@@ -676,7 +706,8 @@ export class WebGLRenderer {
676
706
  } else if (kind === 'points') {
677
707
  if (material.map) d.push('MAP')
678
708
  if (material.sizeAttenuation) d.push('SIZE_ATTENUATION')
679
- }
709
+ } else if (material.dash > 0) d.push('DASHED')
710
+ // (points drawn as squares add SPRITES - see #draw, which knows the node)
680
711
  return d
681
712
  }
682
713
  }
@@ -18,6 +18,9 @@ const PICK_FRAGMENT = /* glsl */ `
18
18
  precision highp float;
19
19
  precision highp int;
20
20
  flat in uint vPickElement;
21
+ #ifdef SPRITES
22
+ in vec2 vUv;
23
+ #endif
21
24
  #ifdef CLIP
22
25
  in vec3 vWorldPosition;
23
26
  #endif
@@ -30,7 +33,11 @@ void main() {
30
33
  clipAway(vWorldPosition);
31
34
  #endif
32
35
  #ifdef ROUND
36
+ #ifdef SPRITES
37
+ vec2 p = vUv * 2.0 - 1.0;
38
+ #else
33
39
  vec2 p = gl_PointCoord * 2.0 - 1.0;
40
+ #endif
34
41
  if (dot(p, p) > 1.0) discard;
35
42
  #endif
36
43
  pickOut = uvec4(uPickId, vPickElement, floatBitsToUint(gl_FragCoord.z), 1u);
@@ -219,9 +226,11 @@ export class Picker {
219
226
  const kind = mode === 'points' ? 'points' : mode === 'lines' ? 'lines' : 'mesh'
220
227
  const defines = ['PICK']
221
228
  if (instances) defines.push('INSTANCED')
229
+ const sprites = kind === 'points' && /** @type {any} */ (node).drawsSprites
222
230
  if (kind === 'points') {
223
231
  if (material.sizeAttenuation) defines.push('SIZE_ATTENUATION')
224
232
  if (!material.map) defines.push('ROUND')
233
+ if (sprites) defines.push('SPRITES')
225
234
  }
226
235
  if (kind === 'mesh' && item.clip) defines.push('CLIP')
227
236
  const program = this.programs.ready(`pick-${kind}`, defines, SOURCES[kind])
@@ -240,9 +249,13 @@ export class Picker {
240
249
  program.set('uResolution', [REGION, REGION])
241
250
  program.set('uWidth', material.width * pixelRatio)
242
251
  program.set('uNear', frame.camera.near)
252
+ // arrowheads pick as their segment; dashes' gaps do too, so a dashed link is as easy to hit
253
+ const arrows = /** @type {any} */ (node).arrowBits ?? 0
254
+ program.set('uArrows', arrows)
255
+ program.set('uArrowSize', /** @type {any} */ (node).arrowPixels?.(material.width) * pixelRatio || 0)
243
256
  const buffers = this.resources.lines(geometry)
244
257
  gl.bindVertexArray(buffers.vao)
245
- gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, Math.min(buffers.count, Math.floor(geometry.drawnCount / 2)))
258
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, arrows ? 12 : 6, Math.min(buffers.count, Math.floor(geometry.drawnCount / 2)))
246
259
  return
247
260
  }
248
261
  if (kind === 'points') {
@@ -250,6 +263,14 @@ export class Picker {
250
263
  // sizes as drawn: world sizes scale by the real projection, not the zoomed one
251
264
  program.set('uSize', material.sizeAttenuation ? material.size : material.size * pixelRatio)
252
265
  program.set('uScale', frame.camera.projection[5] * height / 2)
266
+ if (sprites) {
267
+ // squares, in whatever order they were last drawn - each says which point it is
268
+ program.set('uResolution', [REGION, REGION])
269
+ const buffers = this.resources.sprites(geometry)
270
+ gl.bindVertexArray(buffers.vao)
271
+ gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, buffers.count)
272
+ return
273
+ }
253
274
  const buffers = this.resources.mesh(geometry, null, false)
254
275
  gl.bindVertexArray(buffers.vao)
255
276
  gl.drawArrays(gl.POINTS, 0, geometry.drawnVertices)