@fusion.dev/helix-graph 0.122.0 → 0.123.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -211,9 +211,26 @@ makes one from what a page has, and a field is a stencil itself:
211
211
 
212
212
  | Function | Makes |
213
213
  |---|---|
214
+ | `shapeFromModel(url)` | A solid from a glTF model, `.glb` or `.gltf`, every mesh where the model places it. Async; three.js reads the file |
215
+ | `shapeFromTriangles(positions, index)` | A solid from a closed surface of triangles a page already holds |
216
+ | `shapeFromSvg(svg)` | A flat field from an `<svg>` or its markup: every path, rectangle, circle, ellipse and polygon in its `viewBox` |
217
+ | `shapeFromImage(image, { threshold })` | A flat field from an image's opaque pixels |
214
218
  | `shapeFromPath(path, width, height)` | A flat field from an SVG path |
215
219
  | `shapeFromInside(inside, { width, height, depth })` | A flat field, or a solid where `depth` is given, from a test of what is inside |
216
220
  | `inflate(field, { depth, roundness })` | A solid puffed out of a flat field, as a cushion is, cut through where the flat shape has a hole |
221
+ | `sphere`, `torus`, `box` | Solids measured exactly from their formulas |
222
+ | `onSphere(field, { radius, thickness })` | A flat map wrapped round a ball, as a world map is round a globe |
223
+
224
+ ```js
225
+ const stencil = { ...(await shapeFromModel('/models/brain.glb')), place: 'surface' }
226
+ connectGraph(frame, threeDriver(box), { stencil })
227
+ ```
228
+
229
+ **A model is read a column of cells at a time**, so one of a hundred thousand
230
+ triangles becomes a field in a moment. Any closed mesh works; a mesh with
231
+ holes is read as closed where it can be. A model served from another origin
232
+ needs that origin to allow it, as any `fetch` does. Use a model whose license
233
+ lets your readers copy it, since the examples a reader takes away name it.
217
234
 
218
235
  ## In three dimensions
219
236
 
