@solidrt/3d 0.0.57 → 0.0.58

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.
@@ -142,6 +142,12 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
142
142
  transparent dome and a mesh without normals) parsed with `parseGltf`
143
143
  from a binary import and built by `createModel` into a Group of named
144
144
  parts; clicking a part hides it, clicking the body restores all.
145
+ - `wireframe.tsx` - the rover drawn solid, as a wireframe or as its
146
+ feature edges: `wireframeGeometry`/`edgesGeometry` build "lines"
147
+ geometry over each part's own vertices (one shared vertex upload, a
148
+ second index buffer) and `setGeometry`/`setMaterial` swap it onto the
149
+ live mesh, no node touched. Space, a tap on the rover, or the `wire`
150
+ debug command (`{ mode: "solid" | "wireframe" | "edges" }`) cycles.
145
151
  - `model-load.tsx` - the same rover loaded ASYNC with `loadModel` from
146
152
  `assets/` under a `<Loading>` boundary: the async read lives in a memo,
147
153
  a second memo derives the scene JSX after that read, and the shell
@@ -0,0 +1,107 @@
1
+ // The rover drawn solid, as a wireframe or as its feature edges. Both line
2
+ // forms are GEOMETRY: wireframeGeometry lists every edge of a triangle
3
+ // geometry once, edgesGeometry only the edges where two faces meet at the
4
+ // threshold angle or more (plus the open borders), each as a "lines"
5
+ // geometry over the source's OWN vertex array. The topology rides on the
6
+ // geometry, so the plain unlit material draws it as lines, and the vertex
7
+ // upload is shared, so a mode costs an index buffer, not a second model.
8
+ // The swap is setGeometry + setMaterial on the live mesh: the node, its
9
+ // transform and its pointer handlers are untouched. Space, a tap on the
10
+ // rover or the `wire` debug command ({ mode }) cycles the modes.
11
+
12
+ import { createSignal, onFrame, pct, render } from "@solidrt/core"
13
+ import { createTexture } from "@solidrt/core/gpu"
14
+ import type { TextureId } from "@solidrt/core/gpu"
15
+ import { registerDebug } from "srt:dev"
16
+ import { add, createModel, DirectionalLight, edgesGeometry, equirectToCube, Group, parseGltf, PerspectiveCamera, Scene, setGeometry, setMaterial, unlit, wireframeGeometry } from "@solidrt/3d"
17
+ import type { SceneNode } from "@solidrt/3d"
18
+ import modelBytes from "./model.glb" with { type: "binary" }
19
+
20
+ const MODES = ["solid", "wireframe", "edges"] as const
21
+ type Mode = (typeof MODES)[number]
22
+ // Feature-edge threshold in degrees: above the facet angle of the rover's
23
+ // rounded parts, so their seams stay hidden and only rims and creases draw.
24
+ const EDGE_ANGLE = 20
25
+ const LINE_COLOR: [number, number, number] = [0.85, 0.95, 1]
26
+ // Face edge of the gradient environment cube: a gradient needs no detail.
27
+ const ENV_FACE = 16
28
+ // The gradient, zenith to nadir, sRGB bytes: sky, horizon, ground, below.
29
+ const ENV_ROWS: [number, number, number][] = [
30
+ [96, 122, 168],
31
+ [176, 190, 210],
32
+ [120, 105, 90],
33
+ [60, 52, 45],
34
+ ]
35
+
36
+ // A tiny equirectangular panorama turned into the cube the solid rover's
37
+ // standard materials light by (see model.tsx).
38
+ function gradientEnvironment(): TextureId {
39
+ let px = new Uint8Array(2 * ENV_ROWS.length * 4)
40
+ ENV_ROWS.forEach((c, y) => {
41
+ for (let x = 0; x < 2; x++) px.set([c[0], c[1], c[2], 255], (y * 2 + x) * 4)
42
+ })
43
+ let panorama = createTexture(px, 2, ENV_ROWS.length, { format: "rgba8-srgb", filter: "linear" })
44
+ return equirectToCube(panorama, ENV_FACE, { format: "rgba8-srgb", mipmap: true, label: "gradient-env" })
45
+ }
46
+
47
+ function App() {
48
+ let cube = gradientEnvironment()
49
+ let [t, setT] = createSignal(0)
50
+ onFrame(tick => setT(tick / 1000))
51
+
52
+ let model = createModel(parseGltf(modelBytes), { label: "rover" })
53
+ let b = model.bounds
54
+ let center: [number, number, number] = [(b[0]! + b[3]!) / 2, (b[1]! + b[4]!) / 2, (b[2]! + b[5]!) / 2]
55
+ let radius = Math.hypot(b[3]! - b[0]!, b[4]! - b[1]!, b[5]! - b[2]!) / 2
56
+
57
+ // One line material for every part and both line modes. Per part, what
58
+ // each mode swaps in: the line geometries share the solid's vertices,
59
+ // so building both up front costs their index buffers only.
60
+ let lines = unlit({ color: LINE_COLOR })
61
+ let looks = model.parts.map(part => ({
62
+ mesh: part.mesh,
63
+ solid: { geometry: part.mesh.geometry, material: part.mesh.material },
64
+ wireframe: wireframeGeometry(part.mesh.geometry),
65
+ edges: edgesGeometry(part.mesh.geometry, EDGE_ANGLE),
66
+ }))
67
+ let mode: Mode = "solid"
68
+ let apply = (next: Mode): void => {
69
+ mode = next
70
+ for (let look of looks) {
71
+ // Each call rebuilds the draw entry, so the intermediate state is
72
+ // always the line material over the solid's triangles - a pipeline
73
+ // unlit has anyway - never the solid material over lines.
74
+ if (next === "solid") {
75
+ setGeometry(look.mesh, look.solid.geometry)
76
+ setMaterial(look.mesh, look.solid.material)
77
+ } else {
78
+ setMaterial(look.mesh, lines)
79
+ setGeometry(look.mesh, look[next])
80
+ }
81
+ }
82
+ }
83
+ let cycle = (): void => apply(MODES[(MODES.indexOf(mode) + 1) % MODES.length]!)
84
+ for (let part of model.parts) part.mesh.onTap = cycle
85
+ registerDebug("wire", (args?: Record<string, unknown>) => {
86
+ if (typeof args?.mode === "string" && (MODES as readonly string[]).includes(args.mode)) apply(args.mode as Mode)
87
+ else cycle()
88
+ })
89
+
90
+ return (
91
+ <window
92
+ onKeyDown={e => {
93
+ if (e.key === " ") cycle()
94
+ }}
95
+ >
96
+ <view width={pct(100)} height={pct(100)}>
97
+ <Scene clearColor={[0.1, 0.11, 0.14, 1]} environment={{ cube }} samples={4} label="wireframe">
98
+ <PerspectiveCamera fov={40} position={[center[0] + radius * 1.4, center[1] + radius * 1.1, center[2] + radius * 2.2]} lookAt={center} />
99
+ <DirectionalLight direction={[0.5, -0.8, 0.3]} color={[1, 0.95, 0.85]} intensity={0.9} />
100
+ <Group rotation={[0, t() / 3, 0]} ref={(g: SceneNode) => add(g, model)} />
101
+ </Scene>
102
+ </view>
103
+ </window>
104
+ )
105
+ }
106
+
107
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/3d",
3
- "version": "0.0.57",
3
+ "version": "0.0.58",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -19,6 +19,6 @@
19
19
  ],
