@fusion.dev/helix-graph 0.122.0 → 0.124.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.
@@ -1,7 +1,9 @@
1
1
  import { forceCenter, forceLink, forceManyBody, forceSimulation, } from 'd3-force-3d';
2
- import { AdditiveBlending, BufferAttribute, BufferGeometry, LineSegments, NormalBlending, PerspectiveCamera, Points, Raycaster, Scene, ShaderMaterial, TOUCH, Vector2, Vector3, WebGLRenderer, } from 'three';
2
+ import { AdditiveBlending, BoxGeometry, BufferAttribute, BufferGeometry, DoubleSide, Euler, InstancedBufferAttribute, InstancedMesh, LineSegments, Matrix4, Mesh, NormalBlending, OctahedronGeometry, PerspectiveCamera, Points, Quaternion, Raycaster, Scene, ShaderMaterial, TOUCH, Vector2, Vector3, WebGLRenderer, } from 'three';
3
3
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
4
+ import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
4
5
  import { ease, gesturesHeld, groupsOver, isLight, namesOver, signalsOver, } from '../overlay/overlay.js';
6
+ import { surfaceOf } from './surface.js';
5
7
  const SHAPES = { circle: 0, diamond: 2, ring: 0, square: 1 };
6
8
  const REACH = 100;
7
9
  const STEP = 250;
@@ -10,6 +12,10 @@ const ZOOM = 1.5;
10
12
  const SAMPLE = 96;
11
13
  const LINKS = { dark: { close: 0.3, far: 0.05 }, light: { close: 0.55, far: 0.12 } };
12
14
  const REST = 8000;
15
+ const WASH = { dark: 0.14, full: 1.5, light: 0.18, notes: 6, reach: 3.5 };
16
+ const BLOOM = { dark: 0.28, light: 0 };
17
+ const LIGHT = new Vector3(-0.4, 0.8, 0.45).normalize();
18
+ const LIFT = 0.42;
13
19
  const FOV = 40;
14
20
  const FADE = `
15
21
  uniform float middle;
@@ -22,6 +28,29 @@ float fade(float depth) {
22
28
  return mix(gentle, sharp, steep);
23
29
  }
24
30
  `;
31
+ const ORB = `
32
+ uniform vec3 light;
33
+ uniform float dark;
34
+
35
+ vec4 orb(vec3 base, vec3 n) {
36
+ float diffuse = max(dot(n, light), 0.0);
37
+ float rim = pow(1.0 - clamp(n.z, 0.0, 1.0), 2.2);
38
+ float core = pow(clamp(n.z, 0.0, 1.0), 5.0);
39
+ float glint = pow(max(dot(n, normalize(light + vec3(0.0, 0.0, 1.0))), 0.0), 140.0);
40
+ vec3 energy = base * (0.08 + 0.2 * diffuse);
41
+ energy += base * core * 0.5;
42
+ energy += mix(base, vec3(1.0), 0.6) * pow(clamp(n.z, 0.0, 1.0), 40.0) * 0.55;
43
+ energy += mix(base, vec3(1.0), 0.25) * rim * 1.2;
44
+ energy += vec3(glint * 0.55);
45
+ vec3 frost = mix(base, vec3(1.0), 0.45 * pow(clamp(n.z, 0.0, 1.0), 1.5));
46
+ frost *= 0.86 + 0.14 * diffuse;
47
+ frost = mix(frost, base * 0.78, rim * 0.75);
48
+ frost += vec3(glint * 0.6);
49
+ return dark > 0.5
50
+ ? vec4(energy, mix(0.5, 1.0, max(rim, core)))
51
+ : vec4(frost, mix(0.78, 1.0, rim));
52
+ }
53
+ `;
25
54
  const VERTEX = `
26
55
  ${FADE}
27
56
  attribute float size;
@@ -31,18 +60,29 @@ attribute float glow;
31
60
 
32
61
  uniform float scale;
33
62
  uniform float unit;
63
+ uniform float bloom;
64
+ uniform float halo;
34
65
 
35
66
  varying vec4 vTint;
36
67
  varying float vShape;
37
68
  varying float vGlow;
38
69
  varying float vFade;
70
+ varying float vRoom;
71
+ varying vec3 vCenter;
72
+ varying float vRadius;
39
73
 
40
74
  void main() {
41
75
  vec4 view = modelViewMatrix * vec4(position, 1.0);
42
76
  gl_Position = projectionMatrix * view;
43
77
  float depth = max(-view.z, 0.001);
78
+ /* The halo's pass has room around the note for it, which every note has
79
+ where the ground is dark, and more for a glowing one; the body's pass
80
+ is the note and no more. */
81
+ vRoom = halo > 0.5 ? 1.0 + (bloom > 0.0 ? 0.9 : 0.0) + glow * 1.4 : 1.0;
44
82
  /* As large as the note is, at the distance it is: nearer is larger. */
45
- gl_PointSize = size * unit * scale / depth * (1.0 + glow * 1.4);
83
+ gl_PointSize = size * unit * scale / depth * vRoom;
84
+ vCenter = view.xyz;
85
+ vRadius = size * unit * 0.5;
46
86
  vFade = fade(depth);
47
87
  vTint = tint;
48
88
  vShape = shape;
@@ -50,24 +90,88 @@ void main() {
50
90
  }
51
91
  `;
