@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 +26 -6
- package/dist/connect-graph/connect-graph.d.ts +5 -3
- package/dist/connect-graph/connect-graph.js +9 -4
- package/dist/connect-graph/shape-field.d.ts +14 -0
- package/dist/connect-graph/shape-field.js +3 -3
- package/dist/connect-graph/stencil.d.ts +23 -0
- package/dist/connect-graph/stencil.js +27 -24
- package/dist/connect-graph/surface-web.d.ts +18 -0
- package/dist/connect-graph/surface-web.js +99 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/shapes/flat.d.ts +28 -0
- package/dist/shapes/flat.js +63 -0
- package/dist/shapes/index.d.ts +4 -0
- package/dist/shapes/index.js +4 -0
- package/dist/shapes/model.d.ts +12 -0
- package/dist/shapes/model.js +31 -0
- package/dist/shapes/solids.d.ts +28 -0
- package/dist/shapes/solids.js +47 -0
- package/dist/shapes/triangles.d.ts +20 -0
- package/dist/shapes/triangles.js +78 -0
- package/dist/three/surface.d.ts +13 -0
- package/dist/three/surface.js +112 -0
- package/dist/three/three-driver.js +90 -7
- package/package.json +4 -4
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
|
|
241
|
-
the
|
|
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.**
|
|
247
|
-
|
|
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,
|
|
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
|
|
98
|
-
?
|
|
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
|
-
|
|
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]
|
|
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.
|
|
183
|
-
|
|
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
|
-
|
|
188
|
-
halve(ours.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((
|
|
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
|
+
};
|
package/dist/shapes/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/shapes/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
55
|
-
"@fusion.dev/helix-testing": "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",
|