20
20
  "peerDependencies": {
21
21
  "@solidjs/signals": "2.0.0-rc.6",
22
- "@solidrt/core": "0.0.57"
22
+ "@solidrt/core": "0.0.58"
23
23
  }
24
24
  }
@@ -15,7 +15,7 @@ import { createBuffer, destroyBuffer } from "@solidrt/core/gpu"
15
15
  import type { BufferId, IndexFormat } from "@solidrt/core/gpu"
16
16
  import { createShape, destroyShape } from "flux:spatial"
17
17
  import type { ShapeId } from "flux:spatial"
18
- import { layoutStride } from "./geometry.ts"
18
+ import { geometryTopology, layoutStride } from "./geometry.ts"
19
19
  import type { Geometry } from "./geometry.ts"
20
20
 
21
21
  /** An acquired reference to a geometry's GPU buffers: what a draw entry
@@ -26,14 +26,45 @@ export type GeometryBuffers = {
26
26
  buffer: BufferId
27
27
  index: BufferId
28
28
  indexFormat: IndexFormat
29
- /** The picking shape (positions at 0, uv at 6 of every layout). */
30
- shape: ShapeId
29
+ /** The picking shape (positions at 0, uv at 6 of every layout); null
30
+ * for anything but a triangle list, which picks by its box. */
31
+ shape: ShapeId | null
31
32
  }
