phoria2 1.2.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 (50) hide show
  1. package/README.md +2 -3
  2. package/dist/charts.js +476 -459
  3. package/dist/charts.js.map +1 -1
  4. package/dist/core.js +1192 -963
  5. package/dist/core.js.map +1 -1
  6. package/dist/edit.js +75 -75
  7. package/dist/edit.js.map +1 -1
  8. package/dist/explain.js +1 -1
  9. package/dist/gltf.js +84 -84
  10. package/dist/gltf.js.map +1 -1
  11. package/dist/keyboard.js +188 -0
  12. package/dist/keyboard.js.map +1 -0
  13. package/dist/motion.js +35 -35
  14. package/dist/motion.js.map +1 -1
  15. package/dist/particles.js +191 -118
  16. package/dist/particles.js.map +1 -1
  17. package/dist/passes.js +16 -16
  18. package/dist/passes.js.map +1 -1
  19. package/dist/phoria2.js +1 -1
  20. package/dist/picking.js +30 -14
  21. package/dist/picking.js.map +1 -1
  22. package/dist/snapshot.js +1 -1
  23. package/dist/tooltip.js +1 -1
  24. package/dist/types/View.d.ts +11 -0
  25. package/dist/types/area.d.ts +18 -0
  26. package/dist/types/geometry/Geometry.d.ts +16 -0
  27. package/dist/types/index.d.ts +2 -0
  28. package/dist/types/material/Material.d.ts +15 -0
  29. package/dist/types/material/Texture.d.ts +18 -2
  30. package/dist/types/particles/Emitter.d.ts +39 -2
  31. package/dist/types/scene/drawables.d.ts +23 -2
  32. package/dist/types/webgl/resources.d.ts +12 -2
  33. package/dist/types/webgl/shaders.d.ts +7 -2
  34. package/llms-full.txt +48 -1
  35. package/package.json +1 -1
  36. package/schema/scene.schema.json +48 -0
  37. package/src/View.js +16 -0
  38. package/src/area.js +131 -0
  39. package/src/charts/marks.js +29 -2
  40. package/src/geometry/Geometry.js +16 -1
  41. package/src/index.js +1 -0
  42. package/src/material/Material.js +9 -2
  43. package/src/material/Texture.js +55 -2
  44. package/src/particles/Emitter.js +121 -10
  45. package/src/scene/drawables.js +68 -4
  46. package/src/watch.js +8 -1
  47. package/src/webgl/WebGLRenderer.js +36 -5
  48. package/src/webgl/picking.js +22 -1
  49. package/src/webgl/resources.js +168 -14
  50. package/src/webgl/shaders.js +110 -11
@@ -61,6 +61,28 @@ export type EmitterOptions = NodeOptions & SpawnOptions & {
61
61
  frustumCulled?: boolean;
62
62
  renderOrder?: number;
63
63
  onParticle?: ((p: Particle) => boolean | void) | null;
64
+ sort?: boolean;
65
+ } & LookOptions;
66
+ export type LookOptions = {
67
+ /**
68
+ * a multiple of the material's size, or several each particle goes
69
+ * through over its life - [0.5, 3] for smoke that billows out (default 1)
70
+ */
71
+ size?: number | number[];
72
+ /**
73
+ * each particle's size varies by up to this share either way, 0-1 (default 0)
74
+ */
75
+ sizeSpread?: number;
76
+ /**
77
+ * 0-1, or several each particle goes through - [0, 1, 0] fades in
78
+ * and out (default 1)
79
+ */
80
+ opacity?: number | number[];
81
+ /**
82
+ * radians per second: a number turns each particle up to that
83
+ * fast either way, [min, max] a range. Spinning particles start at a random angle (default 0)
84
+ */
85
+ spin?: number | [number, number];
64
86
  };
65
87
  /**
66
88
  * Particles drawn as points. Add it to a scene and it runs itself.
@@ -102,11 +124,11 @@ export declare class Emitter extends Points {
102
124
  /**
103
125
  * Change several settings at once - a preset, say. Vectors are copied into the emitter's own,
104
126
  * so anything bound to `emitter.gravity` and friends stays bound. Live particles carry on.
105
- * @param {SpawnOptions & { rate?: number, gravity?: Vec3Input, drag?: number, follow?: Node | null,
127
+ * @param {SpawnOptions & LookOptions & { rate?: number, gravity?: Vec3Input, drag?: number, follow?: Node | null,
106
128
  * onParticle?: ((p: Particle) => boolean | void) | null }} options
107
129
  * @returns {this}
108
130
  */