52
92
  const FRAGMENT = `
93
+ uniform mat4 projectionMatrix;
94
+ ${ORB}
95
+ varying vec4 vTint;
96
+ varying float vShape;
97
+ varying float vFade;
98
+ varying vec3 vCenter;
99
+ varying float vRadius;
100
+
101
+ void main() {
102
+ if (vShape > 0.5) discard;
103
+ vec2 q = (gl_PointCoord * 2.0 - 1.0) * vec2(1.0, -1.0);
104
+ float d2 = dot(q, q);
105
+ if (d2 > 1.0) discard;
106
+ vec3 n = vec3(q, sqrt(1.0 - d2));
107
+ vec4 clip = projectionMatrix * vec4(vCenter + n * vRadius, 1.0);
108
+ gl_FragDepth = clip.z / clip.w * 0.5 + 0.5;
109
+ /* Antialiased in the last few percent only, so the glowing rim runs to the edge. */
110
+ float edge = 1.0 - smoothstep(0.965, 1.0, sqrt(d2));
111
+ vec4 lit = orb(vTint.rgb, n);
112
+ float alpha = edge * lit.a * vTint.a * vFade;
113
+ if (alpha < 0.01) discard;
114
+ gl_FragColor = vec4(lit.rgb, alpha);
115
+ }
116
+ `;
117
+ const HALO_FRAGMENT = `
118
+ uniform float bloom;
53
119
  varying vec4 vTint;
54
120
  varying float vShape;
55
121
  varying float vGlow;
56
122
  varying float vFade;
123
+ varying float vRoom;
57
124
 
58
125
  void main() {
59
- vec2 p = gl_PointCoord * 2.0 - 1.0;
60
- /* The note's body inside the sprite; the rest is room for its glow. */
61
- float body = 1.0 / (1.0 + vGlow * 1.4);
62
- float reach = vShape > 1.5 ? (abs(p.x) + abs(p.y)) * 0.78 : vShape > 0.5 ? max(abs(p.x), abs(p.y)) * 1.08 : length(p);
63
- float d = reach / body;
64
- float inside = 1.0 - smoothstep(0.86, 1.0, d);
65
- /* Lit from the middle out, so a note reads as round. */
66
- vec3 color = vTint.rgb * mix(1.15, 0.8, clamp(d, 0.0, 1.0));
67
- float halo = vGlow * (1.0 - smoothstep(1.0, 1.0 / body, d)) * 0.55;
68
- float alpha = (inside + halo * (1.0 - inside)) * vTint.a * vFade;
126
+ vec2 q = (gl_PointCoord * 2.0 - 1.0) * vRoom;
127
+ float d = vShape > 1.5 ? (abs(q.x) + abs(q.y)) * 0.78 : vShape > 0.5 ? max(abs(q.x), abs(q.y)) * 1.08 : length(q);
128
+ /* From just inside the body's edge, under its antialiasing, so no seam of
129
+ dark ground shows between the body and its halo. */
130
+ if (d < 0.965) discard;
131
+ float past = clamp((d - 0.965) / max(vRoom - 0.965, 0.001), 0.0, 1.0);
132
+ float alpha = (bloom + vGlow * 0.6) * pow(1.0 - past, 2.4) * vTint.a * vFade;
69
133
  if (alpha < 0.01) discard;
70
- gl_FragColor = vec4(color, alpha);
134
+ gl_FragColor = vec4(vTint.rgb, alpha);
135
+ }
136
+ `;
137
+ const SHAPE_VERTEX = `
138
+ ${FADE}
139
+ attribute vec4 tone;
140
+ attribute vec4 frame;
141
+ varying vec3 vNormal;
142
+ varying vec4 vTone;
143
+ varying vec4 vFrame;
144
+ varying float vFade;
145
+
146
+ void main() {
147
+ vec4 view = viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0);
148
+ gl_Position = projectionMatrix * view;
149
+ vNormal = normalize(normalMatrix * mat3(instanceMatrix) * normal);
150
+ vTone = tone;
151
+ vFrame = frame;
152
+ vFade = fade(max(-view.z, 0.001));
153
+ }
154
+ `;
155
+ const SHAPE_FRAGMENT = `
156
+ uniform float dark;
157
+ varying vec3 vNormal;
158
+ varying vec4 vTone;
159
+ varying vec4 vFrame;
160
+ varying float vFade;
161
+
162
+ void main() {
163
+ float e = min(min(vFrame.x, vFrame.y), min(vFrame.z, vFrame.w));
164
+ float w = fwidth(e);
165
+ /* The edge as a line about a pixel and a half wide, whatever the size. */
166
+ float line = 1.0 - smoothstep(w * 0.6, w * 2.2, e);
167
+ float inner = exp(-e * 12.0);
168
+ float facing = abs(normalize(vNormal).z);
169
+ vec3 edge = dark > 0.5 ? mix(vTone.rgb, vec3(1.0), 0.35) : vTone.rgb * 0.8;
170
+ vec3 face = vTone.rgb * (dark > 0.5 ? 0.3 + 0.7 * inner : 1.0);
171
+ float faceAlpha = (dark > 0.5 ? 0.1 : 0.16) + 0.3 * inner + 0.12 * (1.0 - facing);
172
+ float alpha = mix(faceAlpha, 1.0, line) * vTone.a * vFade;
173
+ if (alpha < 0.01) discard;
174
+ gl_FragColor = vec4(mix(face, edge, line), alpha);
71
175
  }
72
176
  `;