32
33
 
33
- type GpuEntry = GeometryBuffers & { geometry: Geometry; refs: number }
34
+ type GpuEntry = GeometryBuffers & { geometry: Geometry; vertices: Float32Array; refs: number }
34
35
 
35
36
  let entries = new WeakMap<Geometry, GpuEntry>()
36
37
 
38
+ /** Vertex uploads keyed by the array itself: geometries sharing one
39
+ * vertex array (a wireframe or edges geometry over its source's) share
40
+ * one GPU buffer, each entry holding a reference to it. */
41
+ let vertexUploads = new WeakMap<Float32Array, { buffer: BufferId; refs: number }>()
42
+
43
+ function acquireVertices(geometry: Geometry): BufferId {
44
+ let upload = vertexUploads.get(geometry.vertices)
45
+ if (upload === undefined) {
46
+ upload = {
47
+ buffer: createBuffer(geometry.vertices, {
48
+ autoFree: false,
49
+ label: geometry.label ? geometry.label + "-verts" : undefined,
50
+ }),
51
+ refs: 0,
52
+ }
53
+ vertexUploads.set(geometry.vertices, upload)
54
+ }
55
+ upload.refs++
56
+ return upload.buffer
57
+ }
58
+
59
+ function releaseVertices(vertices: Float32Array): void {
60
+ let upload = vertexUploads.get(vertices)
61
+ if (upload === undefined) return
62
+ upload.refs--
63
+ if (upload.refs > 0) return
64
+ vertexUploads.delete(vertices)
65
+ destroyBuffer(upload.buffer)
66
+ }
67
+
37
68
  /** The geometry's GPU buffers, created on first use, plus the index format
38
69
  * the draw entry must bind them with. Takes a reference - pair every
39
70
  * acquire with a releaseGeometryBuffers of the returned token when the
@@ -43,16 +74,17 @@ export function acquireGeometryBuffers(geometry: Geometry): GeometryBuffers {
43
74
  if (entry === undefined) {
44
75
  entry = {
45
76
  geometry,
46
- buffer: createBuffer(geometry.vertices, {
47
- autoFree: false,
48
- label: geometry.label ? geometry.label + "-verts" : undefined,
49
- }),
77
+ vertices: geometry.vertices,
78
+ buffer: acquireVertices(geometry),
50
79
  index: createBuffer(geometry.indices, {
51
80
  autoFree: false,
52
81
  label: geometry.label ? geometry.label + "-indices" : undefined,
53
82
  }),
54
83
  indexFormat: geometry.indices instanceof Uint32Array ? "uint32" : "uint16",
55
- shape: createShape(geometry.vertices, layoutStride(geometry.layout), 0, 6, geometry.indices),
84
+ shape:
85
+ geometryTopology(geometry) === "triangles"
86
+ ? createShape(geometry.vertices, layoutStride(geometry.layout), 0, 6, geometry.indices)
87
+ : null,
56
88
  refs: 0,
57
89
  }
58
90
  entries.set(geometry, entry)
@@ -74,9 +106,9 @@ export function releaseGeometryBuffers(acquired: GeometryBuffers): void {
74
106
  queueMicrotask(() => {
75
107
  if (entries.get(entry.geometry) !== entry || entry.refs > 0) return
76
108
  entries.delete(entry.geometry)
77
- destroyBuffer(entry.buffer)
109
+ releaseVertices(entry.vertices)
78
110
  destroyBuffer(entry.index)
79
- destroyShape(entry.shape)
111
+ if (entry.shape !== null) destroyShape(entry.shape)
80
112
  })
81
113
  }
82
114
 
@@ -91,7 +123,7 @@ export function disposeGeometry(geometry: Geometry): void {
91
123
  let entry = entries.get(geometry)
92
124
  if (entry === undefined) return
93
125
  entries.delete(geometry)
94
- destroyBuffer(entry.buffer)
126
+ releaseVertices(entry.vertices)
95
127
  destroyBuffer(entry.index)
96
- destroyShape(entry.shape)
128
+ if (entry.shape !== null) destroyShape(entry.shape)
97
129
  }
package/src/geometry.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  // array math (the check rig checks/geometry-check.ts runs it headless on
22
22
  // flux). The GPU buffer step lives in geometry-gpu.ts.
23
23
 
24
- import type { VertexAttribute } from "@solidrt/core/gpu"
24
+ import type { Topology, VertexAttribute } from "@solidrt/core/gpu"
25
25
  import { add, compose, cross, mat4, normalize, normalMatrix, sub, updateRotation, updateScale } from "./math.ts"
26
26
  import type { Quat, TransformUpdate, Vec2, Vec3 } from "./math.ts"
27
27
 
@@ -50,7 +50,10 @@ export const VERTEX_LAYOUTS: Record<"standard" | "colored" | "skinned", VertexAt
50
50
  * format before packing). */