109
- set(options: SpawnOptions & {
131
+ set(options: SpawnOptions & LookOptions & {
110
132
  rate?: number;
111
133
  gravity?: Vec3Input;
112
134
  drag?: number;
@@ -121,6 +143,21 @@ export declare class Emitter extends Points {
121
143
  set color(v: ColorInput | ColorInput[]);
122
144
  /** @returns {[number, number, number][]} */
123
145
  get color(): [number, number, number][];
146
+ /** Each particle's size over its life, as multiples of the material's. @param {number | number[]} v */
147
+ set size(v: number | number[]);
148
+ /** @returns {number[]} */
149
+ get size(): number[];
150
+ /** How much each particle's size varies, 0-1 either way. @param {number} v */
151
+ set sizeSpread(v: number);
152
+ get sizeSpread(): number;
153
+ /** Each particle's opacity over its life. @param {number | number[]} v */
154
+ set opacity(v: number | number[]);
155
+ /** @returns {number[]} */
156
+ get opacity(): number[];
157
+ /** How fast particles turn, radians per second, as a [min, max] range. @param {number | [number, number]} v */
158
+ set spin(v: number | [number, number]);
159
+ /** @returns {[number, number]} */
160
+ get spin(): [number, number];
124
161
  /**
125
162
  * Birth `count` particles at once - explosions, splashes. Options override the emitter's own
126
163
  * spawn settings for this burst only.
@@ -238,19 +238,40 @@ export declare class Points extends Drawable {
238
238
  static get OPTIONS(): string[];
239
239
  /** A label per point from its row of data - or a list of them, several per point. @type {ReturnType<typeof instanceLabels>} */
240
240
  labels: ReturnType<typeof instanceLabels>;
241
- /** @param {DrawableOptions & { labels?: InstanceLabels | InstanceLabels[] | null }} options */
241
+ /**
242
+ * Draw the points back to front, furthest first, each frame - for soft points that cover what is
243
+ * behind them (smoke, with blending 'normal'), which then layer the right way round. Drawn as squares.
244
+ */
245
+ sort: boolean;
246
+ /** @param {DrawableOptions & { labels?: InstanceLabels | InstanceLabels[] | null, sort?: boolean }} options */
242
247
  constructor(options: DrawableOptions & {
243
248
  labels?: InstanceLabels | InstanceLabels[] | null;
249
+ sort?: boolean;
244
250
  });
245
251
  /** @returns {'points'} */
246
252
  get drawMode(): 'points';
247
253
  }
254
+ export type LinesOptions = DrawableOptions & {
255
+ arrows?: 'none' | 'start' | 'end' | 'both';
256
+ arrowSize?: number;
257
+ };
258
+ /**
259
+ * @typedef {DrawableOptions & { arrows?: 'none' | 'start' | 'end' | 'both', arrowSize?: number }} LinesOptions
260
+ */
248
261
  /**
249
262
  * Line segments: indices (or consecutive vertices) in pairs. Width is in pixels and works on
250
- * every platform. edges(geometry) makes a wireframe of any geometry.
263
+ * every platform. edges(geometry) makes a wireframe of any geometry. Segments each starting where
264
+ * the one before ended make a chain - a polyline - which dashes run on along and arrowheads finish.
251
265
  */
252
266
  export declare class Lines extends Drawable {
253
267
  static type: string;
268
+ static get OPTIONS(): string[];
269
+ /** Arrowheads at the ends of each chain: at its 'start', its 'end', 'both' or 'none'. @type {'none' | 'start' | 'end' | 'both'} */
270
+ arrows: 'none' | 'start' | 'end' | 'both';
271
+ /** Arrowhead length in pixels; 0 sizes them from the material's width. */
272
+ arrowSize: number;
273
+ /** @param {LinesOptions} options */
274
+ constructor(options: LinesOptions);
254
275
  /** @returns {'lines'} */
255
276
  get drawMode(): 'lines';
256
277
  }
@@ -101,8 +101,8 @@ export declare class Resources {
101
101
  count: number;
102
102
  } | null, withTangents?: boolean): number;
103
103
  /**
104
- * Vertex array for Lines: one instance per segment with its two endpoints (and colours),
105
- * expanded to a screen-space quad in the vertex shader.
104
+ * Vertex array for Lines: one instance per segment with its two endpoints (and colours) and where
105
+ * it is along its chain, expanded to a screen-space quad - and arrowheads - in the vertex shader.
106
106
  * @param {Geometry} geometry
107
107
  * @returns {MeshBuffers}
108
108
  */
@@ -119,6 +119,16 @@ export declare class Resources {
119
119
  * @param {object} owner @param {Scene} scene
120
120
  */
121
121
  release(owner: object, scene: Scene): void;
122
+ /**
123
+ * Vertex array for points drawn as squares: one instance a point - its position, colour, size,
124
+ * opacity, angle and index - over the corner strip lines use. Given a model-view matrix, the points
125
+ * go back to front (furthest first) - re-sorted every call, as the camera may have moved; without
126
+ * one, in their own order; with undefined, in whatever order they were last sent (picking, which
127
+ * reads each point's index and doesn't mind).
128
+ * @param {Geometry} geometry @param {ArrayLike<number> | null} [modelView]
129
+ * @returns {MeshBuffers}
130
+ */
131
+ sprites(geometry: Geometry, modelView?: ArrayLike<number> | null): MeshBuffers;
122
132
  /** Forget everything - after a lost context, when every GL object is already gone. */
123
133
  reset(): void;
124
134
  }
@@ -8,11 +8,16 @@ export declare const ATTRIBUTES: {
8
8
  instanceColor: number;
9
9
  tangent: number;
10
10
  uv2: number;
11
+ pointSize: number;
12
+ pointOpacity: number;
13
+ pointAngle: number;
14
+ pointIndex: number;
11
15
  segmentStart: number;
12
16
  segmentEnd: number;
13
17
  segmentColorStart: number;
14
18
  segmentColorEnd: number;
15
19
  corner: number;
20
+ segmentInfo: number;
16
21
  };
17
22
  export declare const CLIP = "\n#ifdef CLIP\nuniform vec4 uClipPlane; // world space, unit normal: cut away where dot(n, p) + d > 0\nvoid clipAway(vec3 p) {\n if (dot(uClipPlane.xyz, p) + uClipPlane.w > 0.0) discard;\n}\n#else\nvoid clipAway(vec3 p) {}\n#endif\n";
18
23
  export declare const FOG = "\n#ifdef FOG\nuniform vec3 uFogColor; // linear\nuniform vec2 uFogRange; // linear fog: near, far\nuniform float uFogDensity; // exponential fog\nin float vFogDepth;\nvec3 fogged(vec3 c) {\n#ifdef FOG_LINEAR\n float f = clamp((vFogDepth - uFogRange.x) / max(uFogRange.y - uFogRange.x, 1e-4), 0.0, 1.0);\n#else\n float d = uFogDensity * vFogDepth;\n float f = 1.0 - exp(-d * d);\n#endif\n#ifdef FOG_ADD\n return c * (1.0 - f);\n#elif defined(FOG_MULTIPLY)\n // a filter in the fog fades to white, which leaves what is behind it as it was\n return mix(c, vec3(1.0), f);\n#else\n return mix(c, uFogColor, f);\n#endif\n}\n#else\nvec3 fogged(vec3 c) { return c; }\n#endif\n";
@@ -22,8 +27,8 @@ export declare const EQUIRECT = "\nvec2 equirectUv(vec3 d) {\n return vec2(atan
22
27
  export declare const FULLSCREEN_VERTEX = "\nout vec2 vUv;\nvoid main() {\n vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));\n vUv = p;\n gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}\n";
23
28
  export declare const TONE_MAP = "\nuniform float uExposure;\nuniform int uToneMapping; // 0 none, 1 aces, 2 neutral\n\nvec3 toneMap(vec3 c) {\n c *= uExposure;\n if (uToneMapping == 1) {\n // ACES filmic, Narkowicz's fit\n c = clamp((c * (2.51 * c + 0.03)) / (c * (2.43 * c + 0.59) + 0.14), 0.0, 1.0);\n } else if (uToneMapping == 2) {\n // Khronos PBR Neutral\n float x = min(c.r, min(c.g, c.b));\n float offset = x < 0.08 ? x - 6.25 * x * x : 0.04;\n c -= offset;\n float peak = max(c.r, max(c.g, c.b));\n if (peak > 0.8) {\n float d = 0.2;\n float newPeak = 1.0 - d * d / (peak + d - 0.8);\n c *= newPeak / peak;\n float g = 1.0 - 1.0 / (0.15 * (peak - newPeak) + 1.0);\n c = mix(c, vec3(newPeak), g);\n }\n }\n return c;\n}\n";
24
29
  export declare const MESH_VERTEX = "\nlayout(location = 0) in vec3 position;\nlayout(location = 1) in vec3 normal;\nlayout(location = 2) in vec2 uv;\nlayout(location = 3) in vec3 color;\n#ifdef INSTANCED\nlayout(location = 4) in mat4 instanceMatrix;\nlayout(location = 8) in vec3 instanceColor;\n#endif\n#ifdef ANISOTROPY\nlayout(location = 9) in vec4 tangent;\nout vec4 vTangent;\n#endif\n#ifdef UV2\nlayout(location = 10) in vec2 uv2;\nout vec2 vUv2;\n#endif\n\nuniform mat4 uModel;\nuniform mat3 uNormalMatrix;\nuniform mat4 uViewProjection;\nuniform int uHighlightIndex; // -2 none, -1 the whole node, otherwise the instance\n#ifdef FOG\nuniform mat4 uView;\nout float vFogDepth;\n#endif\n\nout vec3 vWorldPosition;\nout vec3 vNormal;\nout vec2 vUv;\nout vec3 vColor;\nflat out float vHighlight;\n#ifdef TRIPLANAR\n// the mesh's own space, so projected maps stay on it as it moves; and how its normals turn into the world\nout vec3 vObjectPosition;\nout vec3 vObjectNormal;\nout mat3 vObjectToWorld;\n#endif\n#ifdef PICK\nflat out uint vPickElement; // which instance, point or segment, for GPU picking\n#endif\n\n\nvec3 srgbToLinear(vec3 c) {\n return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));\n}\nvec3 linearToSrgb(vec3 c) {\n c = clamp(c, 0.0, 1.0);\n return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));\n}\n\n\n// Matrices times vectors only - a matrix times a matrix per vertex is four times the work, and\n// with 100,000 instances the vertex stage is most of the frame.\nvoid main() {\n vColor = vec3(1.0);\n vHighlight = uHighlightIndex == -1 ? 1.0 : 0.0;\n#ifdef INSTANCED\n vec4 local = instanceMatrix * vec4(position, 1.0);\n // the instance's normal matrix as its cofactor: the inverse transpose up to scale, from three\n // cross products, flipped back for mirroring transforms\n mat3 m = mat3(instanceMatrix);\n mat3 instanceNormal = mat3(cross(m[1], m[2]), cross(m[2], m[0]), cross(m[0], m[1])) * sign(dot(m[0], cross(m[1], m[2])));\n vec3 n = instanceNormal * normal;\n vColor *= instanceColor;\n if (gl_InstanceID == uHighlightIndex) vHighlight = 1.0;\n#else\n vec4 local = vec4(position, 1.0);\n vec3 n = normal;\n#endif\n#ifdef VERTEX_COLORS\n vColor *= srgbToLinear(color);\n#endif\n vec4 world = uModel * local;\n vWorldPosition = world.xyz;\n vNormal = uNormalMatrix * n;\n vUv = uv;\n#ifdef UV2\n vUv2 = uv2;\n#endif\n#ifdef ANISOTROPY\n // a direction along the surface, so it turns as the model does\n#ifdef INSTANCED\n vTangent = vec4(mat3(uModel) * (m * tangent.xyz), tangent.w);\n#else\n vTangent = vec4(mat3(uModel) * tangent.xyz, tangent.w);\n#endif\n#endif\n#ifdef TRIPLANAR\n vObjectPosition = position;\n vObjectNormal = normal;\n#ifdef INSTANCED\n vObjectToWorld = uNormalMatrix * instanceNormal;\n#else\n vObjectToWorld = uNormalMatrix;\n#endif\n#endif\n gl_Position = uViewProjection * world;\n#ifdef FOG\n vFogDepth = -(uView * world).z;\n#endif\n#ifdef PICK\n vPickElement = uint(gl_InstanceID);\n#endif\n}\n";
25
- export declare const POINTS_VERTEX = "\nlayout(location = 0) in vec3 position;\nlayout(location = 3) in vec3 color;\n\nuniform mat4 uModel;\nuniform mat4 uView;\nuniform mat4 uProjection;\nuniform float uSize; // pixels (with the pixel ratio applied), or world units when attenuated\nuniform float uScale; // projection focal term x half the buffer height: world size -> pixels at depth 1\nuniform int uHighlightIndex; // -2 none, -1 every point, otherwise the point\n\nout vec3 vColor;\nflat out float vHighlight;\n#ifdef PICK\nflat out uint vPickElement; // which instance, point or segment, for GPU picking\n#endif\n#ifdef FOG\nout float vFogDepth;\n#endif\n\n\nvec3 srgbToLinear(vec3 c) {\n return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));\n}\nvec3 linearToSrgb(vec3 c) {\n c = clamp(c, 0.0, 1.0);\n return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));\n}\n\n\nvoid main() {\n vec4 viewPosition = uView * (uModel * vec4(position, 1.0));\n gl_Position = uProjection * viewPosition;\n#ifdef FOG\n vFogDepth = -viewPosition.z;\n#endif\n#ifdef SIZE_ATTENUATION\n // perspective shrinks with distance; an orthographic projection (w row 0, 0, 0, 1) doesn't\n float depth = uProjection[3][3] == 1.0 ? 1.0 : max(-viewPosition.z, 1e-4);\n gl_PointSize = uSize * uScale / depth;\n#else\n gl_PointSize = uSize;\n#endif\n vColor = vec3(1.0);\n#ifdef VERTEX_COLORS\n vColor = srgbToLinear(color);\n#endif\n vHighlight = uHighlightIndex == -1 || gl_VertexID == uHighlightIndex ? 1.0 : 0.0;\n#ifdef PICK\n vPickElement = uint(gl_VertexID);\n#endif\n}\n";
26
- export declare const LINES_VERTEX = "\nlayout(location = 0) in vec3 segmentStart;\nlayout(location = 1) in vec3 segmentEnd;\nlayout(location = 2) in vec3 segmentColorStart;\nlayout(location = 3) in vec3 segmentColorEnd;\nlayout(location = 4) in vec2 corner; // x: 0 at start, 1 at end; y: -1 or 1 across\n\nuniform mat4 uModel;\nuniform mat4 uView;\nuniform mat4 uProjection;\nuniform vec2 uResolution; // drawing buffer size in pixels\nuniform float uWidth; // pixels, already scaled by the pixel ratio\nuniform float uNear;\n\nout vec3 vColor;\n#ifdef PICK\nflat out uint vPickElement; // which instance, point or segment, for GPU picking\n#endif\n#ifdef FOG\nout float vFogDepth;\n#endif\n\n\nvec3 srgbToLinear(vec3 c) {\n return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));\n}\nvec3 linearToSrgb(vec3 c) {\n c = clamp(c, 0.0, 1.0);\n return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));\n}\n\n\nvoid main() {\n vec4 a = uView * (uModel * vec4(segmentStart, 1.0));\n vec4 b = uView * (uModel * vec4(segmentEnd, 1.0));\n // clip the segment to the near plane so lines passing behind the camera stay correct\n float clipZ = -uNear;\n if (a.z > clipZ && b.z > clipZ) {\n gl_Position = vec4(2.0, 2.0, 2.0, 1.0); // wholly behind: off screen\n return;\n }\n if (a.z > clipZ) a = mix(a, b, (clipZ - a.z) / (b.z - a.z));\n if (b.z > clipZ) b = mix(b, a, (clipZ - b.z) / (a.z - b.z));\n\n vec4 ca = uProjection * a;\n vec4 cb = uProjection * b;\n vec2 sa = ca.xy / ca.w * uResolution * 0.5;\n vec2 sb = cb.xy / cb.w * uResolution * 0.5;\n vec2 dir = sb - sa;\n float len = length(dir);\n dir = len > 0.0 ? dir / len : vec2(1.0, 0.0);\n vec2 normal = vec2(-dir.y, dir.x);\n\n vec4 clip = mix(ca, cb, corner.x);\n#ifdef FOG\n vFogDepth = -mix(a.z, b.z, corner.x);\n#endif\n // extend past each end by half the width so joins look continuous\n vec2 offset = normal * corner.y * uWidth * 0.5 + dir * (corner.x * 2.0 - 1.0) * uWidth * 0.5;\n clip.xy += offset / (uResolution * 0.5) * clip.w;\n gl_Position = clip;\n#ifdef PICK\n vPickElement = uint(gl_InstanceID);\n#endif\n\n vColor = vec3(1.0);\n#ifdef VERTEX_COLORS\n vColor = srgbToLinear(mix(segmentColorStart, segmentColorEnd, corner.x));\n#endif\n}\n";
30
+ export declare const POINTS_VERTEX = "\nlayout(location = 0) in vec3 position;\nlayout(location = 3) in vec3 color;\n#ifdef SPRITES\n// each point a square: one instance a point, drawn as a strip over the corners (along 0-1, across -1-1)\nlayout(location = 4) in vec2 corner;\nlayout(location = 11) in float pointSize;\nlayout(location = 12) in float pointOpacity;\nlayout(location = 13) in float pointAngle;\nlayout(location = 14) in float pointIndex;\nuniform vec2 uResolution; // drawing buffer size in pixels\nout vec2 vUv;\nout float vOpacity;\n#endif\n\nuniform mat4 uModel;\nuniform mat4 uView;\nuniform mat4 uProjection;\nuniform float uSize; // pixels (with the pixel ratio applied), or world units when attenuated\nuniform float uScale; // projection focal term x half the buffer height: world size -> pixels at depth 1\nuniform int uHighlightIndex; // -2 none, -1 every point, otherwise the point\n\nout vec3 vColor;\nflat out float vHighlight;\n#ifdef PICK\nflat out uint vPickElement; // which instance, point or segment, for GPU picking\n#endif\n#ifdef FOG\nout float vFogDepth;\n#endif\n\n\nvec3 srgbToLinear(vec3 c) {\n return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));\n}\nvec3 linearToSrgb(vec3 c) {\n c = clamp(c, 0.0, 1.0);\n return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));\n}\n\n\nvoid main() {\n vec4 viewPosition = uView * (uModel * vec4(position, 1.0));\n#ifdef SPRITES\n // the square's corner, turned by the point's angle: the texture turns with it\n vec2 c = vec2(corner.x * 2.0 - 1.0, corner.y);\n float s = sin(pointAngle), k = cos(pointAngle);\n vec2 turned = vec2(c.x * k - c.y * s, c.x * s + c.y * k);\n float size = uSize * pointSize;\n#ifdef SIZE_ATTENUATION\n // world units: the square sits in the view, facing the camera\n viewPosition.xy += turned * size * 0.5;\n gl_Position = uProjection * viewPosition;\n#else\n // pixels: the square is laid out on the screen\n gl_Position = uProjection * viewPosition;\n gl_Position.xy += turned * size / uResolution * gl_Position.w;\n#endif\n // the image upright on the square, its top at the top, as a point's is\n vUv = vec2(c.x * 0.5 + 0.5, 0.5 - c.y * 0.5);\n vOpacity = pointOpacity;\n int self = int(pointIndex + 0.5);\n#else\n gl_Position = uProjection * viewPosition;\n int self = gl_VertexID;\n#endif\n#ifdef FOG\n vFogDepth = -viewPosition.z;\n#endif\n#ifdef SPRITES\n#elif defined(SIZE_ATTENUATION)\n // perspective shrinks with distance; an orthographic projection (w row 0, 0, 0, 1) doesn't\n float depth = uProjection[3][3] == 1.0 ? 1.0 : max(-viewPosition.z, 1e-4);\n gl_PointSize = uSize * uScale / depth;\n#else\n gl_PointSize = uSize;\n#endif\n vColor = vec3(1.0);\n#ifdef VERTEX_COLORS\n vColor = srgbToLinear(color);\n#endif\n vHighlight = uHighlightIndex == -1 || self == uHighlightIndex ? 1.0 : 0.0;\n#ifdef PICK\n vPickElement = uint(self);\n#endif\n}\n";
31
+ export declare const LINES_VERTEX = "\nlayout(location = 0) in vec3 segmentStart;\nlayout(location = 1) in vec3 segmentEnd;\nlayout(location = 2) in vec3 segmentColorStart;\nlayout(location = 3) in vec3 segmentColorEnd;\n// the distance along its chain (segments joined end to start) where it starts, in the model's\n// units; 1 if it starts a chain; 1 if it ends one\nlayout(location = 5) in vec3 segmentInfo;\n\nuniform mat4 uModel;\nuniform mat4 uView;\nuniform mat4 uProjection;\nuniform vec2 uResolution; // drawing buffer size in pixels\nuniform float uWidth; // pixels, already scaled by the pixel ratio\nuniform float uNear;\nuniform int uArrows; // arrowheads: 1 at the start of each chain, 2 at its end, 3 at both\nuniform float uArrowSize; // their length in pixels, scaled by the pixel ratio\n\n// six vertices an instance make the segment's quad, as two triangles - x: 0 at its start, 1 at its\n// end; y: -1 or 1 across - and six more its two arrowheads, drawn only when asked for\nconst vec2 QUAD[6] = vec2[6](vec2(0.0, -1.0), vec2(0.0, 1.0), vec2(1.0, -1.0), vec2(1.0, -1.0), vec2(0.0, 1.0), vec2(1.0, 1.0));\n\nout vec3 vColor;\n#ifdef DASHED\nout float vDistance; // along the chain, in the model's units\nflat out float vArrow; // 1 on an arrowhead, which is never dashed\n#endif\n#ifdef PICK\nflat out uint vPickElement; // which instance, point or segment, for GPU picking\n#endif\n#ifdef FOG\nout float vFogDepth;\n#endif\n\n\nvec3 srgbToLinear(vec3 c) {\n return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));\n}\nvec3 linearToSrgb(vec3 c) {\n c = clamp(c, 0.0, 1.0);\n return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));\n}\n\n\nvoid main() {\n vec4 a = uView * (uModel * vec4(segmentStart, 1.0));\n vec4 b = uView * (uModel * vec4(segmentEnd, 1.0));\n // clip the segment to the near plane so lines passing behind the camera stay correct\n float clipZ = -uNear;\n if (a.z > clipZ && b.z > clipZ) {\n gl_Position = vec4(2.0, 2.0, 2.0, 1.0); // wholly behind: off screen\n return;\n }\n if (a.z > clipZ) a = mix(a, b, (clipZ - a.z) / (b.z - a.z));\n if (b.z > clipZ) b = mix(b, a, (clipZ - b.z) / (a.z - b.z));\n\n vec4 ca = uProjection * a;\n vec4 cb = uProjection * b;\n vec2 sa = ca.xy / ca.w * uResolution * 0.5;\n vec2 sb = cb.xy / cb.w * uResolution * 0.5;\n vec2 dir = sb - sa;\n float len = length(dir);\n dir = len > 0.0 ? dir / len : vec2(1.0, 0.0);\n vec2 normal = vec2(-dir.y, dir.x);\n\n bool arrowStart = (uArrows & 1) != 0 && segmentInfo.y > 0.5;\n bool arrowEnd = (uArrows & 2) != 0 && segmentInfo.z > 0.5;\n float along, arrow = 0.0;\n vec2 offset;\n if (gl_VertexID < 6) {\n vec2 c = QUAD[gl_VertexID];\n along = c.x;\n // past each end by half the width so joins look continuous - or short of it, under an arrowhead\n float startOut = arrowStart ? -uArrowSize * 0.8 : uWidth * 0.5;\n float endOut = arrowEnd ? -uArrowSize * 0.8 : uWidth * 0.5;\n offset = normal * c.y * uWidth * 0.5 + dir * (along > 0.5 ? endOut : -startOut);\n } else {\n // an arrowhead: its tip on the chain's end, pointing out along the segment\n int which = (gl_VertexID - 6) / 3, k = gl_VertexID - 6 - which * 3;\n if (which == 0 ? !arrowStart : !arrowEnd) {\n gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n return;\n }\n along = float(which);\n vec2 d = which == 1 ? dir : -dir;\n float wing = uArrowSize * 0.45;\n offset = k == 0 ? vec2(0.0) : -d * uArrowSize + normal * (k == 1 ? wing : -wing);\n arrow = 1.0;\n }\n\n vec4 clip = mix(ca, cb, along);\n#ifdef FOG\n vFogDepth = -mix(a.z, b.z, along);\n#endif\n clip.xy += offset / (uResolution * 0.5) * clip.w;\n gl_Position = clip;\n#ifdef DASHED\n vDistance = segmentInfo.x + length(segmentEnd - segmentStart) * along;\n vArrow = arrow;\n#endif\n#ifdef PICK\n vPickElement = uint(gl_InstanceID);\n#endif\n\n vColor = vec3(1.0);\n#ifdef VERTEX_COLORS\n vColor = srgbToLinear(mix(segmentColorStart, segmentColorEnd, along));\n#endif\n}\n";
27
32
  export type ShaderKind = 'mesh' | 'points' | 'lines';
28
33
  /**
29
34
  * Vertex and fragment source for a kind of drawable, or a pass's own pair, with a set of feature defines.
package/llms-full.txt CHANGED
@@ -139,6 +139,8 @@ Methods:
139
139
  - `on('click' | 'hover', (hit, event) => ...)` - see Interaction. Returns an unsubscribe function.
140
140
  - `tooltip(fn)` - set the tooltip function later.
141
141
  - `pick(x, y)` / `pick(event)` - the hit under a canvas position; `pickAll` for every hit.
142
+ - `pickArea(area)` - every hit inside a box `{ x, y, width, height }` or a lasso of `[x, y]` points
143
+ (CSS pixels), nearest first - see Interaction.
142
144
  - `rayAt(x, y)` / `rayAt(event)` - the world `Ray` through a canvas position, for placing things:
143
145
  `ray.at(ray.intersectPlane([0, 1, 0], [0, 0, 0]))` is the point on the floor under the pointer
144
146
  (`intersectPlane` returns null when the ray runs along the plane or away from it).
@@ -222,6 +224,24 @@ new Lines({ geometry: edges(box(2)), material: { color: '#333', width: 2 } })
222
224
  new Label({ text: 'Rack A1', position: [0, 2, 0], className: 'my-label', place: 'above' })
223
225
  ```
224
226
 
227
+ **Lines** dashed and with arrowheads - flow and direction in network diagrams. Dashes are material
228
+ options in the model's units: `dash` (0 is solid), `gap` (0 is the dash's length) and `dashOffset`,
229
+ which moves them along - animate it for traffic. Arrowheads are Lines options: `arrows: 'start' |
230
+ 'end' | 'both'` puts them at the ends of each chain (segments each starting where the one before
231
+ ended - a polyline is one chain, separate links are one each), `arrowSize` in pixels (0, the default,
232
+ sizes them from the width). The dashes run on along a chain, and arrowheads are never dashed.
233
+
234
+ ```js
235
+ const flow = view.scene.add(new Lines({ geometry: links, arrows: 'end', material: { color: '#4cc9f0', width: 2, dash: 0.3, gap: 0.15 } }))
236
+ view.start((dt) => { flow.material.dashOffset += dt * 0.8 }) // traffic moving along each link
237
+ ```
238
+
239
+ **Points** each their own size, opacity or angle: give the geometry `sizes` (multiples of the material's
240
+ `size`), `opacities` (0-1) or `angles` (radians, turning the point's `map`), one per vertex. Such points
241
+ are drawn as camera-facing squares, so any size works and they stay whole at the edge of the view.
242
+ `sort: true` draws them furthest first each frame - for soft points with `blending: 'normal'`
243
+ (smoke, cloud) that would otherwise layer wrongly; with additive blending order doesn't matter.
244
+
225
245
  **InstancedMesh** - many copies of one geometry in one draw call, for bars, racks, nodes:
226
246
 
227
247
  ```js
@@ -361,7 +381,10 @@ unless its `colorSpace` was set on purpose - `normalMap: await loadTexture('n.pn
361
381
 
362
382
  **Canvas and video textures**: a texture's `image` can be a canvas - draw on it, then call
363
383
  `texture.needsUpdate()` - or a `<video>`, whose frames upload by themselves while it plays, with
364
- nothing to call. `new Texture({ url: 'promo.mp4' })` (or .webm, .ogv, .mov, .m4v) makes the video
384
+ nothing to call. For a big canvas drawn on a little at a time (paint, a map being revealed), say what
385
+ changed - `texture.needsUpdate({ x, y, width, height })`, in the canvas's pixels from its top left -
386
+ and only that rectangle is sent, rather than the whole canvas every frame (several calls before a
387
+ frame add up). `new Texture({ url: 'promo.mp4' })` (or .webm, .ogv, .mov, .m4v) makes the video
365
388
  for you, muted and looping so browsers let it start by itself; `texture.image` is that `<video>` -
366
389
  pause, seek or unmute it as any other. A browser that blocks autoplay leaves it on its first frame
367
390
  until `texture.image.play()` from a click. Video textures make no mipmaps (they would be remade
@@ -537,6 +560,16 @@ view.on('hover', (hit) => show(hit?.datum)) // fires when the th
537
560
  - The **nearest surface** under the pointer is the hit - a link drawn over a site's top wins the
538
561
  hover there. To let one kind of thing through, give it `pickable: false`; to choose among
539
562
  everything under the pointer, `view.pickAll(event)` lists every hit, nearest first.
563
+ - **Selecting an area** - `view.pickArea({ x, y, width, height })` (width and height may be negative,
564
+ as a drag up or left makes them) or `view.pickArea([[x, y], ...])` for a lasso: a hit for each point,
565
+ instance and line segment whose place on screen is inside, and each mesh whose middle is. Places, not
566
+ surfaces: what is hidden behind other things is found too, so a box over a scatter takes that whole
567
+ region. Hidden, unpickable and zero-scale instances are left out. Turn `view.controls.enabled` off
568
+ while the user drags out the area.
569
+
570
+ ```js
571
+ const chosen = view.pickArea({ x: 40, y: 30, width: 200, height: 120 }).map((h) => h.datum)
572
+ ```
540
573
  - **Rays of your own** - line of sight, what's below a dropped object, picking in tests: a
541
574
  `Raycaster` finds what any ray hits, on the CPU. Set `raycaster.ray.origin` and `.direction`, or aim
542
575
  it with `setFromCamera([x, y], camera)` (-1 to 1, y up); `intersect(nodeOrNodes)` returns hits,
@@ -807,6 +840,20 @@ second), `follow` (a node whose position is the origin each frame - a trail), `o
807
840
  default material is small additive points. `burst(count, spawnOptions)`, `clear()`, `set(options)`,
808
841
  `count`.
809
842
 
843
+ How particles look over their lives: `size` (a multiple of the material's size, or a list each particle
844
+ goes through - `[0.5, 3]` billows), `sizeSpread` (each one's size varies by up to this share, 0-1),
845
+ `opacity` (0-1, or a list - `[0, 1, 0]` fades in and out) and `spin` (radians per second: a number
846
+ either way, or `[min, max]`; spinning particles start at a random angle). For smoke, a soft `map`,
847
+ `blending: 'normal'` and `sort: true`:
848
+
849
+ ```js
850
+ const smoke = view.scene.add(new Emitter({
851
+ rate: 40, velocity: [0, 1.5, 0], velocitySpread: 0.4, lifetime: [3, 5], color: ['#777', '#333'],
852
+ size: [0.6, 4], sizeSpread: 0.3, opacity: [0, 0.6, 0], spin: 0.5, sort: true,
853
+ material: { map: puff, size: 1, blending: 'normal' },
854
+ }))
855
+ ```
856
+
810
857
  `Body` moves any node: `node.onUpdate(new Body({ velocity, gravity, drag }).update)`;
811
858
  `body.impulse([x, y, z])` kicks it.
812
859
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phoria2",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Small, friendly 3D for the web - a WebGL 2 renderer with charts, labels, picking, particles and glTF",
5
5
  "keywords": [
6
6
  "3d",
@@ -102,6 +102,10 @@
102
102
  "properties": {
103
103
  "type": {
104
104
  "const": "points"
105
+ },
106
+ "sort": {
107
+ "type": "boolean",
108
+ "description": "Draw the points furthest first each frame, for soft points that cover what is behind them."
105
109
  }
106
110
  },
107
111
  "unevaluatedProperties": false
@@ -113,6 +117,15 @@
113
117
  "properties": {
114
118
  "type": {
115
119
  "const": "lines"
120
+ },
121
+ "arrows": {
122
+ "enum": ["none", "start", "end", "both"],
123
+ "description": "Arrowheads at the ends of each chain of joined segments."
124
+ },
125
+ "arrowSize": {
126
+ "type": "number",
127
+ "description": "Arrowhead length in pixels; 0 sizes them from the material's width.",
128
+ "minimum": 0
116
129
  }
117
130
  },
118
131
  "unevaluatedProperties": false
@@ -916,6 +929,20 @@
916
929
  "description": "Lines: width in pixels.",
917
930
  "minimum": 0
918
931
  },
932
+ "dash": {
933
+ "type": "number",
934
+ "description": "Lines: dashed, with dashes this long in the model's units; 0 is solid.",
935
+ "minimum": 0
936
+ },
937
+ "gap": {
938
+ "type": "number",
939
+ "description": "Lines: the gaps between dashes; 0 is as long as the dashes.",
940
+ "minimum": 0
941
+ },
942
+ "dashOffset": {
943
+ "type": "number",
944
+ "description": "Lines: moves the dashes along the lines - animate it for flow."
945
+ },
919
946
  "alphaTest": {
920
947
  "type": "number",
921
948
  "description": "Cut-outs: drop pixels with less alpha.",
@@ -1280,6 +1307,27 @@
1280
1307
  "type": "number"
1281
1308
  }
1282
1309
  },
1310
+ "sizes": {
1311
+ "type": "array",
1312
+ "description": "Points: one per vertex, a multiple of the material's size.",
1313
+ "items": {
1314
+ "type": "number"
1315
+ }
1316
+ },
1317
+ "opacities": {
1318
+ "type": "array",
1319
+ "description": "Points: one per vertex, 0-1, times the material's opacity.",
1320
+ "items": {
1321
+ "type": "number"
1322
+ }
1323
+ },
1324
+ "angles": {
1325
+ "type": "array",
1326
+ "description": "Points: one per vertex, radians anticlockwise on screen.",
1327
+ "items": {
1328
+ "type": "number"
1329
+ }
1330
+ },
1283
1331
  "indices": {
1284
1332
  "type": "array",
1285
1333
  "items": {
package/src/View.js CHANGED
@@ -4,6 +4,7 @@ import { DRAG_START, OrbitControl } from './OrbitControl.js'
4
4
  import { cameraPreset, fitPoints, framingScale, presetDirection } from './presets.js'
5
5
  import { EASES } from './ease.js'
6
6
  import { Raycaster } from './Raycaster.js'
7
+ import { pickArea } from './area.js'
7
8
  import { ChangeWatch } from './watch.js'
8
9
  import { Box3 } from './value/Box3.js'
9
10
  import { Color } from './value/Color.js'
@@ -803,6 +804,21 @@ export class View {
803
804
  return new Mat4().multiplyMatrices(camera.projectionMatrix, camera.viewMatrix)
804
805
  }
805
806
 
807
+ /**
808
+ * Everything inside an area of the canvas, nearest first: a box `{ x, y, width, height }` or a
809
+ * lasso of `[x, y]` points, CSS pixels from the canvas's top left. Each point, instance and line
810
+ * segment is its own hit, found by where it is (a mesh by its middle) - hidden behind others or
811
+ * not - so a box dragged over a scatter selects that region of it.
812
+ *
813
+ * const picked = view.pickArea({ x: 40, y: 30, width: 200, height: 120 }).map((h) => h.datum)
814
+ * @param {import('./area.js').PickArea} area
815
+ * @returns {Hit[]}
816
+ */
817
+ pickArea(area) {
818
+ this.scene.updateWorldTree()
819
+ return pickArea(this.scene, this.camera, this.#viewProjection(), this.#width, this.#height, area)
820
+ }
821
+
806
822
  /** Every hit under a canvas position, nearest first. @param {number | MouseEvent} x @param {number} [y] @returns {Hit[]} */
807
823
  pickAll(x, y) {
808
824
  if (typeof x === 'object') {
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
+ }
@@ -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
- : new Points({ geometry: emptyPoints(64), material: { size: ctx.spec.channels.size?.value ?? unit * 1.4, vertexColors: true, ...material },
158
- labels: labelsOf(ctx, 'above') })
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) })
@@ -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. */
@@ -62,6 +71,7 @@ export class Geometry {
62
71
  if (options.uvs) this.uvs = toFloat32('uvs', options.uvs, 2, n)
63
72
  if (options.uvs2) this.uvs2 = toFloat32('uvs2', options.uvs2, 2, n)
64
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)
65
75
  if (options.indices) {
66
76
  // -1: no indices at all refer to no vertex, so an empty geometry can say so
67
77
  let max = -1
@@ -191,6 +201,9 @@ export class Geometry {
191
201
  uvs: this.uvs ?? undefined,
192
202
  uvs2: this.uvs2 ?? undefined,
193
203
  colors: this.colors ?? undefined,
204
+ sizes: this.sizes ?? undefined,
205
+ opacities: this.opacities ?? undefined,
206
+ angles: this.angles ?? undefined,
194
207
  indices: this.indices ?? undefined,
195
208
  })
196
209
  g.source = this.source && { ...this.source }
@@ -218,6 +231,7 @@ export class Geometry {
218
231
  if (this.uvs) json.uvs = Array.from(this.uvs)
219
232
  if (this.uvs2) json.uvs2 = Array.from(this.uvs2)
220
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]))
221
235
  if (this.indices) json.indices = Array.from(this.indices)
222
236
  return json
223
237
  }
@@ -242,6 +256,7 @@ export class Geometry {
242
256
  this.uvs = expand(this.uvs, 2)
243
257
  this.uvs2 = expand(this.uvs2, 2)
244
258
  this.colors = expand(this.colors, 3)
259
+ for (const name of POINT_ARRAYS) this[name] = expand(this[name], 1)
245
260
  this.indices = null
246
261
  this.source = null
247
262
  }