73
177
  const LINE_VERTEX = `
@@ -92,6 +196,65 @@ void main() {
92
196
  gl_FragColor = vec4(vColor, opacity * vFade);
93
197
  }
94
198
  `;
199
+ const SOLID_VERTEX = `
200
+ attribute vec4 wash;
201
+ varying vec3 vNormal;
202
+ varying vec3 vView;
203
+ varying vec4 vWash;
204
+
205
+ void main() {
206
+ vec4 view = modelViewMatrix * vec4(position, 1.0);
207
+ gl_Position = projectionMatrix * view;
208
+ vNormal = normalize(normalMatrix * normal);
209
+ vView = normalize(-view.xyz);
210
+ vWash = wash;
211
+ }
212
+ `;
213
+ const SOLID_FRAGMENT = `
214
+ uniform vec3 ground;
215
+ uniform vec3 ink;
216
+ uniform float strength;
217
+ varying vec3 vNormal;
218
+ varying vec3 vView;
219
+ varying vec4 vWash;
220
+
221
+ void main() {
222
+ /* **Each region washed in its notes' color**, as a translucent stain on
223
+ the ground's color, and lit: a breath of ink where light from above and
224
+ to the left falls, and the region's own color glowing at the edge
225
+ turning away. Felt more than seen: the notes on it are what is drawn. */
226
+ vec3 normal = normalize(vNormal);
227
+ vec3 view = normalize(vView);
228
+ if (dot(normal, view) < 0.0) normal = -normal;
229
+ float edge = 1.0 - dot(normal, view);
230
+ float lit = max(dot(normal, normalize(vec3(-0.4, 0.8, 0.45))), 0.0);
231
+ float washed = strength * vWash.a;
232
+ vec3 base = mix(ground, vWash.rgb, washed);
233
+ vec3 shaded = mix(base, ink, 0.1 * pow(edge, 2.0) + 0.17 * lit * lit);
234
+ vec3 rim = mix(shaded, vWash.rgb, washed * 1.6 * pow(edge, 3.0));
235
+ gl_FragColor = vec4(rim, 1.0);
236
+ }
237
+ `;
238
+ const framed = (geometry) => {
239
+ const uv = geometry.getAttribute('uv');
240
+ const count = geometry.getAttribute('position').count;
241
+ const frame = new Float32Array(count * 4);
242
+ if (geometry.getIndex() && uv) {
243
+ for (let at = 0; at < count; at++) {
244
+ const u = uv.getX(at);
245
+ const v = uv.getY(at);
246
+ frame.set([u, 1 - u, v, 1 - v], at * 4);
247
+ }
248
+ }
249
+ else {
250
+ for (let at = 0; at < count; at++) {
251
+ const corner = at % 3;
252
+ frame.set([corner === 0 ? 1 : 0, corner === 1 ? 1 : 0, corner === 2 ? 1 : 0, 1], at * 4);
253
+ }
254
+ }
255
+ geometry.setAttribute('frame', new BufferAttribute(frame, 4));
256
+ return geometry;
257
+ };
95
258
  const channels = (color) => {
96
259
  const [red = 128, green = 128, blue = 128] = color.match(/[\d.]+/g)?.map(Number) ?? [];
97
260
  return [red / 255, green / 255, blue / 255];
@@ -135,6 +298,9 @@ export const threeDriver = (container, options = {}) => {
135
298
  let scale = 1;
136
299
  let positions = new Float32Array();
137
300
  const uniforms = {
301
+ bloom: { value: BLOOM.dark },
302
+ dark: { value: 1 },
303
+ light: { value: new Vector3(-0.4, 0.8, 0.45).normalize() },
138
304
  middle: { value: 1 },
139
305
  radius: { value: 1 },
140
306
  scale: { value: 1 },
@@ -154,18 +320,222 @@ export const threeDriver = (container, options = {}) => {
154
320
  vertexShader: LINE_VERTEX,
155
321
  });
156
322
  const points = new Points(new BufferGeometry(), new ShaderMaterial({
157
- depthWrite: false,
158
323
  fragmentShader: FRAGMENT,
159
324
  transparent: true,
160
- uniforms,
325
+ uniforms: { ...uniforms, halo: { value: 0 } },
161
326
  vertexShader: VERTEX,
162
327
  }));
163
328
  points.frustumCulled = false;
329
+ const halos = new Points(points.geometry, new ShaderMaterial({
330
+ depthWrite: false,
331
+ fragmentShader: HALO_FRAGMENT,
332
+ transparent: true,
333
+ uniforms: { ...uniforms, halo: { value: 1 } },
334
+ vertexShader: VERTEX,
335
+ }));
336
+ halos.frustumCulled = false;
337
+ halos.renderOrder = 1;
338
+ const shapeMaterial = new ShaderMaterial({
339
+ depthWrite: false,
340
+ fragmentShader: SHAPE_FRAGMENT,
341
+ side: DoubleSide,
342
+ transparent: true,
343
+ uniforms: {
344
+ dark: uniforms.dark,
345
+ middle: uniforms.middle,
346
+ radius: uniforms.radius,
347
+ steep: uniforms.steep,
348
+ },
349
+ vertexShader: SHAPE_VERTEX,
350
+ });
351
+ const solids = {
352
+ cube: {
353
+ geometry: framed(new BoxGeometry(1.5, 1.5, 1.5)),
354
+ mesh: undefined,
355
+ spots: [],
356
+ },
357
+ octahedron: {
358
+ geometry: framed(new OctahedronGeometry(1.15)),
359
+ mesh: undefined,
360
+ spots: [],
361
+ },
362
+ };
164
363
  const quiet = new LineSegments(new BufferGeometry(), lines(LINKS.dark.far));
165
364
  const lit = new LineSegments(new BufferGeometry(), lines(0.9));
166
365
  quiet.frustumCulled = false;
167
366
  lit.frustumCulled = false;
168
- scene.add(quiet, lit, points);
367
+ const solid = new Mesh(new BufferGeometry(), new ShaderMaterial({
368
+ fragmentShader: SOLID_FRAGMENT,
369
+ polygonOffset: true,
370
+ polygonOffsetFactor: 2,
371
+ polygonOffsetUnits: 2,
372
+ side: DoubleSide,
373
+ uniforms: {
374
+ ground: { value: [0, 0, 0] },
375
+ ink: { value: [1, 1, 1] },
376
+ strength: { value: WASH.dark },
377
+ },
378
+ vertexShader: SOLID_VERTEX,
379
+ }));
380
+ solid.renderOrder = -1;
381
+ solid.visible = false;
382
+ solid.frustumCulled = false;
383
+ scene.add(solid, quiet, lit, points, halos);
384
+ let shapeFrame;
385
+ let surfaced = false;
386
+ let solidAt = Number.NaN;
387
+ const shapeSolid = () => {
388
+ const frame = shapeFrame;
389
+ if (!frame || !surfaced || frame.field.depth <= 0) {
390
+ solid.visible = false;
391
+ return;
392
+ }
393
+ if (solidAt === scale) {
394
+ solid.visible = true;
395
+ return;
396
+ }
397
+ solidAt = scale;
398
+ const { field } = frame;
399
+ const inset = frame.shell * 1.15;
400
+ let geometry = new BufferGeometry();
401
+ if (field.mesh) {
402
+ geometry.setAttribute('position', new BufferAttribute(field.mesh.corners.slice(), 3));
403
+ geometry.setIndex(new BufferAttribute(field.mesh.triangles, 1));
404
+ geometry = mergeVertices(geometry);
405
+ const place = geometry.getAttribute('position');
406
+ const step = Math.max(field.width, field.height, field.depth) / 64;
407
+ for (let at = 0; at < place.count; at++) {
408
+ const x = place.getX(at);
409
+ const y = place.getY(at);
410
+ const z = place.getZ(at);
411
+ const gx = field.distance(x + step, y, z) - field.distance(x - step, y, z);
412
+ const gy = field.distance(x, y + step, z) - field.distance(x, y - step, z);
413
+ const gz = field.distance(x, y, z + step) - field.distance(x, y, z - step);
414
+ const length = Math.hypot(gx, gy, gz) || 1;
415
+ place.setXYZ(at, x - (gx / length) * inset, y - (gy / length) * inset, z - (gz / length) * inset);
416
+ }
417
+ }
418
+ else {
419
+ const { corners, triangles } = surfaceOf(field, { at: -inset });
420
+ geometry.setAttribute('position', new BufferAttribute(corners, 3));
421
+ geometry.setIndex(new BufferAttribute(triangles, 1));
422
+ }
423
+ const place = geometry.getAttribute('position');
424
+ const { depth, height, width } = field;
425
+ for (let at = 0; at < place.count; at++) {
426
+ place.setXYZ(at, (frame.middle.x + (place.getX(at) - width / 2) * frame.scale) * scale, (frame.middle.y - (place.getY(at) - height / 2) * frame.scale) * scale, (frame.middle.z + (place.getZ(at) - depth / 2) * frame.scale) * scale);
427
+ }
428
+ geometry.computeVertexNormals();
429
+ solid.geometry.dispose();
430
+ solid.geometry = geometry;
431
+ solid.visible = true;
432
+ tieSurface();
433
+ };
434
+ let washFrom = new Int32Array();
435
+ let washWeight = new Float32Array();
436
+ let noteTints = new Float32Array();
437
+ const tieSurface = () => {
438
+ const place = solid.geometry.getAttribute('position');
439
+ if (!solid.visible || !place || ids.length === 0) {
440
+ washFrom = new Int32Array();
441
+ return;
442
+ }
443
+ const sample = Math.min(ids.length, 200);
444
+ const nearest = [];
445
+ for (let step = 0; step < sample; step++) {
446
+ const one = Math.floor((step * ids.length) / sample);
447
+ let best = Number.POSITIVE_INFINITY;
448
+ for (let other = 0; other < ids.length; other++) {
449
+ if (other !== one) {
450
+ best = Math.min(best, at(one).distanceToSquared(at(other)));
451
+ }
452
+ }
453
+ nearest.push(Math.sqrt(best));
454
+ }
455
+ nearest.sort((one, other) => one - other);
456
+ const reach = (nearest[Math.floor(nearest.length / 2)] ?? 1) * WASH.reach;
457
+ const cells = new Map();
458
+ const key = (x, y, z) => `${Math.floor(x / reach)},${Math.floor(y / reach)},${Math.floor(z / reach)}`;
459
+ for (let spot = 0; spot < ids.length; spot++) {
460
+ const k = key(positions[spot * 3], positions[spot * 3 + 1], positions[spot * 3 + 2]);
461
+ const list = cells.get(k);
462
+ if (list) {
463
+ list.push(spot);
464
+ }
465
+ else {
466
+ cells.set(k, [spot]);
467
+ }
468
+ }
469
+ washFrom = new Int32Array(place.count * WASH.notes).fill(-1);
470
+ washWeight = new Float32Array(place.count * WASH.notes);
471
+ const best = new Float32Array(WASH.notes);
472
+ const which = new Int32Array(WASH.notes);
473
+ for (let corner = 0; corner < place.count; corner++) {
474
+ const x = place.getX(corner);
475
+ const y = place.getY(corner);
476
+ const z = place.getZ(corner);
477
+ best.fill(Number.POSITIVE_INFINITY);
478
+ which.fill(-1);
479
+ const cx = Math.floor(x / reach);
480
+ const cy = Math.floor(y / reach);
481
+ const cz = Math.floor(z / reach);
482
+ for (let dx = -1; dx <= 1; dx++) {
483
+ for (let dy = -1; dy <= 1; dy++) {
484
+ for (let dz = -1; dz <= 1; dz++) {
485
+ for (const spot of cells.get(`${cx + dx},${cy + dy},${cz + dz}`) ?? []) {
486
+ const far = (positions[spot * 3] - x) ** 2 +
487
+ (positions[spot * 3 + 1] - y) ** 2 +
488
+ (positions[spot * 3 + 2] - z) ** 2;
489
+ if (far < best[WASH.notes - 1]) {
490
+ let slot = WASH.notes - 1;
491
+ while (slot > 0 && best[slot - 1] > far) {
492
+ best[slot] = best[slot - 1];
493
+ which[slot] = which[slot - 1];
494
+ slot -= 1;
495
+ }
496
+ best[slot] = far;
497
+ which[slot] = spot;
498
+ }
499
+ }
500
+ }
501
+ }
502
+ }
503
+ for (let slot = 0; slot < WASH.notes; slot++) {
504
+ washFrom[corner * WASH.notes + slot] = which[slot];
505
+ const far = Math.sqrt(best[slot]) / reach;
506
+ washWeight[corner * WASH.notes + slot] = far < 1 ? (1 - far * far) ** 2 : 0;
507
+ }
508
+ }
509
+ washSurface();
510
+ };
511
+ const washSurface = () => {
512
+ const place = solid.geometry.getAttribute('position');
513
+ if (!place || washFrom.length !== place.count * WASH.notes || noteTints.length === 0) {
514
+ return;
515
+ }
516
+ const wash = new Float32Array(place.count * 4);
517
+ for (let corner = 0; corner < place.count; corner++) {
518
+ let red = 0;
519
+ let green = 0;
520
+ let blue = 0;
521
+ let total = 0;
522
+ for (let slot = 0; slot < WASH.notes; slot++) {
523
+ const spot = washFrom[corner * WASH.notes + slot];
524
+ const weight = washWeight[corner * WASH.notes + slot] * (noteTints[spot * 4 + 3] ?? 0);
525
+ if (spot < 0 || weight <= 0) {
526
+ continue;
527
+ }
528
+ red += noteTints[spot * 4] * weight;
529
+ green += noteTints[spot * 4 + 1] * weight;
530
+ blue += noteTints[spot * 4 + 2] * weight;
531
+ total += weight;
532
+ }
533
+ if (total > 0) {
534
+ wash.set([red / total, green / total, blue / total, Math.min(total / WASH.full, 1)], corner * 4);
535
+ }
536
+ }
537
+ solid.geometry.setAttribute('wash', new BufferAttribute(wash, 4));
538
+ };
169
539
  const at = (spot) => new Vector3(positions[spot * 3], positions[spot * 3 + 1], positions[spot * 3 + 2]);
170
540
  const onScreen = (id) => {
171
541
  const spot = index.get(id);
@@ -189,14 +559,39 @@ export const threeDriver = (container, options = {}) => {
189
559
  };
190
560
  };
191
561
  const held = gesturesHeld(container);
192
- const signals = signalsOver(container, onScreen);
562
+ const arc = (from, to) => {
563
+ const one = index.get(from);
564
+ const other = index.get(to);
565
+ if (one === undefined || other === undefined) {
566
+ return undefined;
567
+ }
568
+ const a = at(one);
569
+ const b = at(other);
570
+ const middle = a.clone().add(b).multiplyScalar(0.5);
571
+ const center = points.geometry.boundingSphere?.center ?? controls.target;
572
+ const out = middle.clone().sub(center);
573
+ if (out.lengthSq() < 1e-9) {
574
+ out.copy(camera.up);
575
+ }
576
+ const peak = middle.add(out.normalize().multiplyScalar(a.distanceTo(b) * LIFT * 2));
577
+ const depth = peak.clone().applyMatrix4(camera.matrixWorldInverse).z;
578
+ if (depth > 0) {
579
+ return undefined;
580
+ }
581
+ peak.project(camera);
582
+ const { clientHeight: height, clientWidth: width } = container;
583
+ return { x: ((peak.x + 1) / 2) * width, y: ((1 - peak.y) / 2) * height };
584
+ };
585
+ const signals = signalsOver(container, (id) => nearOnScreen(id), arc);
193
586
  const nearOnScreen = (id) => {
194
587
  const spot = index.get(id);
195
588
  if (spot === undefined) {
196
589
  return undefined;
197
590
  }
198
591
  const depth = -at(spot).applyMatrix4(camera.matrixWorldInverse).z;
199
- return uniforms.steep.value > 0 && depth > uniforms.middle.value ? undefined : onScreen(id);
592
+ return (uniforms.steep.value > 0 || solid.visible) && depth > uniforms.middle.value
593
+ ? undefined
594
+ : onScreen(id);
200
595
  };
201
596
  const groups = groupsOver(container, nearOnScreen);
202
597
  const names = namesOver(container, onScreen);
@@ -276,19 +671,91 @@ export const threeDriver = (container, options = {}) => {
276
671
  });
277
672
  const geometry = points.geometry;
278
673
  geometry.setAttribute('tint', new BufferAttribute(tints, 4));
674
+ noteTints = tints;
675
+ washSurface();
279
676
  geometry.setAttribute('size', new BufferAttribute(drawn, 1));
280
677
  geometry.setAttribute('shape', new BufferAttribute(shapes, 1));
281
678
  geometry.setAttribute('glow', new BufferAttribute(glows, 1));
679
+ drawnSizes = drawn;
680
+ meshShapes(shapes, tints);
282
681
  dark = !isLight(looks.ground);
682
+ const solidUniforms = solid.material.uniforms;
683
+ solidUniforms.ground.value = channels(looks.ground);
684
+ solidUniforms.ink.value = channels(looks.ink);
685
+ solid.material.uniforms.strength.value = dark ? WASH.dark : WASH.light;
686
+ uniforms.bloom.value = dark ? BLOOM.dark : BLOOM.light;
687
+ uniforms.dark.value = dark ? 1 : 0;
283
688
  const blending = dark ? AdditiveBlending : NormalBlending;
284
- points.material.blending = blending;
689
+ halos.material.blending = blending;
690
+ shapeMaterial.blending = blending;
691
+ shapeMaterial.needsUpdate = true;
285
692
  quiet.material.blending = blending;
286
693
  lit.material.blending = blending;
287
- points.material.needsUpdate = true;
694
+ halos.material.needsUpdate = true;
288
695
  quiet.material.needsUpdate = true;
289
696
  lit.material.needsUpdate = true;
290
697
  paintLinks();
291
698
  };