51
51
  export const STANDARD_FLOATS = 8
52
52
 
53
- const FORMAT_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
53
+ /** Floats per attribute format: the vertex vocabulary itself, so it is
54
+ * also the list a declared format must be one of (vertex attributes and
55
+ * instance attributes both). */
56
+ export const FORMAT_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
54
57
 
55
58
  /** The attribute list of a layout (a preset name resolves to its list). */
56
59
  export function layoutAttributes(layout?: VertexLayout): VertexAttribute[] {
@@ -123,7 +126,35 @@ export function validateGeometry(geometry: Geometry): void {
123
126
  name + ": " + geometry.vertices.length + " vertex floats is not a whole number of " + stride + "-float (" + layoutKey(layout) + ") vertices",
124
127
  )
125
128
  }
126
- if (geometry.indices.length === 0) throw new Error(name + ": no indices")
129
+ let topology = geometryTopology(geometry)
130
+ let count = geometry.indices.length
131
+ // Lines and points may be empty: a feature-edge pass over a smooth
132
+ // closed shape has nothing to draw, and that is a result, not a bug.
133
+ switch (topology) {
134
+ case "triangles":
135
+ if (count === 0) throw new Error(name + ": no indices")
136
+ if (count % 3 !== 0) throw new Error(name + ": " + count + " indices is not a whole number of triangles")
137
+ break
138
+ case "lines":
139
+ if (count % 2 !== 0) throw new Error(name + ": " + count + " indices is not a whole number of lines")
140
+ break
141
+ case "line-strip":
142
+ if (count < 2) throw new Error(name + ": a line strip needs at least 2 indices")
143
+ break
144
+ case "triangle-strip":
145
+ if (count < 3) throw new Error(name + ": a triangle strip needs at least 3 indices")
146
+ break
147
+ case "points":
148
+ break
149
+ default:
150
+ throw new Error(name + ": unknown topology '" + String(topology) + "'")
151
+ }
152
+ }
153
+
154
+ /** The geometry's topology with the default applied: what its indices
155
+ * list, "triangles" unless it says otherwise. */
156
+ export function geometryTopology(geometry: Geometry): Topology {
157
+ return geometry.topology ?? "triangles"
127
158
  }
