@fusion.dev/helix-graph 0.124.0 → 0.126.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 +17 -0
- package/dist/connect-graph/shape-field.d.ts +5 -0
- package/dist/connect-graph/stencil.d.ts +8 -0
- package/dist/connect-graph/stencil.js +86 -10
- package/dist/shapes/flat.d.ts +2 -1
- package/dist/shapes/flat.js +53 -4
- package/dist/shapes/model.d.ts +4 -1
- package/dist/shapes/model.js +15 -1
- package/dist/shapes/triangles.d.ts +10 -1
- package/dist/shapes/triangles.js +63 -1
- package/dist/three/three-driver.js +56 -6
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -239,6 +239,23 @@ const stencil = { ...(await shapeFromModel('/models/brain.glb')), place: 'surfac
|
|
|
239
239
|
connectGraph(frame, threeDriver(box), { stencil })
|
|
240
240
|
```
|
|
241
241
|
|
|
242
|
+
**`regions` sends each group to a named part of the shape**, such as a lobe
|
|
243
|
+
of a brain or a state on a map:
|
|
244
|
+
|
|
245
|
+
```js
|
|
246
|
+
const stencil = {
|
|
247
|
+
...(await shapeFromModel('/models/brain.glb')),
|
|
248
|
+
place: 'surface',
|
|
249
|
+
regions: { Vision: 'occipital', Hearing: 'temporal', Balance: 'cerebellum' },
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
A model's parts are its meshes, named as it names them, where it has two or
|
|
254
|
+
more; an SVG's are its shapes with an `id`, or in a group with one; and a
|
|
255
|
+
field a page makes says its own with `regionOf(x, y, z)`. Each part fills
|
|
256
|
+
evenly with the groups sent to it, and a group sent nowhere, or to a part the
|
|
257
|
+
shape does not have, shares the parts no group claims.
|
|
258
|
+
|
|
242
259
|
**A model is read a column of cells at a time**, so one of a hundred thousand
|
|
243
260
|
triangles becomes a field in a moment. Any closed mesh works; a mesh with
|
|
244
261
|
holes is read as closed where it can be. A model served from another origin
|
|
@@ -24,6 +24,11 @@ export interface ShapeField {
|
|
|
24
24
|
corners: Float32Array;
|
|
25
25
|
triangles: Uint32Array;
|
|
26
26
|
} | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Which named part of the shape a point is in, where the shape has named
|
|
29
|
+
* parts: the part nearest it, or undefined where it has none.
|
|
30
|
+
*/
|
|
31
|
+
regionOf?(x: number, y: number, z?: number): string | undefined;
|
|
27
32
|
/** How wide the box is. */
|
|
28
33
|
readonly width: number;
|
|
29
34
|
}
|
|
@@ -23,6 +23,14 @@ export type GraphStencil = (ShapeField | {
|
|
|
23
23
|
place?: 'fill' | 'surface' | undefined;
|
|
24
24
|
/** How thick the surface's shell is, as a share of the shape's size. 0.04 by default. */
|
|
25
25
|
shell?: number | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Which named part of the shape each group's notes go to, by group, such
|
|
28
|
+
* as `{ Vision: 'occipital' }`, where the shape has named parts: a model's
|
|
29
|
+
* named meshes, or an SVG's elements with an `id`. A group not named here,
|
|
30
|
+
* or naming a part the shape does not have, shares the parts no group
|
|
31
|
+
* claims.
|
|
32
|
+
*/
|
|
33
|
+
regions?: Readonly<Record<string, string>> | undefined;
|
|
26
34
|
};
|
|
27
35
|
/** The field a stencil is, or is made into. */
|
|
28
36
|
export declare const fieldOf: (stencil: GraphStencil) => ShapeField;
|
|
@@ -78,6 +78,62 @@ export const placesIn = (field, count, { place = 'fill', shell = SHELL } = {}) =
|
|
|
78
78
|
}
|
|
79
79
|
return [];
|
|
80
80
|
};
|
|
81
|
+
const placesByRegion = (field, wanted, options) => {
|
|
82
|
+
const regionOf = field.regionOf;
|
|
83
|
+
const count = [...wanted.values()].reduce((sum, many) => sum + many, 0);
|
|
84
|
+
if (!regionOf) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
for (let times = 3; times <= 24; times *= 2) {
|
|
88
|
+
const pool = placesIn(field, count * times, options);
|
|
89
|
+
if (pool.length === 0) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
const byRegion = new Map();
|
|
93
|
+
const inRegion = pool.map((place) => regionOf(place.x, place.y, place.z));
|
|
94
|
+
pool.forEach((place, at) => {
|
|
95
|
+
const region = inRegion[at];
|
|
96
|
+
const list = byRegion.get(region);
|
|
97
|
+
if (list) {
|
|
98
|
+
list.push(place);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
byRegion.set(region, [place]);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
const claimed = new Set([...wanted.keys()].filter((region) => region !== undefined));
|
|
105
|
+
const places = [];
|
|
106
|
+
const regions = [];
|
|
107
|
+
let short = false;
|
|
108
|
+
for (const [region, many] of wanted) {
|
|
109
|
+
if (region === undefined) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const own = byRegion.get(region) ?? [];
|
|
113
|
+
if (own.length < many) {
|
|
114
|
+
short = true;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
for (const place of own.slice(0, many)) {
|
|
118
|
+
places.push(place);
|
|
119
|
+
regions.push(region);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const rest = wanted.get(undefined) ?? 0;
|
|
123
|
+
const taken = new Set(places);
|
|
124
|
+
const free = pool.filter((_, at) => !claimed.has(inRegion[at]));
|
|
125
|
+
const spare = free.length >= rest ? free : pool.filter((place) => !taken.has(place));
|
|
126
|
+
if (short || spare.length < rest) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
for (const place of spare.slice(0, rest)) {
|
|
130
|
+
places.push(place);
|
|
131
|
+
regions.push(undefined);
|
|
132
|
+
}
|
|
133
|
+
return { places, regions };
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
};
|
|
81
137
|
const nested = (items, keys) => {
|
|
82
138
|
const [key, ...rest] = keys;
|
|
83
139
|
if (!key) {
|
|
@@ -101,10 +157,26 @@ export const fitInStencil = (positions, stencil, { clustersOf, } = {}) => {
|
|
|
101
157
|
return { positions: new Map() };
|
|
102
158
|
}
|
|
103
159
|
const field = fieldOf(stencil);
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
160
|
+
const options = { place: stencil.place ?? 'fill', shell: stencil.shell ?? SHELL };
|
|
161
|
+
const groupOf = (id) => clustersOf?.(id)[0];
|
|
162
|
+
const noteRegion = new Map(notes.map((note) => {
|
|
163
|
+
const group = groupOf(note.id);
|
|
164
|
+
return [note.id, group === undefined ? undefined : stencil.regions?.[group]];
|
|
165
|
+
}));
|
|
166
|
+
const wanted = new Map();
|
|
167
|
+
for (const region of noteRegion.values()) {
|
|
168
|
+
wanted.set(region, (wanted.get(region) ?? 0) + 1);
|
|
169
|
+
}
|
|
170
|
+
const regional = stencil.regions && [...wanted.keys()].some((region) => region !== undefined)
|
|
171
|
+
? placesByRegion(field, wanted, options)
|
|
172
|
+
: undefined;
|
|
173
|
+
if (!regional) {
|
|
174
|
+
for (const id of noteRegion.keys()) {
|
|
175
|
+
noteRegion.set(id, undefined);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const places = regional?.places ?? placesIn(field, notes.length, options);
|
|
179
|
+
const placeRegion = regional?.regions ?? places.map(() => undefined);
|
|
108
180
|
if (places.length < notes.length) {
|
|
109
181
|
return { positions: new Map(positions) };
|
|
110
182
|
}
|
|
@@ -237,13 +309,17 @@ export const fitInStencil = (positions, stencil, { clustersOf, } = {}) => {
|
|
|
237
309
|
halve(ours.slice(0, half), near, level);
|
|
238
310
|
halve(ours.slice(half), rest, level);
|
|
239
311
|
};
|
|
240
|
-
|
|
312
|
+
for (const region of wanted.keys()) {
|
|
313
|
+
halve(notes.filter((note) => noteRegion.get(note.id) === region), targets.flatMap((_, at) => (placeRegion[at] === region ? [at] : [])));
|
|
314
|
+
}
|
|
241
315
|
return { frame: { field, middle, scale, shell: thickness }, positions: fitted };
|
|
242
316
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
317
|
+
for (const region of wanted.keys()) {
|
|
318
|
+
const ours = nested(notes.filter((note) => noteRegion.get(note.id) === region), [(note) => note.at.x, (note) => note.at.y]);
|
|
319
|
+
const theirs = nested(spots.filter((_, at) => placeRegion[at] === region), [(spot) => spot.across, (spot) => spot.down]);
|
|
320
|
+
ours.forEach((note, at) => {
|
|
321
|
+
fitted.set(note.id, theirs[at].at);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
248
324
|
return { frame: { field, middle, scale, shell: thickness }, positions: fitted };
|
|
249
325
|
};
|
package/dist/shapes/flat.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { type ShapeField } from '../connect-graph/shape-field.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* A flat field from an SVG: an `<svg>` element, or its markup. Its box is
|
|
4
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.
|
|
5
|
+
* polygon in it, together, their holes cut by the even-odd rule. Each shape
|
|
6
|
+
* with an `id`, or in a group with one, is a named part.
|
|
6
7
|
*
|
|
7
8
|
* Transforms on the shapes are not read: draw the outline in the `viewBox`'s
|
|
8
9
|
* own units, as an exported icon or logo is.
|
package/dist/shapes/flat.js
CHANGED
|
@@ -35,10 +35,59 @@ export const shapeFromSvg = (svg, resolution = FLAT) => {
|
|
|
35
35
|
height: height || Number(element.getAttribute('height') ?? 0),
|
|
36
36
|
width: width || Number(element.getAttribute('width') ?? 0),
|
|
37
37
|
};
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
const shapes = [...element.querySelectorAll('path, rect, circle, ellipse, polygon')];
|
|
39
|
+
const path = shapes.map(pathOf).join(' ');
|
|
40
|
+
const field = fieldFromPath(path, box.width || 1, box.height || 1, resolution);
|
|
41
|
+
const named = shapes.flatMap((shape) => {
|
|
42
|
+
const name = shape.closest('[id]')?.getAttribute('id');
|
|
43
|
+
return name && shape.closest('[id]') !== element ? [{ name, path: pathOf(shape) }] : [];
|
|
44
|
+
});
|
|
45
|
+
return named.length > 0 ? { ...field, regionOf: partsOf(named, field, resolution) } : field;
|
|
46
|
+
};
|
|
47
|
+
const partsOf = (named, box, resolution) => {
|
|
48
|
+
const cell = Math.max(box.width, box.height, 1) / resolution;
|
|
49
|
+
const nx = Math.max(1, Math.ceil(box.width / cell));
|
|
50
|
+
const ny = Math.max(1, Math.ceil(box.height / cell));
|
|
51
|
+
const canvas = document.createElement('canvas');
|
|
52
|
+
canvas.width = nx;
|
|
53
|
+
canvas.height = ny;
|
|
54
|
+
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
55
|
+
const labels = new Uint16Array(nx * ny);
|
|
56
|
+
if (context) {
|
|
57
|
+
context.scale(1 / cell, 1 / cell);
|
|
58
|
+
named.forEach(({ path }, at) => {
|
|
59
|
+
const label = at + 1;
|
|
60
|
+
context.fillStyle = `rgb(${label & 255}, ${(label >> 8) & 255}, 0)`;
|
|
61
|
+
context.fill(new Path2D(path), 'evenodd');
|
|
62
|
+
});
|
|
63
|
+
const pixels = context.getImageData(0, 0, nx, ny).data;
|
|
64
|
+
for (let at = 0; at < labels.length; at++) {
|
|
65
|
+
if ((pixels[at * 4 + 3] ?? 0) === 255 && (pixels[at * 4 + 2] ?? 0) === 0) {
|
|
66
|
+
const label = (pixels[at * 4] ?? 0) + ((pixels[at * 4 + 1] ?? 0) << 8);
|
|
67
|
+
labels[at] = label <= named.length ? label : 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return (x, y) => {
|
|
72
|
+
const column = Math.floor(x / cell);
|
|
73
|
+
const row = Math.floor(y / cell);
|
|
74
|
+
for (let ring = 0; ring <= 3; ring++) {
|
|
75
|
+
for (let dy = -ring; dy <= ring; dy++) {
|
|
76
|
+
for (let dx = -ring; dx <= ring; dx++) {
|
|
77
|
+
const c = column + dx;
|
|
78
|
+
const r = row + dy;
|
|
79
|
+
if (c < 0 || r < 0 || c >= nx || r >= ny) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const label = labels[r * nx + c] ?? 0;
|
|
83
|
+
if (label > 0) {
|
|
84
|
+
return named[label - 1]?.name;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
};
|
|
42
91
|
};
|
|
43
92
|
export const shapeFromImage = (image, { resolution = FLAT, threshold = 0.5 } = {}) => {
|
|
44
93
|
const size = (value) => typeof value === 'number' ? value : value.baseVal.value;
|
package/dist/shapes/model.d.ts
CHANGED
|
@@ -3,7 +3,10 @@ import { type TriangleOptions } from './triangles.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* A solid field from a glTF model, the web's standard format for one: a
|
|
5
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
|
|
6
|
+
* read, placed as the model places it, and where it has two meshes or more,
|
|
7
|
+
* each is a part of the field, named as the model names it, which a
|
|
8
|
+
* stencil's `regions` sends groups to. A mesh the model leaves unnamed is
|
|
9
|
+
* named as three.js names it, `mesh_0` and on.
|
|
7
10
|
*
|
|
8
11
|
* **three.js reads the file**, imported when this is first called, so a page
|
|
9
12
|
* that draws no model never downloads a loader. It is an optional peer of
|
package/dist/shapes/model.js
CHANGED
|
@@ -10,6 +10,8 @@ export const shapeFromModel = async (source, options = {}) => {
|
|
|
10
10
|
: await loader.parseAsync(source, '');
|
|
11
11
|
model.scene.updateMatrixWorld(true);
|
|
12
12
|
const corners = [];
|
|
13
|
+
const names = [];
|
|
14
|
+
const ofTriangle = [];
|
|
13
15
|
const point = new Vector3();
|
|
14
16
|
model.scene.traverse((object) => {
|
|
15
17
|
if (!(object instanceof Mesh)) {
|
|
@@ -20,12 +22,24 @@ export const shapeFromModel = async (source, options = {}) => {
|
|
|
20
22
|
if (!place) {
|
|
21
23
|
return;
|
|
22
24
|
}
|
|
25
|
+
const name = object.name || geometry.name || '';
|
|
26
|
+
let part = names.indexOf(name);
|
|
27
|
+
if (name && part < 0) {
|
|
28
|
+
part = names.push(name) - 1;
|
|
29
|
+
}
|
|
23
30
|
const index = geometry.getIndex();
|
|
24
31
|
const count = index ? index.count : place.count;
|
|
25
32
|
for (let at = 0; at < count; at++) {
|
|
26
33
|
point.fromBufferAttribute(place, index ? index.getX(at) : at).applyMatrix4(object.matrixWorld);
|
|
27
34
|
corners.push(point.x, point.y, point.z);
|
|
28
35
|
}
|
|
36
|
+
for (let triangle = 0; triangle < Math.floor(count / 3); triangle++) {
|
|
37
|
+
ofTriangle.push(part);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
const named = names.length > 1 && !ofTriangle.includes(-1);
|
|
41
|
+
return shapeFromTriangles(corners, null, {
|
|
42
|
+
...options,
|
|
43
|
+
...(named ? { regions: { names, ofTriangle } } : {}),
|
|
29
44
|
});
|
|
30
|
-
return shapeFromTriangles(corners, null, options);
|
|
31
45
|
};
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { type ShapeField } from '../connect-graph/shape-field.js';
|
|
2
2
|
/** How a solid is read from triangles. */
|
|
3
3
|
export interface TriangleOptions {
|
|
4
|
+
/**
|
|
5
|
+
* The named parts the triangles make: each part's name, and which part
|
|
6
|
+
* each triangle is in, by its place in `names`. Gives the field
|
|
7
|
+
* `regionOf`, the part a point is in: the part of the triangle nearest it.
|
|
8
|
+
*/
|
|
9
|
+
regions?: {
|
|
10
|
+
names: readonly string[];
|
|
11
|
+
ofTriangle: ArrayLike<number>;
|
|
12
|
+
} | undefined;
|
|
4
13
|
/** How many cells the field has along the solid's longest side. 96 by default. */
|
|
5
14
|
resolution?: number | undefined;
|
|
6
15
|
}
|
|
@@ -17,4 +26,4 @@ export interface TriangleOptions {
|
|
|
17
26
|
* minutes. A surface with gaps is read as closed where it can be, and a gap
|
|
18
27
|
* shows as a column filled or emptied through.
|
|
19
28
|
*/
|
|
20
|
-
export declare const shapeFromTriangles: (positions: ArrayLike<number>, index?: ArrayLike<number> | null | undefined, { resolution }?: TriangleOptions) => ShapeField;
|
|
29
|
+
export declare const shapeFromTriangles: (positions: ArrayLike<number>, index?: ArrayLike<number> | null | undefined, { regions, resolution }?: TriangleOptions) => ShapeField;
|
package/dist/shapes/triangles.js
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
import { fieldFromCells, SOLID } from '../connect-graph/shape-field.js';
|
|
2
2
|
const NUDGE = 1e-6 * Math.PI;
|
|
3
|
-
|
|
3
|
+
const NEAREST = 40;
|
|
4
|
+
const nearestPart = (middles, parts, names, box) => {
|
|
5
|
+
const size = Math.max(box.width, box.height, box.depth) / NEAREST;
|
|
6
|
+
const cells = new Map();
|
|
7
|
+
const key = (cx, cy, cz) => `${cx},${cy},${cz}`;
|
|
8
|
+
const count = middles.length / 3;
|
|
9
|
+
for (let at = 0; at < count; at++) {
|
|
10
|
+
const k = key(Math.floor(middles[at * 3] / size), Math.floor(middles[at * 3 + 1] / size), Math.floor(middles[at * 3 + 2] / size));
|
|
11
|
+
const list = cells.get(k);
|
|
12
|
+
if (list) {
|
|
13
|
+
list.push(at);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
cells.set(k, [at]);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return (x, y, z = box.depth / 2) => {
|
|
20
|
+
const cx = Math.floor(x / size);
|
|
21
|
+
const cy = Math.floor(y / size);
|
|
22
|
+
const cz = Math.floor(z / size);
|
|
23
|
+
let best = -1;
|
|
24
|
+
let nearest = Number.POSITIVE_INFINITY;
|
|
25
|
+
for (let ring = 0; ring <= NEAREST; ring++) {
|
|
26
|
+
for (let dx = -ring; dx <= ring; dx++) {
|
|
27
|
+
for (let dy = -ring; dy <= ring; dy++) {
|
|
28
|
+
for (let dz = -ring; dz <= ring; dz++) {
|
|
29
|
+
if (Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz)) !== ring) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
for (const at of cells.get(key(cx + dx, cy + dy, cz + dz)) ?? []) {
|
|
33
|
+
const far = (middles[at * 3] - x) ** 2 +
|
|
34
|
+
(middles[at * 3 + 1] - y) ** 2 +
|
|
35
|
+
(middles[at * 3 + 2] - z) ** 2;
|
|
36
|
+
if (far < nearest) {
|
|
37
|
+
nearest = far;
|
|
38
|
+
best = at;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (best >= 0 && Math.sqrt(nearest) <= ring * size) {
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return best < 0 ? undefined : names[parts[best]];
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
export const shapeFromTriangles = (positions, index, { regions, resolution = SOLID } = {}) => {
|
|
4
52
|
const corners = Math.floor(positions.length / 3);
|
|
5
53
|
const low = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
|
|
6
54
|
const high = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
|
|
@@ -84,9 +132,23 @@ export const shapeFromTriangles = (positions, index, { resolution = SOLID } = {}
|
|
|
84
132
|
height: ny * cell,
|
|
85
133
|
width: nx * cell,
|
|
86
134
|
});
|
|
135
|
+
let regionOf;
|
|
136
|
+
if (regions && regions.names.length > 0) {
|
|
137
|
+
const middles = new Float32Array(triangles * 3);
|
|
138
|
+
for (let triangle = 0; triangle < triangles; triangle++) {
|
|
139
|
+
for (let which = 0; which < 3; which++) {
|
|
140
|
+
const at = corner(triangle, which);
|
|
141
|
+
middles[triangle * 3] = middles[triangle * 3] + (at[0] * cell) / 3;
|
|
142
|
+
middles[triangle * 3 + 1] = middles[triangle * 3 + 1] + (at[1] * cell) / 3;
|
|
143
|
+
middles[triangle * 3 + 2] = middles[triangle * 3 + 2] + (at[2] * cell) / 3;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
regionOf = nearestPart(middles, regions.ofTriangle, regions.names, field);
|
|
147
|
+
}
|
|
87
148
|
return {
|
|
88
149
|
...field,
|
|
89
150
|
distance: field.distance,
|
|
90
151
|
mesh: { corners: meshCorners, triangles: meshTriangles },
|
|
152
|
+
...(regionOf ? { regionOf } : {}),
|
|
91
153
|
};
|
|
92
154
|
};
|
|
@@ -255,6 +255,42 @@ const framed = (geometry) => {
|
|
|
255
255
|
geometry.setAttribute('frame', new BufferAttribute(frame, 4));
|
|
256
256
|
return geometry;
|
|
257
257
|
};
|
|
258
|
+
const SMOOTH = { surface: 3, ways: 6 };
|
|
259
|
+
const neighborsOf = (index, count) => {
|
|
260
|
+
const sets = Array.from({ length: count }, () => new Set());
|
|
261
|
+
for (let at = 0; at < index.count; at += 3) {
|
|
262
|
+
const a = index.getX(at);
|
|
263
|
+
const b = index.getX(at + 1);
|
|
264
|
+
const c = index.getX(at + 2);
|
|
265
|
+
sets[a]?.add(b).add(c);
|
|
266
|
+
sets[b]?.add(a).add(c);
|
|
267
|
+
sets[c]?.add(a).add(b);
|
|
268
|
+
}
|
|
269
|
+
return sets.map((set) => Int32Array.from(set));
|
|
270
|
+
};
|
|
271
|
+
const averaged = (values, neighbors, share) => {
|
|
272
|
+
const out = new Float32Array(values.length);
|
|
273
|
+
neighbors.forEach((around, at) => {
|
|
274
|
+
let x = 0;
|
|
275
|
+
let y = 0;
|
|
276
|
+
let z = 0;
|
|
277
|
+
for (const other of around) {
|
|
278
|
+
x += values[other * 3];
|
|
279
|
+
y += values[other * 3 + 1];
|
|
280
|
+
z += values[other * 3 + 2];
|
|
281
|
+
}
|
|
282
|
+
const many = around.length || 1;
|
|
283
|
+
for (const [axis, sum] of [
|
|
284
|
+
[0, x],
|
|
285
|
+
[1, y],
|
|
286
|
+
[2, z],
|
|
287
|
+
]) {
|
|
288
|
+
const value = values[at * 3 + axis];
|
|
289
|
+
out[at * 3 + axis] = around.length ? value + share * (sum / many - value) : value;
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
return out;
|
|
293
|
+
};
|
|
258
294
|
const channels = (color) => {
|
|
259
295
|
const [red = 128, green = 128, blue = 128] = color.match(/[\d.]+/g)?.map(Number) ?? [];
|
|
260
296
|
return [red / 255, green / 255, blue / 255];
|
|
@@ -403,17 +439,31 @@ export const threeDriver = (container, options = {}) => {
|
|
|
403
439
|
geometry.setIndex(new BufferAttribute(field.mesh.triangles, 1));
|
|
404
440
|
geometry = mergeVertices(geometry);
|
|
405
441
|
const place = geometry.getAttribute('position');
|
|
442
|
+
const count = place.count;
|
|
443
|
+
const neighbors = neighborsOf(geometry.getIndex(), count);
|
|
406
444
|
const step = Math.max(field.width, field.height, field.depth) / 64;
|
|
407
|
-
|
|
445
|
+
let way = new Float32Array(count * 3);
|
|
446
|
+
for (let at = 0; at < count; at++) {
|
|
408
447
|
const x = place.getX(at);
|
|
409
448
|
const y = place.getY(at);
|
|
410
449
|
const z = place.getZ(at);
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
450
|
+
way[at * 3] = field.distance(x + step, y, z) - field.distance(x - step, y, z);
|
|
451
|
+
way[at * 3 + 1] = field.distance(x, y + step, z) - field.distance(x, y - step, z);
|
|
452
|
+
way[at * 3 + 2] = field.distance(x, y, z + step) - field.distance(x, y, z - step);
|
|
453
|
+
}
|
|
454
|
+
for (let pass = 0; pass < SMOOTH.ways; pass++) {
|
|
455
|
+
way = averaged(way, neighbors, 0.5);
|
|
456
|
+
}
|
|
457
|
+
for (let at = 0; at < count; at++) {
|
|
458
|
+
const length = Math.hypot(way[at * 3], way[at * 3 + 1], way[at * 3 + 2]) ||
|
|
459
|
+
1;
|
|
460
|
+
place.setXYZ(at, place.getX(at) - (way[at * 3] / length) * inset, place.getY(at) - (way[at * 3 + 1] / length) * inset, place.getZ(at) - (way[at * 3 + 2] / length) * inset);
|
|
461
|
+
}
|
|
462
|
+
let corners = place.array;
|
|
463
|
+
for (let pass = 0; pass < SMOOTH.surface; pass++) {
|
|
464
|
+
corners = averaged(averaged(corners, neighbors, 0.5), neighbors, -0.53);
|
|
416
465
|
}
|
|
466
|
+
place.array.set(corners);
|
|
417
467
|
}
|
|
418
468
|
else {
|
|
419
469
|
const { corners, triangles } = surfaceOf(field, { at: -inset });
|
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.126.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.126.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.126.0",
|
|
55
|
+
"@fusion.dev/helix-testing": "0.126.0",
|
|
56
56
|
"@types/bun": "^1.3.0",
|
|
57
57
|
"@types/three": "^0.186.0",
|
|
58
58
|
"d3-force-3d": "^3.0.6",
|