@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 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 places = placesIn(field, notes.length, {
105
- place: stencil.place ?? 'fill',
106
- shell: stencil.shell ?? SHELL,
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
- halve([...notes], targets.map((_, at) => at));
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
- const ours = nested(notes, [(note) => note.at.x, (note) => note.at.y]);
244
- const theirs = nested(spots, [(spot) => spot.across, (spot) => spot.down]);
245
- ours.forEach((note, at) => {
246
- fitted.set(note.id, theirs[at].at);
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
  };
@@ -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.
@@ -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 path = [...element.querySelectorAll('path, rect, circle, ellipse, polygon')]
39
- .map(pathOf)
40
- .join(' ');
41
- return fieldFromPath(path, box.width || 1, box.height || 1, resolution);
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;
@@ -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
@@ -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;
@@ -1,6 +1,54 @@
1
1
  import { fieldFromCells, SOLID } from '../connect-graph/shape-field.js';
2
2
  const NUDGE = 1e-6 * Math.PI;
3
- export const shapeFromTriangles = (positions, index, { resolution = SOLID } = {}) => {
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
- for (let at = 0; at < place.count; at++) {
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
- const gx = field.distance(x + step, y, z) - field.distance(x - step, y, z);
412
- const gy = field.distance(x, y + step, z) - field.distance(x, y - step, z);
413
- const gz = field.distance(x, y, z + step) - field.distance(x, y, z - step);
414
- const length = Math.hypot(gx, gy, gz) || 1;
415
- place.setXYZ(at, x - (gx / length) * inset, y - (gy / length) * inset, z - (gz / length) * inset);
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.124.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.124.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.124.0",
55
- "@fusion.dev/helix-testing": "0.124.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",