128
159
 
129
160
  /** The options every generator shares (each generator's own options type
@@ -194,6 +225,20 @@ export type Geometry = {
194
225
  /** The array type picks the draw's index format: Uint32Array past 64k
195
226
  * vertices. */
196
227
  indices: Uint16Array | Uint32Array
228
+ /** What the indices describe; absent means "triangles". The index
229
+ * buffer and the primitive it lists travel together (Godot's surface
230
+ * primitive, Unity's Mesh.SetIndices topology, Three's Line/Points
231
+ * object types over one BufferGeometry): the material draws whatever
232
+ * topology its geometry carries, one pipeline per (layout, topology)
233
+ * pair. Only a triangle list gets the picking shape - a lines, points
234
+ * or strip geometry picks (and collides) by its bounds box, the tier a
235
+ * sprite or an instanced mesh is at, so hits carry no face/uv. Lines
236
+ * are one pixel wide on GL ES whatever the display density. A lines
237
+ * or points geometry may have no indices at all (it then draws
238
+ * nothing; the other topologies reject that at add()).
239
+ * wireframeGeometry/edgesGeometry build "lines" over a triangle
240
+ * geometry's own vertices. */
241
+ topology?: Topology
197
242
  /** Vertex layout; absent means "standard". Must match the material's
198
243
  * layout - the scene rejects a mismatched pair at add(). */
199
244
  layout?: VertexLayout
