@kupcad/ui 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,24 +1,18 @@
1
1
  // A mesh's faces, as its feature edges bound them: the triangles joined across their edges where
2
- // they meet at less than EDGE_ANGLE_DEG (model.js draws an edge where they meet at more). A face
2
+ // they don't turn (topology.js; model.js draws an edge where they do). A face
3
3
  // is flat when its triangles all face one way, curved otherwise (a cylinder's side); its area is
4
4
  // in the scene's millimetres. A geometry's faces are found once, as the pointer first meets each;
5
5
  // a face's triangles are what the viewer marks the face under the pointer with.
6
6
 
7
7
  import { Vector3 } from 'three';
8
- import { EDGE_ANGLE_DEG } from './model.js';
8
+ import { topologyOf, turns } from './topology.js';
9
9
 
10
10
  /** @import { BufferGeometry, Matrix4, Mesh } from 'three' */
11
11
  /** @import { Face } from './types.js' */
12
+ /** @import { Topology } from './topology.js' */
12
13
 
13
14
  /** Triangles that face ways this close (the cosine of their angle) are one flat face's. */
14
15
  const FLAT_COS = Math.cos((0.5 * Math.PI) / 180);
15
- const SMOOTH_COS = Math.cos((EDGE_ANGLE_DEG * Math.PI) / 180);
16
- /** Two corners nearer than this, of the geometry's size, are one point: its triangles meet there. */
17
- const SAME_POINT = 1e-6;
18
- /** Slots in a hash table per key it is given, at least: it stays at most half full. */
19
- const TABLE_ROOM = 2;
20
- /** Odd numbers that spread a key's parts over a hash table's slots. */
21
- const SPREAD = [0x9e3779b1, 0x85ebca77, 0xc2b2ae3d];
22
16
  /** A face's way is an axis's when this close to it (the cosine). */
23
17
  const AXIS_COS = Math.cos((1 * Math.PI) / 180);
24
18
  /** The axes a face may face, and what the views call them. */
@@ -32,14 +26,8 @@ const AXES = /** @type {const} */ ([
32
26
  ]);
33
27
 
34
28
  /**
35
- * A geometry's triangles, which meet which across an edge, and the face each was found in (-1
36
- * for none yet). A triangle's sides are numbered `triangle * 3 + corner`, each from its corner
37
- * to the next.
38
- * @typedef {object} Topology
39
- * @property {Float32Array} normals a unit normal per triangle, 3 numbers each
40
- * @property {Int32Array} edgeOf the edge each side lies on, by its ends' points
41
- * @property {Int32Array} start where each edge's sides begin in `sides` (one more, the end)
42
- * @property {Int32Array} sides the sides on each edge, edge by edge
29
+ * A geometry's faces as found so far: the face each triangle was found in (-1 for none yet).
30
+ * @typedef {object} Faces
43
31
  * @property {Int32Array} faceOf
44
32
  * @property {FaceOf[]} faces
45
33
  */