699
+ let drawnSizes = new Float32Array();
700
+ const meshShapes = (shapes, tints) => {
701
+ for (const [code, solidShape] of [
702
+ [SHAPES.square, solids.cube],
703
+ [SHAPES.diamond, solids.octahedron],
704
+ ]) {
705
+ const spots = [];
706
+ shapes.forEach((shape, spot) => {
707
+ if (shape === code) {
708
+ spots.push(spot);
709
+ }
710
+ });
711
+ solidShape.spots = spots;
712
+ if (!solidShape.mesh || solidShape.mesh.count !== spots.length) {
713
+ if (solidShape.mesh) {
714
+ scene.remove(solidShape.mesh);
715
+ solidShape.mesh.dispose();
716
+ }
717
+ solidShape.mesh =
718
+ spots.length > 0
719
+ ? new InstancedMesh(solidShape.geometry, shapeMaterial, spots.length)
720
+ : undefined;
721
+ if (solidShape.mesh) {
722
+ solidShape.mesh.frustumCulled = false;
723
+ scene.add(solidShape.mesh);
724
+ }
725
+ }
726
+ const mesh = solidShape.mesh;
727
+ if (mesh) {
728
+ const tone = new Float32Array(spots.length * 4);
729
+ spots.forEach((spot, at) => {
730
+ tone.set(tints.subarray(spot * 4, spot * 4 + 4), at * 4);
731
+ });
732
+ mesh.geometry = solidShape.geometry;
733
+ mesh.geometry.setAttribute('tone', new InstancedBufferAttribute(tone, 4));
734
+ }
735
+ }
736
+ placeShapes();
737
+ };
738
+ const turned = new Quaternion();
739
+ const placed = new Matrix4();
740
+ const where = new Vector3();
741
+ const scaled = new Vector3();
742
+ const placeShapes = () => {
743
+ for (const solidShape of [solids.cube, solids.octahedron]) {
744
+ const mesh = solidShape.mesh;
745
+ if (!mesh) {
746
+ continue;
747
+ }
748
+ solidShape.spots.forEach((spot, at) => {
749
+ const size = (drawnSizes[spot] ?? 1) * uniforms.unit.value * 0.5;
750
+ const twist = depthOf(ids[spot] ?? '');
751
+ turned.setFromEuler(new Euler(0.45 + twist * 0.3, 0.6 + twist, 0.2 * twist));
752
+ where.set(positions[spot * 3], positions[spot * 3 + 1], positions[spot * 3 + 2]);
753
+ scaled.setScalar(size);
754
+ mesh.setMatrixAt(at, placed.compose(where, turned, scaled));
755
+ });
756
+ mesh.instanceMatrix.needsUpdate = true;
757
+ }
758
+ };
292
759
  const paintLinks = () => {
293
760
  if (!looks) {
294
761
  return;
@@ -353,6 +820,9 @@ export const threeDriver = (container, options = {}) => {
353
820
  points.geometry.computeBoundingSphere();
354
821
  hovered = undefined;
355
822
  paint();
823
+ if (solid.visible) {
824
+ tieSurface();
825
+ }
356
826
  };
357
827
  const toWorld = (node) => [
358
828
  node.x * scale,
@@ -362,6 +832,8 @@ export const threeDriver = (container, options = {}) => {
362
832
  let morph;
363
833
  let layout;
364
834
  const cue = () => {
835
+ camera.updateMatrixWorld();
836
+ uniforms.light.value.copy(LIGHT).transformDirection(camera.matrixWorldInverse);
365
837
  const distance = camera.position.distanceTo(controls.target);
366
838
  const { center, radius } = points.geometry.boundingSphere ?? {
367
839
  center: controls.target,
@@ -446,6 +918,9 @@ export const threeDriver = (container, options = {}) => {
446
918
  now - handled > REST;
447
919
  controls.update();
448
920
  cue();
921
+ if (solids.cube.mesh || solids.octahedron.mesh) {
922
+ placeShapes();
923
+ }
449
924
  renderer.render(scene, camera);
450
925
  groups.draw();
451
926
  drawNames();
@@ -483,9 +958,14 @@ export const threeDriver = (container, options = {}) => {
483
958
  hovering = requestAnimationFrame(() => {
484
959
  raycaster.setFromCamera(pointer, camera);
485
960
  raycaster.params.Points = { threshold: uniforms.unit.value * 2.4 };
961
+ const front = solid.visible
962
+ ? (raycaster.intersectObject(solid, false)[0]?.distance ?? Number.POSITIVE_INFINITY)
963
+ : Number.POSITIVE_INFINITY;
486
964
  const hit = raycaster.intersectObject(points, false).find((one) => {
487
965
  const id = one.index === undefined ? undefined : ids[one.index];
488
- return id !== undefined && looks?.node(id).hidden !== true;
966
+ return (id !== undefined &&
967
+ looks?.node(id).hidden !== true &&
968
+ one.distance < front + uniforms.unit.value * 3);
489
969
  });
490
970
  const next = hit?.index;
491
971
  if (next !== hovered) {
@@ -520,6 +1000,7 @@ export const threeDriver = (container, options = {}) => {
520
1000
  });
521
1001
  moved = false;
522
1002
  load(nodes, next, places);
1003
+ shapeSolid();
523
1004
  fitAll(0);
524
1005
  },
525
1006
  fit: (fitted, animate) => {
@@ -627,8 +1108,13 @@ export const threeDriver = (container, options = {}) => {
627
1108
  layout = undefined;
628
1109
  };
629
1110
  },
630
- shape: (place) => {
631
- uniforms.steep.value = place === 'surface' ? 1 : 0;
1111
+ shape: (place, frame) => {
1112
+ surfaced = place === 'surface';
1113
+ if (frame !== shapeFrame) {
1114
+ shapeFrame = frame;
1115
+ solidAt = Number.NaN;
1116
+ }
1117
+ uniforms.steep.value = surfaced && !frame ? 1 : 0;
632
1118
  },
633
1119
  signal: (from, to, colors, animate) => {
634
1120
  signals.signal(from, to, colors, animate);
@@ -642,7 +1128,13 @@ export const threeDriver = (container, options = {}) => {
642
1128
  canvas.removeEventListener('pointermove', hover);
643
1129
  canvas.removeEventListener('pointerleave', leave);
644
1130
  controls.dispose();
645
- for (const object of [points, quiet, lit]) {
1131
+ for (const solidShape of [solids.cube, solids.octahedron]) {
1132
+ solidShape.mesh?.dispose();
1133
+ solidShape.geometry.dispose();
1134
+ }
1135
+ shapeMaterial.dispose();
1136
+ halos.material.dispose();
1137
+ for (const object of [points, quiet, lit, solid]) {
646
1138
  object.geometry.dispose();
647
1139
  object.material.dispose();
648
1140
  }