@@ -270,6 +315,7 @@ export function withAttribute(geometry: Geometry, attr: VertexAttribute, fill: A
270
315
  return {
271
316
  vertices: out,
272
317
  indices: geometry.indices,
318
+ topology: geometry.topology,
273
319
  layout,
274
320
  label: label ?? (geometry.label ? geometry.label + "-" + attr.name : undefined),
275
321
  }
@@ -394,6 +440,7 @@ export function transformGeometry(geometry: Geometry, transform: TransformUpdate
394
440
  return {
395
441
  vertices: out,
396
442
  indices: geometry.indices,
443
+ topology: geometry.topology,
397
444
  layout: geometry.layout,
398
445
  label: label ?? (geometry.label ? geometry.label + "-transformed" : undefined),
399
446
  }
@@ -403,7 +450,9 @@ export function transformGeometry(geometry: Geometry, transform: TransformUpdate
403
450
  * Concatenate geometries into one: vertices appended in order, indices
404
451
  * offset to match, uint32 indices past 64k vertices. Every part must share
405
452
  * one layout - a mixed list throws, because the strides differ and a merge
406
- * that picked one would draw garbage, not a mesh missing a channel. The
453
+ * that picked one would draw garbage, not a mesh missing a channel - and
454
+ * one list topology (a mixed list throws the same way; strips cannot be
455
+ * concatenated at all, the seam would join them). The
407
456
  * second half of authoring a static scene as data (Three's
408
457
  * `BufferGeometryUtils.mergeGeometries`): the result is one draw entry and
409
458
  * one uModel write however many parts went in, so only what actually moves
@@ -414,12 +463,17 @@ export function mergeGeometries(parts: Geometry[], label?: string): Geometry {
414
463
  let layout = parts[0]!.layout
415
464
  let key = layoutKey(layout)
416
465
  let stride = layoutStride(layout)
466
+ let topology = geometryTopology(parts[0]!)
467
+ if (topology === "line-strip" || topology === "triangle-strip") throw new Error("mergeGeometries: cannot merge " + topology + " geometry")
417
468
  let floats = 0
418
469
  let indexCount = 0
419
470
  for (let part of parts) {
420
471
  if (layoutKey(part.layout) !== key) {
421
472
  throw new Error("mergeGeometries: mixed layouts (" + key + " and " + layoutKey(part.layout) + ")")
422
473
  }
474
+ if (geometryTopology(part) !== topology) {
475
+ throw new Error("mergeGeometries: mixed topologies (" + topology + " and " + geometryTopology(part) + ")")
476
+ }
423
477
  if (part.vertices.length % stride !== 0) {
424
478
  throw new Error("mergeGeometries: a part's vertex data is not a whole number of " + key + " vertices")
425
479
  }
@@ -439,7 +493,134 @@ export function mergeGeometries(parts: Geometry[], label?: string): Geometry {
439
493
  vOffset += part.vertices.length
440
494
  iOffset += src.length
441
495
  }
442
- return { vertices, indices, layout, label }
496
+ return { vertices, indices, topology: parts[0]!.topology, layout, label }
497
+ }
498
+
499
+ /** Positions closer than this (model units) are one vertex to the edge
500
+ * builders: a uv seam or a per-face normal split duplicates a position,
501
+ * and an edge is the same edge whichever copy a triangle names. */
502
+ const WELD_PRECISION = 1e-4
503
+
504
+ /** Degrees between two faces' normals from which the edge between them
505
+ * counts as a feature edge in edgesGeometry; under it the faces read as
506
+ * one smooth surface and the edge stays hidden. Three's default. */
507
+ const EDGES_THRESHOLD_ANGLE = 1
508
+
509
+ /** One id per vertex, shared by every vertex at the same position (to
510
+ * WELD_PRECISION) whatever its normal and uv; plus the id count. */
511
+ function weldPositions(geometry: Geometry): { ids: Uint32Array; count: number } {
512
+ let v = geometry.vertices
513
+ let stride = layoutStride(geometry.layout)
514
+ let count = v.length / stride
515
+ let ids = new Uint32Array(count)
516
+ let seen = new Map<string, number>()
517
+ let scale = 1 / WELD_PRECISION
518
+ for (let i = 0; i < count; i++) {
519
+ let b = i * stride
520
+ let key = Math.round(v[b]! * scale) + "," + Math.round(v[b + 1]! * scale) + "," + Math.round(v[b + 2]! * scale)
521
+ let id = seen.get(key)
522
+ if (id === undefined) {
523
+ id = seen.size
524
+ seen.set(key, id)
525
+ }
526
+ ids[i] = id
527
+ }
528
+ return { ids, count: seen.size }
529
+ }
530
+
531
+ /** The edge table of a triangle geometry, welded by position, as a lines
532
+ * index list: every edge once for `cosThreshold` null, else the feature
533
+ * edges - those with one face (a border) or whose two faces' normals
534
+ * dot at or below the threshold. Each edge names the vertex pair of the
535
+ * first triangle that had it. */
536
+ function edgeIndices(geometry: Geometry, name: string, cosThreshold: number | null): Uint16Array | Uint32Array {
537
+ if (geometryTopology(geometry) !== "triangles") {
538
+ throw new Error(name + ": needs a triangle geometry, got " + geometryTopology(geometry))
539
+ }
540
+ let v = geometry.vertices
541
+ let stride = layoutStride(geometry.layout)
542
+ let weld = weldPositions(geometry)
543
+ let src = geometry.indices
544
+ type Edge = { a: number; b: number; nx: number; ny: number; nz: number; faces: number; sharp: boolean }
545
+ let edges = new Map<number, Edge>()
546
+ for (let t = 0; t + 2 < src.length; t += 3) {
547
+ let i0 = src[t]!, i1 = src[t + 1]!, i2 = src[t + 2]!
548
+ let p0 = i0 * stride, p1 = i1 * stride, p2 = i2 * stride
549
+ // The face normal, for the feature test.
550
+ let ux = v[p1]! - v[p0]!, uy = v[p1 + 1]! - v[p0 + 1]!, uz = v[p1 + 2]! - v[p0 + 2]!
551
+ let wx = v[p2]! - v[p0]!, wy = v[p2 + 1]! - v[p0 + 1]!, wz = v[p2 + 2]! - v[p0 + 2]!
552
+ let nx = uy * wz - uz * wy, ny = uz * wx - ux * wz, nz = ux * wy - uy * wx
553
+ let len = Math.hypot(nx, ny, nz) || 1
554
+ nx /= len
555
+ ny /= len
556
+ nz /= len
557
+ for (let k = 0; k < 3; k++) {
558
+ let ia = k === 0 ? i0 : k === 1 ? i1 : i2
559
+ let ib = k === 0 ? i1 : k === 1 ? i2 : i0
560
+ let wa = weld.ids[ia]!, wb = weld.ids[ib]!
561
+ if (wa === wb) continue
562
+ let key = Math.min(wa, wb) * weld.count + Math.max(wa, wb)
563
+ let e = edges.get(key)
564
+ if (e === undefined) {
565
+ edges.set(key, { a: ia, b: ib, nx, ny, nz, faces: 1, sharp: false })
566
+ } else {
567
+ e.faces++
568
+ if (cosThreshold !== null && !e.sharp && e.nx * nx + e.ny * ny + e.nz * nz <= cosThreshold) e.sharp = true
569
+ }
570
+ }
571
+ }
572
+ let out: number[] = []
573
+ for (let e of edges.values()) {
574
+ if (cosThreshold === null || e.faces === 1 || e.sharp) out.push(e.a, e.b)
575
+ }
576
+ return packIndices(out, v.length / stride)
577
+ }
578
+
579
+ /**
580
+ * Every edge of a triangle geometry as a "lines" geometry over the SAME
581
+ * vertices (Three's WireframeGeometry): each triangle's three sides, once
582
+ * each - edges are matched by position, so a uv seam or a per-face
583
+ * normal split draws one line, not two on top of each other. The
584
+ * source's vertex array and layout are shared by reference, and so is
585
+ * their GPU upload, which makes the wireframe a second index buffer over
586
+ * the same vertices: a "skinned" part's wireframe draws in its pose
587
+ * under `unlit({ skinned: true })` with the mesh's own palette, and any
588
+ * material draws it as lines because the topology rides on the
589
+ * geometry. Lines are one pixel wide.
590
+ */
591
+ export function wireframeGeometry(geometry: Geometry, label?: string): Geometry {
592
+ return {
593
+ vertices: geometry.vertices,
594
+ indices: edgeIndices(geometry, "wireframeGeometry", null),
595
+ topology: "lines",
596
+ layout: geometry.layout,
597
+ label: label ?? (geometry.label ? geometry.label + "-wireframe" : undefined),
598
+ }
599
+ }
600
+
601
+ /**
602
+ * The feature edges of a triangle geometry as a "lines" geometry over the
603
+ * same vertices (Three's EdgesGeometry): an edge draws when its two faces
604
+ * meet at `thresholdAngle` degrees or more, or when only one face has it
605
+ * (an open border): the outline that a dense mesh's wireframe, a solid
606
+ * blob, cannot give. A box draws its twelve edges and no face diagonals
607
+ * at any threshold; a generated round shape is faceted, so its facet
608
+ * lines show until the threshold passes its facet angle (360 divided by
609
+ * the radial segments: a default cylinder loses its side seams and keeps
610
+ * its two rims from 16 degrees, a default sphere draws nothing from 16).
611
+ * A smooth closed shape above its facet angle has no edges at all, and
612
+ * the empty lines geometry that comes back attaches and draws nothing.
613
+ * Shares vertices, layout and GPU upload with the source like
614
+ * wireframeGeometry.
615
+ */
616
+ export function edgesGeometry(geometry: Geometry, thresholdAngle = EDGES_THRESHOLD_ANGLE, label?: string): Geometry {
617
+ return {
618
+ vertices: geometry.vertices,
619
+ indices: edgeIndices(geometry, "edgesGeometry", Math.cos((thresholdAngle * Math.PI) / 180)),
620
+ topology: "lines",
621
+ layout: geometry.layout,
622
+ label: label ?? (geometry.label ? geometry.label + "-edges" : undefined),
623
+ }
443
624
  }
444
625
 
445
626
  // Indices for a row-major (cellRows + 1) x (cellCols + 1) vertex grid: two