@@ -53,8 +41,8 @@ const AXES = /** @type {const} */ ([
53
41
  * @property {{ matrix: Matrix4, value: number } | null} area
54
42
  */
55
43
 
56
- /** @type {WeakMap<BufferGeometry, Topology>} */
57
- const topologies = new WeakMap();
44
+ /** @type {WeakMap<BufferGeometry, Faces>} */
45
+ const found = new WeakMap();
58
46
 
59
47
  /**
60
48
  * The face of `mesh` its triangle `triangle` is in, in the scene's terms.
@@ -93,144 +81,25 @@ export function faceTriangles(mesh, triangle) {
93
81
  * @param {number} triangle
94
82
  */
95
83
  function faceContaining(geometry, triangle) {
96
- let topology = topologies.get(geometry);
97
- if (!topology) {
98
- topology = topologyOf(geometry);
99
- topologies.set(geometry, topology);
84
+ const topology = topologyOf(geometry);
85
+ let faces = found.get(geometry);
86
+ if (!faces) {
87
+ faces = { faceOf: new Int32Array(topology.normals.length / 3).fill(-1), faces: [] };
88
+ found.set(geometry, faces);
100
89
  }
101
- if (topology.faceOf[triangle] < 0) findFace(topology, triangle);
102
- return topology.faces[topology.faceOf[triangle]];
90
+ if (faces.faceOf[triangle] < 0) findFace(topology, faces, triangle);
91
+ return faces.faces[faces.faceOf[triangle]];
103
92
  }
104
93
 
105
94
  /**
106
- * The triangles of `geometry`, their normals, and which meet which: across an edge whose two
107
- * ends each triangle has (by their points, as a mesh's faces each have corners of their own).
108
- * Typed arrays throughout: a mesh of a million triangles took seconds, a string and an array
109
- * made for each.
110
- * @param {BufferGeometry} geometry
111
- * @returns {Topology}
112
- */
113
- function topologyOf(geometry) {
114
- const position = geometry.getAttribute('position');
115
- const index = geometry.getIndex();
116
- const count = (index ? index.count : position.count) / 3;
117
- const sideCount = count * 3;
118
- /** @param {number} side */
119
- const vertex = (side) => (index ? index.getX(side) : side);
120
- const pointOf = pointsOf(geometry);
121
- const normals = new Float32Array(count * 3);
122
- const a = new Vector3();
123
- const b = new Vector3();
124
- const c = new Vector3();
125
- for (let t = 0; t < count; t++) {
126
- a.fromBufferAttribute(position, vertex(t * 3));
127
- b.fromBufferAttribute(position, vertex(t * 3 + 1));
128
- c.fromBufferAttribute(position, vertex(t * 3 + 2));
129
- c.sub(b)
130
- .cross(a.sub(b))
131
- .normalize()
132
- .toArray(normals, t * 3);
133
- }
134
- // Each side's ends, the lower point first, so a side and its twin are one key
135
- const low = new Int32Array(sideCount);
136
- const high = new Int32Array(sideCount);
137
- for (let side = 0; side < sideCount; side++) {
138
- const next = side % 3 === 2 ? side - 2 : side + 1;
139
- const p = pointOf[vertex(side)];
140
- const q = pointOf[vertex(next)];
141
- low[side] = Math.min(p, q);
142
- high[side] = Math.max(p, q);
143
- }
144
- const edges = intern(
145
- sideCount,
146
- (side) => Math.imul(low[side], SPREAD[0]) ^ Math.imul(high[side], SPREAD[1]),
147
- (side, other) => low[side] === low[other] && high[side] === high[other],
148
- );
149
- const start = new Int32Array(edges.count + 1);
150
- for (let side = 0; side < sideCount; side++) start[edges.ids[side] + 1]++;
151
- for (let edge = 0; edge < edges.count; edge++) start[edge + 1] += start[edge];
152
- const sides = new Int32Array(sideCount);
153
- const filled = start.slice(0, edges.count);
154
- for (let side = 0; side < sideCount; side++) sides[filled[edges.ids[side]]++] = side;
155
- return {
156
- normals,
157
- edgeOf: edges.ids,
158
- start,
159
- sides,
160
- faceOf: new Int32Array(count).fill(-1),
161
- faces: [],
162
- };
163
- }
164
-
165
- /**
166
- * The point each vertex of `geometry` is at: vertices nearer than SAME_POINT of its size are at
167
- * one.
168
- * @param {BufferGeometry} geometry
169
- */
170
- function pointsOf(geometry) {
171
- const position = geometry.getAttribute('position');
172
- geometry.computeBoundingBox();
173
- const size = geometry.boundingBox?.getSize(new Vector3()).length() || 1;
174
- const step = size * SAME_POINT;
175
- const grid = new Float64Array(position.count * 3);
176
- for (let v = 0; v < position.count; v++) {
177
- grid[v * 3] = Math.round(position.getX(v) / step);
178
- grid[v * 3 + 1] = Math.round(position.getY(v) / step);
179
- grid[v * 3 + 2] = Math.round(position.getZ(v) / step);
180
- }
181
- return intern(
182
- position.count,
183
- (v) =>
184
- Math.imul(grid[v * 3], SPREAD[0]) ^
185
- Math.imul(grid[v * 3 + 1], SPREAD[1]) ^
186
- Math.imul(grid[v * 3 + 2], SPREAD[2]),
187
- (v, w) =>
188
- grid[v * 3] === grid[w * 3] &&
189
- grid[v * 3 + 1] === grid[w * 3 + 1] &&
190
- grid[v * 3 + 2] === grid[w * 3 + 2],
191
- ).ids;
192
- }
193
-
194
- /**
195
- * Ids for `count` keys, the same for equal keys and counted from 0 in the order they first
196
- * come: by an open-addressing table of the first key of each id.
197
- * @param {number} count
198
- * @param {(key: number) => number} hash
199
- * @param {(key: number, other: number) => boolean} same
200
- * @returns {{ ids: Int32Array, count: number }}
201
- */
202
- function intern(count, hash, same) {
203
- let size = 1;
204
- while (size < count * TABLE_ROOM) size *= 2;
205
- const mask = size - 1;
206
- const firsts = new Int32Array(size).fill(-1);
207
- const ids = new Int32Array(count);
208
- let distinct = 0;
209
- for (let key = 0; key < count; key++) {
210
- for (let slot = hash(key) & mask; ; slot = (slot + 1) & mask) {
211
- const first = firsts[slot];
212
- if (first < 0) {
213
- firsts[slot] = key;
214
- ids[key] = distinct++;
215
- break;
216
- }
217
- if (same(key, first)) {
218
- ids[key] = ids[first];
219
- break;
220
- }
221
- }
222
- }
223
- return { ids, count: distinct };
224
- }
225
-
226
- /**
227
- * Finds the face `seed` is in: the triangles reached across edges that turn less than the
228
- * feature edges' angle; flat when they all face the seed's way.
95
+ * Finds the face `seed` is in: the triangles reached across edges that don't turn; flat when
96
+ * they all face the seed's way.
229
97
  * @param {Topology} topology
98
+ * @param {Faces} found the faces found so far, which it joins
230
99
  * @param {number} seed
231
100
  */
232
- function findFace(topology, seed) {
233
- const { normals, edgeOf, start, sides, faceOf, faces } = topology;
101
+ function findFace(topology, { faceOf, faces }, seed) {
102
+ const { normals, edgeOf, start, sides } = topology;
234
103
  const id = faces.length;
235
104
  /** @param {number} t @param {number} u */
236
105
  const cos = (t, u) =>
@@ -246,7 +115,7 @@ function findFace(topology, seed) {
246
115
  const edge = edgeOf[side];
247
116
  for (let on = start[edge]; on < start[edge + 1]; on++) {
248
117
  const u = Math.floor(sides[on] / 3);
249
- if (faceOf[u] >= 0 || cos(t, u) < SMOOTH_COS) continue;
118
+ if (faceOf[u] >= 0 || turns(topology, t, u)) continue;
250
119
  faceOf[u] = id;
251
120
  triangles.push(u);
252
121
  if (cos(seed, u) < FLAT_COS) flat = false;
@@ -0,0 +1,41 @@
1
+ // What a ray meets among a model's meshes, through a bounds tree of each geometry
2
+ // (three-mesh-bvh), made the first time a ray is cast at it: three.js's own raycast tests every
3
+ // triangle, 14 ms a pointer move at 500k triangles. The tree is indirect, so the geometry's
4
+ // index stays as it is and a hit's `faceIndex` is the triangle the faces (faces.js) know.
5
+
6
+ import { MeshBVH } from 'three-mesh-bvh';
7
+
8
+ /** @import { BufferGeometry, Intersection, Mesh, Raycaster } from 'three' */
9
+
10
+ /** @type {WeakMap<BufferGeometry, MeshBVH>} */
11
+ const trees = new WeakMap();
12
+
13
+ /**
14
+ * The hits of `raycaster`'s ray on `meshes` (not their children), nearest first, as
15
+ * `raycaster.intersectObjects(meshes, false)` gives them.
16
+ * @param {Raycaster} raycaster
17
+ * @param {Mesh[]} meshes
18
+ * @returns {Intersection[]}
19
+ */
20
+ export function intersectMeshes(raycaster, meshes) {
21
+ /** @type {Intersection[]} */
22
+ const hits = [];
23
+ for (const mesh of meshes) {
24
+ if (!mesh.layers.test(raycaster.layers)) continue;
25
+ treeOf(mesh.geometry).raycastObject3D(mesh, raycaster, hits);
26
+ }
27
+ return hits.toSorted((a, b) => a.distance - b.distance);
28
+ }
29
+
30
+ /**
31
+ * @param {BufferGeometry} geometry
32
+ * @returns {MeshBVH}
33
+ */
34
+ function treeOf(geometry) {
35
+ let tree = trees.get(geometry);
36
+ if (!tree) {
37
+ tree = new MeshBVH(geometry, { indirect: true });
38
+ trees.set(geometry, tree);
39
+ }
40
+ return tree;
41
+ }
@@ -19,6 +19,8 @@ import { faceTriangles } from './faces.js';
19
19
  /** How much of the selection's colour the copy under the pointer takes on, and its face. */
20
20
  export const COPY_GLOW = 0.2;
21
21
  export const FACE_GLOW = 0.4;
22
+ /** A mark is drawn this far after its mesh in the order: before the next (orders are whole). */
23
+ const MARK_AFTER = 0.5;
22
24
  /** What a mark draws while it marks nothing. */
23
25
  const NOTHING = new BufferGeometry();
24
26
 
@@ -72,6 +74,9 @@ export class HoverMark {
72
74
  material.needsUpdate = true;
73
75
  }
74
76
  mesh.add(this.#copy);
77
+ // Over the mesh, a drawing's shape drawn in its order (model.js), before what is next
78
+ this.#copy.renderOrder = mesh.renderOrder + MARK_AFTER;
79
+ this.#face.renderOrder = mesh.renderOrder + MARK_AFTER;
75
80
  }
76
81
  }
77
82
  this.#face.removeFromParent();
@@ -7,7 +7,6 @@
7
7
 
8
8
  import {
9
9
  DoubleSide,
10
- EdgesGeometry,
11
10
  Float32BufferAttribute,
12
11
  BufferGeometry,
13
12
  GreaterDepth,
@@ -21,14 +20,14 @@ import {
21
20
  } from 'three';
22
21
  import { SVGLoader } from 'three/addons/loaders/SVGLoader.js';
23
22
  import { disposeGeometries } from './dispose.js';
23
+ import { LAYERS } from './environment.js';
24
+ import { featureEdgesOf } from './topology.js';
24
25
 
25
26
  /** @import { Material, Object3D } from 'three' */
26
27
  /** @import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js' */
27
28
  /** @import { Model, Part } from '@kupcad/protocol/types' */
28
29
  /** @import { SceneModel, Shape, Theme } from './types.js' */
29
30
 
30
- /** Where a solid's faces meet at more than this many degrees, an edge is drawn: its features. */
31
- export const EDGE_ANGLE_DEG = 20;
32
31
  /** A ghost is seen through. */
33
32
  export const GHOST_OPACITY = 0.2;
34
33
  /** How much of a highlighted solid shows where something hides it. */
@@ -147,7 +146,7 @@ function solidShape(mesh, partOf, nodeName, { edgeMaterial, highlightEdges }, th
147
146
  if (highlighted) material.color.set(theme.highlight);
148
147
  Object.assign(material, FACES_BEHIND_EDGES);
149
148
  const edges = new LineSegments(
150
- new EdgesGeometry(mesh.geometry, EDGE_ANGLE_DEG),
149
+ featureEdgesOf(mesh.geometry),
151
150
  highlighted ? highlightEdges : edgeMaterial,
152
151
  );
153
152
  mesh.add(edges);
@@ -204,7 +203,9 @@ function partAbove(object, partOf, nodeName) {
204
203
  /**
205
204
  * A drawing (export_svg's) on the XY plane: each filled path a shape named by its id, with its
206
205
  * outline; an open path its outline alone. The core negated every y, SVG's running down the
207
- * page; the group turns it back.
206
+ * page; the group turns it back. Its paths are drawn in the file's order, each over the floor
207
+ * and the ones before it, as SVG draws them: all at z = 0, by depth they fought, a shape's
208
+ * letters lost in streaks of the plate under them, and the floor's grid over both.
208
209
  * @param {string} svg
209
210
  * @param {Theme} theme
210
211
  * @returns {SceneModel}
@@ -212,8 +213,11 @@ function partAbove(object, partOf, nodeName) {
212
213
  function drawing(svg, theme) {
213
214
  const root = new Group();
214
215
  root.scale.y = -1;
215
- // Outlines on the background
216
- const edgeMaterial = new LineBasicMaterial({ color: theme.wire });
216
+ root.renderOrder = LAYERS.drawing;
217
+ let order = 0;
218
+ // Outlines on the background. Each thing a drawing draws is see-through to three.js, as are
219
+ // its fills, which it draws after everything opaque whatever their order: one list, in order
220
+ const edgeMaterial = new LineBasicMaterial({ color: theme.wire, transparent: true });
217
221
  /** @type {Shape[]} */
218
222
  const shapes = [];
219
223
  for (const path of new SVGLoader().parse(svg).paths) {
@@ -222,6 +226,7 @@ function drawing(svg, theme) {
222
226
  const filled = style.fill !== undefined && style.fill !== 'none';
223
227
  const edges = new LineSegments(outlineOf(path, filled), edgeMaterial);
224
228
  if (!filled) {
229
+ edges.renderOrder = order++;
225
230
  root.add(edges);
226
231
  continue;
227
232
  }
@@ -229,11 +234,14 @@ function drawing(svg, theme) {
229
234
  const material = new MeshBasicMaterial({
230
235
  color: path.color,
231
236
  side: DoubleSide,
232
- transparent: opacity < 1,
237
+ transparent: true,
233
238
  opacity,
234
- ...FACES_BEHIND_EDGES,
239
+ // Over what is drawn before it; it writes no depth then, so its outline, after it, shows
240
+ depthTest: false,
235
241
  });
236
242
  const mesh = new Mesh(new ShapeGeometry(path.toShapes()), material);
243
+ mesh.renderOrder = order++;
244
+ edges.renderOrder = order++;
237
245
  mesh.add(edges);
238
246
  root.add(mesh);
239
247
  const id = /** @type {Element | undefined} */ (path.userData?.node)?.getAttribute('id');
@@ -2,13 +2,14 @@
2
2
  // cuts capped where the plane runs inside a solid, by three.js's stencil technique
3
3
  // (webgl_clipping_stencil): each solid's back faces count up in the stencil buffer and its front
4
4
  // faces down, both clipped, so where the count isn't 0 the plane is inside a solid, and a plane
5
- // drawn there in the cap's colour closes the cut.
5
+ // drawn there in the cap's colour closes the cut. A face on the plane (a cut at the bottom, or
6
+ // at a step's height) is kept: whether it was clipped was the rounding of each pixel's, so the
7
+ // count, and the cap, came and went in streaks.
6
8
 
7
9
  import {
8
10
  AlwaysStencilFunc,
9
11
  BackSide,
10
12
  DecrementWrapStencilOp,
11
- DoubleSide,
12
13
  FrontSide,
13
14
  Group,
14
15
  IncrementWrapStencilOp,
@@ -35,6 +36,11 @@ export const RENDER_ORDER = { stencil: 1, cap: 1.1, model: 2 };
35
36
  const AXES = { x: new Vector3(1, 0, 0), y: new Vector3(0, 1, 0), z: new Vector3(0, 0, 1) };
36
37
  /** The cap plane reaches this far past the model's bounding sphere's radius. */
37
38
  const CAP_REACH = 4;
39
+ /**
40
+ * The plane lies this far (of the model's radius) past the offset, toward what is cut away, so
41
+ * a face at the offset is kept whatever the rounding: far past it, a hair to the eye.
42
+ */
43
+ const KEEP_ON_PLANE = 1e-4;
38
44
  const PLANE_NORMAL = new Vector3(0, 0, 1);
39
45
 
40
46
  export class SectionView {
@@ -58,8 +64,9 @@ export class SectionView {
58
64
  constructor(theme) {
59
65
  const material = new MeshStandardMaterial({
60
66
  color: theme.cap,
61
- // Seen from the side cut away, against the plane's normal, which points to what is kept
62
- side: DoubleSide,
67
+ // Seen from the side cut away, its back: its normal is the plane's, which points to what
68
+ // is kept. From that side the solid hides it, so a face on the plane can't fight it there
69
+ side: BackSide,
63
70
  stencilWrite: true,
64
71
  stencilRef: 0,
65
72
  stencilFunc: NotEqualStencilFunc,
@@ -95,6 +102,7 @@ export class SectionView {
95
102
  // What lies past the offset, along the axis (against it when flipped), is cut away
96
103
  this.plane.normal.copy(AXES[cut.section.axis]).multiplyScalar(cut.section.flip ? 1 : -1);
97
104
  this.plane.constant = cut.section.flip ? -cut.section.offset : cut.section.offset;
105
+ this.plane.constant += bounds.radius * KEEP_ON_PLANE;
98
106
  }
99
107
  this.cap.visible = cut !== null && capped;
100
108
  for (const material of [...(model ? ownMaterials(model) : []), ...also]) {
@@ -3,16 +3,18 @@
3
3
  // a picked part in its orangered, on the neutrals of @kupcad/ui's tokens; a section's cut
4
4
  // faces in dodgerblue, so no other warm colour reads as the selection.
5
5
 
6
+ import { BRAND } from './brand.js';
7
+
6
8
  /** @import { Theme } from './types.js' */
7
9
 
8
10
  /** @type {Theme} */
9
11
  export const LIGHT = {
10
12
  background: '#eef1f4',
11
13
  grid: '#c9cfd6',
12
- plate: '#e3e7eb',
13
- edges: '#1a1b1c',
14
- wire: '#1a1b1c',
15
- selection: '#ff5f14',
14
+ plate: BRAND.gainsboro,
15
+ edges: BRAND.black,
16
+ wire: BRAND.black,
17
+ selection: BRAND.orange,
16
18
  cap: '#0099ff',
17
19
  ghost: '#7d8794',
18
20
  highlight: '#d6409f',
@@ -25,7 +27,7 @@ export const DARK = {
25
27
  plate: '#1f2022',
26
28
  edges: '#0b0b0c',
27
29
  wire: '#c9ced4',
28
- selection: '#ff5f14',
30
+ selection: BRAND.orange,
29
31
  cap: '#3daeff',
30
32
  ghost: '#8a94a1',
31
33
  highlight: '#f06cb8',
@@ -0,0 +1,223 @@
1
+ // A geometry's triangles as a surface: which meet which across an edge, by their corners' points
2
+ // (a mesh's faces may each have corners of their own), and the way each faces. Found once per
3
+ // geometry and kept beside it: the model's feature edges are drawn from it (model.js), where two
4
+ // triangles turn by more than EDGE_ANGLE_DEG, and the faces they bound are found in it (faces.js),
5
+ // so an edge and a face's border are one.
6
+
7
+ import { BufferGeometry, Float32BufferAttribute, Vector3 } from 'three';
8
+
9
+ /** Where a solid's faces meet at more than this many degrees, an edge is drawn: its features. */
10
+ export const EDGE_ANGLE_DEG = 20;
11
+ const EDGE_COS = Math.cos((EDGE_ANGLE_DEG * Math.PI) / 180);
12
+ /** Two corners nearer than this, of the geometry's size, are one point: its triangles meet there. */
13
+ const SAME_POINT = 1e-6;
14
+ /** Slots in a hash table per key it is given, at least: it stays at most half full. */
15
+ const TABLE_ROOM = 2;
16
+ /** Odd numbers that spread a key's parts over a hash table's slots. */
17
+ const SPREAD = [0x9e3779b1, 0x85ebca77, 0xc2b2ae3d];
18
+
19
+ /**
20
+ * A geometry's triangles and which meet which across an edge. A triangle's sides are numbered
21
+ * `triangle * 3 + corner`, each from its corner to the next.
22
+ * @typedef {object} Topology
23
+ * @property {Float32Array} normals a unit normal per triangle, 3 numbers each
24
+ * @property {Uint8Array} degenerate 1 for a triangle two of whose corners are one point
25
+ * @property {Int32Array} edgeOf the edge each side lies on, by its ends' points
26
+ * @property {Int32Array} start where each edge's sides begin in `sides` (one more, the end)
27
+ * @property {Int32Array} sides the sides on each edge, edge by edge
28
+ */
29
+
30
+ /** @type {WeakMap<BufferGeometry, Topology>} */
31
+ const topologies = new WeakMap();
32
+
33
+ /**
34
+ * The topology of `geometry`, found the first time it is asked for.
35
+ * @param {BufferGeometry} geometry
36
+ * @returns {Topology}
37
+ */
38
+ export function topologyOf(geometry) {
39
+ let topology = topologies.get(geometry);
40
+ if (!topology) {
41
+ topology = build(geometry);
42
+ topologies.set(geometry, topology);
43
+ }
44
+ return topology;
45
+ }
46
+
47
+ /**
48
+ * Whether triangles `t` and `u` turn by more than EDGE_ANGLE_DEG where they meet: an edge is
49
+ * drawn between them, and they are of two faces.
50
+ * @param {Topology} topology
51
+ * @param {number} t
52
+ * @param {number} u
53
+ */
54
+ export function turns({ normals }, t, u) {
55
+ const cos =
56
+ normals[t * 3] * normals[u * 3] +
57
+ normals[t * 3 + 1] * normals[u * 3 + 1] +
58
+ normals[t * 3 + 2] * normals[u * 3 + 2];
59
+ return cos <= EDGE_COS;
60
+ }
61
+
62
+ /**
63
+ * The feature edges of `geometry` as line segments: each edge where its two triangles turn by
64
+ * more than EDGE_ANGLE_DEG, or with one triangle (the border of an open mesh) or more than two;
65
+ * a degenerate triangle has none. As three.js's EdgesGeometry draws them, which made a string for
66
+ * each corner: 1.2 s at 500k triangles.
67
+ * @param {BufferGeometry} geometry
68
+ * @returns {BufferGeometry}
69
+ */
70
+ export function featureEdgesOf(geometry) {
71
+ const topology = topologyOf(geometry);
72
+ const { degenerate, start, sides } = topology;
73
+ const position = geometry.getAttribute('position');
74
+ const index = geometry.getIndex();
75
+ /** @param {number} side */
76
+ const vertex = (side) => (index ? index.getX(side) : side);
77
+ const drawn = [];
78
+ for (let edge = 0; edge + 1 < start.length; edge++) {
79
+ let first = -1;
80
+ let count = 0;
81
+ let turned = false;
82
+ for (let on = start[edge]; on < start[edge + 1]; on++) {
83
+ const t = Math.floor(sides[on] / 3);
84
+ if (degenerate[t]) continue;
85
+ if (count === 0) first = sides[on];
86
+ else turned = turns(topology, Math.floor(first / 3), t);
87
+ count++;
88
+ }
89
+ if (count === 1 || count > 2 || turned) drawn.push(first);
90
+ }
91
+ const positions = new Float32Array(drawn.length * 6);
92
+ drawn.forEach((side, i) => {
93
+ for (const [end, v] of [vertex(side), vertex(nextSide(side))].entries()) {
94
+ positions[i * 6 + end * 3] = position.getX(v);
95
+ positions[i * 6 + end * 3 + 1] = position.getY(v);
96
+ positions[i * 6 + end * 3 + 2] = position.getZ(v);
97
+ }
98
+ });
99
+ return new BufferGeometry().setAttribute('position', new Float32BufferAttribute(positions, 3));
100
+ }
101
+
102
+ /**
103
+ * The side after `side` around its triangle.
104
+ * @param {number} side
105
+ */
106
+ function nextSide(side) {
107
+ return side % 3 === 2 ? side - 2 : side + 1;
108
+ }
109
+
110
+ /**
111
+ * The triangles of `geometry`, their normals, and which meet which: across an edge whose two
112
+ * ends each triangle has (by their points, as a mesh's faces each have corners of their own).
113
+ * Typed arrays throughout: a mesh of a million triangles took seconds, a string and an array
114
+ * made for each.
115
+ * @param {BufferGeometry} geometry
116
+ * @returns {Topology}
117
+ */
118
+ function build(geometry) {
119
+ const position = geometry.getAttribute('position');
120
+ const index = geometry.getIndex();
121
+ const count = (index ? index.count : position.count) / 3;
122
+ const sideCount = count * 3;
123
+ /** @param {number} side */
124
+ const vertex = (side) => (index ? index.getX(side) : side);
125
+ const pointOf = pointsOf(geometry);
126
+ const normals = new Float32Array(count * 3);
127
+ const a = new Vector3();
128
+ const b = new Vector3();
129
+ const c = new Vector3();
130
+ for (let t = 0; t < count; t++) {
131
+ a.fromBufferAttribute(position, vertex(t * 3));
132
+ b.fromBufferAttribute(position, vertex(t * 3 + 1));
133
+ c.fromBufferAttribute(position, vertex(t * 3 + 2));
134
+ c.sub(b)
135
+ .cross(a.sub(b))
136
+ .normalize()
137
+ .toArray(normals, t * 3);
138
+ }
139
+ // Each side's ends, the lower point first, so a side and its twin are one key
140
+ const low = new Int32Array(sideCount);
141
+ const high = new Int32Array(sideCount);
142
+ const degenerate = new Uint8Array(count);
143
+ for (let side = 0; side < sideCount; side++) {
144
+ const p = pointOf[vertex(side)];
145
+ const q = pointOf[vertex(nextSide(side))];
146
+ low[side] = Math.min(p, q);
147
+ high[side] = Math.max(p, q);
148
+ if (p === q) degenerate[Math.floor(side / 3)] = 1;
149
+ }
150
+ const edges = intern(
151
+ sideCount,
152
+ (side) => Math.imul(low[side], SPREAD[0]) ^ Math.imul(high[side], SPREAD[1]),
153
+ (side, other) => low[side] === low[other] && high[side] === high[other],
154
+ );
155
+ const start = new Int32Array(edges.count + 1);
156
+ for (let side = 0; side < sideCount; side++) start[edges.ids[side] + 1]++;
157
+ for (let edge = 0; edge < edges.count; edge++) start[edge + 1] += start[edge];
158
+ const sides = new Int32Array(sideCount);
159
+ const filled = start.slice(0, edges.count);
160
+ for (let side = 0; side < sideCount; side++) sides[filled[edges.ids[side]]++] = side;
161
+ return { normals, degenerate, edgeOf: edges.ids, start, sides };
162
+ }
163
+
164
+ /**
165
+ * The point each vertex of `geometry` is at: vertices nearer than SAME_POINT of its size are at
166
+ * one.
167
+ * @param {BufferGeometry} geometry
168
+ */
169
+ function pointsOf(geometry) {
170
+ const position = geometry.getAttribute('position');
171
+ geometry.computeBoundingBox();
172
+ const size = geometry.boundingBox?.getSize(new Vector3()).length() || 1;
173
+ const step = size * SAME_POINT;
174
+ const grid = new Float64Array(position.count * 3);
175
+ for (let v = 0; v < position.count; v++) {
176
+ grid[v * 3] = Math.round(position.getX(v) / step);
177
+ grid[v * 3 + 1] = Math.round(position.getY(v) / step);
178
+ grid[v * 3 + 2] = Math.round(position.getZ(v) / step);
179
+ }
180
+ return intern(
181
+ position.count,
182
+ (v) =>
183
+ Math.imul(grid[v * 3], SPREAD[0]) ^
184
+ Math.imul(grid[v * 3 + 1], SPREAD[1]) ^
185
+ Math.imul(grid[v * 3 + 2], SPREAD[2]),
186
+ (v, w) =>
187
+ grid[v * 3] === grid[w * 3] &&
188
+ grid[v * 3 + 1] === grid[w * 3 + 1] &&
189
+ grid[v * 3 + 2] === grid[w * 3 + 2],
190
+ ).ids;
191
+ }
192
+
193
+ /**
194
+ * Ids for `count` keys, the same for equal keys and counted from 0 in the order they first
195
+ * come: by an open-addressing table of the first key of each id.
196
+ * @param {number} count
197
+ * @param {(key: number) => number} hash
198
+ * @param {(key: number, other: number) => boolean} same
199
+ * @returns {{ ids: Int32Array, count: number }}
200
+ */
201
+ function intern(count, hash, same) {
202
+ let size = 1;
203
+ while (size < count * TABLE_ROOM) size *= 2;
204
+ const mask = size - 1;
205
+ const firsts = new Int32Array(size).fill(-1);
206
+ const ids = new Int32Array(count);
207
+ let distinct = 0;
208
+ for (let key = 0; key < count; key++) {
209
+ for (let slot = hash(key) & mask; ; slot = (slot + 1) & mask) {
210
+ const first = firsts[slot];
211
+ if (first < 0) {
212
+ firsts[slot] = key;
213
+ ids[key] = distinct++;
214
+ break;
215
+ }
216
+ if (same(key, first)) {
217
+ ids[key] = ids[first];
218
+ break;
219
+ }
220
+ }
221
+ }
222
+ return { ids, count: distinct };
223
+ }