@@ -237,15 +254,18 @@ connectGraph(frame, threeDriver(box), {
237
254
 
238
255
  - **A solid stencil draws the notes in the solid**: all through it, or, with
239
256
  `place: 'surface'`, in a thin shell just inside its edge, which is how a
240
- solid reads as the thing it is shaped like. Each group takes one side of
241
- the shape, and each hub's notes a patch around it.
257
+ solid reads as the thing it is shaped like. Each group takes one piece of
258
+ the surface, measured across it, so a group on a knot is a stretch of its
259
+ tube, and each hub's notes a patch around it.
260
+ - **On a surface, the shape itself is drawn** in the ground's color just
261
+ inside the notes, so what is behind it is hidden, and the pointer finds
262
+ only the notes in front.
242
263
  - **Without a stencil, a layout runs in three dimensions** with d3-force-3d.
243
264
  - **Dragging turns the graph, and the wheel and a pinch bring it nearer.** It
244
265
  turns slowly by itself while nobody is handling it, and never for a reader
245
266
  who asks for less motion.
246
- - **The far side recedes.** On a surface, the far side of the shape falls to
247
- a tenth, so the near side reads; through a graph, its far notes are a
248
- little quieter than its near ones.
267
+ - **The far side recedes.** Through a graph, its far notes are a little
268
+ quieter than its near ones.
249
269
  - **Each group is named over its near side**, and a group's name moves a line
250
270
  where it would cover another's.
251
271
 
@@ -264,7 +284,7 @@ connectGraph(frame, threeDriver(box), {
264
284
  | `@fusion.dev/helix-graph/sigma` | `sigmaDriver(sigma, options)` |
265
285
  | `@fusion.dev/helix-graph/cosmos` | `cosmosDriver(graph, box, options)` |
266
286
  | `@fusion.dev/helix-graph/three` | `threeDriver(box, options)` |
267
- | `@fusion.dev/helix-graph/shapes` | `shapeFromPath`, `shapeFromInside`, `inflate`, and the `ShapeField` type |
287
+ | `@fusion.dev/helix-graph/shapes` | `shapeFromModel`, `shapeFromTriangles`, `shapeFromSvg`, `shapeFromImage`, `shapeFromPath`, `shapeFromInside`, `inflate`, `sphere`, `torus`, `box`, `onSphere`, and the `ShapeField` type |
268
288
 
269
289
  **Sigma, cosmos.gl, three.js and d3-force-3d are optional peers, and graphology is the page's.** Each
270
290
  driver types its renderer by the calls it makes on it, so a page brings
@@ -2,7 +2,7 @@ import { type ViewportGestureMode } from '@fusion.dev/helix-core';
2
2
  import { type DrawnLink, type DrawnNode, type GraphChanges } from './diff.js';
3
3
  import { type GraphLooks } from './looks.js';
4
4
  import { type GraphPoint } from './seed.js';
5
- import { type GraphStencil } from './stencil.js';
5
+ import { type GraphStencil, type StencilFrame } from './stencil.js';
6
6
  export type { DrawnLink, DrawnNode, GraphChanges } from './diff.js';
7
7
  /**
8
8
  * What a renderer has to answer for the frame to drive it, and nothing that is
@@ -85,9 +85,11 @@ export interface GraphDriver {
85
85
  * Where in a shape the notes are drawn, where a `stencil` gives one:
86
86
  * `surface`, a shell, whose far side a renderer that draws in three
87
87
  * dimensions sets back so its near side reads; `fill`, all through it; or
88
- * undefined, no shape.
88
+ * undefined, no shape. `frame` is the shape and where it was put among the
89
+ * notes, once the connector has drawn the notes in it, for a renderer that
90
+ * draws the shape as well.
89
91
  */
90
- shape?(place: 'fill' | 'surface' | undefined): void;
92
+ shape?(place: 'fill' | 'surface' | undefined, frame?: StencilFrame): void;
91
93
  /** Removes whatever the driver added to the renderer or the page, when the connection ends. */
92
94
  stop?(): void;
93
95
  }
@@ -3,7 +3,7 @@ import { arrangeByGroup } from './arrange.js';
3
3
  import { diffGraph, isEmpty, linkKey, } from './diff.js';
4
4
  import { graphColorsFrom, looksOf, toneColor, } from './looks.js';
5
5
  import { seedPositions } from './seed.js';
6
- import { fieldOf, fitToStencil } from './stencil.js';
6
+ import { fieldOf, fitInStencil } from './stencil.js';
7
7
  const viewportOf = (frame) => {
8
8
  const box = (frame.shadowRoot?.querySelector('[part="viewport"]') ?? frame).getBoundingClientRect();
9
9
  return { height: box.height, width: box.width };
@@ -12,6 +12,7 @@ const still = () => typeof matchMedia === 'function' && matchMedia('(prefers-red
12
12
  export const connectGraph = (frame, driver, options = {}) => {
13
13
  const graph = frame;
14
14
  let known = new Map();
15
+ let shapeFrame;
15
16
  let drawnNodes = new Map();
16
17
  let drawnLinks = new Map();
17
18
  let first = true;
@@ -94,12 +95,16 @@ export const connectGraph = (frame, driver, options = {}) => {
94
95
  }
95
96
  }
96
97
  let { positions, unplaced } = seedPositions(nodes, [...nextLinks.values()], known);
97
- const shaped = options.stencil && ids.size > 0 && unplaced.size === ids.size
98
- ? fitToStencil(arrangeByGroup(nodes, [...nextLinks.values()], {
98
+ const fit = options.stencil && ids.size > 0 && unplaced.size === ids.size
99
+ ? fitInStencil(arrangeByGroup(nodes, [...nextLinks.values()], {
99
100
  solid: fieldOf(options.stencil).depth > 0,
100
101
  }), options.stencil)
101
102
  : undefined;
102
- driver.shape?.(options.stencil ? (options.stencil.place ?? 'fill') : undefined);
103
+ const shaped = fit?.positions;
104
+ if (fit?.frame) {
105
+ shapeFrame = fit.frame;
106
+ }
107
+ driver.shape?.(options.stencil ? (options.stencil.place ?? 'fill') : undefined, shapeFrame);
103
108
  if (shaped) {
104
109
  positions = shaped;
105
110
  unplaced = new Set();
@@ -17,6 +17,20 @@ export interface ShapeField {
17
17
  /** How wide the box is. */
18
18
  readonly width: number;
19
19
  }
20
+ /** How many cells a field has along its longer side, flat and solid. */
21
+ export declare const FLAT = 512;
22
+ export declare const SOLID = 96;
23
+ /**
24
+ * A field from a grid of cells marked inside, `cell` box units a cell, x
25
+ * fastest and z slowest: the distance to the nearest outside cell, taken
26
+ * from the distance to the nearest inside one, so the sign says which side
27
+ * of the edge a point is on.
28
+ */
29
+ export declare const fieldFromCells: (inside: Uint8Array, sizes: readonly [number, number, number], cell: number, box: {
30
+ depth: number;
31
+ height: number;
32
+ width: number;
33
+ }) => ShapeField;
20
34
  /**
21
35
  * A field from a test of whether a point in the box is inside, sampled at
22
36
  * the center of each cell: flat when `depth` is zero, solid otherwise.
@@ -1,5 +1,5 @@
1
- const FLAT = 512;
2
- const SOLID = 96;
1
+ export const FLAT = 512;
2
+ export const SOLID = 96;
3
3
  const FAR = 1e20;
4
4
  const line = (values, count, out) => {
5
5
  const hull = new Int32Array(count);
@@ -67,7 +67,7 @@ const transform = (marked, sizes) => {
67
67
  }
68
68
  return grid;
69
69
  };
70
- const fieldFromCells = (inside, sizes, cell, box) => {
70
+ export const fieldFromCells = (inside, sizes, cell, box) => {
71
71
  const outside = new Uint8Array(inside.length);
72
72
  for (let at = 0; at < inside.length; at++) {
73
73
  outside[at] = inside[at] ? 0 : 1;
@@ -48,3 +48,26 @@ export declare const placesIn: (field: ShapeField, count: number, { place, shell
48
48
  * they were.
49
49
  */
50
50
  export declare const fitToStencil: (positions: ReadonlyMap<string, GraphPoint>, stencil: GraphStencil) => Map<string, GraphPoint>;
51
+ /**
52
+ * Where a stencil's box was put, in the places' units: its middle, and how
53
+ * many of the places' units one of the box's is. A point in the box at `x`,
54
+ * `y` and `z` is at `middle.x + (x - width / 2) * scale`,
55
+ * `middle.y - (y - height / 2) * scale` and `middle.z + (z - depth / 2) * scale`.
56
+ */
57
+ export interface StencilFrame {
58
+ field: ShapeField;
59
+ middle: {
60
+ x: number;
61
+ y: number;
62
+ z: number;
63
+ };
64
+ /** How many of the places' units one of the box's is. */
65
+ scale: number;
66
+ /** How thick the shell the notes were placed in is, in the box's units: none where they fill the shape. */
67
+ shell: number;
68
+ }
69
+ /** `fitToStencil`, saying where it put the stencil's box as well, where it fitted the places at all. */
70
+ export declare const fitInStencil: (positions: ReadonlyMap<string, GraphPoint>, stencil: GraphStencil) => {
71
+ frame?: StencilFrame | undefined;
72
+ positions: Map<string, GraphPoint>;
73
+ };
@@ -1,4 +1,5 @@
1
1
  import { fieldFromInside, fieldFromPath } from './shape-field.js';
2
+ import { surfaceWeb } from './surface-web.js';
2
3
  const PROBE = { flat: 64, solid: 32 };
3
4
  const SHELL = 0.04;
4
5
  const WAVE = 0.07;
@@ -93,10 +94,11 @@ const nested = (items, keys) => {
93
94
  }
94
95
  return out;
95
96
  };
96
- export const fitToStencil = (positions, stencil) => {
97
+ export const fitToStencil = (positions, stencil) => fitInStencil(positions, stencil).positions;
98
+ export const fitInStencil = (positions, stencil) => {
97
99
  const notes = [...positions].map(([id, at]) => ({ at, id }));
98
100
  if (notes.length === 0) {
99
- return new Map();
101
+ return { positions: new Map() };
100
102
  }
101
103
  const field = fieldOf(stencil);
102
104
  const places = placesIn(field, notes.length, {
@@ -104,9 +106,12 @@ export const fitToStencil = (positions, stencil) => {
104
106
  shell: stencil.shell ?? SHELL,
105
107
  });
106
108
  if (places.length < notes.length) {
107
- return new Map(positions);
109
+ return { positions: new Map(positions) };
108
110
  }
109
111
  const solid = field.depth > 0;
112
+ const thickness = (stencil.place ?? 'fill') === 'surface'
113
+ ? (stencil.shell ?? SHELL) * Math.max(field.width, field.height, field.depth)
114
+ : 0;
110
115
  const low = {
111
116
  x: Number.POSITIVE_INFINITY,
112
117
  y: Number.POSITIVE_INFINITY,
@@ -142,17 +147,6 @@ export const fitToStencil = (positions, stencil) => {
142
147
  });
143
148
  const fitted = new Map();
144
149
  if (solid) {
145
- const surface = (stencil.place ?? 'fill') === 'surface';
146
- const way = (point) => {
147
- if (!surface) {
148
- return point;
149
- }
150
- const x = point.x - middle.x;
151
- const y = point.y - middle.y;
152
- const z = (point.z ?? 0) - middle.z;
153
- const length = Math.hypot(x, y, z) || 1;
154
- return { x: x / length, y: y / length, z: z / length };
155
- };
156
150
  const widest = (points) => {
157
151
  let best = 'x';
158
152
  let spread = Number.NEGATIVE_INFINITY;
@@ -171,29 +165,38 @@ export const fitToStencil = (positions, stencil) => {
171
165
  }
172
166
  return best;
173
167
  };
168
+ const targets = spots.map((spot) => spot.at);
169
+ const web = (stencil.place ?? 'fill') === 'surface' ? surfaceWeb(targets) : undefined;
170
+ const halveTargets = (places, first) => {
171
+ if (web && places.length > 2) {
172
+ return web.halve(places, first);
173
+ }
174
+ const axis = widest(places.map((at) => targets[at]));
175
+ const ordered = [...places].sort((one, other) => (targets[one][axis] ?? 0) - (targets[other][axis] ?? 0));
176
+ return [ordered.slice(0, first), ordered.slice(first)];
177
+ };
174
178
  const halve = (ours, theirs) => {
175
179
  if (ours.length === 1) {
176
- fitted.set(ours[0].id, theirs[0].at);
180
+ fitted.set(ours[0].id, targets[theirs[0]]);
177
181
  return;
178
182
  }
179
183
  if (ours.length === 0) {
180
184
  return;
181
185
  }
182
- const mine = widest(ours.map((note) => note.key));
183
- const yours = widest(theirs.map((spot) => spot.key));
184
- ours.sort((one, other) => (one.key[mine] ?? 0) - (other.key[mine] ?? 0));
185
- theirs.sort((one, other) => (one.key[yours] ?? 0) - (other.key[yours] ?? 0));
186
+ const mine = widest(ours.map((note) => note.at));
187
+ ours.sort((one, other) => (one.at[mine] ?? 0) - (other.at[mine] ?? 0));
186
188
  const half = Math.floor(ours.length / 2);
187
- halve(ours.slice(0, half), theirs.slice(0, half));
188
- halve(ours.slice(half), theirs.slice(half));
189
+ const [near, rest] = halveTargets(theirs, half);
190
+ halve(ours.slice(0, half), near);
191
+ halve(ours.slice(half), rest);
189
192
  };
190
- halve(notes.map((note) => ({ ...note, key: way(note.at) })), spots.map((spot) => ({ at: spot.at, key: way(spot.at) })));
191
- return fitted;
193
+ halve([...notes], targets.map((_, at) => at));
194
+ return { frame: { field, middle, scale, shell: thickness }, positions: fitted };
192
195
  }
193
196
  const ours = nested(notes, [(note) => note.at.x, (note) => note.at.y]);
194
197
  const theirs = nested(spots, [(spot) => spot.across, (spot) => spot.down]);
195
198
  ours.forEach((note, at) => {
196
199
  fitted.set(note.id, theirs[at].at);
197
200
  });
198
- return fitted;
201
+ return { frame: { field, middle, scale, shell: thickness }, positions: fitted };
199
202
  };
@@ -0,0 +1,18 @@
1
+ import type { GraphPoint } from './seed.js';
2
+ /**
3
+ * Places on a surface, joined to their near neighbors, and split in halves
4
+ * by how far apart they are across the surface, not through the air.
5
+ *
6
+ * **Half a surface measured across it is one piece of it**: a stretch of a
7
+ * knot's tube, or one lobe of a brain. Half of it by position can be its
8
+ * front and its back, and half of it by direction from its middle can be
9
+ * three passes of a tube that crosses itself.
10
+ */
11
+ export declare const surfaceWeb: (points: readonly GraphPoint[]) => {
12
+ /**
13
+ * A set of places in two halves, `first` as many as the first: the
14
+ * places nearer one end of the set, across the surface, and the rest.
15
+ * The ends are two places about as far apart across it as any.
16
+ */
17
+ halve: (places: readonly number[], first: number) => [number[], number[]];
18
+ };
@@ -0,0 +1,99 @@
1
+ export const surfaceWeb = (points) => {
2
+ const count = points.length;
3
+ const x = (at) => points[at].x;
4
+ const y = (at) => points[at].y;
5
+ const z = (at) => points[at].z ?? 0;
6
+ const far = (one, other) => Math.hypot(x(one) - x(other), y(one) - y(other), z(one) - z(other));
7
+ const sample = Math.min(count, 256);
8
+ const nearest = [];
9
+ for (let step = 0; step < sample; step++) {
10
+ const one = Math.floor((step * count) / sample);
11
+ let best = Number.POSITIVE_INFINITY;
12
+ for (let other = 0; other < count; other++) {
13
+ if (other !== one) {
14
+ best = Math.min(best, far(one, other));
15
+ }
16
+ }
17
+ if (Number.isFinite(best)) {
18
+ nearest.push(best);
19
+ }
20
+ }
21
+ nearest.sort((one, other) => one - other);
22
+ const reach = (nearest[Math.floor(nearest.length / 2)] ?? 1) * 2.2;
23
+ const cells = new Map();
24
+ const cellOf = (at) => [
25
+ Math.floor(x(at) / reach),
26
+ Math.floor(y(at) / reach),
27
+ Math.floor(z(at) / reach),
28
+ ];
29
+ for (let at = 0; at < count; at++) {
30
+ const key = cellOf(at).join(',');
31
+ const list = cells.get(key);
32
+ if (list) {
33
+ list.push(at);
34
+ }
35
+ else {
36
+ cells.set(key, [at]);
37
+ }
38
+ }
39
+ const neighbors = Array.from({ length: count }, () => []);
40
+ for (let at = 0; at < count; at++) {
41
+ const [cx, cy, cz] = cellOf(at);
42
+ for (let dx = -1; dx <= 1; dx++) {
43
+ for (let dy = -1; dy <= 1; dy++) {
44
+ for (let dz = -1; dz <= 1; dz++) {
45
+ for (const other of cells.get(`${cx + dx},${cy + dy},${cz + dz}`) ?? []) {
46
+ if (other > at && far(at, other) <= reach) {
47
+ ;
48
+ neighbors[at].push(other);
49
+ neighbors[other].push(at);
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ const member = new Int32Array(count).fill(-1);
57
+ let round = 0;
58
+ const steps = (from, out) => {
59
+ out.fill(-1);
60
+ const queue = [from];
61
+ out[from] = 0;
62
+ let last = from;
63
+ for (let head = 0; head < queue.length; head++) {
64
+ const at = queue[head];
65
+ last = at;
66
+ for (const next of neighbors[at]) {
67
+ if (member[next] === round && out[next] === -1) {
68
+ out[next] = out[at] + 1;
69
+ queue.push(next);
70
+ }
71
+ }
72
+ }
73
+ return last;
74
+ };
75
+ const fromA = new Int32Array(count);
76
+ const fromB = new Int32Array(count);
77
+ return {
78
+ halve: (places, first) => {
79
+ round += 1;
80
+ for (const at of places) {
81
+ member[at] = round;
82
+ }
83
+ const a = steps(places[0], fromA);
84
+ const b = steps(a, fromA);
85
+ steps(b, fromB);
86
+ const unreached = places.length * 4;
87
+ const key = (at) => {
88
+ const one = fromA[at];
89
+ const other = fromB[at];
90
+ if (one < 0 || other < 0) {
91
+ return unreached + far(at, a) - far(at, b);
92
+ }
93
+ return one - other;
94
+ };
95
+ const ordered = [...places].sort((one, other) => key(one) - key(other));
96
+ return [ordered.slice(0, first), ordered.slice(first)];
97
+ },
98
+ };
99
+ };
package/dist/index.d.ts CHANGED
@@ -2,4 +2,4 @@ export { type ConnectGraphOptions, connectGraph, type DrawnLink, type DrawnNode,
2
2
  export { diffGraph } from './connect-graph/diff.js';
3
3
  export { type GraphColors, type GraphLooks, graphColorsFrom, type LinkLook, type NodeLook, } from './connect-graph/looks.js';
4
4
  export { type GraphPoint, seedPositions } from './connect-graph/seed.js';
5
- export { fitToStencil, type GraphStencil } from './connect-graph/stencil.js';
5
+ export { fitToStencil, type GraphStencil, type StencilFrame, } from './connect-graph/stencil.js';
package/dist/index.js CHANGED
@@ -2,4 +2,4 @@ export { connectGraph, } from './connect-graph/connect-graph.js';
2
2
  export { diffGraph } from './connect-graph/diff.js';
3
3
  export { graphColorsFrom, } from './connect-graph/looks.js';
4
4
  export { seedPositions } from './connect-graph/seed.js';
5
- export { fitToStencil } from './connect-graph/stencil.js';
5
+ export { fitToStencil, } from './connect-graph/stencil.js';
@@ -0,0 +1,28 @@
1
+ import { type ShapeField } from '../connect-graph/shape-field.js';
2
+ /**
3
+ * A flat field from an SVG: an `<svg>` element, or its markup. Its box is
4
+ * its `viewBox`, and its shape every path, rectangle, circle, ellipse and
5
+ * polygon in it, together, their holes cut by the even-odd rule.
6
+ *
7
+ * Transforms on the shapes are not read: draw the outline in the `viewBox`'s
8
+ * own units, as an exported icon or logo is.
9
+ */
10
+ export declare const shapeFromSvg: (svg: SVGSVGElement | string, resolution?: number) => ShapeField;
11
+ /** How an image is read as a shape. */
12
+ export interface ImageOptions {
13
+ /** How opaque a pixel is to be inside, from 0 to 1. 0.5 by default. */
14
+ threshold?: number | undefined;
15
+ /** How many cells the field has along the image's longer side. 512 by default. */
16
+ resolution?: number | undefined;
17
+ }
18
+ /**
19
+ * A flat field from an image's opaque pixels: a logo, a silhouette, or a
20
+ * mask drawn for the purpose. Its box is the image's size in pixels.
21
+ *
22
+ * An image from another site is read only where that site allows it, as a
23
+ * canvas reads any image.
24
+ */
25
+ export declare const shapeFromImage: (image: CanvasImageSource & {
26
+ height: number | SVGAnimatedLength;
27
+ width: number | SVGAnimatedLength;
28
+ }, { resolution, threshold }?: ImageOptions) => ShapeField;
@@ -0,0 +1,63 @@
1
+ import { FLAT, fieldFromCells, fieldFromPath, } from '../connect-graph/shape-field.js';
2
+ const pathOf = (element) => {
3
+ const number = (name) => Number(element.getAttribute(name) ?? 0);
4
+ switch (element.localName) {
5
+ case 'path':
6
+ return element.getAttribute('d') ?? '';
7
+ case 'rect': {
8
+ const [x, y, width, height] = [number('x'), number('y'), number('width'), number('height')];
9
+ return `M${x} ${y}h${width}v${height}h${-width}Z`;
10
+ }
11
+ case 'circle':
12
+ case 'ellipse': {
13
+ const [cx, cy] = [number('cx'), number('cy')];
14
+ const rx = element.localName === 'circle' ? number('r') : number('rx');
15
+ const ry = element.localName === 'circle' ? number('r') : number('ry');
16
+ return `M${cx - rx} ${cy}a${rx} ${ry} 0 1 0 ${rx * 2} 0a${rx} ${ry} 0 1 0 ${-rx * 2} 0Z`;
17
+ }
18
+ case 'polygon': {
19
+ const points = element.getAttribute('points')?.trim() ?? '';
20
+ return points ? `M${points}Z` : '';
21
+ }
22
+ default:
23
+ return '';
24
+ }
25
+ };
26
+ export const shapeFromSvg = (svg, resolution = FLAT) => {
27
+ const element = typeof svg === 'string'
28
+ ? new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement
29
+ : svg;
30
+ const [, , width = 0, height = 0] = (element.getAttribute('viewBox') ?? '')
31
+ .trim()
32
+ .split(/[\s,]+/)
33
+ .map(Number);
34
+ const box = {
35
+ height: height || Number(element.getAttribute('height') ?? 0),
36
+ width: width || Number(element.getAttribute('width') ?? 0),
37
+ };
38
+ const path = [...element.querySelectorAll('path, rect, circle, ellipse, polygon')]
39
+ .map(pathOf)
40
+ .join(' ');
41
+ return fieldFromPath(path, box.width || 1, box.height || 1, resolution);
42
+ };
43
+ export const shapeFromImage = (image, { resolution = FLAT, threshold = 0.5 } = {}) => {
44
+ const size = (value) => typeof value === 'number' ? value : value.baseVal.value;
45
+ const width = Math.max(size(image.width), 1);
46
+ const height = Math.max(size(image.height), 1);
47
+ const cell = Math.max(width, height) / resolution;
48
+ const nx = Math.max(1, Math.ceil(width / cell));
49
+ const ny = Math.max(1, Math.ceil(height / cell));
50
+ const canvas = document.createElement('canvas');
51
+ canvas.width = nx;
52
+ canvas.height = ny;
53
+ const context = canvas.getContext('2d', { willReadFrequently: true });
54
+ const cells = new Uint8Array(nx * ny);
55
+ if (context) {
56
+ context.drawImage(image, 0, 0, nx, ny);
57
+ const alpha = context.getImageData(0, 0, nx, ny).data;
58
+ for (let at = 0; at < cells.length; at++) {
59
+ cells[at] = (alpha[at * 4 + 3] ?? 0) / 255 >= threshold ? 1 : 0;
60
+ }
61
+ }
62
+ return fieldFromCells(cells, [nx, ny, 1], cell, { depth: 0, height, width });
63
+ };
@@ -1 +1,5 @@
1
1
  export { fieldFromInside as shapeFromInside, fieldFromPath as shapeFromPath, inflate, type ShapeField, } from '../connect-graph/shape-field.js';
2
+ export { type ImageOptions, shapeFromImage, shapeFromSvg } from './flat.js';
3
+ export { shapeFromModel } from './model.js';
4
+ export { box, onSphere, sphere, torus } from './solids.js';
5
+ export { shapeFromTriangles, type TriangleOptions } from './triangles.js';
@@ -1 +1,5 @@
1
1
  export { fieldFromInside as shapeFromInside, fieldFromPath as shapeFromPath, inflate, } from '../connect-graph/shape-field.js';
2
+ export { shapeFromImage, shapeFromSvg } from './flat.js';
3
+ export { shapeFromModel } from './model.js';
4
+ export { box, onSphere, sphere, torus } from './solids.js';
5
+ export { shapeFromTriangles } from './triangles.js';
@@ -0,0 +1,12 @@
1
+ import type { ShapeField } from '../connect-graph/shape-field.js';
2
+ import { type TriangleOptions } from './triangles.js';
3
+ /**
4
+ * A solid field from a glTF model, the web's standard format for one: a
5
+ * URL to a `.glb` or `.gltf` file, or the file's bytes. Every mesh in it is
6
+ * read, placed as the model places it.
7
+ *
8
+ * **three.js reads the file**, imported when this is first called, so a page
9
+ * that draws no model never downloads a loader. It is an optional peer of
10
+ * this package.
11
+ */
12
+ export declare const shapeFromModel: (source: string | ArrayBuffer, options?: TriangleOptions) => Promise<ShapeField>;
@@ -0,0 +1,31 @@
1
+ import { shapeFromTriangles } from './triangles.js';
2
+ export const shapeFromModel = async (source, options = {}) => {
3
+ const [{ GLTFLoader }, { Mesh, Vector3 }] = await Promise.all([
4
+ import('three/addons/loaders/GLTFLoader.js'),
5
+ import('three'),
6
+ ]);
7
+ const loader = new GLTFLoader();
8
+ const model = typeof source === 'string'
9
+ ? await loader.loadAsync(source)
10
+ : await loader.parseAsync(source, '');
11
+ model.scene.updateMatrixWorld(true);
12
+ const corners = [];
13
+ const point = new Vector3();
14
+ model.scene.traverse((object) => {
15
+ if (!(object instanceof Mesh)) {
16
+ return;
17
+ }
18
+ const geometry = object.geometry;
19
+ const place = geometry.getAttribute('position');
20
+ if (!place) {
21
+ return;
22
+ }
23
+ const index = geometry.getIndex();
24
+ const count = index ? index.count : place.count;
25
+ for (let at = 0; at < count; at++) {
26
+ point.fromBufferAttribute(place, index ? index.getX(at) : at).applyMatrix4(object.matrixWorld);
27
+ corners.push(point.x, point.y, point.z);
28
+ }
29
+ });
30
+ return shapeFromTriangles(corners, null, options);
31
+ };
@@ -0,0 +1,28 @@
1
+ import type { ShapeField } from '../connect-graph/shape-field.js';
2
+ /** A ball `radius` across from its middle. */
3
+ export declare const sphere: ({ radius }?: {
4
+ radius?: number;
5
+ }) => ShapeField;
6
+ /** A ring: `radius` from its middle to the middle of its tube, which is `tube` thick from its own middle out. */
7
+ export declare const torus: ({ radius, tube, }?: {
8
+ radius?: number;
9
+ tube?: number;
10
+ }) => ShapeField;
11
+ /** A box `width` by `height` by `depth`, its edges rounded `round` in. */
12
+ export declare const box: ({ depth, height, round, width, }?: {
13
+ depth?: number;
14
+ height?: number;
15
+ round?: number;
16
+ width?: number;
17
+ }) => ShapeField;
18
+ /**
19
+ * A flat shape wrapped round a ball, as a map of the world is round a globe:
20
+ * the flat shape's width is the way round and its height pole to pole, as an
21
+ * equirectangular map is drawn. The solid is a shell `thickness` deep at the
22
+ * ball's surface, wherever the flat shape is, so notes placed in it draw the
23
+ * map in three dimensions.
24
+ */
25
+ export declare const onSphere: (flat: ShapeField, { radius, thickness }?: {
26
+ radius?: number;
27
+ thickness?: number;
28
+ }) => ShapeField;
@@ -0,0 +1,47 @@
1
+ export const sphere = ({ radius = 50 } = {}) => ({
2
+ depth: radius * 2,
3
+ distance: (x, y, z = radius) => Math.hypot(x - radius, y - radius, z - radius) - radius,
4
+ height: radius * 2,
5
+ width: radius * 2,
6
+ });
7
+ export const torus = ({ radius = 50, tube = 18, } = {}) => {
8
+ const reach = radius + tube;
9
+ return {
10
+ depth: tube * 2,
11
+ distance: (x, y, z = tube) => Math.hypot(Math.hypot(x - reach, y - reach) - radius, z - tube) - tube,
12
+ height: reach * 2,
13
+ width: reach * 2,
14
+ };
15
+ };
16
+ export const box = ({ depth = 100, height = 100, round = 0, width = 100, } = {}) => ({
17
+ depth,
18
+ distance: (x, y, z = depth / 2) => {
19
+ const qx = Math.abs(x - width / 2) - width / 2 + round;
20
+ const qy = Math.abs(y - height / 2) - height / 2 + round;
21
+ const qz = Math.abs(z - depth / 2) - depth / 2 + round;
22
+ return (Math.hypot(Math.max(qx, 0), Math.max(qy, 0), Math.max(qz, 0)) +
23
+ Math.min(Math.max(qx, qy, qz), 0) -
24
+ round);
25
+ },
26
+ height,
27
+ width,
28
+ });
29
+ export const onSphere = (flat, { radius = 50, thickness = radius * 0.08 } = {}) => {
30
+ const along = (Math.PI * 2 * radius) / Math.max(flat.width, 1e-9);
31
+ return {
32
+ depth: radius * 2,
33
+ distance: (x, y, z = radius) => {
34
+ const dx = x - radius;
35
+ const dy = y - radius;
36
+ const dz = z - radius;
37
+ const out = Math.hypot(dx, dy, dz);
38
+ const shell = Math.abs(out - (radius - thickness / 2)) - thickness / 2;
39
+ const longitude = Math.atan2(dx, dz);
40
+ const latitude = Math.acos(Math.min(Math.max(dy / (out || 1), -1), 1));
41
+ const map = flat.distance(((longitude + Math.PI) / (Math.PI * 2)) * flat.width, (latitude / Math.PI) * flat.height);
42
+ return Math.max(shell, map * along);
43
+ },
44
+ height: radius * 2,
45
+ width: radius * 2,
46
+ };
47
+ };
@@ -0,0 +1,20 @@
1
+ import { type ShapeField } from '../connect-graph/shape-field.js';
2
+ /** How a solid is read from triangles. */
3
+ export interface TriangleOptions {
4
+ /** How many cells the field has along the solid's longest side. 96 by default. */
5
+ resolution?: number | undefined;
6
+ }
7
+ /**
8
+ * A solid field from a closed surface of triangles: `positions` three
9
+ * numbers a corner, and `index` three corners a triangle, or every three
10
+ * corners in order where there is none. Its y points up, as a model's does,
11
+ * and its box is the surface's own bounds, a cell of room around them.
12
+ *
13
+ * **Read a column of cells at a time**: each triangle marks where it crosses
14
+ * the columns under it, and a cell is inside where an odd number of
15
+ * crossings lie in front of it. A model of a hundred thousand triangles is
16
+ * read in a moment, where testing each cell against each triangle would take
17
+ * minutes. A surface with gaps is read as closed where it can be, and a gap
18
+ * shows as a column filled or emptied through.
19
+ */
20
+ export declare const shapeFromTriangles: (positions: ArrayLike<number>, index?: ArrayLike<number> | null | undefined, { resolution }?: TriangleOptions) => ShapeField;
@@ -0,0 +1,78 @@
1
+ import { fieldFromCells, SOLID } from '../connect-graph/shape-field.js';
2
+ const NUDGE = 1e-6 * Math.PI;
3
+ export const shapeFromTriangles = (positions, index, { resolution = SOLID } = {}) => {
4
+ const corners = Math.floor(positions.length / 3);
5
+ const low = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
6
+ const high = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
7
+ for (let at = 0; at < corners; at++) {
8
+ for (let axis = 0; axis < 3; axis++) {
9
+ const value = positions[at * 3 + axis];
10
+ low[axis] = Math.min(low[axis], value);
11
+ high[axis] = Math.max(high[axis], value);
12
+ }
13
+ }
14
+ if (corners === 0) {
15
+ return fieldFromCells(new Uint8Array(1), [1, 1, 1], 1, { depth: 1, height: 1, width: 1 });
16
+ }
17
+ const span = Math.max(high[0] - low[0], high[1] - low[1], high[2] - low[2], 1e-9);
18
+ const cell = span / Math.max(resolution - 2, 1);
19
+ const origin = low.map((value) => value - cell);
20
+ const nx = Math.ceil((high[0] - low[0]) / cell) + 2;
21
+ const ny = Math.ceil((high[1] - low[1]) / cell) + 2;
22
+ const nz = Math.ceil((high[2] - low[2]) / cell) + 2;
23
+ const crossings = Array.from({ length: nx * ny }, () => []);
24
+ const triangles = index ? Math.floor(index.length / 3) : Math.floor(corners / 3);
25
+ const corner = (triangle, which) => {
26
+ const at = index ? index[triangle * 3 + which] : triangle * 3 + which;
27
+ return [
28
+ (positions[at * 3] - origin[0]) / cell,
29
+ (high[1] + cell - positions[at * 3 + 1]) / cell,
30
+ (positions[at * 3 + 2] - origin[2]) / cell,
31
+ ];
32
+ };
33
+ for (let triangle = 0; triangle < triangles; triangle++) {
34
+ const [ax, ay, az] = corner(triangle, 0);
35
+ const [bx, by, bz] = corner(triangle, 1);
36
+ const [cx, cy, cz] = corner(triangle, 2);
37
+ const area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay);
38
+ if (Math.abs(area) < 1e-12) {
39
+ continue;
40
+ }
41
+ const left = Math.max(0, Math.ceil(Math.min(ax, bx, cx) - 0.5));
42
+ const right = Math.min(nx - 1, Math.floor(Math.max(ax, bx, cx) - 0.5));
43
+ const top = Math.max(0, Math.ceil(Math.min(ay, by, cy) - 0.5));
44
+ const bottom = Math.min(ny - 1, Math.floor(Math.max(ay, by, cy) - 0.5));
45
+ for (let row = top; row <= bottom; row++) {
46
+ for (let column = left; column <= right; column++) {
47
+ const px = column + 0.5 + NUDGE;
48
+ const py = row + 0.5 + NUDGE * Math.SQRT2;
49
+ const u = ((bx - px) * (cy - py) - (cx - px) * (by - py)) / area;
50
+ const v = ((cx - px) * (ay - py) - (ax - px) * (cy - py)) / area;
51
+ const w = 1 - u - v;
52
+ if (u < 0 || v < 0 || w < 0) {
53
+ continue;
54
+ }
55
+ ;
56
+ crossings[row * nx + column].push(u * az + v * bz + w * cz);
57
+ }
58
+ }
59
+ }
60
+ const cells = new Uint8Array(nx * ny * nz);
61
+ for (let row = 0; row < ny; row++) {
62
+ for (let column = 0; column < nx; column++) {
63
+ const along = crossings[row * nx + column].sort((one, other) => one - other);
64
+ for (let pair = 0; pair + 1 < along.length; pair += 2) {
65
+ const from = Math.max(0, Math.ceil(along[pair] - 0.5));
66
+ const to = Math.min(nz - 1, Math.floor(along[pair + 1] - 0.5));
67
+ for (let layer = from; layer <= to; layer++) {
68
+ cells[layer * nx * ny + row * nx + column] = 1;
69
+ }
70
+ }
71
+ }
72
+ }
73
+ return fieldFromCells(cells, [nx, ny, nz], cell, {
74
+ depth: nz * cell,
75
+ height: ny * cell,
76
+ width: nx * cell,
77
+ });
78
+ };
@@ -0,0 +1,13 @@
1
+ import type { ShapeField } from '../connect-graph/shape-field.js';
2
+ /**
3
+ * The surface of a solid field where its distance is `at`, as triangles:
4
+ * corners three numbers each in the field's box, and triangles three corners
5
+ * each. Surface nets: a corner in each cell the surface passes through, at
6
+ * the middle of where it crosses the cell's edges, and two triangles across
7
+ * each edge it crosses. Smooth enough to hide what is behind a shape, and
8
+ * made in a moment.
9
+ */
10
+ export declare const surfaceOf: (field: ShapeField, at?: number) => {
11
+ corners: Float32Array;
12
+ triangles: Uint32Array;
13
+ };
@@ -0,0 +1,112 @@
1
+ const CELLS = 72;
2
+ export const surfaceOf = (field, at = 0) => {
3
+ const { depth, height, width } = field;
4
+ const cell = Math.max(width, height, depth) / CELLS;
5
+ const nx = Math.ceil(width / cell) + 3;
6
+ const ny = Math.ceil(height / cell) + 3;
7
+ const nz = Math.ceil(depth / cell) + 3;
8
+ const value = new Float32Array(nx * ny * nz);
9
+ const spot = (x, y, z) => (z * ny + y) * nx + x;
10
+ for (let z = 0; z < nz; z++) {
11
+ for (let y = 0; y < ny; y++) {
12
+ for (let x = 0; x < nx; x++) {
13
+ value[spot(x, y, z)] = field.distance((x - 1) * cell, (y - 1) * cell, (z - 1) * cell) - at;
14
+ }
15
+ }
16
+ }
17
+ const cornerOf = new Int32Array((nx - 1) * (ny - 1) * (nz - 1)).fill(-1);
18
+ const cellSpot = (x, y, z) => (z * (ny - 1) + y) * (nx - 1) + x;
19
+ const corners = [];
20
+ const edges = [
21
+ [0, 1],
22
+ [2, 3],
23
+ [4, 5],
24
+ [6, 7],
25
+ [0, 2],
26
+ [1, 3],
27
+ [4, 6],
28
+ [5, 7],
29
+ [0, 4],
30
+ [1, 5],
31
+ [2, 6],
32
+ [3, 7],
33
+ ];
34
+ const offsets = [
35
+ [0, 0, 0],
36
+ [1, 0, 0],
37
+ [0, 1, 0],
38
+ [1, 1, 0],
39
+ [0, 0, 1],
40
+ [1, 0, 1],
41
+ [0, 1, 1],
42
+ [1, 1, 1],
43
+ ];
44
+ const values = new Float32Array(8);
45
+ for (let z = 0; z < nz - 1; z++) {
46
+ for (let y = 0; y < ny - 1; y++) {
47
+ for (let x = 0; x < nx - 1; x++) {
48
+ let inside = 0;
49
+ for (let corner = 0; corner < 8; corner++) {
50
+ const [dx, dy, dz] = offsets[corner];
51
+ const sample = value[spot(x + dx, y + dy, z + dz)];
52
+ values[corner] = sample;
53
+ if (sample < 0) {
54
+ inside += 1;
55
+ }
56
+ }
57
+ if (inside === 0 || inside === 8) {
58
+ continue;
59
+ }
60
+ let sx = 0;
61
+ let sy = 0;
62
+ let sz = 0;
63
+ let crossed = 0;
64
+ for (const [from, to] of edges) {
65
+ const a = values[from];
66
+ const b = values[to];
67
+ if (a < 0 === b < 0) {
68
+ continue;
69
+ }
70
+ const t = a / (a - b);
71
+ const [ax, ay, az] = offsets[from];
72
+ const [bx, by, bz] = offsets[to];
73
+ sx += ax + (bx - ax) * t;
74
+ sy += ay + (by - ay) * t;
75
+ sz += az + (bz - az) * t;
76
+ crossed += 1;
77
+ }
78
+ cornerOf[cellSpot(x, y, z)] = corners.length / 3;
79
+ corners.push((x + sx / crossed - 1) * cell, (y + sy / crossed - 1) * cell, (z + sz / crossed - 1) * cell);
80
+ }
81
+ }
82
+ }
83
+ const triangles = [];
84
+ const quad = (a, b, c, d, flip) => {
85
+ if (a < 0 || b < 0 || c < 0 || d < 0) {
86
+ return;
87
+ }
88
+ if (flip) {
89
+ triangles.push(a, c, b, a, d, c);
90
+ }
91
+ else {
92
+ triangles.push(a, b, c, a, c, d);
93
+ }
94
+ };
95
+ for (let z = 1; z < nz - 1; z++) {
96
+ for (let y = 1; y < ny - 1; y++) {
97
+ for (let x = 1; x < nx - 1; x++) {
98
+ const here = value[spot(x, y, z)] < 0;
99
+ if (x < nx - 1 && here !== value[spot(x + 1, y, z)] < 0) {
100
+ quad(cornerOf[cellSpot(x, y - 1, z - 1)], cornerOf[cellSpot(x, y, z - 1)], cornerOf[cellSpot(x, y, z)], cornerOf[cellSpot(x, y - 1, z)], here);
101
+ }
102
+ if (y < ny - 1 && here !== value[spot(x, y + 1, z)] < 0) {
103
+ quad(cornerOf[cellSpot(x - 1, y, z - 1)], cornerOf[cellSpot(x - 1, y, z)], cornerOf[cellSpot(x, y, z)], cornerOf[cellSpot(x, y, z - 1)], here);
104
+ }
105
+ if (z < nz - 1 && here !== value[spot(x, y, z + 1)] < 0) {
106
+ quad(cornerOf[cellSpot(x - 1, y - 1, z)], cornerOf[cellSpot(x, y - 1, z)], cornerOf[cellSpot(x, y, z)], cornerOf[cellSpot(x - 1, y, z)], here);
107
+ }
108
+ }
109
+ }
110
+ }
111
+ return { corners: new Float32Array(corners), triangles: new Uint32Array(triangles) };
112
+ };
@@ -1,7 +1,8 @@
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, BufferAttribute, BufferGeometry, DoubleSide, LineSegments, Mesh, NormalBlending, PerspectiveCamera, Points, Raycaster, Scene, ShaderMaterial, TOUCH, Vector2, Vector3, WebGLRenderer, } from 'three';
3
3
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
4
4
  import { ease, gesturesHeld, groupsOver, isLight, namesOver, signalsOver, } from '../overlay/overlay.js';
5
+ import { surfaceOf } from './surface.js';
5
6
  const SHAPES = { circle: 0, diamond: 2, ring: 0, square: 1 };
6
7
  const REACH = 100;
7
8
  const STEP = 250;
@@ -92,6 +93,30 @@ void main() {
92
93
  gl_FragColor = vec4(vColor, opacity * vFade);
93
94
  }
94
95
  `;
96
+ const SOLID_VERTEX = `
97
+ varying vec3 vNormal;
98
+ varying vec3 vView;
99
+
100
+ void main() {
101
+ vec4 view = modelViewMatrix * vec4(position, 1.0);
102
+ gl_Position = projectionMatrix * view;
103
+ vNormal = normalize(normalMatrix * normal);
104
+ vView = normalize(-view.xyz);
105
+ }
106
+ `;
107
+ const SOLID_FRAGMENT = `
108
+ uniform vec3 ground;
109
+ uniform vec3 ink;
110
+ varying vec3 vNormal;
111
+ varying vec3 vView;
112
+
113
+ void main() {
114
+ /* The ground's color, a breath of ink at the edge turning away, so the
115
+ shape is felt and not seen: the notes on it are what is drawn. */
116
+ float edge = 1.0 - abs(dot(normalize(vNormal), normalize(vView)));
117
+ gl_FragColor = vec4(mix(ground, ink, 0.16 * pow(edge, 2.0)), 1.0);
118
+ }
119
+ `;
95
120
  const channels = (color) => {
96
121
  const [red = 128, green = 128, blue = 128] = color.match(/[\d.]+/g)?.map(Number) ?? [];
97
122
  return [red / 255, green / 255, blue / 255];
@@ -165,7 +190,49 @@ export const threeDriver = (container, options = {}) => {
165
190
  const lit = new LineSegments(new BufferGeometry(), lines(0.9));
166
191
  quiet.frustumCulled = false;
167
192
  lit.frustumCulled = false;
168
- scene.add(quiet, lit, points);
193
+ const solid = new Mesh(new BufferGeometry(), new ShaderMaterial({
194
+ fragmentShader: SOLID_FRAGMENT,
195
+ polygonOffset: true,
196
+ polygonOffsetFactor: 2,
197
+ polygonOffsetUnits: 2,
198
+ side: DoubleSide,
199
+ uniforms: { ground: { value: [0, 0, 0] }, ink: { value: [1, 1, 1] } },
200
+ vertexShader: SOLID_VERTEX,
201
+ }));
202
+ solid.renderOrder = -1;
203
+ solid.visible = false;
204
+ solid.frustumCulled = false;
205
+ scene.add(solid, quiet, lit, points);
206
+ let shapeFrame;
207
+ let surfaced = false;
208
+ let solidAt = Number.NaN;
209
+ const shapeSolid = () => {
210
+ const frame = shapeFrame;
211
+ if (!frame || !surfaced || frame.field.depth <= 0) {
212
+ solid.visible = false;
213
+ return;
214
+ }
215
+ if (solidAt === scale) {
216
+ solid.visible = true;
217
+ return;
218
+ }
219
+ solidAt = scale;
220
+ const { corners, triangles } = surfaceOf(frame.field, -frame.shell * 0.6);
221
+ const { depth, height, width } = frame.field;
222
+ for (let at = 0; at < corners.length; at += 3) {
223
+ corners[at] = (frame.middle.x + (corners[at] - width / 2) * frame.scale) * scale;
224
+ corners[at + 1] =
225
+ (frame.middle.y - (corners[at + 1] - height / 2) * frame.scale) * scale;
226
+ corners[at + 2] =
227
+ (frame.middle.z + (corners[at + 2] - depth / 2) * frame.scale) * scale;
228
+ }
229
+ solid.geometry.dispose();
230
+ solid.geometry = new BufferGeometry();
231
+ solid.geometry.setAttribute('position', new BufferAttribute(corners, 3));
232
+ solid.geometry.setIndex(new BufferAttribute(triangles, 1));
233
+ solid.geometry.computeVertexNormals();
234
+ solid.visible = true;
235
+ };
169
236
  const at = (spot) => new Vector3(positions[spot * 3], positions[spot * 3 + 1], positions[spot * 3 + 2]);
170
237
  const onScreen = (id) => {
171
238
  const spot = index.get(id);
@@ -196,7 +263,9 @@ export const threeDriver = (container, options = {}) => {
196
263
  return undefined;
197
264
  }
198
265
  const depth = -at(spot).applyMatrix4(camera.matrixWorldInverse).z;
199
- return uniforms.steep.value > 0 && depth > uniforms.middle.value ? undefined : onScreen(id);
266
+ return (uniforms.steep.value > 0 || solid.visible) && depth > uniforms.middle.value
267
+ ? undefined
268
+ : onScreen(id);
200
269
  };
201
270
  const groups = groupsOver(container, nearOnScreen);
202
271
  const names = namesOver(container, onScreen);
@@ -280,6 +349,9 @@ export const threeDriver = (container, options = {}) => {
280
349
  geometry.setAttribute('shape', new BufferAttribute(shapes, 1));
281
350
  geometry.setAttribute('glow', new BufferAttribute(glows, 1));
282
351
  dark = !isLight(looks.ground);
352
+ const solidUniforms = solid.material.uniforms;
353
+ solidUniforms.ground.value = channels(looks.ground);
354
+ solidUniforms.ink.value = channels(looks.ink);
283
355
  const blending = dark ? AdditiveBlending : NormalBlending;
284
356
  points.material.blending = blending;
285
357
  quiet.material.blending = blending;
@@ -483,9 +555,14 @@ export const threeDriver = (container, options = {}) => {
483
555
  hovering = requestAnimationFrame(() => {
484
556
  raycaster.setFromCamera(pointer, camera);
485
557
  raycaster.params.Points = { threshold: uniforms.unit.value * 2.4 };
558
+ const front = solid.visible
559
+ ? (raycaster.intersectObject(solid, false)[0]?.distance ?? Number.POSITIVE_INFINITY)
560
+ : Number.POSITIVE_INFINITY;
486
561
  const hit = raycaster.intersectObject(points, false).find((one) => {
487
562
  const id = one.index === undefined ? undefined : ids[one.index];
488
- return id !== undefined && looks?.node(id).hidden !== true;
563
+ return (id !== undefined &&
564
+ looks?.node(id).hidden !== true &&
565
+ one.distance < front + uniforms.unit.value * 3);
489
566
  });
490
567
  const next = hit?.index;
491
568
  if (next !== hovered) {
@@ -520,6 +597,7 @@ export const threeDriver = (container, options = {}) => {
520
597
  });
521
598
  moved = false;
522
599
  load(nodes, next, places);
600
+ shapeSolid();
523
601
  fitAll(0);
524
602
  },
525
603
  fit: (fitted, animate) => {
@@ -627,8 +705,13 @@ export const threeDriver = (container, options = {}) => {
627
705
  layout = undefined;
628
706
  };
629
707
  },
630
- shape: (place) => {
631
- uniforms.steep.value = place === 'surface' ? 1 : 0;
708
+ shape: (place, frame) => {
709
+ surfaced = place === 'surface';
710
+ if (frame !== shapeFrame) {
711
+ shapeFrame = frame;
712
+ solidAt = Number.NaN;
713
+ }
714
+ uniforms.steep.value = surfaced && !frame ? 1 : 0;
632
715
  },
633
716
  signal: (from, to, colors, animate) => {
634
717
  signals.signal(from, to, colors, animate);
@@ -642,7 +725,7 @@ export const threeDriver = (container, options = {}) => {
642
725
  canvas.removeEventListener('pointermove', hover);
643
726
  canvas.removeEventListener('pointerleave', leave);
644
727
  controls.dispose();
645
- for (const object of [points, quiet, lit]) {
728
+ for (const object of [points, quiet, lit, solid]) {
646
729
  object.geometry.dispose();
647
730
  object.material.dispose();
648
731
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-graph",
3
3
  "description": "Connects the Helix Graph frame to a graph renderer: Sigma.js, cosmos.gl, three.js, or a driver of your own.",
4
- "version": "0.122.0",
4
+ "version": "0.123.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -47,12 +47,12 @@
47
47
  },
48
48
  "bugs": "https://github.com/project-fusion/fusion/issues",
49
49
  "dependencies": {
50
- "@fusion.dev/helix-core": "0.122.0"
50
+ "@fusion.dev/helix-core": "0.123.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@cosmos.gl/graph": "^3.4.2",
54
- "@fusion.dev/helix-headless": "0.122.0",
55
- "@fusion.dev/helix-testing": "0.122.0",
54
+ "@fusion.dev/helix-headless": "0.123.0",
55
+ "@fusion.dev/helix-testing": "0.123.0",
56
56
  "@types/bun": "^1.3.0",
57
57
  "@types/three": "^0.186.0",
58
58
  "d3-force-3d": "^3.0.6",