@solidrt/3d 0.0.57 → 0.0.59

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.
Files changed (72) hide show
  1. package/AGENTS.md +1479 -678
  2. package/README.md +4 -2
  3. package/examples/README.md +56 -4
  4. package/examples/bloom.tsx +113 -0
  5. package/examples/cascades.tsx +4 -4
  6. package/examples/collision.tsx +6 -6
  7. package/examples/exits.tsx +96 -0
  8. package/examples/first-person.tsx +5 -5
  9. package/examples/fleet.tsx +4 -4
  10. package/examples/fog.tsx +5 -5
  11. package/examples/instanced.tsx +70 -50
  12. package/examples/lamps.tsx +4 -4
  13. package/examples/lod.tsx +125 -0
  14. package/examples/materials.tsx +9 -9
  15. package/examples/morph.tsx +174 -0
  16. package/examples/normals.tsx +118 -0
  17. package/examples/{lit.tsx → phong.tsx} +9 -9
  18. package/examples/polyhedra.tsx +61 -0
  19. package/examples/scene-atlas.tsx +70 -0
  20. package/examples/scene-views.tsx +5 -5
  21. package/examples/shadows.tsx +9 -9
  22. package/examples/sky-lit.tsx +3 -3
  23. package/examples/skybox.tsx +5 -5
  24. package/examples/standard.tsx +2 -2
  25. package/examples/streams.tsx +122 -0
  26. package/examples/sweep-paths.tsx +5 -5
  27. package/examples/wireframe.tsx +129 -0
  28. package/package.json +3 -3
  29. package/src/clip.ts +18 -5
  30. package/src/color.ts +11 -2
  31. package/src/components/context.tsx +11 -2
  32. package/src/components/directional-light.tsx +2 -2
  33. package/src/components/first-person-camera.tsx +4 -4
  34. package/src/components/group.tsx +9 -5
  35. package/src/components/hemisphere-light.tsx +2 -2
  36. package/src/components/index.ts +4 -1
  37. package/src/components/instanced-mesh.tsx +28 -10
  38. package/src/components/lod.tsx +102 -0
  39. package/src/components/mesh.tsx +24 -6
  40. package/src/components/node-props.ts +35 -5
  41. package/src/components/orbit-camera.tsx +15 -12
  42. package/src/components/point-light.tsx +2 -2
  43. package/src/components/record-mesh.tsx +12 -7
  44. package/src/components/scene.tsx +55 -2
  45. package/src/components/spot-light.tsx +2 -2
  46. package/src/components/sprite.tsx +2 -2
  47. package/src/components/view3d.tsx +18 -1
  48. package/src/environment.ts +9 -8
  49. package/src/first-person.ts +125 -38
  50. package/src/geometry-gpu.ts +187 -22
  51. package/src/geometry.ts +1718 -203
  52. package/src/glsl.ts +398 -113
  53. package/src/gltf.ts +457 -154
  54. package/src/index.ts +32 -21
  55. package/src/input.ts +11 -2
  56. package/src/light.ts +7 -8
  57. package/src/lod.ts +148 -0
  58. package/src/material.ts +322 -190
  59. package/src/mesh.ts +492 -259
  60. package/src/mixer.ts +27 -14
  61. package/src/model-file.ts +54 -15
  62. package/src/model.ts +151 -54
  63. package/src/motion.ts +17 -0
  64. package/src/node.ts +423 -23
  65. package/src/orbit.ts +378 -116
  66. package/src/profile.ts +4 -25
  67. package/src/resolve.ts +181 -0
  68. package/src/scene-shadows.ts +94 -93
  69. package/src/scene.ts +608 -185
  70. package/src/skeleton.ts +211 -0
  71. package/src/sweep.ts +27 -8
  72. package/tools/model.ts +3 -1
package/src/geometry.ts CHANGED
@@ -1,69 +1,234 @@
1
1
  // Geometry: one interleaved vertex buffer described by a layout - an
2
- // ordered attribute list that always starts with the standard prefix
3
- // (position vec3, normal vec3, uv vec2: 8 floats, what every generator
4
- // emits) and may carry further channels after it. The layout is open data:
5
- // `withAttribute` appends any named channel (Three's `setAttribute`), and
6
- // "colored" names the one common case, the prefix plus an aColor vec4 (12
7
- // floats) as the per-vertex data channel for custom materials (tint, baked
8
- // AO, any four scalars). Materials read attributes by name and adapt to
9
- // whatever layout their geometry carries (one pipeline per layout met),
10
- // so a geometry may carry more channels than a material reads.
11
- // Indices are uint16 or uint32 (the generators here emit uint16; hand-built
12
- // geometry past 64k vertices uses a Uint32Array and the draw entry follows
13
- // the array type). Winding is counter-clockwise seen from outside in the
14
- // y-up world, which the standard camera rig (perspective() with its baked
15
- // y flip) presents as the engine's displayed-CCW front faces: every
16
- // generator here culls correctly with cull: "back". Normals ride along
17
- // unused by the unlit materials so the layout is ready for lights without
18
- // a geometry change (inactive attributes are skipped but keep the stride).
2
+ // ordered attribute list that starts with the position (float32x3) and
3
+ // may carry any named channels after it, each in one of the vertex formats
4
+ // (the float32 family, halves, normalized integers, and unsigned and
5
+ // signed integers up to 32 bits; see VERTEX_FORMATS). The base prefix (position, normal, uv: 8 floats)
6
+ // is what every generator emits and what the stock materials read; a
7
+ // hand-built layout may leave the normal and uv out when nothing reads
8
+ // them, and may pack a channel (a color in unorm8x4 is 4 bytes, not 16).
9
+ // The layout is open data: `withAttribute` appends any named channel
10
+ // (Three's `setAttribute`), and "colored" names the one common case, the
11
+ // prefix plus an aColor float32x4 as the per-vertex data channel for
12
+ // custom materials (tint, baked AO, any four scalars). Materials read
13
+ // attributes by name and adapt to whatever layout their geometry carries
14
+ // (one pipeline per layout met), so a geometry may carry more channels
15
+ // than a material reads, and a packed channel feeds the same float `in`
16
+ // as a float one; an integer channel (uint*/sint*) feeds an integer `in`
17
+ // (uvec*/ivec*) and nothing else, WebGPU's rule. Indices are uint16 or uint32 (the generators here emit
18
+ // uint16; hand-built geometry past 64k vertices uses a Uint32Array and
19
+ // the draw entry follows the array type). Winding is counter-clockwise
20
+ // seen from outside in the y-up world, which the standard camera rig
21
+ // (perspective() with its baked y flip) presents as the engine's
22
+ // displayed-CCW front faces: every generator here culls correctly with
23
+ // cull: "back". Normals ride along unused by the unlit materials so the
24
+ // layout is ready for lights without a geometry change (inactive
25
+ // attributes are skipped but keep the stride).
26
+ //
27
+ // A geometry may carry more than one vertex buffer: `vertices` under
28
+ // `layout` is stream 0 (where aPos lives), and `streams` holds any number
29
+ // of extra buffers with layouts of their own, every one holding the same
30
+ // vertex count - Unity's vertex streams, Three's one-buffer-per-channel
31
+ // when each stream carries one attribute. A stream is what an app rewrites
32
+ // per frame without touching the rest (updateVertices in geometry-gpu.ts).
33
+ //
34
+ // No function here knows an offset or a stride: vertex data is read and
35
+ // written through `geometryAttribute`, an accessor bound to one attribute
36
+ // that finds the stream carrying it and decodes and encodes through the
37
+ // format's codec.
19
38
  //
20
39
  // Pure module by design - geometry is data, and every function here is
21
40
  // array math (the check rig checks/geometry-check.ts runs it headless on
22
41
  // flux). The GPU buffer step lives in geometry-gpu.ts.
23
42
 
24
- import type { VertexAttribute } from "@solidrt/core/gpu"
43
+ import type { Topology, VertexAttribute, VertexFormat } from "@solidrt/core/gpu"
44
+ import { premultipliedColor } from "./color.ts"
25
45
  import { add, compose, cross, mat4, normalize, normalMatrix, sub, updateRotation, updateScale } from "./math.ts"
26
46
  import type { Quat, TransformUpdate, Vec2, Vec3 } from "./math.ts"
47
+ import type { Capsule } from "./scene.ts"
27
48
 
28
- /** A vertex layout: the named presets, or an explicit attribute list that
29
- * must begin with the standard prefix (aPos vec3, aNormal vec3, aUV vec2).
30
- * Absent on a Geometry means "standard". */
31
- export type VertexLayout = "standard" | "colored" | "skinned" | VertexAttribute[]
49
+ /** A vertex layout: the named presets, or an explicit attribute list
50
+ * that begins with `aPos` float32x3 (placement is universal) and carries
51
+ * any named channels after it. The base prefix (aPos float32x3,
52
+ * aNormal float32x3, aUV float32x2) is what every generator emits and
53
+ * what the stock materials read, not a rule a layout must follow: a
54
+ * material reads channels by name and a missing one throws at add(), so
55
+ * a point carrying a position and one packed channel is 16 bytes, not
56
+ * 48. Absent on a Geometry means "base". */
57
+ export type VertexLayout = "base" | "colored" | "skinned" | VertexAttribute[]
32
58
 
33
- const STANDARD_ATTRIBUTES: VertexAttribute[] = [
34
- { name: "aPos", format: "vec3" },
35
- { name: "aNormal", format: "vec3" },
36
- { name: "aUV", format: "vec2" },
59
+ const BASE_ATTRIBUTES: VertexAttribute[] = [
60
+ { name: "aPos", format: "float32x3" },
61
+ { name: "aNormal", format: "float32x3" },
62
+ { name: "aUV", format: "float32x2" },
37
63
  ]
38
64
 
39
65
  /** The attribute lists behind the named layouts. Every layout shares the
40
- * standard prefix, so one shader vocabulary serves all of them. */
41
- export const VERTEX_LAYOUTS: Record<"standard" | "colored" | "skinned", VertexAttribute[]> = {
42
- standard: STANDARD_ATTRIBUTES,
43
- colored: [...STANDARD_ATTRIBUTES, { name: "aColor", format: "vec4" }],
44
- // The rigged-model layout: 4 joint indices (as floats - exact to 2^24)
45
- // and their weights per vertex, what a skinned vertex stage reads.
46
- skinned: [...STANDARD_ATTRIBUTES, { name: "aJoints", format: "vec4" }, { name: "aWeights", format: "vec4" }],
66
+ * base prefix, so one shader vocabulary serves all of them. */
67
+ export const VERTEX_LAYOUTS: Record<"base" | "colored" | "skinned", VertexAttribute[]> = {
68
+ base: BASE_ATTRIBUTES,
69
+ colored: [...BASE_ATTRIBUTES, { name: "aColor", format: "float32x4" }],
70
+ // The rigged-model layout: 4 joint indices (unsigned bytes, what an
71
+ // exporter writes for a rig under 256 joints; a wider rig lands in list
72
+ // form with uint16x4) and their weights per vertex, what a skinned
73
+ // vertex stage reads (`in uvec4 aJoints`).
74
+ skinned: [...BASE_ATTRIBUTES, { name: "aJoints", format: "uint8x4" }, { name: "aWeights", format: "float32x4" }],
47
75
  }
48
76
 
49
- /** Floats per vertex in the "standard" layout (the generators' own write
77
+ /** Floats per vertex in the "base" layout (the generators' own write
50
78
  * format before packing). */
51
- export const STANDARD_FLOATS = 8
79
+ export const BASE_FLOATS = 8
80
+
81
+ /** The shader `in` family a vertex format feeds (WebGPU's rule: the
82
+ * format decides): "float" for the float and normalized formats (`in
83
+ * vec*`), "uint" for uint* (`in uvec*`), "sint" for sint* (`in ivec*`). */
84
+ export type FormatKind = "float" | "uint" | "sint"
85
+
86
+ /** One vertex format's codec: its size, its component count, its kind,
87
+ * and the read and write of component `k` of an attribute at byte `at`
88
+ * of a DataView, in the value the shader sees (a normalized integer
89
+ * decodes to 0..1 / -1..1, an unnormalized one to its exact value).
90
+ * Every format is a multiple of 4 bytes (WebGPU's alignment rule), which
91
+ * is what keeps every offset and stride 4-aligned with no padding
92
+ * arithmetic anywhere. */
93
+ export type FormatCodec = {
94
+ bytes: number
95
+ components: number
96
+ kind: FormatKind
97
+ get(dv: DataView, at: number, k: number): number
98
+ set(dv: DataView, at: number, k: number, v: number): void
99
+ }
100
+
101
+ // The integer component kinds behind the packed formats: width, the
102
+ // value that maps to 1 (GL ES 3.0's normalization rule, -max mapping to
103
+ // -1 and the one value below it clamped there), and the raw accessors.
104
+ type IntKind = { bytes: number; max: number; get(dv: DataView, at: number): number; set(dv: DataView, at: number, v: number): void }
105
+ const U8: IntKind = { bytes: 1, max: 255, get: (dv, at) => dv.getUint8(at), set: (dv, at, v) => dv.setUint8(at, v) }
106
+ const S8: IntKind = { bytes: 1, max: 127, get: (dv, at) => dv.getInt8(at), set: (dv, at, v) => dv.setInt8(at, v) }
107
+ const U16: IntKind = { bytes: 2, max: 65535, get: (dv, at) => dv.getUint16(at, true), set: (dv, at, v) => dv.setUint16(at, v, true) }
108
+ const S16: IntKind = { bytes: 2, max: 32767, get: (dv, at) => dv.getInt16(at, true), set: (dv, at, v) => dv.setInt16(at, v, true) }
109
+ const U32: IntKind = { bytes: 4, max: 4294967295, get: (dv, at) => dv.getUint32(at, true), set: (dv, at, v) => dv.setUint32(at, v, true) }
110
+ const S32: IntKind = { bytes: 4, max: 2147483647, get: (dv, at) => dv.getInt32(at, true), set: (dv, at, v) => dv.setInt32(at, v, true) }
111
+
112
+ function clamp(v: number, lo: number, hi: number): number {
113
+ return v < lo ? lo : v > hi ? hi : v
114
+ }
115
+
116
+ function floatCodec(components: number, bytes: number, get: (dv: DataView, at: number) => number, set: (dv: DataView, at: number, v: number) => void): FormatCodec {
117
+ return {
118
+ bytes: components * bytes,
119
+ components,
120
+ kind: "float",
121
+ get: (dv, at, k) => get(dv, at + k * bytes),
122
+ set: (dv, at, k, v) => set(dv, at + k * bytes, v),
123
+ }
124
+ }
125
+
126
+ function unormCodec(components: number, int: IntKind): FormatCodec {
127
+ return {
128
+ bytes: components * int.bytes,
129
+ components,
130
+ kind: "float",
131
+ get: (dv, at, k) => int.get(dv, at + k * int.bytes) / int.max,
132
+ set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, 0, 1) * int.max)),
133
+ }
134
+ }
135
+
136
+ function snormCodec(components: number, int: IntKind): FormatCodec {
137
+ return {
138
+ bytes: components * int.bytes,
139
+ components,
140
+ kind: "float",
141
+ get: (dv, at, k) => Math.max(int.get(dv, at + k * int.bytes) / int.max, -1),
142
+ set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, -1, 1) * int.max)),
143
+ }
144
+ }
145
+
146
+ function uintCodec(components: number, int: IntKind): FormatCodec {
147
+ return {
148
+ bytes: components * int.bytes,
149
+ components,
150
+ kind: "uint",
151
+ get: (dv, at, k) => int.get(dv, at + k * int.bytes),
152
+ set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, 0, int.max))),
153
+ }
154
+ }
52
155
 
53
- const FORMAT_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
156
+ function sintCodec(components: number, int: IntKind): FormatCodec {
157
+ return {
158
+ bytes: components * int.bytes,
159
+ components,
160
+ kind: "sint",
161
+ get: (dv, at, k) => int.get(dv, at + k * int.bytes),
162
+ set: (dv, at, k, v) => int.set(dv, at + k * int.bytes, Math.round(clamp(v, -int.max - 1, int.max))),
163
+ }
164
+ }
165
+
166
+ let getF32 = (dv: DataView, at: number) => dv.getFloat32(at, true)
167
+ let setF32 = (dv: DataView, at: number, v: number) => dv.setFloat32(at, v, true)
168
+ let getF16 = (dv: DataView, at: number) => dv.getFloat16(at, true)
169
+ let setF16 = (dv: DataView, at: number, v: number) => dv.setFloat16(at, v, true)
170
+
171
+ /** The vertex vocabulary: one codec per format (the engine's table in
172
+ * the same spelling), so it is also the list a declared format must be
173
+ * one of. A format feeds the shader `in` of its kind and component
174
+ * count (`formatFeeds`). */
175
+ export const VERTEX_FORMATS: Record<VertexFormat, FormatCodec> = {
176
+ float32: floatCodec(1, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
177
+ float32x2: floatCodec(2, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
178
+ float32x3: floatCodec(3, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
179
+ float32x4: floatCodec(4, Float32Array.BYTES_PER_ELEMENT, getF32, setF32),
180
+ float16x2: floatCodec(2, Float16Array.BYTES_PER_ELEMENT, getF16, setF16),
181
+ float16x4: floatCodec(4, Float16Array.BYTES_PER_ELEMENT, getF16, setF16),
182
+ unorm8x4: unormCodec(4, U8),
183
+ snorm8x4: snormCodec(4, S8),
184
+ unorm16x2: unormCodec(2, U16),
185
+ unorm16x4: unormCodec(4, U16),
186
+ snorm16x2: snormCodec(2, S16),
187
+ snorm16x4: snormCodec(4, S16),
188
+ uint8x4: uintCodec(4, U8),
189
+ uint16x2: uintCodec(2, U16),
190
+ uint16x4: uintCodec(4, U16),
191
+ uint32: uintCodec(1, U32),
192
+ uint32x2: uintCodec(2, U32),
193
+ uint32x3: uintCodec(3, U32),
194
+ uint32x4: uintCodec(4, U32),
195
+ sint8x4: sintCodec(4, S8),
196
+ sint16x2: sintCodec(2, S16),
197
+ sint16x4: sintCodec(4, S16),
198
+ sint32: sintCodec(1, S32),
199
+ sint32x2: sintCodec(2, S32),
200
+ sint32x3: sintCodec(3, S32),
201
+ sint32x4: sintCodec(4, S32),
202
+ }
203
+
204
+ /** Whether a layout declaring `declared` may feed a program input the
205
+ * engine reflects as `reflected` (float32xN for `vec*`, uint32xN for
206
+ * `uvec*`, sint32xN for `ivec*`): same component count and same kind,
207
+ * the engine's own pipeline rule. */
208
+ export function formatFeeds(declared: VertexFormat, reflected: VertexFormat): boolean {
209
+ let d = VERTEX_FORMATS[declared]
210
+ let r = VERTEX_FORMATS[reflected]
211
+ return d.components === r.components && d.kind === r.kind
212
+ }
213
+
214
+ /** Whether a format is one of the float32 family: what a Float32Array
215
+ * holds directly, and what the generators write. */
216
+ export function isFloatFormat(format: VertexFormat): boolean {
217
+ return format.startsWith("float32")
218
+ }
54
219
 
55
220
  /** The attribute list of a layout (a preset name resolves to its list). */
56
221
  export function layoutAttributes(layout?: VertexLayout): VertexAttribute[] {
57
- if (layout === undefined || layout === "standard") return VERTEX_LAYOUTS.standard
222
+ if (layout === undefined || layout === "base") return VERTEX_LAYOUTS.base
58
223
  if (layout === "colored") return VERTEX_LAYOUTS.colored
59
224
  if (layout === "skinned") return VERTEX_LAYOUTS.skinned
60
225
  return layout
61
226
  }
62
227
 
63
- /** Floats per vertex of a layout - its interleave stride. */
228
+ /** Bytes per vertex of a layout - its interleave stride. */
64
229
  export function layoutStride(layout?: VertexLayout): number {
65
230
  let stride = 0
66
- for (let attr of layoutAttributes(layout)) stride += FORMAT_FLOATS[attr.format]
231
+ for (let attr of layoutAttributes(layout)) stride += VERTEX_FORMATS[attr.format].bytes
67
232
  return stride
68
233
  }
69
234
 
@@ -75,27 +240,31 @@ export function layoutKey(layout?: VertexLayout): string {
75
240
  .join(",")
76
241
  }
77
242
 
78
- /** Where an attribute sits in the interleave: its float offset and size.
79
- * Null when the layout does not carry that name. */
80
- export function layoutSlot(layout: VertexLayout | undefined, name: string): { offset: number; size: number; format: VertexAttribute["format"] } | null {
243
+ /** Where an attribute sits in the interleave: its byte offset, format
244
+ * and component count. Null when the layout does not carry that name. */
245
+ export function layoutSlot(layout: VertexLayout | undefined, name: string): { offset: number; format: VertexFormat; components: number } | null {
81
246
  let offset = 0
82
247
  for (let attr of layoutAttributes(layout)) {
83
- let size = FORMAT_FLOATS[attr.format]
84
- if (attr.name === name) return { offset, size, format: attr.format }
85
- offset += size
248
+ let codec = VERTEX_FORMATS[attr.format]
249
+ if (attr.name === name) return { offset, format: attr.format, components: codec.components }
250
+ offset += codec.bytes
86
251
  }
87
252
  return null
88
253
  }
89
254
 
90
- /** The prefix check every layout must pass: standard attributes first, in
91
- * order, and no duplicate names after them. */
255
+ /** Whether every attribute of a layout is float32-family: such a buffer
256
+ * is a Float32Array of `layoutStride / 4` floats per vertex, the
257
+ * generators' write form. */
258
+ export function isFloatLayout(layout?: VertexLayout): boolean {
259
+ return layoutAttributes(layout).every(a => isFloatFormat(a.format))
260
+ }
261
+
262
+ /** The check every layout must pass: aPos float32x3 first and no
263
+ * duplicate names. */
92
264
  function checkLayout(layout: VertexAttribute[], where: string): void {
93
- for (let i = 0; i < STANDARD_ATTRIBUTES.length; i++) {
94
- let want = STANDARD_ATTRIBUTES[i]!
95
- let got = layout[i]
96
- if (got === undefined || got.name !== want.name || got.format !== want.format) {
97
- throw new Error(where + ": a layout must start with the standard prefix (aPos vec3, aNormal vec3, aUV vec2)")
98
- }
265
+ let first = layout[0]
266
+ if (first === undefined || first.name !== "aPos" || first.format !== "float32x3") {
267
+ throw new Error(where + ": a layout must start with aPos float32x3")
99
268
  }
100
269
  let seen = new Set<string>()
101
270
  for (let attr of layout) {
@@ -104,11 +273,149 @@ function checkLayout(layout: VertexAttribute[], where: string): void {
104
273
  }
105
274
  }
106
275
 
276
+ /** The bytes of a vertex buffer, whatever view holds them. */
277
+ export function vertexBytes(vertices: ArrayBufferView): Uint8Array {
278
+ return new Uint8Array(vertices.buffer, vertices.byteOffset, vertices.byteLength)
279
+ }
280
+
281
+ /** The view a vertex buffer of `layout` is handed out as: a Float32Array
282
+ * over an all-float layout (the generators' form, indexable as floats),
283
+ * a Uint8Array over a layout with a packed channel. */
284
+ export function vertexView(layout: VertexLayout | undefined, buffer: ArrayBuffer, byteOffset = 0, byteLength = buffer.byteLength - byteOffset): ArrayBufferView {
285
+ return isFloatLayout(layout)
286
+ ? new Float32Array(buffer, byteOffset, byteLength / Float32Array.BYTES_PER_ELEMENT)
287
+ : new Uint8Array(buffer, byteOffset, byteLength)
288
+ }
289
+
290
+ /** Vertices in a buffer of `layout`. Throws naming `where` when the byte
291
+ * length is not a whole number of them, or the view is not 4-aligned
292
+ * (every stride is, and the GPU upload and the picking shape count on
293
+ * it). */
294
+ export function vertexCount(vertices: ArrayBufferView, layout: VertexLayout | undefined, where: string): number {
295
+ let stride = layoutStride(layout)
296
+ if (vertices.byteOffset % Float32Array.BYTES_PER_ELEMENT !== 0) {
297
+ throw new Error(where + ": vertex data must start on a 4-byte boundary")
298
+ }
299
+ if (vertices.byteLength % stride !== 0) {
300
+ throw new Error(where + ": " + vertices.byteLength + " vertex bytes is not a whole number of " + stride + "-byte (" + layoutKey(layout) + ") vertices")
301
+ }
302
+ return vertices.byteLength / stride
303
+ }
304
+
305
+ /** A view on one attribute of a vertex buffer: component `k` of vertex
306
+ * `i`, read and written as the float the shader sees, through the
307
+ * format's codec. The one way anything touches vertex data, so no
308
+ * caller knows an offset, a stride or a format. */
309
+ export type AttributeAccess = {
310
+ format: VertexFormat
311
+ components: number
312
+ get(i: number, k: number): number
313
+ set(i: number, k: number, v: number): void
314
+ }
315
+
316
+ /** The accessor for `name` over a bare vertex buffer of `layout`; null
317
+ * when the layout lacks the name. */
318
+ export function attributeAccess(vertices: ArrayBufferView, layout: VertexLayout | undefined, name: string): AttributeAccess | null {
319
+ let slot = layoutSlot(layout, name)
320
+ if (slot === null) return null
321
+ let stride = layoutStride(layout)
322
+ let codec = VERTEX_FORMATS[slot.format]
323
+ let offset = slot.offset
324
+ let dv = new DataView(vertices.buffer, vertices.byteOffset, vertices.byteLength)
325
+ return {
326
+ format: slot.format,
327
+ components: codec.components,
328
+ get: (i, k) => codec.get(dv, i * stride + offset, k),
329
+ set: (i, k, v) => codec.set(dv, i * stride + offset, k, v),
330
+ }
331
+ }
332
+
333
+ /** One extra vertex buffer of a geometry (see `Geometry.streams`): its
334
+ * own attribute list and bytes, holding the geometry's vertex count. */
335
+ export type VertexStream = { layout: VertexAttribute[]; vertices: ArrayBufferView }
336
+
337
+ /** Every vertex buffer of a geometry in pipeline order: stream 0
338
+ * (`vertices` under `layout`), then `streams`. */
339
+ export function geometryStreams(geometry: Geometry): { layout: VertexLayout | undefined; vertices: ArrayBufferView }[] {
340
+ let out: { layout: VertexLayout | undefined; vertices: ArrayBufferView }[] = [{ layout: geometry.layout, vertices: geometry.vertices }]
341
+ for (let stream of geometry.streams ?? []) out.push(stream)
342
+ return out
343
+ }
344
+
345
+ /** The attribute lists of a geometry's streams, in order: what a pipeline
346
+ * declares as its vertex-step buffer layouts. */
347
+ export function geometryLayouts(geometry: Geometry): VertexAttribute[][] {
348
+ return geometryStreams(geometry).map(s => layoutAttributes(s.layout))
349
+ }
350
+
351
+ /** A geometry's layout identity across every stream (`layoutKey` per
352
+ * stream, `|` between them): two geometries with equal keys bind the same
353
+ * pipeline. */
354
+ export function geometryKey(geometry: Geometry): string {
355
+ return geometryStreams(geometry)
356
+ .map(s => layoutKey(s.layout))
357
+ .join("|")
358
+ }
359
+
360
+ /** Where an attribute sits in a geometry: its stream index and its slot
361
+ * there. Null when no stream carries the name. */
362
+ export function geometrySlot(geometry: Geometry, name: string): { stream: number; offset: number; format: VertexFormat; components: number } | null {
363
+ let streams = geometryStreams(geometry)
364
+ for (let i = 0; i < streams.length; i++) {
365
+ let slot = layoutSlot(streams[i]!.layout, name)
366
+ if (slot !== null) return { stream: i, ...slot }
367
+ }
368
+ return null
369
+ }
370
+
371
+ /** The geometry's vertex count: stream 0's, which every extra stream must
372
+ * hold too. Throws naming `where` on a stream of another count. */
373
+ export function geometryVertexCount(geometry: Geometry, where: string): number {
374
+ let streams = geometryStreams(geometry)
375
+ let count = vertexCount(streams[0]!.vertices, streams[0]!.layout, where)
376
+ for (let i = 1; i < streams.length; i++) {
377
+ let n = vertexCount(streams[i]!.vertices, streams[i]!.layout, where + " stream " + i)
378
+ if (n !== count) throw new Error(where + ": stream " + i + " holds " + n + " vertices, stream 0 holds " + count)
379
+ }
380
+ return count
381
+ }
382
+
383
+ /** `attributeAccess` over whichever of a geometry's streams carries
384
+ * `name`; null when none does. */
385
+ export function geometryAttribute(geometry: Geometry, name: string): AttributeAccess | null {
386
+ for (let stream of geometryStreams(geometry)) {
387
+ let access = attributeAccess(stream.vertices, stream.layout, name)
388
+ if (access !== null) return access
389
+ }
390
+ return null
391
+ }
392
+
393
+ // The accessor a reader of the standard channels falls back on when the
394
+ // layout lacks the channel: zeros, so a point cloud without a normal or
395
+ // uv reads (0, 0, 0) and (0, 0).
396
+ const ZERO_ACCESS: AttributeAccess = { format: "float32", components: 0, get: () => 0, set: () => {} }
397
+
398
+ /** The stream rules: every extra stream declares at least one attribute
399
+ * and no name appears in two streams (each is one shader `in`). */
400
+ function checkStreams(geometry: Geometry, where: string): void {
401
+ let seen = new Set<string>()
402
+ let layouts = geometryLayouts(geometry)
403
+ for (let i = 0; i < layouts.length; i++) {
404
+ let attrs = layouts[i]!
405
+ if (i > 0 && attrs.length === 0) throw new Error(where + ": stream " + i + " declares no attributes")
406
+ for (let attr of attrs) {
407
+ if (seen.has(attr.name)) throw new Error(where + ": attribute '" + attr.name + "' appears in two streams")
408
+ seen.add(attr.name)
409
+ }
410
+ }
411
+ }
412
+
107
413
  /**
108
- * The structural check for geometry about to draw: the layout passes the
109
- * prefix rule, the vertex float count is a whole number of its stride,
110
- * and indices are present. Throws naming the geometry. The scene runs it
111
- * at add() so hand-built geometry (a bare `layout: "colored"` over a
414
+ * The structural check for geometry about to draw: the layout starts
415
+ * with aPos, every stream holds a whole number of the geometry's
416
+ * vertices under its stride, no attribute name repeats across streams,
417
+ * and indices are present. Throws naming the geometry. The scene runs it at
418
+ * add() so hand-built geometry (a bare `layout: "colored"` over a
112
419
  * miscounted array) fails there instead of drawing garbage triangles.
113
420
  * Deliberately no max-index scan: that is O(indices) per add, and the
114
421
  * generators and merge/transform keep indices in range by construction.
@@ -117,13 +424,37 @@ export function validateGeometry(geometry: Geometry): void {
117
424
  let name = geometry.label ? "geometry '" + geometry.label + "'" : "geometry"
118
425
  let layout = geometry.layout
119
426
  if (layout !== undefined && typeof layout !== "string") checkLayout(layout, name)
120
- let stride = layoutStride(layout)
121
- if (geometry.vertices.length % stride !== 0) {
122
- throw new Error(
123
- name + ": " + geometry.vertices.length + " vertex floats is not a whole number of " + stride + "-float (" + layoutKey(layout) + ") vertices",
124
- )
427
+ checkStreams(geometry, name)
428
+ geometryVertexCount(geometry, name)
429
+ let topology = geometryTopology(geometry)
430
+ let count = geometry.indices.length
431
+ // Lines and points may be empty: a feature-edge pass over a smooth
432
+ // closed shape has nothing to draw, and that is a result, not a bug.
433
+ switch (topology) {
434
+ case "triangles":
435
+ if (count === 0) throw new Error(name + ": no indices")
436
+ if (count % 3 !== 0) throw new Error(name + ": " + count + " indices is not a whole number of triangles")
437
+ break
438
+ case "lines":
439
+ if (count % 2 !== 0) throw new Error(name + ": " + count + " indices is not a whole number of lines")
440
+ break
441
+ case "line-strip":
442
+ if (count < 2) throw new Error(name + ": a line strip needs at least 2 indices")
443
+ break
444
+ case "triangle-strip":
445
+ if (count < 3) throw new Error(name + ": a triangle strip needs at least 3 indices")
446
+ break
447
+ case "points":
448
+ break
449
+ default:
450
+ throw new Error(name + ": unknown topology '" + String(topology) + "'")
125
451
  }
126
- if (geometry.indices.length === 0) throw new Error(name + ": no indices")
452
+ }
453
+
454
+ /** The geometry's topology with the default applied: what its indices
455
+ * list, "triangles" unless it says otherwise. */
456
+ export function geometryTopology(geometry: Geometry): Topology {
457
+ return geometry.topology ?? "triangles"
127
458
  }
128
459
 
129
460
  /** The options every generator shares (each generator's own options type
@@ -134,9 +465,30 @@ export function validateGeometry(geometry: Geometry): void {
134
465
  * channel geometry is built in one pass instead of generate-then-repack. */
135
466
  export type GeometryOptions = { label?: string; layout?: VertexLayout }
136
467
 
137
- /** The generator tail: pack standard-layout vertices (number[] of 8 per
468
+ /** The generator path writes the standard channels in their standard
469
+ * order into a Float32Array, so a layout a generator is asked to emit
470
+ * must start with the prefix and stay float32-family throughout; past
471
+ * the prefix the names are open. Packing is a pass over the result
472
+ * (`withAttribute` with a packed format), not a generator option. */
473
+ function checkGeneratorLayout(layout: VertexAttribute[], where: string): void {
474
+ checkLayout(layout, where)
475
+ for (let i = 0; i < BASE_ATTRIBUTES.length; i++) {
476
+ let want = BASE_ATTRIBUTES[i]!
477
+ let got = layout[i]
478
+ if (got === undefined || got.name !== want.name || got.format !== want.format) {
479
+ throw new Error(where + ": a generator layout must start with the base prefix (aPos float32x3, aNormal float32x3, aUV float32x2)")
480
+ }
481
+ }
482
+ for (let attr of layout) {
483
+ if (!isFloatFormat(attr.format)) {
484
+ throw new Error(where + ": a generator layout is float32-family throughout ('" + attr.name + "' is " + attr.format + "); pack a channel with withAttribute over the result")
485
+ }
486
+ }
487
+ }
488
+
489
+ /** The generator tail: pack base-layout vertices (number[] of 8 per
138
490
  * vertex, or an already-written Float32Array) and indices into a Geometry
139
- * of the requested layout. A wider layout spreads the standard channels
491
+ * of the requested layout. A wider layout spreads the base channels
140
492
  * to its stride, leaving the extra slots zero. */
141
493
  export function packGeometry(
142
494
  verts: ArrayLike<number>,
@@ -144,40 +496,39 @@ export function packGeometry(
144
496
  options: GeometryOptions = {},
145
497
  ): Geometry {
146
498
  let { label, layout } = options
147
- if (verts.length % STANDARD_FLOATS !== 0) {
148
- throw new Error("packGeometry: vertex data is not a whole number of standard-layout vertices")
499
+ if (verts.length % BASE_FLOATS !== 0) {
500
+ throw new Error("packGeometry: vertex data is not a whole number of base-layout vertices")
149
501
  }
150
- let count = verts.length / STANDARD_FLOATS
502
+ let count = verts.length / BASE_FLOATS
151
503
  let packedIndices = indices instanceof Uint16Array || indices instanceof Uint32Array ? indices : packIndices(indices, count)
152
- let attrs = layoutAttributes(layout)
153
- if (layout !== undefined && typeof layout !== "string") checkLayout(attrs, "packGeometry")
154
- let stride = layoutStride(attrs)
155
- if (stride === STANDARD_FLOATS) {
504
+ let stride = generatorStride(options, "packGeometry")
505
+ if (stride === BASE_FLOATS) {
156
506
  let vertices = verts instanceof Float32Array ? verts : new Float32Array(verts)
157
507
  return layout === undefined ? { vertices, indices: packedIndices, label } : { vertices, indices: packedIndices, layout, label }
158
508
  }
159
509
  let vertices = new Float32Array(count * stride)
160
510
  for (let i = 0; i < count; i++) {
161
- let s = i * STANDARD_FLOATS
511
+ let s = i * BASE_FLOATS
162
512
  let d = i * stride
163
- for (let k = 0; k < STANDARD_FLOATS; k++) vertices[d + k] = verts[s + k]!
513
+ for (let k = 0; k < BASE_FLOATS; k++) vertices[d + k] = verts[s + k]!
164
514
  }
165
515
  return { vertices, indices: packedIndices, layout, label }
166
516
  }
167
517
 
168
- // The stride a generator writing a Float32Array directly must use for the
169
- // requested layout, and the matching finish (no repack: the data is
170
- // already laid out).
171
- function generatorStride(options: GeometryOptions): number {
518
+ // The stride in FLOATS a generator writing a Float32Array directly must
519
+ // use for the requested layout (float32-family by the generator rule, so
520
+ // the byte stride is a whole number of floats), and the matching finish
521
+ // (no repack: the data is already laid out).
522
+ function generatorStride(options: GeometryOptions, where = "generator layout"): number {
172
523
  let { layout } = options
173
524
  let attrs = layoutAttributes(layout)
174
- if (layout !== undefined && typeof layout !== "string") checkLayout(attrs, "generator layout")
175
- return layoutStride(attrs)
525
+ if (layout !== undefined && typeof layout !== "string") checkGeneratorLayout(attrs, where)
526
+ return layoutStride(attrs) / Float32Array.BYTES_PER_ELEMENT
176
527
  }
177
528
 
178
529
  function finishGeometry(vertices: Float32Array, indices: number[], options: GeometryOptions): Geometry {
179
530
  let { label, layout } = options
180
- let packed = packIndices(indices, vertices.length / layoutStride(layout))
531
+ let packed = packIndices(indices, vertexCount(vertices, layout, "generator"))
181
532
  return layout === undefined ? { vertices, indices: packed, label } : { vertices, indices: packed, layout, label }
182
533
  }
183
534
 
@@ -188,35 +539,193 @@ export function packIndices(indices: number[], vertexCount: number): Uint16Array
188
539
  }
189
540
 
190
541
  export type Geometry = {
191
- /** Interleaved [pos.xyz, normal.xyz, uv.xy] per vertex, plus color.rgba
192
- * in the "colored" layout. */
193
- vertices: Float32Array
542
+ /** The interleaved vertex bytes in the layout's order: a Float32Array
543
+ * for an all-float layout (the generators' form: [pos.xyz, normal.xyz,
544
+ * uv.xy] per vertex, plus color.rgba in "colored"), any 4-aligned view
545
+ * otherwise. Read and write channels through `geometryAttribute`; the
546
+ * view type is never the contract. */
547
+ vertices: ArrayBufferView
194
548
  /** The array type picks the draw's index format: Uint32Array past 64k
195
549
  * vertices. */
196
550
  indices: Uint16Array | Uint32Array
197
- /** Vertex layout; absent means "standard". Must match the material's
551
+ /** Extra vertex buffers beside `vertices` (which is stream 0, where
552
+ * aPos lives): each its own attribute list over its own bytes, holding
553
+ * the same vertex count. A pipeline reads every stream; a material
554
+ * finds a channel by name whichever stream carries it. The shape for
555
+ * data that changes on its own schedule: the per-frame channel in a
556
+ * stream of its own, rewritten in place with updateVertices while the
557
+ * static channels stay put. `withAttribute` with a `stream` option
558
+ * appends to or opens one. Absent means one buffer. */
559
+ streams?: VertexStream[]
560
+ /** What the indices describe; absent means "triangles". The index
561
+ * buffer and the primitive it lists travel together (Godot's surface
562
+ * primitive, Unity's Mesh.SetIndices topology, Three's Line/Points
563
+ * object types over one BufferGeometry): the material draws whatever
564
+ * topology its geometry carries, one pipeline per (layout, topology)
565
+ * pair. Only a triangle list gets the picking shape - a lines, points
566
+ * or strip geometry picks (and collides) by its bounds box, the tier a
567
+ * sprite or an instanced mesh is at, so hits carry no face/uv. Lines
568
+ * are one pixel wide on GL ES whatever the display density. A lines
569
+ * or points geometry may have no indices at all (it then draws
570
+ * nothing; the other topologies reject that at add()).
571
+ * wireframeGeometry/edgesGeometry build "lines" over a triangle
572
+ * geometry's own vertices. */
573
+ topology?: Topology
574
+ /** Vertex layout; absent means "base". Must match the material's
198
575
  * layout - the scene rejects a mismatched pair at add(). */
199
576
  layout?: VertexLayout
577
+ /** Morph targets (blend shapes), packed sparse by vertex - see
578
+ * `MorphTargets` and `packMorphTargets`. A morphed material displaces
579
+ * positions and normals by the node's weights in the vertex stage
580
+ * before any skinning; picking, the transparent sort and every read of
581
+ * the retained tree see the base shape, and `geometryBounds` grows by
582
+ * the targets' extent. Absent means none. */
583
+ morphs?: MorphTargets
200
584
  /** Debug name for the lazily-created GPU buffers. */
201
585
  label?: string
202
586
  _bounds?: Float32Array
203
587
  }
204
588
 
589
+ /** One morph target as authored, glTF's primitive target: per-vertex
590
+ * POSITION deltas (3 per vertex over the geometry's vertex count) and
591
+ * optional NORMAL deltas, added to the base channels scaled by the
592
+ * target's weight. Deltas only: an absolute target is its shape minus
593
+ * the base. What `packMorphTargets` takes. */
594
+ export type MorphTarget = { name: string; position: ArrayLike<number>; normal?: ArrayLike<number> | null }
595
+
596
+ /** Floats per texel of the morph packing (an rgba32f texel). */
597
+ export const MORPH_TEXEL_FLOATS = 4
598
+ /** Texels per packed entry: [target, dx, dy, dz] then [nx, ny, nz, 0]. */
599
+ export const MORPH_ENTRY_TEXELS = 2
600
+
601
+ /**
602
+ * A geometry's morph targets packed SPARSE BY VERTEX, the form the vertex
603
+ * stage reads: rgba32f texels as a flat float array (the GPU step lays
604
+ * them into the rows of one texture; a shader turns a texel index into
605
+ * its coordinates by the texture width). Texel v, for every vertex v, is
606
+ * the vertex's header `[offset, count, 0, 0]`: the texel index of its
607
+ * first entry and how many targets displace it. The entries follow, two
608
+ * texels each (`MORPH_ENTRY_TEXELS`): `[target, dx, dy, dz]` and
609
+ * `[nx, ny, nz, 0]`. A vertex no target moves has count 0, and a target
610
+ * that leaves a vertex at zero delta (both position and normal exactly
611
+ * zero) writes no entry for it, so memory is proportional to the deltas
612
+ * the targets author (a mouth on a face, an exporter's sparse accessors)
613
+ * and never vertices x targets - which is what lifts a dense layout's
614
+ * ceiling of about a hundred targets on a 20k-vertex face. `extent` is
615
+ * the per-axis growth of the geometry's box under any weights in [0, 1]:
616
+ * `[minDx, minDy, minDz, maxDx, maxDy, maxDz]`, the most negative and
617
+ * most positive per-vertex sums of the deltas over that vertex's targets
618
+ * (weights past 1 may leave it).
619
+ */
620
+ export type MorphTargets = {
621
+ /** One name per target, in target order: what setMorphWeights resolves. */
622
+ names: string[]
623
+ texels: Float32Array
624
+ extent: Float32Array
625
+ }
626
+
627
+ /**
628
+ * Pack morph targets for a geometry of `vertexCount` vertices (see
629
+ * `MorphTargets` for the layout). Pure array math, run by the glTF parser
630
+ * at bake time and by `withMorphTargets` for hand-built shapes.
631
+ */
632
+ export function packMorphTargets(vertexCount: number, targets: MorphTarget[]): MorphTargets {
633
+ targets.forEach((t, i) => {
634
+ if (t.position.length !== vertexCount * 3) {
635
+ throw new Error("packMorphTargets: target " + i + " has " + t.position.length + " position floats for " + vertexCount + " vertices")
636
+ }
637
+ if (t.normal != null && t.normal.length !== vertexCount * 3) {
638
+ throw new Error("packMorphTargets: target " + i + " has " + t.normal.length + " normal floats for " + vertexCount + " vertices")
639
+ }
640
+ })
641
+ let moves = (t: MorphTarget, v: number): boolean => {
642
+ let p = v * 3
643
+ if (t.position[p] !== 0 || t.position[p + 1] !== 0 || t.position[p + 2] !== 0) return true
644
+ return t.normal != null && (t.normal[p] !== 0 || t.normal[p + 1] !== 0 || t.normal[p + 2] !== 0)
645
+ }
646
+ // Pass one: how many targets displace each vertex, which fixes every
647
+ // vertex's run of entries and so the headers.
648
+ let counts = new Uint32Array(vertexCount)
649
+ let total = 0
650
+ for (let t of targets) {
651
+ for (let v = 0; v < vertexCount; v++) {
652
+ if (moves(t, v)) {
653
+ counts[v]!++
654
+ total++
655
+ }
656
+ }
657
+ }
658
+ let texels = new Float32Array((vertexCount + total * MORPH_ENTRY_TEXELS) * MORPH_TEXEL_FLOATS)
659
+ let cursor = new Uint32Array(vertexCount)
660
+ let next = vertexCount
661
+ for (let v = 0; v < vertexCount; v++) {
662
+ texels[v * MORPH_TEXEL_FLOATS] = next
663
+ texels[v * MORPH_TEXEL_FLOATS + 1] = counts[v]!
664
+ cursor[v] = next
665
+ next += counts[v]! * MORPH_ENTRY_TEXELS
666
+ }
667
+ // Pass two: the entries, in target order per vertex, and the per-vertex
668
+ // negative and positive delta sums the extent reduces over.
669
+ let sums = new Float32Array(vertexCount * 6)
670
+ for (let t = 0; t < targets.length; t++) {
671
+ let target = targets[t]!
672
+ for (let v = 0; v < vertexCount; v++) {
673
+ if (!moves(target, v)) continue
674
+ let at = cursor[v]! * MORPH_TEXEL_FLOATS
675
+ cursor[v] = cursor[v]! + MORPH_ENTRY_TEXELS
676
+ let p = v * 3
677
+ texels[at] = t
678
+ for (let k = 0; k < 3; k++) {
679
+ let d = target.position[p + k]!
680
+ texels[at + 1 + k] = d
681
+ texels[at + MORPH_TEXEL_FLOATS + k] = target.normal != null ? target.normal[p + k]! : 0
682
+ let side = d < 0 ? v * 6 + k : v * 6 + 3 + k
683
+ sums[side] = sums[side]! + d
684
+ }
685
+ }
686
+ }
687
+ let extent = new Float32Array(6)
688
+ for (let v = 0; v < vertexCount; v++) {
689
+ for (let k = 0; k < 3; k++) {
690
+ if (sums[v * 6 + k]! < extent[k]!) extent[k] = sums[v * 6 + k]!
691
+ if (sums[v * 6 + 3 + k]! > extent[3 + k]!) extent[3 + k] = sums[v * 6 + 3 + k]!
692
+ }
693
+ }
694
+ return { names: targets.map((t) => t.name), texels, extent }
695
+ }
696
+
697
+ /**
698
+ * A geometry with morph targets: a new geometry over the source's
699
+ * vertices and indices (shared, so both bind the same GPU buffers) whose
700
+ * `morphs` is `targets` packed. Three's `geometry.morphAttributes` for
701
+ * relative targets; a shape's blend shapes from a modeller arrive this
702
+ * way through the glTF loader.
703
+ */
704
+ export function withMorphTargets(geometry: Geometry, targets: MorphTarget[], label?: string): Geometry {
705
+ let out: Geometry = { ...geometry, morphs: packMorphTargets(geometryVertexCount(geometry, "withMorphTargets"), targets) }
706
+ delete out._bounds
707
+ if (label !== undefined) out.label = label
708
+ return out
709
+ }
710
+
205
711
  /**
206
712
  * The geometry's LOCAL axis-aligned bounds as [minX, minY, minZ, maxX,
207
- * maxY, maxZ], computed from the vertices on first use and cached (like
208
- * the GPU buffers, geometry is treated as immutable after creation).
209
- * Picking's narrowphase volume; a flat geometry legitimately has zero
210
- * extent on an axis. An empty geometry yields a zero box at the origin.
713
+ * maxY, maxZ], computed from the vertices on first use (grown by the
714
+ * morph targets' extent when it carries any) and cached; a stream-0
715
+ * updateVertices drops the cache. What the transparent sort and
716
+ * a model's bounds read (the picking index keeps its own copy of the
717
+ * positions and derives its box there); a flat geometry legitimately has
718
+ * zero extent on an axis. An empty geometry yields a zero box at the
719
+ * origin.
211
720
  */
212
721
  export function geometryBounds(geometry: Geometry): Float32Array {
213
722
  let bounds = geometry._bounds
214
723
  if (bounds === undefined) {
215
724
  bounds = new Float32Array([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity])
216
- let v = geometry.vertices
217
- let stride = layoutStride(geometry.layout)
218
- for (let i = 0; i + 2 < v.length; i += stride) {
219
- let x = v[i]!, y = v[i + 1]!, z = v[i + 2]!
725
+ let count = geometryVertexCount(geometry, "geometryBounds")
726
+ let pos = geometryAttribute(geometry, "aPos")!
727
+ for (let i = 0; i < count; i++) {
728
+ let x = pos.get(i, 0), y = pos.get(i, 1), z = pos.get(i, 2)
220
729
  if (x < bounds[0]!) bounds[0] = x
221
730
  if (y < bounds[1]!) bounds[1] = y
222
731
  if (z < bounds[2]!) bounds[2] = z
@@ -225,6 +734,11 @@ export function geometryBounds(geometry: Geometry): Float32Array {
225
734
  if (z > bounds[5]!) bounds[5] = z
226
735
  }
227
736
  if (bounds[0]! > bounds[3]!) bounds.fill(0)
737
+ else if (geometry.morphs !== undefined) {
738
+ // Morph targets displace vertices at draw time: the box grows by
739
+ // their extent so any weights in [0, 1] keep the shape inside it.
740
+ for (let k = 0; k < 6; k++) bounds[k] = bounds[k]! + geometry.morphs.extent[k]!
741
+ }
228
742
  geometry._bounds = bounds
229
743
  }
230
744
  return bounds
@@ -232,124 +746,166 @@ export function geometryBounds(geometry: Geometry): Float32Array {
232
746
 
233
747
  /** Per-vertex values for withAttribute/fillAttribute: a flat array of the
234
748
  * attribute's size per vertex, or a callback deriving each vertex's value
235
- * from the standard channels (what a baker wants). */
749
+ * from the base channels (what a baker wants). A channel the layout
750
+ * lacks (a point cloud without a normal or uv) arrives as zeros. */
236
751
  export type AttributeFill = ArrayLike<number> | ((index: number, pos: Vec3, normal: Vec3, uv: Vec2) => ArrayLike<number>)
237
752
  /** AttributeFill for the aColor vec4 channel (4 per vertex). */
238
753
  export type ColorFill = AttributeFill
239
754
 
755
+ /** Options of `withAttribute`: the result's label, and which stream the
756
+ * channel lands in - 0 (the default) interleaves it into the main buffer,
757
+ * 1..n appends it to that extra stream, n + 1 opens a new stream holding
758
+ * it alone (Three's one buffer per channel; the shape for a channel
759
+ * rewritten per frame). */
760
+ export type WithAttributeOptions = { label?: string; stream?: number }
761
+
240
762
  /**
241
763
  * Append a named channel to a geometry: a new geometry (the source is
242
764
  * untouched, its GPU buffers stay independent) whose layout is the
243
765
  * source's plus `attr`, every existing channel copied through and the new
244
766
  * slots written from `fill`. This is Three's `geometry.setAttribute` for
245
767
  * an interleaved buffer - the one generic primitive; `withColors` is its
246
- * aColor spelling. A material reads the channel by declaring the matching
247
- * `in` (name and format) in its vertex stage.
768
+ * aColor spelling. A material reads the channel by declaring an `in` of
769
+ * the matching name and component count in its vertex stage. A packed
770
+ * format (`unorm8x4` for a color, `snorm16x2`, `float16x4`, ...) is how a
771
+ * channel is compressed: the fill is given in float and encoded on write.
772
+ * `options.stream` picks the stream the channel lands in (see
773
+ * `WithAttributeOptions`); the other streams are shared with the source.
248
774
  */
249
- export function withAttribute(geometry: Geometry, attr: VertexAttribute, fill: AttributeFill, label?: string): Geometry {
250
- let srcLayout = layoutAttributes(geometry.layout)
251
- if (layoutSlot(srcLayout, attr.name) !== null) {
775
+ export function withAttribute(geometry: Geometry, attr: VertexAttribute, fill: AttributeFill, options: WithAttributeOptions = {}): Geometry {
776
+ if (geometrySlot(geometry, attr.name) !== null) {
252
777
  throw new Error("withAttribute: geometry already carries '" + attr.name + "'")
253
778
  }
254
- let srcStride = layoutStride(srcLayout)
255
- if (geometry.vertices.length % srcStride !== 0) {
256
- throw new Error("withAttribute: vertex data is not a whole number of " + layoutKey(srcLayout) + " vertices")
779
+ if (!(attr.format in VERTEX_FORMATS)) {
780
+ throw new Error("withAttribute: unknown format '" + String(attr.format) + "' for " + attr.name + " (expected " + Object.keys(VERTEX_FORMATS).join(", ") + ")")
257
781
  }
258
- let layout = [...srcLayout, { name: attr.name, format: attr.format }]
259
- checkLayout(layout, "withAttribute")
260
- let stride = layoutStride(layout)
261
- let count = geometry.vertices.length / srcStride
262
- let src = geometry.vertices
263
- let out = new Float32Array(count * stride)
264
- for (let i = 0; i < count; i++) {
265
- let s = i * srcStride
266
- let d = i * stride
267
- for (let k = 0; k < srcStride; k++) out[d + k] = src[s + k]!
782
+ let count = geometryVertexCount(geometry, "withAttribute")
783
+ let streams = geometryStreams(geometry)
784
+ let target = options.stream ?? 0
785
+ if (!Number.isInteger(target) || target < 0 || target > streams.length) {
786
+ throw new Error("withAttribute: stream " + target + " is out of range; the geometry has streams 0.." + (streams.length - 1) + " and " + streams.length + " opens a new one")
268
787
  }
269
- fillSlot(out, layout, attr.name, fill, 0)
270
- return {
271
- vertices: out,
788
+ let added: VertexAttribute = { name: attr.name, format: attr.format }
789
+ let layout: VertexAttribute[]
790
+ let vertices: ArrayBufferView
791
+ if (target === streams.length) {
792
+ layout = [added]
793
+ vertices = vertexView(layout, new ArrayBuffer(count * layoutStride(layout)))
794
+ } else {
795
+ let source = streams[target]!
796
+ let srcLayout = layoutAttributes(source.layout)
797
+ let srcStride = layoutStride(srcLayout)
798
+ layout = [...srcLayout, added]
799
+ if (target === 0) checkLayout(layout, "withAttribute")
800
+ let stride = layoutStride(layout)
801
+ let src = vertexBytes(source.vertices)
802
+ let buffer = new ArrayBuffer(count * stride)
803
+ let dst = new Uint8Array(buffer)
804
+ for (let i = 0; i < count; i++) dst.set(src.subarray(i * srcStride, (i + 1) * srcStride), i * stride)
805
+ vertices = vertexView(layout, buffer)
806
+ }
807
+ // The callback reads the standard channels from the SOURCE geometry,
808
+ // whichever stream carries them; the new slot is written on the fresh
809
+ // buffer.
810
+ fillWith(attributeAccess(vertices, layout, attr.name)!, geometry, count, attr.name, fill, 0)
811
+ let extra = (geometry.streams ?? []).slice()
812
+ if (target === 0) {
813
+ // The main buffer was rebuilt; the extra streams ride along.
814
+ } else if (target === streams.length) {
815
+ extra.push({ layout, vertices })
816
+ } else {
817
+ extra[target - 1] = { layout, vertices }
818
+ }
819
+ let out: Geometry = {
820
+ vertices: target === 0 ? vertices : geometry.vertices,
272
821
  indices: geometry.indices,
273
- layout,
274
- label: label ?? (geometry.label ? geometry.label + "-" + attr.name : undefined),
822
+ topology: geometry.topology,
823
+ layout: target === 0 ? layout : geometry.layout,
824
+ label: options.label ?? (geometry.label ? geometry.label + "-" + attr.name : undefined),
275
825
  }
826
+ if (extra.length > 0) out.streams = extra
827
+ return out
276
828
  }
277
829
 
278
830
  /**
279
- * Derive a "colored"-layout geometry from a standard one: the same
280
- * positions, normals, uvs and indices, plus an aColor vec4 per vertex -
281
- * the data channel for materials whose vertex stage reads `in vec4 aColor`
282
- * (a tint, baked ambient occlusion, any four scalars; the name is the
283
- * standard vocabulary, the contents are yours). `withAttribute` with the
284
- * aColor channel; the "colored" preset name is kept on the result.
831
+ * Derive a "colored"-layout geometry from a base one: the same
832
+ * positions, normals, uvs and indices, plus an aColor float32x4 per
833
+ * vertex - the data channel for materials whose vertex stage reads `in
834
+ * vec4 aColor` (a tint, baked ambient occlusion, any four scalars; the
835
+ * name is the standard vocabulary, the contents are yours - raw floats,
836
+ * so a tint the stock materials read under `vertexColors` is
837
+ * premultiplied linear: encode an sRGB pick with premultipliedColor).
838
+ * `withAttribute` with the aColor channel; the "colored" preset name is
839
+ * kept on the result. For a 4-byte color use `withAttribute` with
840
+ * `unorm8x4` directly.
285
841
  */
286
842
  export function withColors(geometry: Geometry, fill: ColorFill, label?: string): Geometry {
287
- if (layoutSlot(geometry.layout, "aColor") !== null) {
843
+ if (geometrySlot(geometry, "aColor") !== null) {
288
844
  throw new Error("withColors: geometry already carries an aColor channel")
289
845
  }
290
- let out = withAttribute(geometry, { name: "aColor", format: "vec4" }, fill, label ?? (geometry.label ? geometry.label + "-colored" : undefined))
846
+ let out = withAttribute(geometry, { name: "aColor", format: "float32x4" }, fill, { label: label ?? (geometry.label ? geometry.label + "-colored" : undefined) })
291
847
  if (layoutKey(out.layout) === layoutKey("colored")) out.layout = "colored"
292
848
  return out
293
849
  }
294
850
 
295
851
  /**
296
852
  * The in-place primitive under withAttribute: write one channel the
297
- * geometry's layout already carries (withAttribute ADDS a channel; this
298
- * overwrites an existing one). The pos/normal/uv the callback receives
299
- * are read from the buffer itself, so a builder baking transforms while
300
- * writing hands the baker world-space vertices. Fills vertices
301
- * [first, first + count) - count defaults to the rest of the buffer -
302
- * and `fill` indexes relative to `first`, so a per-part callback works
303
- * unchanged for both APIs. Returns `geometry.vertices`.
853
+ * geometry already carries, in whichever stream (withAttribute ADDS a
854
+ * channel; this overwrites an existing one). The pos/normal/uv the
855
+ * callback receives are read from the geometry itself, so a builder
856
+ * baking transforms while writing hands the baker world-space vertices.
857
+ * Fills vertices [first, first + count) - count defaults to the rest of
858
+ * the buffer - and `fill` indexes relative to `first`, so a per-part
859
+ * callback works unchanged for both APIs. Returns the vertices of the
860
+ * stream holding the channel (what updateVertices re-uploads).
304
861
  */
305
- export function fillAttribute(geometry: Geometry, name: string, fill: AttributeFill, first = 0, count?: number): Float32Array {
306
- return fillSlot(geometry.vertices, geometry.layout, name, fill, first, count)
862
+ export function fillAttribute(geometry: Geometry, name: string, fill: AttributeFill, first = 0, count?: number): ArrayBufferView {
863
+ let slot = geometrySlot(geometry, name)
864
+ if (slot === null) throw new Error("fillAttribute: geometry has no '" + name + "' attribute")
865
+ let total = geometryVertexCount(geometry, "fillAttribute")
866
+ fillWith(geometryAttribute(geometry, name)!, geometry, total, name, fill, first, count)
867
+ return geometryStreams(geometry)[slot.stream]!.vertices
307
868
  }
308
869
 
309
- /** The raw form behind fillAttribute (withAttribute writes its fresh
310
- * buffer through it, before the Geometry exists): a bare array carries no
311
- * layout tag, so the caller states the layout and only the arithmetic is
312
- * checked. */
313
- function fillSlot(vertices: Float32Array, layout: VertexLayout | undefined, name: string, fill: AttributeFill, first: number, count?: number): Float32Array {
314
- let slot = layoutSlot(layout, name)
315
- if (slot === null) throw new Error("fillAttribute: layout has no '" + name + "' attribute")
316
- let stride = layoutStride(layout)
317
- if (vertices.length % stride !== 0) {
318
- throw new Error("fillAttribute: vertex data is not a whole number of " + layoutKey(layout) + " vertices")
319
- }
320
- let total = vertices.length / stride
870
+ /** The write loop behind withAttribute and fillAttribute: `slot` is the
871
+ * accessor written, `source` the geometry the callback's standard
872
+ * channels are read from (zeros for a channel it lacks), `total` its
873
+ * vertex count. */
874
+ function fillWith(slot: AttributeAccess, source: Geometry, total: number, name: string, fill: AttributeFill, first: number, count?: number): void {
321
875
  let n = count ?? total - first
322
876
  if (!Number.isInteger(first) || !Number.isInteger(n) || first < 0 || n < 0 || first + n > total) {
323
877
  throw new Error("fillAttribute: range [" + first + ", " + (first + n) + ") is outside the buffer's " + total + " vertices")
324
878
  }
325
- let size = slot.size
879
+ let size = slot.components
326
880
  let fn = typeof fill === "function" ? fill : null
327
881
  let flat = typeof fill === "function" ? null : fill
328
882
  if (flat !== null && flat.length !== n * size) {
329
- throw new Error("fillAttribute: fill has " + flat.length + " floats, expected " + size + " per vertex (" + n * size + ")")
883
+ throw new Error("fillAttribute: fill has " + flat.length + " values, expected " + size + " per vertex (" + n * size + ")")
330
884
  }
885
+ let pos = geometryAttribute(source, "aPos")!
886
+ let nrm = geometryAttribute(source, "aNormal") ?? ZERO_ACCESS
887
+ let uv = geometryAttribute(source, "aUV") ?? ZERO_ACCESS
331
888
  for (let i = 0; i < n; i++) {
332
- let d = (first + i) * stride
889
+ let v = first + i
333
890
  let value: ArrayLike<number>
334
891
  let s: number
335
892
  if (fn !== null) {
336
- value = fn(i, [vertices[d]!, vertices[d + 1]!, vertices[d + 2]!], [vertices[d + 3]!, vertices[d + 4]!, vertices[d + 5]!], [vertices[d + 6]!, vertices[d + 7]!])
893
+ value = fn(i, [pos.get(v, 0), pos.get(v, 1), pos.get(v, 2)], [nrm.get(v, 0), nrm.get(v, 1), nrm.get(v, 2)], [uv.get(v, 0), uv.get(v, 1)])
337
894
  s = 0
338
895
  if (value.length !== size) {
339
- throw new Error("fillAttribute: fill callback returned " + value.length + " floats for '" + name + "', expected " + size)
896
+ throw new Error("fillAttribute: fill callback returned " + value.length + " values for '" + name + "', expected " + size)
340
897
  }
341
898
  } else {
342
899
  value = flat!
343
900
  s = i * size
344
901
  }
345
- for (let k = 0; k < size; k++) vertices[d + slot.offset + k] = value[s + k]!
902
+ for (let k = 0; k < size; k++) slot.set(v, k, value[s + k]!)
346
903
  }
347
- return vertices
348
904
  }
349
905
 
350
906
  /** `fillAttribute` for the aColor channel of a color-carrying geometry
351
907
  * (fill is 4 per vertex). */
352
- export function fillColors(geometry: Geometry, fill: ColorFill, first = 0, count?: number): Float32Array {
908
+ export function fillColors(geometry: Geometry, fill: ColorFill, first = 0, count?: number): ArrayBufferView {
353
909
  return fillAttribute(geometry, "aColor", fill, first, count)
354
910
  }
355
911
 
@@ -358,9 +914,11 @@ export function fillColors(geometry: Geometry, fill: ColorFill, first = 0, count
358
914
  * quaternion, not both; number = uniform scale; absent = identity) into a
359
915
  * geometry: a new geometry (the source is
360
916
  * untouched, its GPU buffers stay independent) whose positions are moved
361
- * by the transform and whose normals follow through the inverse-transpose,
362
- * renormalized - correct under non-uniform scale. UVs, colors, indices and
363
- * layout copy through. This is Three's `geometry.applyMatrix4`, the first
917
+ * by the transform and whose normals (when the layout carries aNormal)
918
+ * follow through the inverse-transpose, renormalized - correct under
919
+ * non-uniform scale. UVs, colors, indices and layout copy through; extra
920
+ * streams are shared with the source (positions and normals live in
921
+ * stream 0). This is Three's `geometry.applyMatrix4`, the first
364
922
  * half of authoring a static scene as data: transform each part into place,
365
923
  * mergeGeometries the parts, draw one mesh.
366
924
  */
@@ -371,39 +929,43 @@ export function transformGeometry(geometry: Geometry, transform: TransformUpdate
371
929
  if (transform.scale !== undefined) updateScale(scl, transform.scale)
372
930
  let m = compose(mat4(), transform.position ?? [0, 0, 0], rot, scl)
373
931
  let n = normalMatrix(mat4(), m)
374
- let stride = layoutStride(geometry.layout)
375
- let src = geometry.vertices
376
- if (src.length % stride !== 0) {
377
- throw new Error("transformGeometry: vertex data is not a whole number of " + layoutKey(geometry.layout) + " vertices")
378
- }
379
- let out = new Float32Array(src)
380
- for (let i = 0; i < out.length; i += stride) {
381
- let x = src[i]!, y = src[i + 1]!, z = src[i + 2]!
382
- out[i] = m[0] * x + m[4] * y + m[8] * z + m[12]
383
- out[i + 1] = m[1] * x + m[5] * y + m[9] * z + m[13]
384
- out[i + 2] = m[2] * x + m[6] * y + m[10] * z + m[14]
385
- let nx = src[i + 3]!, ny = src[i + 4]!, nz = src[i + 5]!
932
+ let count = geometryVertexCount(geometry, "transformGeometry")
933
+ let out = vertexView(geometry.layout, vertexBytes(geometry.vertices).slice().buffer)
934
+ let pos = attributeAccess(out, geometry.layout, "aPos")!
935
+ let nrm = attributeAccess(out, geometry.layout, "aNormal")
936
+ for (let i = 0; i < count; i++) {
937
+ let x = pos.get(i, 0), y = pos.get(i, 1), z = pos.get(i, 2)
938
+ pos.set(i, 0, m[0] * x + m[4] * y + m[8] * z + m[12])
939
+ pos.set(i, 1, m[1] * x + m[5] * y + m[9] * z + m[13])
940
+ pos.set(i, 2, m[2] * x + m[6] * y + m[10] * z + m[14])
941
+ if (nrm === null) continue
942
+ let nx = nrm.get(i, 0), ny = nrm.get(i, 1), nz = nrm.get(i, 2)
386
943
  let tx = n[0] * nx + n[4] * ny + n[8] * nz
387
944
  let ty = n[1] * nx + n[5] * ny + n[9] * nz
388
945
  let tz = n[2] * nx + n[6] * ny + n[10] * nz
389
946
  let len = Math.hypot(tx, ty, tz) || 1
390
- out[i + 3] = tx / len
391
- out[i + 4] = ty / len
392
- out[i + 5] = tz / len
947
+ nrm.set(i, 0, tx / len)
948
+ nrm.set(i, 1, ty / len)
949
+ nrm.set(i, 2, tz / len)
393
950
  }
394
- return {
951
+ let result: Geometry = {
395
952
  vertices: out,
396
953
  indices: geometry.indices,
954
+ topology: geometry.topology,
397
955
  layout: geometry.layout,
398
956
  label: label ?? (geometry.label ? geometry.label + "-transformed" : undefined),
399
957
  }
958
+ if (geometry.streams !== undefined) result.streams = geometry.streams
959
+ return result
400
960
  }
401
961
 
402
962
  /**
403
963
  * Concatenate geometries into one: vertices appended in order, indices
404
964
  * offset to match, uint32 indices past 64k vertices. Every part must share
405
965
  * 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
966
+ * that picked one would draw garbage, not a mesh missing a channel - and
967
+ * one list topology (a mixed list throws the same way; strips cannot be
968
+ * concatenated at all, the seam would join them). The
407
969
  * second half of authoring a static scene as data (Three's
408
970
  * `BufferGeometryUtils.mergeGeometries`): the result is one draw entry and
409
971
  * one uModel write however many parts went in, so only what actually moves
@@ -411,35 +973,679 @@ export function transformGeometry(geometry: Geometry, transform: TransformUpdate
411
973
  */
412
974
  export function mergeGeometries(parts: Geometry[], label?: string): Geometry {
413
975
  if (parts.length === 0) throw new Error("mergeGeometries: no parts")
414
- let layout = parts[0]!.layout
415
- let key = layoutKey(layout)
416
- let stride = layoutStride(layout)
417
- let floats = 0
976
+ let first = parts[0]!
977
+ let key = geometryKey(first)
978
+ let topology = geometryTopology(first)
979
+ if (topology === "line-strip" || topology === "triangle-strip") throw new Error("mergeGeometries: cannot merge " + topology + " geometry")
980
+ let vertexTotal = 0
418
981
  let indexCount = 0
419
982
  for (let part of parts) {
420
- if (layoutKey(part.layout) !== key) {
421
- throw new Error("mergeGeometries: mixed layouts (" + key + " and " + layoutKey(part.layout) + ")")
983
+ if (geometryKey(part) !== key) {
984
+ throw new Error("mergeGeometries: mixed layouts (" + key + " and " + geometryKey(part) + ")")
422
985
  }
423
- if (part.vertices.length % stride !== 0) {
424
- throw new Error("mergeGeometries: a part's vertex data is not a whole number of " + key + " vertices")
986
+ if (geometryTopology(part) !== topology) {
987
+ throw new Error("mergeGeometries: mixed topologies (" + topology + " and " + geometryTopology(part) + ")")
425
988
  }
426
- floats += part.vertices.length
989
+ vertexTotal += geometryVertexCount(part, "mergeGeometries")
427
990
  indexCount += part.indices.length
428
991
  }
429
- let vertexCount = floats / stride
430
- let vertices = new Float32Array(floats)
431
- let indices = vertexCount > 65535 ? new Uint32Array(indexCount) : new Uint16Array(indexCount)
432
- let vOffset = 0
992
+ // Every stream concatenates the same way: part after part, at the
993
+ // stream's own stride.
994
+ let layouts = geometryStreams(first).map(s => s.layout)
995
+ let merged = layouts.map(layout => {
996
+ let stride = layoutStride(layout)
997
+ let bytes = new Uint8Array(vertexTotal * stride)
998
+ return { layout, stride, bytes }
999
+ })
1000
+ let indices = vertexTotal > 65535 ? new Uint32Array(indexCount) : new Uint16Array(indexCount)
1001
+ let base = 0
433
1002
  let iOffset = 0
434
1003
  for (let part of parts) {
435
- vertices.set(part.vertices, vOffset)
436
- let base = vOffset / stride
1004
+ let streams = geometryStreams(part)
1005
+ for (let k = 0; k < merged.length; k++) merged[k]!.bytes.set(vertexBytes(streams[k]!.vertices), base * merged[k]!.stride)
437
1006
  let src = part.indices
438
1007
  for (let i = 0; i < src.length; i++) indices[iOffset + i] = src[i]! + base
439
- vOffset += part.vertices.length
1008
+ base += geometryVertexCount(part, "mergeGeometries")
440
1009
  iOffset += src.length
441
1010
  }
442
- return { vertices, indices, layout, label }
1011
+ let out: Geometry = { vertices: vertexView(first.layout, merged[0]!.bytes.buffer), indices, topology: first.topology, layout: first.layout, label }
1012
+ if (merged.length > 1) out.streams = merged.slice(1).map(m => ({ layout: layoutAttributes(m.layout), vertices: vertexView(m.layout, m.bytes.buffer) }))
1013
+ return out
1014
+ }
1015
+
1016
+ /** Vertex values closer than this are equal to the welds: positions
1017
+ * (model units) to the edge builders, every channel to mergeVertices and
1018
+ * the corner weld of withNormals. Three's mergeVertices tolerance. */
1019
+ const WELD_PRECISION = 1e-4
1020
+
1021
+ /** Degrees between two faces' normals from which the edge between them
1022
+ * counts as a feature edge in edgesGeometry; under it the faces read as
1023
+ * one smooth surface and the edge stays hidden. Three's default. */
1024
+ const EDGES_THRESHOLD_ANGLE = 1
1025
+
1026
+ /** Degrees between two faces' normals from which the edge between them
1027
+ * shades as a crease in withNormals; under it the faces blend into one
1028
+ * smooth surface. Three's toCreasedNormals default and Unity's import
1029
+ * smoothing angle. */
1030
+ const CREASE_ANGLE = 60
1031
+
1032
+ /** One id per vertex, shared by every vertex whose `channels` agree to
1033
+ * `precision` in every component; plus the id count. Ids are dense, in
1034
+ * first-occurrence order. The edge builders weld on the position alone
1035
+ * (a uv seam or a per-face normal split is one edge whichever copy a
1036
+ * triangle names); mergeVertices welds on every channel. */
1037
+ function weldVertices(geometry: Geometry, channels: AttributeAccess[], precision: number, where: string): { ids: Uint32Array; count: number } {
1038
+ let count = geometryVertexCount(geometry, where)
1039
+ let ids = new Uint32Array(count)
1040
+ let seen = new Map<string, number>()
1041
+ let scale = 1 / precision
1042
+ for (let i = 0; i < count; i++) {
1043
+ let key = ""
1044
+ for (let c of channels) {
1045
+ for (let k = 0; k < c.components; k++) key += Math.round(c.get(i, k) * scale) + ","
1046
+ }
1047
+ let id = seen.get(key)
1048
+ if (id === undefined) {
1049
+ id = seen.size
1050
+ seen.set(key, id)
1051
+ }
1052
+ ids[i] = id
1053
+ }
1054
+ return { ids, count: seen.size }
1055
+ }
1056
+
1057
+ function weldPositions(geometry: Geometry, where: string): { ids: Uint32Array; count: number } {
1058
+ return weldVertices(geometry, [geometryAttribute(geometry, "aPos")!], WELD_PRECISION, where)
1059
+ }
1060
+
1061
+ /** An accessor per attribute of every stream, in layout order. */
1062
+ function allChannels(geometry: Geometry): AttributeAccess[] {
1063
+ let out: AttributeAccess[] = []
1064
+ for (let stream of geometryStreams(geometry)) {
1065
+ for (let attr of layoutAttributes(stream.layout)) out.push(attributeAccess(stream.vertices, stream.layout, attr.name)!)
1066
+ }
1067
+ return out
1068
+ }
1069
+
1070
+ /** The edge table of a triangle geometry, welded by position, as a lines
1071
+ * index list: every edge once for `cosThreshold` null, else the feature
1072
+ * edges - those with one face (a border) or whose two faces' normals
1073
+ * dot at or below the threshold. Each edge names the vertex pair of the
1074
+ * first triangle that had it. */
1075
+ function edgeIndices(geometry: Geometry, name: string, cosThreshold: number | null): Uint16Array | Uint32Array {
1076
+ if (geometryTopology(geometry) !== "triangles") {
1077
+ throw new Error(name + ": needs a triangle geometry, got " + geometryTopology(geometry))
1078
+ }
1079
+ let pos = geometryAttribute(geometry, "aPos")!
1080
+ let weld = weldPositions(geometry, name)
1081
+ let src = geometry.indices
1082
+ type Edge = { a: number; b: number; nx: number; ny: number; nz: number; faces: number; sharp: boolean }
1083
+ let edges = new Map<number, Edge>()
1084
+ for (let t = 0; t + 2 < src.length; t += 3) {
1085
+ let i0 = src[t]!, i1 = src[t + 1]!, i2 = src[t + 2]!
1086
+ // The face normal, for the feature test.
1087
+ let ux = pos.get(i1, 0) - pos.get(i0, 0), uy = pos.get(i1, 1) - pos.get(i0, 1), uz = pos.get(i1, 2) - pos.get(i0, 2)
1088
+ let wx = pos.get(i2, 0) - pos.get(i0, 0), wy = pos.get(i2, 1) - pos.get(i0, 1), wz = pos.get(i2, 2) - pos.get(i0, 2)
1089
+ let nx = uy * wz - uz * wy, ny = uz * wx - ux * wz, nz = ux * wy - uy * wx
1090
+ let len = Math.hypot(nx, ny, nz) || 1
1091
+ nx /= len
1092
+ ny /= len
1093
+ nz /= len
1094
+ for (let k = 0; k < 3; k++) {
1095
+ let ia = k === 0 ? i0 : k === 1 ? i1 : i2
1096
+ let ib = k === 0 ? i1 : k === 1 ? i2 : i0
1097
+ let wa = weld.ids[ia]!, wb = weld.ids[ib]!
1098
+ if (wa === wb) continue
1099
+ let key = Math.min(wa, wb) * weld.count + Math.max(wa, wb)
1100
+ let e = edges.get(key)
1101
+ if (e === undefined) {
1102
+ edges.set(key, { a: ia, b: ib, nx, ny, nz, faces: 1, sharp: false })
1103
+ } else {
1104
+ e.faces++
1105
+ if (cosThreshold !== null && !e.sharp && e.nx * nx + e.ny * ny + e.nz * nz <= cosThreshold) e.sharp = true
1106
+ }
1107
+ }
1108
+ }
1109
+ let out: number[] = []
1110
+ for (let e of edges.values()) {
1111
+ if (cosThreshold === null || e.faces === 1 || e.sharp) out.push(e.a, e.b)
1112
+ }
1113
+ return packIndices(out, weld.ids.length)
1114
+ }
1115
+
1116
+ /**
1117
+ * Every edge of a triangle geometry as a "lines" geometry over the SAME
1118
+ * vertices (Three's WireframeGeometry): each triangle's three sides, once
1119
+ * each - edges are matched by position, so a uv seam or a per-face
1120
+ * normal split draws one line, not two on top of each other. The
1121
+ * source's vertex array and layout are shared by reference, and so is
1122
+ * their GPU upload, which makes the wireframe a second index buffer over
1123
+ * the same vertices: a "skinned" part's wireframe draws in its pose
1124
+ * under `unlit({ skinned: true })` with the mesh's own palette, and any
1125
+ * material draws it as lines because the topology rides on the
1126
+ * geometry. Lines are one pixel wide.
1127
+ */
1128
+ export function wireframeGeometry(geometry: Geometry, label?: string): Geometry {
1129
+ return overVertices(geometry, edgeIndices(geometry, "wireframeGeometry", null), label ?? (geometry.label ? geometry.label + "-wireframe" : undefined))
1130
+ }
1131
+
1132
+ // A "lines" geometry over the same vertices (every stream shared) as its
1133
+ // source, with its own indices: the edge builders' tail.
1134
+ function overVertices(geometry: Geometry, indices: Uint16Array | Uint32Array, label: string | undefined): Geometry {
1135
+ let out: Geometry = { vertices: geometry.vertices, indices, topology: "lines", layout: geometry.layout, label }
1136
+ if (geometry.streams !== undefined) out.streams = geometry.streams
1137
+ return out
1138
+ }
1139
+
1140
+ /**
1141
+ * The feature edges of a triangle geometry as a "lines" geometry over the
1142
+ * same vertices (Three's EdgesGeometry): an edge draws when its two faces
1143
+ * meet at `thresholdAngle` degrees or more, or when only one face has it
1144
+ * (an open border): the outline that a dense mesh's wireframe, a solid
1145
+ * blob, cannot give. A box draws its twelve edges and no face diagonals
1146
+ * at any threshold; a generated round shape is faceted, so its facet
1147
+ * lines show until the threshold passes its facet angle (360 divided by
1148
+ * the radial segments: a default cylinder loses its side seams and keeps
1149
+ * its two rims from 16 degrees, a default sphere draws nothing from 16).
1150
+ * A smooth closed shape above its facet angle has no edges at all, and
1151
+ * the empty lines geometry that comes back attaches and draws nothing.
1152
+ * Shares vertices, layout and GPU upload with the source like
1153
+ * wireframeGeometry.
1154
+ */
1155
+ export function edgesGeometry(geometry: Geometry, thresholdAngle = EDGES_THRESHOLD_ANGLE, label?: string): Geometry {
1156
+ return overVertices(geometry, edgeIndices(geometry, "edgesGeometry", Math.cos((thresholdAngle * Math.PI) / 180)), label ?? (geometry.label ? geometry.label + "-edges" : undefined))
1157
+ }
1158
+
1159
+ /** The identity index list over `count` vertices, in the width
1160
+ * packIndices picks. */
1161
+ function identityIndices(count: number): Uint16Array | Uint32Array {
1162
+ let out = count > 65535 ? new Uint32Array(count) : new Uint16Array(count)
1163
+ for (let i = 0; i < count; i++) out[i] = i
1164
+ return out
1165
+ }
1166
+
1167
+ /** Every stream of `geometry` rebuilt over `count` vertices, vertex `i`
1168
+ * of the result copied from source vertex `from[i]` - the gather behind
1169
+ * toNonIndexed (from = the index list) and mergeVertices (from = the
1170
+ * first vertex of each weld id). Throws on a source index out of range,
1171
+ * which the O(n) pass gets for free. */
1172
+ function gatherVertices(geometry: Geometry, from: ArrayLike<number>, count: number, where: string): ArrayBufferView[] {
1173
+ let total = geometryVertexCount(geometry, where)
1174
+ return geometryStreams(geometry).map(stream => {
1175
+ let stride = layoutStride(stream.layout)
1176
+ let src = vertexBytes(stream.vertices)
1177
+ let dst = new Uint8Array(count * stride)
1178
+ for (let i = 0; i < count; i++) {
1179
+ let v = from[i]!
1180
+ if (v >= total) throw new Error(where + ": index " + v + " is outside the geometry's " + total + " vertices")
1181
+ dst.set(src.subarray(v * stride, (v + 1) * stride), i * stride)
1182
+ }
1183
+ return vertexView(stream.layout, dst.buffer)
1184
+ })
1185
+ }
1186
+
1187
+ // A geometry over gathered streams with its own indices: the tail of the
1188
+ // index ops. Layout and topology copy through.
1189
+ function overGathered(geometry: Geometry, gathered: ArrayBufferView[], indices: Uint16Array | Uint32Array, label: string | undefined): Geometry {
1190
+ let out: Geometry = { vertices: gathered[0]!, indices, topology: geometry.topology, layout: geometry.layout, label }
1191
+ if (geometry.streams !== undefined) out.streams = geometry.streams.map((s, k) => ({ layout: s.layout, vertices: gathered[k + 1]! }))
1192
+ return out
1193
+ }
1194
+
1195
+ /**
1196
+ * Every index its own vertex (Three's toNonIndexed, Godot's
1197
+ * SurfaceTool.deindex): a new geometry of `indices.length` vertices, each
1198
+ * stream gathered through the index list, whose own indices are the
1199
+ * identity 0..n-1 - a draw entry always indexes here, so "non-indexed"
1200
+ * means no vertex is shared. What per-face data needs: after the split a
1201
+ * face's three corners are its own, so computeVertexNormals gives flat
1202
+ * shading and fillAttribute can write a per-face color. Topology copies
1203
+ * through (a strip stays a strip); mergeVertices is the inverse. Throws
1204
+ * on a morphed geometry: the sparse packing is per base vertex, so split
1205
+ * before withMorphTargets.
1206
+ */
1207
+ export function toNonIndexed(geometry: Geometry, label?: string): Geometry {
1208
+ if (geometry.morphs !== undefined) throw new Error("toNonIndexed: geometry carries morph targets; split before withMorphTargets")
1209
+ let n = geometry.indices.length
1210
+ return overGathered(geometry, gatherVertices(geometry, geometry.indices, n, "toNonIndexed"), identityIndices(n), label ?? (geometry.label ? geometry.label + "-nonindexed" : undefined))
1211
+ }
1212
+
1213
+ /**
1214
+ * Weld the vertices that agree in every channel of every stream to
1215
+ * `tolerance` (model units for positions, the same absolute step for
1216
+ * every other channel) into one (Three's BufferGeometryUtils.mergeVertices,
1217
+ * Godot's SurfaceTool.index): a new geometry keeping the first copy of
1218
+ * each in first-occurrence order, indices remapped, so a triangle soup
1219
+ * (an STL, a hand-written face list, a toNonIndexed result) becomes an
1220
+ * indexed mesh whose shared vertices computeVertexNormals averages
1221
+ * across. Vertices that differ only in a normal or a uv stay apart: this
1222
+ * is the exact inverse of toNonIndexed, not a position weld, so a seam
1223
+ * or a crease survives it (withNormals is the op that decides creases).
1224
+ * Faces are kept as they are, degenerate ones included. Throws on a
1225
+ * morphed geometry like toNonIndexed.
1226
+ */
1227
+ export function mergeVertices(geometry: Geometry, tolerance = WELD_PRECISION, label?: string): Geometry {
1228
+ if (geometry.morphs !== undefined) throw new Error("mergeVertices: geometry carries morph targets; weld before withMorphTargets")
1229
+ if (!(tolerance > 0)) throw new Error("mergeVertices: tolerance must be positive, got " + tolerance)
1230
+ let weld = weldVertices(geometry, allChannels(geometry), tolerance, "mergeVertices")
1231
+ let first = new Uint32Array(weld.count)
1232
+ let seen = new Uint8Array(weld.count)
1233
+ for (let i = 0; i < weld.ids.length; i++) {
1234
+ let id = weld.ids[i]!
1235
+ if (seen[id] === 0) {
1236
+ seen[id] = 1
1237
+ first[id] = i
1238
+ }
1239
+ }
1240
+ let src = geometry.indices
1241
+ let indices = weld.count > 65535 ? new Uint32Array(src.length) : new Uint16Array(src.length)
1242
+ for (let i = 0; i < src.length; i++) {
1243
+ let id = weld.ids[src[i]!]
1244
+ if (id === undefined) throw new Error("mergeVertices: index " + src[i] + " is outside the geometry's " + weld.ids.length + " vertices")
1245
+ indices[i] = id
1246
+ }
1247
+ return overGathered(geometry, gatherVertices(geometry, first, weld.count, "mergeVertices"), indices, label ?? (geometry.label ? geometry.label + "-merged" : undefined))
1248
+ }
1249
+
1250
+ /** Per face its unit normal (3 floats per face) and per corner the
1251
+ * angle the face makes there (radians, 3 per face): the weight every
1252
+ * normal computed here sums with. Weighting by the corner angle rather
1253
+ * than by face area (Three's computeVertexNormals) or not at all (Godot)
1254
+ * makes the result independent of how a surface was triangulated: a
1255
+ * quad split into two triangles contributes the same 90 degrees at each
1256
+ * corner whichever way its diagonal runs, so a fully smoothed cube gets
1257
+ * exact corner diagonals where an area sum leans toward the faces whose
1258
+ * diagonal touches the corner. A degenerate face has a zero normal and
1259
+ * zero angles, so it counts for nothing. */
1260
+ function faceFrames(geometry: Geometry, where: string): { normals: Float32Array; angles: Float32Array } {
1261
+ let count = geometryVertexCount(geometry, where)
1262
+ let pos = geometryAttribute(geometry, "aPos")!
1263
+ let src = geometry.indices
1264
+ let faces = Math.floor(src.length / 3)
1265
+ let normals = new Float32Array(faces * 3)
1266
+ let angles = new Float32Array(faces * 3)
1267
+ let corner = (a2: number, b2: number, c2: number): number => {
1268
+ // The angle opposite side b, by the law of cosines; 0 for a corner
1269
+ // with a zero-length side.
1270
+ let denom = 2 * Math.sqrt(a2 * c2)
1271
+ return denom === 0 ? 0 : Math.acos(Math.min(1, Math.max(-1, (a2 + c2 - b2) / denom)))
1272
+ }
1273
+ for (let f = 0; f < faces; f++) {
1274
+ let i0 = src[f * 3]!, i1 = src[f * 3 + 1]!, i2 = src[f * 3 + 2]!
1275
+ if (i0 >= count || i1 >= count || i2 >= count) throw new Error(where + ": face " + f + " names a vertex outside the geometry's " + count)
1276
+ let x0 = pos.get(i0, 0), y0 = pos.get(i0, 1), z0 = pos.get(i0, 2)
1277
+ let x1 = pos.get(i1, 0), y1 = pos.get(i1, 1), z1 = pos.get(i1, 2)
1278
+ let x2 = pos.get(i2, 0), y2 = pos.get(i2, 1), z2 = pos.get(i2, 2)
1279
+ let ux = x1 - x0, uy = y1 - y0, uz = z1 - z0
1280
+ let wx = x2 - x0, wy = y2 - y0, wz = z2 - z0
1281
+ let nx = uy * wz - uz * wy, ny = uz * wx - ux * wz, nz = ux * wy - uy * wx
1282
+ let len = Math.hypot(nx, ny, nz)
1283
+ if (len === 0) continue
1284
+ normals[f * 3] = nx / len
1285
+ normals[f * 3 + 1] = ny / len
1286
+ normals[f * 3 + 2] = nz / len
1287
+ // Squared side lengths: a = i0-i1, b = i1-i2, c = i2-i0.
1288
+ let a2 = ux * ux + uy * uy + uz * uz
1289
+ let b2 = (x2 - x1) ** 2 + (y2 - y1) ** 2 + (z2 - z1) ** 2
1290
+ let c2 = wx * wx + wy * wy + wz * wz
1291
+ angles[f * 3] = corner(a2, b2, c2)
1292
+ angles[f * 3 + 1] = corner(a2, c2, b2)
1293
+ angles[f * 3 + 2] = corner(b2, a2, c2)
1294
+ }
1295
+ return { normals, angles }
1296
+ }
1297
+
1298
+ /** Write the unit vector along (x, y, z) as vertex `i`'s normal; a zero
1299
+ * sum (a vertex no face names, or only degenerate ones) writes zero. */
1300
+ function writeUnit(nrm: AttributeAccess, i: number, x: number, y: number, z: number): void {
1301
+ let len = Math.hypot(x, y, z) || 1
1302
+ nrm.set(i, 0, x / len)
1303
+ nrm.set(i, 1, y / len)
1304
+ nrm.set(i, 2, z / len)
1305
+ }
1306
+
1307
+ /**
1308
+ * A copy with normals computed from its faces, creased where faces meet
1309
+ * at `creaseAngle` degrees or more (Three's toCreasedNormals, Unity's
1310
+ * import smoothing angle; Godot's smooth groups are the same decision
1311
+ * made per vertex): the authoring path, where computeVertexNormals
1312
+ * (geometry-gpu.ts, the core-backed in-place recompute) is the per-frame
1313
+ * one. Faces are matched by POSITION, so a triangle soup,
1314
+ * a hand-written face list, a mergeGeometries result and a geometry
1315
+ * with no aNormal channel at all (one is added, float32x3, after the
1316
+ * existing channels) all work, and a uv seam shades smooth across it.
1317
+ * 0 is flat shading, 180 smooths everything, the default keeps a
1318
+ * hard-edged mesh hard. Each face corner takes the corner-angle-weighted
1319
+ * sum of the faces at its position within the angle of its own face,
1320
+ * then the corners weld back by every channel, so the result is indexed
1321
+ * and split only where a crease or a seam needs it: a box comes out at
1322
+ * its 24 vertices under 90 degrees, and at 180 its normals become the
1323
+ * exact corner diagonals (corners whose uvs coincide weld too). Composed
1324
+ * from three exported ops - toNonIndexed, the corner normals,
1325
+ * mergeVertices - so every stream is rebuilt (a copy) and a morphed
1326
+ * geometry throws like toNonIndexed.
1327
+ */
1328
+ export function withNormals(geometry: Geometry, creaseAngle = CREASE_ANGLE, label?: string): Geometry {
1329
+ if (geometryTopology(geometry) !== "triangles") throw new Error("withNormals: needs a triangle geometry, got " + geometryTopology(geometry))
1330
+ if (!(creaseAngle >= 0 && creaseAngle <= 180)) throw new Error("withNormals: creaseAngle must be 0..180 degrees, got " + creaseAngle)
1331
+ let count = geometryVertexCount(geometry, "withNormals")
1332
+ let source = geometrySlot(geometry, "aNormal") === null ? withAttribute(geometry, { name: "aNormal", format: "float32x3" }, new Float32Array(count * 3)) : geometry
1333
+ let slot = geometrySlot(source, "aNormal")!
1334
+ // Written through the accessor, so any float-kind format with room for
1335
+ // three components takes the result, a packed snorm16x4 included.
1336
+ if (VERTEX_FORMATS[slot.format].kind !== "float" || slot.components < 3) {
1337
+ throw new Error("withNormals: aNormal is " + slot.format + ", not a float format of three or more components")
1338
+ }
1339
+ let split = toNonIndexed(source)
1340
+ let { normals, angles } = faceFrames(split, "withNormals")
1341
+ let corners = angles.length
1342
+ // The corners at each welded position, as a CSR table: corner c of the
1343
+ // split geometry is its vertex c and belongs to face c / 3.
1344
+ let weld = weldPositions(split, "withNormals")
1345
+ let starts = new Uint32Array(weld.count + 1)
1346
+ for (let c = 0; c < corners; c++) starts[weld.ids[c]! + 1] = starts[weld.ids[c]! + 1]! + 1
1347
+ for (let p = 0; p < weld.count; p++) starts[p + 1] = starts[p + 1]! + starts[p]!
1348
+ let fill = starts.slice(0, weld.count)
1349
+ let at = new Uint32Array(corners)
1350
+ for (let c = 0; c < corners; c++) {
1351
+ let p = weld.ids[c]!
1352
+ at[fill[p]!] = c
1353
+ fill[p] = fill[p]! + 1
1354
+ }
1355
+ let cosCrease = Math.cos((creaseAngle * Math.PI) / 180)
1356
+ let nrm = geometryAttribute(split, "aNormal")!
1357
+ for (let c = 0; c < corners; c++) {
1358
+ let f = Math.floor(c / 3) * 3
1359
+ let p = weld.ids[c]!
1360
+ let sx = 0, sy = 0, sz = 0
1361
+ for (let k = starts[p]!; k < starts[p + 1]!; k++) {
1362
+ let other = at[k]!
1363
+ let g = Math.floor(other / 3) * 3
1364
+ // A face always counts toward its own corners: its self-dot can
1365
+ // fall a rounding step under 1, which the angle 0 test would miss.
1366
+ if (g !== f && normals[f]! * normals[g]! + normals[f + 1]! * normals[g + 1]! + normals[f + 2]! * normals[g + 2]! < cosCrease) continue
1367
+ let w = angles[other]!
1368
+ sx += normals[g]! * w
1369
+ sy += normals[g + 1]! * w
1370
+ sz += normals[g + 2]! * w
1371
+ }
1372
+ writeUnit(nrm, c, sx, sy, sz)
1373
+ }
1374
+ return mergeVertices(split, WELD_PRECISION, label ?? (geometry.label ? geometry.label + "-normals" : undefined))
1375
+ }
1376
+
1377
+ // The debug helpers: Three's GridHelper, AxesHelper, Box3Helper and
1378
+ // PlaneHelper as "lines" builders (Godot and Unity keep the equivalents in
1379
+ // the editor; with no editor and topology on the geometry they are plain
1380
+ // data here). A helper is a Geometry like any other: a node places it, a
1381
+ // material draws it. Normals and uvs fill the base prefix and mean
1382
+ // nothing on a line.
1383
+
1384
+ // Three's GridHelper defaults, sRGB: the subdivision lines (0x888888) and
1385
+ // the two through the origin (0x444444).
1386
+ const GRID_COLOR: [number, number, number] = [0.53, 0.53, 0.53]
1387
+ const GRID_CENTER_COLOR: [number, number, number] = [0.27, 0.27, 0.27]
1388
+ // Three's defaults: a 10 by 10 grid of unit cells.
1389
+ const GRID_SIZE = 10
1390
+ const GRID_DIVISIONS = 10
1391
+ // X red, Y green, Z blue: the axis colors of every engine.
1392
+ const AXIS_COLORS: [number, number, number][] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
1393
+
1394
+ // The layout a colored helper emits: "colored" unless the caller asked for
1395
+ // a wider one, which must still carry the aColor channel the helper writes.
1396
+ function helperLayout(options: GeometryOptions, name: string): VertexLayout {
1397
+ let layout = options.layout ?? "colored"
1398
+ if (layoutSlot(layout, "aColor") === null) throw new Error(name + ": layout has no aColor channel to write")
1399
+ return layout
1400
+ }
1401
+
1402
+ // The helper tail: pack, mark as lines, write the colors when there are any.
1403
+ function packLines(verts: number[], indices: number[] | Uint16Array | Uint32Array, options: GeometryOptions, colors?: number[]): Geometry {
1404
+ let geometry = packGeometry(verts, indices, options)
1405
+ geometry.topology = "lines"
1406
+ if (colors !== undefined) fillColors(geometry, colors)
1407
+ return geometry
1408
+ }
1409
+
1410
+ /**
1411
+ * A square grid of lines in the XZ plane at y 0, centered on the origin
1412
+ * (Three's GridHelper with its defaults): `divisions` cells of
1413
+ * `size / divisions` along each side, the subdivision lines in `color`
1414
+ * and the two lines through the origin in `centerColor`, which exist only
1415
+ * when `divisions` is even. Colors are sRGB 0..1 like a material's,
1416
+ * written premultiplied linear into the "colored" layout's aColor:
1417
+ * `unlit({ vertexColors: true })` draws them, and a material that reads no
1418
+ * aColor (a plain `unlit({ color })`) draws the grid in its own color.
1419
+ * (divisions + 1) * 4 vertices, one per line end. Lines are one pixel
1420
+ * wide.
1421
+ */
1422
+ export type GridHelperOptions = GeometryOptions & {
1423
+ /** Side length of the square, default 10. */
1424
+ size?: number
1425
+ /** Cells along each side, default 10 (unit cells). */
1426
+ divisions?: number
1427
+ /** sRGB color of the subdivision lines, default Three's 0x888888. */
1428
+ color?: [number, number, number] | [number, number, number, number]
1429
+ /** sRGB color of the two lines through the origin (present when
1430
+ * `divisions` is even), default Three's 0x444444. */
1431
+ centerColor?: [number, number, number] | [number, number, number, number]
1432
+ }
1433
+
1434
+ export function gridHelper(options: GridHelperOptions = {}): Geometry {
1435
+ let { size = GRID_SIZE, divisions = GRID_DIVISIONS, color = GRID_COLOR, centerColor = GRID_CENTER_COLOR } = options
1436
+ if (!(Number.isInteger(divisions) && divisions >= 1)) throw new Error("gridHelper: divisions must be a whole number of 1 or more, got " + divisions)
1437
+ let half = size / 2
1438
+ let step = size / divisions
1439
+ let line = premultipliedColor(color)
1440
+ let center = premultipliedColor(centerColor)
1441
+ let verts: number[] = []
1442
+ let colors: number[] = []
1443
+ for (let i = 0; i <= divisions; i++) {
1444
+ let k = -half + i * step
1445
+ let t = i / divisions
1446
+ // The line across x at z = k, then the line across z at x = k.
1447
+ verts.push(-half, 0, k, 0, 1, 0, 0, t, half, 0, k, 0, 1, 0, 1, t)
1448
+ verts.push(k, 0, -half, 0, 1, 0, t, 0, k, 0, half, 0, 1, 0, t, 1)
1449
+ let c = i * 2 === divisions ? center : line
1450
+ for (let end = 0; end < 4; end++) colors.push(c[0]!, c[1]!, c[2]!, c[3]!)
1451
+ }
1452
+ let indices: number[] = []
1453
+ for (let i = 0; i < verts.length / BASE_FLOATS; i++) indices.push(i)
1454
+ return packLines(verts, indices, { label: options.label, layout: helperLayout(options, "gridHelper") }, colors)
1455
+ }
1456
+
1457
+ /** Three segments from the origin along +x, +y and +z, X red, Y green,
1458
+ * Z blue (the axis colors of every engine; Three's AxesHelper), in the
1459
+ * "colored" layout like gridHelper: 6 vertices. */
1460
+ export type AxesHelperOptions = GeometryOptions & {
1461
+ /** Length of each axis segment, default 1. */
1462
+ size?: number
1463
+ }
1464
+
1465
+ export function axesHelper(options: AxesHelperOptions = {}): Geometry {
1466
+ let { size = 1 } = options
1467
+ let verts: number[] = []
1468
+ let colors: number[] = []
1469
+ AXIS_COLORS.forEach((axis, i) => {
1470
+ let d = [0, 0, 0]
1471
+ d[i] = 1
1472
+ verts.push(0, 0, 0, d[0]!, d[1]!, d[2]!, 0, 0)
1473
+ verts.push(d[0]! * size, d[1]! * size, d[2]! * size, d[0]!, d[1]!, d[2]!, 1, 0)
1474
+ let c = premultipliedColor(axis)
1475
+ colors.push(c[0]!, c[1]!, c[2]!, c[3]!, c[0]!, c[1]!, c[2]!, c[3]!)
1476
+ })
1477
+ return packLines(verts, [0, 1, 2, 3, 4, 5], { label: options.label, layout: helperLayout(options, "axesHelper") }, colors)
1478
+ }
1479
+
1480
+ /**
1481
+ * The twelve edges of an axis-aligned box given as `[minX, minY, minZ,
1482
+ * maxX, maxY, maxZ]` - what geometryBounds, a model's `bounds` and the
1483
+ * spatial queries return - as "lines" geometry (Three's Box3Helper): 8
1484
+ * vertices, 24 indices, base layout, so a plain `unlit({ color })`
1485
+ * draws it. The bounds are baked in local space: put the helper under the
1486
+ * node whose bounds they are. For a box that moves every frame do what
1487
+ * Three does - draw `edgesGeometry(box())`, a unit cube's edges, on a
1488
+ * node whose position is the box center and whose scale is its size, and
1489
+ * update the transform instead of rebuilding geometry.
1490
+ */
1491
+ export function box3Helper(bounds: ArrayLike<number>, options: GeometryOptions = {}): Geometry {
1492
+ if (bounds.length !== 6) throw new Error("box3Helper: bounds must be [minX, minY, minZ, maxX, maxY, maxZ], got " + bounds.length + " values")
1493
+ let verts: number[] = []
1494
+ let indices: number[] = []
1495
+ // Corner i has bit 0 for x, bit 1 for y, bit 2 for z (set = the max
1496
+ // side); an edge joins two corners that differ in exactly one bit. The
1497
+ // normal is the corner's outward diagonal.
1498
+ let n = 1 / Math.sqrt(3)
1499
+ for (let i = 0; i < 8; i++) {
1500
+ let x = (i & 1) !== 0, y = (i & 2) !== 0, z = (i & 4) !== 0
1501
+ verts.push(bounds[x ? 3 : 0]!, bounds[y ? 4 : 1]!, bounds[z ? 5 : 2]!, x ? n : -n, y ? n : -n, z ? n : -n, 0, 0)
1502
+ for (let bit = 1; bit < 8; bit <<= 1) if ((i & bit) === 0) indices.push(i, i | bit)
1503
+ }
1504
+ return packLines(verts, indices, options)
1505
+ }
1506
+
1507
+ /**
1508
+ * A collision capsule's outline as "lines" geometry (Unity's capsule
1509
+ * collider gizmo, Godot's CapsuleShape3D gizmo; Three has no capsule
1510
+ * helper): the `{ a, b, radius }` volume overlap()/sweep()/moveAndSlide
1511
+ * take, drawn in the space it was given, so a scene-space volume goes
1512
+ * under a node with the identity transform like a box3Helper of query
1513
+ * bounds. A ring around each end of the segment, four lines between the
1514
+ * rings, and two half circles over each cap in perpendicular planes; a
1515
+ * == b is a sphere and draws its three great circles. `segments` is the
1516
+ * ring resolution, a multiple of 4 so the lines and arcs land on ring
1517
+ * points. Standard layout, normals radial from the segment.
1518
+ */
1519
+ export type CapsuleHelperOptions = GeometryOptions & {
1520
+ /** Points per ring, a positive multiple of 4, default 32. */
1521
+ segments?: number
1522
+ }
1523
+
1524
+ export function capsuleHelper(volume: Capsule, options: CapsuleHelperOptions = {}): Geometry {
1525
+ let { segments = 32 } = options
1526
+ if (!Number.isInteger(segments) || segments < 4 || segments % 4 !== 0) throw new Error("capsuleHelper: segments must be a positive multiple of 4")
1527
+ let { a, b, radius } = volume
1528
+ let span = sub(b, a)
1529
+ let single = Math.hypot(span[0], span[1], span[2]) === 0
1530
+ let axis = single ? [0, 1, 0] as Vec3 : normalize(span)
1531
+ // A frame around the axis: u from the world axis least aligned with it.
1532
+ let ax = Math.abs(axis[0]), ay = Math.abs(axis[1]), az = Math.abs(axis[2])
1533
+ let pick: Vec3 = ax <= ay && ax <= az ? [1, 0, 0] : ay <= az ? [0, 1, 0] : [0, 0, 1]
1534
+ let u = normalize(cross(axis, pick))
1535
+ let v = cross(axis, u)
1536
+ let verts: number[] = []
1537
+ let indices: number[] = []
1538
+ // A point on the cap sphere at `center` in direction c*p + s*q.
1539
+ let push = (center: Vec3, p: Vec3, q: Vec3, c: number, sn: number): void => {
1540
+ let nx = c * p[0] + sn * q[0]
1541
+ let ny = c * p[1] + sn * q[1]
1542
+ let nz = c * p[2] + sn * q[2]
1543
+ verts.push(center[0] + radius * nx, center[1] + radius * ny, center[2] + radius * nz, nx, ny, nz, 0, 0)
1544
+ }
1545
+ let ring = (center: Vec3): number => {
1546
+ let base = verts.length / BASE_FLOATS
1547
+ for (let i = 0; i < segments; i++) {
1548
+ let t = (i / segments) * Math.PI * 2
1549
+ push(center, u, v, Math.cos(t), Math.sin(t))
1550
+ indices.push(base + i, base + ((i + 1) % segments))
1551
+ }
1552
+ return base
1553
+ }
1554
+ // Half circle from +p over `out` (the cap's pole direction) to -p.
1555
+ let arc = (center: Vec3, p: Vec3, out: Vec3): void => {
1556
+ let base = verts.length / BASE_FLOATS
1557
+ let cells = segments / 2
1558
+ for (let i = 0; i <= cells; i++) {
1559
+ let t = (i / cells) * Math.PI
1560
+ push(center, p, out, Math.cos(t), Math.sin(t))
1561
+ if (i > 0) indices.push(base + i - 1, base + i)
1562
+ }
1563
+ }
1564
+ let ringA = ring(a)
1565
+ if (!single) {
1566
+ let ringB = ring(b)
1567
+ let quarter = segments / 4
1568
+ for (let k = 0; k < 4; k++) indices.push(ringA + k * quarter, ringB + k * quarter)
1569
+ }
1570
+ let down: Vec3 = [-axis[0], -axis[1], -axis[2]]
1571
+ arc(a, u, down)
1572
+ arc(a, v, down)
1573
+ arc(b, u, axis)
1574
+ arc(b, v, axis)
1575
+ return packLines(verts, indices, options)
1576
+ }
1577
+
1578
+ /**
1579
+ * A plane marker as "lines" geometry (Three's PlaneHelper without its
1580
+ * translucent fill): a `size` square in the XY plane at the origin facing
1581
+ * +z exactly like plane(), its two diagonals, and a unit segment from the
1582
+ * center along the normal so the facing reads. Place it with the node
1583
+ * transform the way plane() is placed (Three's takes a Plane and does the
1584
+ * lookAt itself; there is no Plane type here). 6 vertices, 14 indices,
1585
+ * base layout.
1586
+ */
1587
+ export type PlaneHelperOptions = GeometryOptions & {
1588
+ /** Side length of the square, default 1. */
1589
+ size?: number
1590
+ }
1591
+
1592
+ export function planeHelper(options: PlaneHelperOptions = {}): Geometry {
1593
+ let { size = 1 } = options
1594
+ let h = size / 2
1595
+ // prettier-ignore
1596
+ let verts = [
1597
+ -h, -h, 0, 0, 0, 1, 0, 1,
1598
+ h, -h, 0, 0, 0, 1, 1, 1,
1599
+ h, h, 0, 0, 0, 1, 1, 0,
1600
+ -h, h, 0, 0, 0, 1, 0, 0,
1601
+ 0, 0, 0, 0, 0, 1, 0.5, 0.5,
1602
+ 0, 0, 1, 0, 0, 1, 0.5, 0.5,
1603
+ ]
1604
+ // The outline, the two diagonals, the normal tick.
1605
+ return packLines(verts, [0, 1, 1, 2, 2, 3, 3, 0, 0, 2, 1, 3, 4, 5], options)
1606
+ }
1607
+
1608
+ // Three's ArrowHelper proportions: the head is a fifth of the length and
1609
+ // a fifth as wide as it is long.
1610
+ const ARROW_HEAD_LENGTH = 0.2
1611
+ const ARROW_HEAD_WIDTH = 0.2
1612
+
1613
+ /**
1614
+ * An arrow from the origin along +y as "lines" geometry (Three's
1615
+ * ArrowHelper, whose local arrow points up +y too, with its solid cone
1616
+ * replaced by a pyramid outline): the shaft, four lines from the tip to a
1617
+ * diamond base, and the base itself. Aim it with the node transform -
1618
+ * `quatFromTo(out, [0, 1, 0], direction)` is the rotation - and put the
1619
+ * origin in `position`; Three's constructor takes both, here they are the
1620
+ * node's. 7 vertices, 18 indices, base layout.
1621
+ */
1622
+ export type ArrowHelperOptions = GeometryOptions & {
1623
+ /** Tip distance from the origin, default 1. */
1624
+ length?: number
1625
+ /** Head length from base to tip, default a fifth of `length`. */
1626
+ headLength?: number
1627
+ /** Head width across the base, default a fifth of `headLength`. */
1628
+ headWidth?: number
1629
+ }
1630
+
1631
+ export function arrowHelper(options: ArrowHelperOptions = {}): Geometry {
1632
+ let { length = 1 } = options
1633
+ let headLength = options.headLength ?? length * ARROW_HEAD_LENGTH
1634
+ let headWidth = options.headWidth ?? headLength * ARROW_HEAD_WIDTH
1635
+ let base = length - headLength
1636
+ let w = headWidth / 2
1637
+ // prettier-ignore
1638
+ let verts = [
1639
+ 0, 0, 0, 0, 1, 0, 0, 0,
1640
+ 0, base, 0, 0, 1, 0, 0, 1,
1641
+ 0, length, 0, 0, 1, 0, 0, 1,
1642
+ w, base, 0, 0, 1, 0, 1, 1,
1643
+ 0, base, w, 0, 1, 0, 1, 1,
1644
+ -w, base, 0, 0, 1, 0, 1, 1,
1645
+ 0, base, -w, 0, 1, 0, 1, 1,
1646
+ ]
1647
+ // The shaft, the four tip lines, the base diamond.
1648
+ return packLines(verts, [0, 1, 2, 3, 2, 4, 2, 5, 2, 6, 3, 4, 4, 5, 5, 6, 6, 3], options)
443
1649
  }
444
1650
 
445
1651
  // Indices for a row-major (cellRows + 1) x (cellCols + 1) vertex grid: two
@@ -463,6 +1669,45 @@ function gridIndices(cellRows: number, cellCols: number, skipFirst = false, skip
463
1669
  }
464
1670
 
465
1671
  /** An axis-aligned box centered on the origin: 24 vertices, 36 indices. */
1672
+ /** Options of normalsHelper: the line length and color. */
1673
+ export type NormalsHelperOptions = GeometryOptions & {
1674
+ /** Length of each normal line, default 1 (Three's). */
1675
+ size?: number
1676
+ /** Line color, sRGB 0..1; default red (Three's). */
1677
+ color?: [number, number, number]
1678
+ }
1679
+
1680
+ // Three's VertexNormalsHelper default, sRGB red.
1681
+ const NORMALS_HELPER_COLOR: [number, number, number] = [1, 0, 0]
1682
+
1683
+ /**
1684
+ * One line per vertex from its position along its normal, `size` long
1685
+ * (Three's VertexNormalsHelper as a static builder like the other
1686
+ * helpers): 2 vertices per source vertex, "colored" layout in `color`,
1687
+ * drawn by `unlit({ vertexColors: true })` under the same node as the
1688
+ * mesh so the lines follow it. The way to see what computeVertexNormals
1689
+ * and withNormals produced: a crease shows as lines fanning from one
1690
+ * position, a smooth vertex as one line. Any layout carrying aNormal in
1691
+ * any format qualifies; a "skinned" mesh's helper is its rest pose.
1692
+ */
1693
+ export function normalsHelper(geometry: Geometry, options: NormalsHelperOptions = {}): Geometry {
1694
+ let { size = 1, color = NORMALS_HELPER_COLOR } = options
1695
+ let nrm = geometryAttribute(geometry, "aNormal")
1696
+ if (nrm === null) throw new Error("normalsHelper: geometry has no aNormal channel")
1697
+ let pos = geometryAttribute(geometry, "aPos")!
1698
+ let count = geometryVertexCount(geometry, "normalsHelper")
1699
+ let c = premultipliedColor(color)
1700
+ let verts: number[] = []
1701
+ let colors: number[] = []
1702
+ for (let i = 0; i < count; i++) {
1703
+ let x = pos.get(i, 0), y = pos.get(i, 1), z = pos.get(i, 2)
1704
+ let nx = nrm.get(i, 0), ny = nrm.get(i, 1), nz = nrm.get(i, 2)
1705
+ verts.push(x, y, z, nx, ny, nz, 0, 0, x + nx * size, y + ny * size, z + nz * size, nx, ny, nz, 1, 0)
1706
+ colors.push(c[0]!, c[1]!, c[2]!, c[3]!, c[0]!, c[1]!, c[2]!, c[3]!)
1707
+ }
1708
+ return packLines(verts, identityIndices(count * 2), { label: options.label ?? (geometry.label ? geometry.label + "-normals-helper" : undefined), layout: helperLayout(options, "normalsHelper") }, colors)
1709
+ }
1710
+
466
1711
  export type BoxOptions = GeometryOptions & { width?: number; height?: number; depth?: number }
467
1712
 
468
1713
  export function box(options: BoxOptions = {}): Geometry {
@@ -475,7 +1720,7 @@ export function box(options: BoxOptions = {}): Geometry {
475
1720
  type P = [number, number, number]
476
1721
  // Corners a (bottom-left) through d (top-left), CCW seen from outside.
477
1722
  let quad = (a: P, b: P, c: P, d: P, n: P) => {
478
- let base = verts.length / STANDARD_FLOATS
1723
+ let base = verts.length / BASE_FLOATS
479
1724
  let uv = [[0, 1], [1, 1], [1, 0], [0, 0]]
480
1725
  let corners = [a, b, c, d]
481
1726
  for (let i = 0; i < 4; i++) {
@@ -588,17 +1833,20 @@ export function torusKnot(options: TorusKnotOptions = {}): Geometry {
588
1833
  * case); side normals tilt with the taper. Side UVs: u around the
589
1834
  * circumference, v 0 at the top to 1 at the bottom; caps get a planar
590
1835
  * disc map. A zero radius skips that cap and the degenerate side
591
- * triangles at the apex.
1836
+ * triangles at the apex. heightSegments subdivides the side top to
1837
+ * bottom (Three's, default 1) for deformation and per-vertex gradients.
592
1838
  */
593
1839
  export type CylinderOptions = GeometryOptions & {
594
1840
  radiusTop?: number
595
1841
  radiusBottom?: number
596
1842
  height?: number
597
1843
  radialSegments?: number
1844
+ heightSegments?: number
598
1845
  }
599
1846
 
600
1847
  export function cylinder(options: CylinderOptions = {}): Geometry {
601
- let { radiusTop = 0.5, radiusBottom = 0.5, height = 1, radialSegments = 24 } = options
1848
+ let { radiusTop = 0.5, radiusBottom = 0.5, height = 1, radialSegments = 24, heightSegments = 1 } = options
1849
+ if (!Number.isInteger(heightSegments) || heightSegments < 1) throw new Error("cylinder: heightSegments must be a positive integer")
602
1850
  let h = height / 2
603
1851
  let cols = radialSegments + 1
604
1852
  let verts: number[] = []
@@ -606,24 +1854,23 @@ export function cylinder(options: CylinderOptions = {}): Geometry {
606
1854
  let slant = Math.hypot(height, radiusBottom - radiusTop) || 1
607
1855
  let nr = height / slant
608
1856
  let ny = (radiusBottom - radiusTop) / slant
609
- let rows = [
610
- { r: radiusTop, y: h, v: 0 },
611
- { r: radiusBottom, y: -h, v: 1 },
612
- ]
613
- for (let row of rows) {
1857
+ for (let iy = 0; iy <= heightSegments; iy++) {
1858
+ let v = iy / heightSegments
1859
+ let r = radiusTop + (radiusBottom - radiusTop) * v
1860
+ let y = h - height * v
614
1861
  for (let ix = 0; ix < cols; ix++) {
615
1862
  let u = ix / radialSegments
616
1863
  let phi = u * Math.PI * 2
617
1864
  let dx = -Math.cos(phi)
618
1865
  let dz = Math.sin(phi)
619
- verts.push(row.r * dx, row.y, row.r * dz, nr * dx, ny, nr * dz, u, row.v)
1866
+ verts.push(r * dx, y, r * dz, nr * dx, ny, nr * dz, u, v)
620
1867
  }
621
1868
  }
622
- let indices = gridIndices(1, radialSegments, radiusTop <= 0, radiusBottom <= 0)
1869
+ let indices = gridIndices(heightSegments, radialSegments, radiusTop <= 0, radiusBottom <= 0)
623
1870
  // Caps fan around a center vertex; the planar UV map has no seam, so the
624
1871
  // ring wraps with modulo instead of duplicating a column.
625
1872
  let cap = (r: number, y: number, up: number) => {
626
- let base = verts.length / STANDARD_FLOATS
1873
+ let base = verts.length / BASE_FLOATS
627
1874
  verts.push(0, y, 0, 0, up, 0, 0.5, 0.5)
628
1875
  for (let i = 0; i < radialSegments; i++) {
629
1876
  let phi = (i / radialSegments) * Math.PI * 2
@@ -645,13 +1892,75 @@ export function cylinder(options: CylinderOptions = {}): Geometry {
645
1892
  /** A capped cone on the y axis, centered on the origin: `cylinder()` with
646
1893
  * a zero top radius (each apex vertex carries its column's side normal, so
647
1894
  * the surface shades smoothly around). */
648
- export type ConeOptions = GeometryOptions & { radius?: number; height?: number; radialSegments?: number }
1895
+ export type ConeOptions = GeometryOptions & { radius?: number; height?: number; radialSegments?: number; heightSegments?: number }
649
1896
 
650
1897
  export function cone(options: ConeOptions = {}): Geometry {
651
1898
  let { radius = 0.5, height = 1, radialSegments = 24, ...rest } = options
652
1899
  return cylinder({ ...rest, radiusTop: 0, radiusBottom: radius, height, radialSegments })
653
1900
  }
654
1901
 
1902
+ /**
1903
+ * A capsule on the y axis, centered on the origin: a cylinder of `radius`
1904
+ * closed by two hemispheres. `height` is the TOTAL extent pole to pole
1905
+ * like cylinder()'s (Godot's CapsuleMesh and Unity's capsule agree;
1906
+ * Three's `height` is the middle section only, ours minus 2 * radius),
1907
+ * so `height: 2 * radius` is a sphere and anything less throws.
1908
+ * capSegments subdivides each hemisphere pole to equator, radialSegments
1909
+ * the circumference, heightSegments the band (Three's, default 1).
1910
+ * Normals are radial from each cap's center, so the band shades as a
1911
+ * cylinder. UVs: u around, v 0 at the top pole to 1 at the bottom by arc
1912
+ * length. The matching collision volume is
1913
+ * `{ a: [0, -(height / 2 - radius), 0], b: [0, height / 2 - radius, 0], radius }`.
1914
+ */
1915
+ export type CapsuleOptions = GeometryOptions & {
1916
+ radius?: number
1917
+ height?: number
1918
+ capSegments?: number
1919
+ radialSegments?: number
1920
+ heightSegments?: number
1921
+ }
1922
+
1923
+ export function capsule(options: CapsuleOptions = {}): Geometry {
1924
+ let { radius = 0.5, height = 1, capSegments = 8, radialSegments = 24, heightSegments = 1 } = options
1925
+ if (!Number.isInteger(heightSegments) || heightSegments < 1) throw new Error("capsule: heightSegments must be a positive integer")
1926
+ // Half the middle section: each hemisphere's center sits at +-half.
1927
+ let half = height / 2 - radius
1928
+ if (half < 0) throw new Error("capsule: height must be at least 2 * radius")
1929
+ // Arc length pole to pole: two quarter circles and the band.
1930
+ let arc = Math.PI * radius + 2 * half
1931
+ let verts: number[] = []
1932
+ // A sphere() row at polar angle theta, its center shifted along y.
1933
+ let row = (theta: number, offset: number, v: number): void => {
1934
+ let sinT = Math.sin(theta)
1935
+ let cosT = Math.cos(theta)
1936
+ for (let ix = 0; ix <= radialSegments; ix++) {
1937
+ let u = ix / radialSegments
1938
+ let phi = u * Math.PI * 2
1939
+ let nx = -Math.cos(phi) * sinT
1940
+ let ny = cosT
1941
+ let nz = Math.sin(phi) * sinT
1942
+ verts.push(radius * nx, radius * ny + offset, radius * nz, nx, ny, nz, u, v)
1943
+ }
1944
+ }
1945
+ // capSegments + 1 rows per hemisphere; the two equator rows sit at the
1946
+ // ends of the band, with heightSegments - 1 equator rows between them.
1947
+ for (let i = 0; i <= capSegments; i++) {
1948
+ let theta = (i / capSegments) * (Math.PI / 2)
1949
+ row(theta, half, (radius * theta) / arc)
1950
+ }
1951
+ for (let i = 1; i < heightSegments; i++) {
1952
+ let t = i / heightSegments
1953
+ row(Math.PI / 2, half - 2 * half * t, (radius * (Math.PI / 2) + 2 * half * t) / arc)
1954
+ }
1955
+ for (let i = 0; i <= capSegments; i++) {
1956
+ let theta = Math.PI / 2 + (i / capSegments) * (Math.PI / 2)
1957
+ row(theta, -half, (radius * theta + 2 * half) / arc)
1958
+ }
1959
+ // Both pole rows are collapsed to the pole point, as in sphere().
1960
+ let indices = gridIndices(2 * capSegments + heightSegments, radialSegments, true, true)
1961
+ return packGeometry(verts, indices, options)
1962
+ }
1963
+
655
1964
  /**
656
1965
  * A torus lying flat, centered on the origin: the ring lies in the XZ
657
1966
  * plane with the hole on the y axis - the y-up orientation torusKnot also
@@ -770,3 +2079,209 @@ export function sphere(options: SphereOptions = {}): Geometry {
770
2079
  let indices = gridIndices(heightSegments, widthSegments, true, true)
771
2080
  return packGeometry(verts, indices, options)
772
2081
  }
2082
+
2083
+ // The polyhedron family, Three's PolyhedronGeometry: a convex solid's
2084
+ // corner list projected onto its circumsphere. `detail` splits every face
2085
+ // into (detail + 1)^2 triangles before the projection, so one builder is
2086
+ // the flat-shaded solid at 0 and a sphere of uniform triangles above it:
2087
+ // icosahedron({ detail: 3 }) is the icosphere, no pole pinch, which the
2088
+ // lat/long sphere() cannot express. Non-indexed as in Three, every
2089
+ // triangle owning its three vertices, which is what lets the normals
2090
+ // switch with detail: face normals at 0 (a dodecahedron reads as twelve
2091
+ // flat pentagons), radial above (smooth). UVs are the spherical map with
2092
+ // Three's per-triangle seam patch, which stretches toward the poles: the
2093
+ // textured sphere stays sphere(), these serve flat-shaded, low-poly and
2094
+ // procedurally shaded looks. Three's corner tables port verbatim, their
2095
+ // winding already CCW from outside. Godot and Unity ship neither the
2096
+ // family nor an icosphere (their primitives are blockout shapes and
2097
+ // modeling happens in Blender); nothing here touches the collision
2098
+ // volumes, which are analytic.
2099
+
2100
+ // The seam patch, Three's thresholds: a triangle with a u above
2101
+ // UV_SEAM_HIGH and one below UV_SEAM_LOW straddles the u = 0/1 seam, so
2102
+ // its u values below UV_SEAM_WRAP are lifted by a full turn and the
2103
+ // triangle samples across the seam instead of the whole texture backwards.
2104
+ const UV_SEAM_HIGH = 0.9
2105
+ const UV_SEAM_LOW = 0.1
2106
+ const UV_SEAM_WRAP = 0.2
2107
+
2108
+ export type PolyhedronOptions = GeometryOptions & {
2109
+ /** Circumsphere radius, default 0.5 like sphere() (Three's is 1). */
2110
+ radius?: number
2111
+ /** Subdivision: every edge split `detail + 1` ways, a non-negative
2112
+ * integer. Default 0, the flat solid. */
2113
+ detail?: number
2114
+ }
2115
+
2116
+ /**
2117
+ * The generic builder over Three's data form: `vertices` a flat xyz list,
2118
+ * `indices` its triangles, CCW seen from outside. F * (detail + 1)^2
2119
+ * triangles, three vertices each, indices 0..n-1.
2120
+ */
2121
+ export function polyhedron(vertices: ArrayLike<number>, indices: ArrayLike<number>, options: PolyhedronOptions = {}): Geometry {
2122
+ let { radius = 0.5, detail = 0 } = options
2123
+ if (!Number.isInteger(detail) || detail < 0) throw new Error("polyhedron: detail must be a non-negative integer")
2124
+ if (vertices.length % 3 !== 0) throw new Error("polyhedron: vertices must be xyz triples")
2125
+ if (indices.length % 3 !== 0) throw new Error("polyhedron: indices must be triangles")
2126
+ let cols = detail + 1
2127
+ // Unit directions of the subdivided triangles' corners, three per
2128
+ // triangle. A grid point is the integer-weighted mean of its face's
2129
+ // corners (weights summing to cols), always summed in corner order: a
2130
+ // point on a shared edge has one zero weight, so both faces compute the
2131
+ // same two-term sum and it comes out bit-identical, one vertex to the
2132
+ // position weld in edgesGeometry/wireframeGeometry.
2133
+ let dirs: number[] = []
2134
+ let corner = (i: number): Vec3 => [vertices[i * 3]!, vertices[i * 3 + 1]!, vertices[i * 3 + 2]!]
2135
+ for (let t = 0; t < indices.length; t += 3) {
2136
+ let a = corner(indices[t]!)
2137
+ let b = corner(indices[t + 1]!)
2138
+ let c = corner(indices[t + 2]!)
2139
+ let point = (wa: number, wb: number, wc: number): void => {
2140
+ let x = (wa * a[0] + wb * b[0] + wc * c[0]) / cols
2141
+ let y = (wa * a[1] + wb * b[1] + wc * c[1]) / cols
2142
+ let z = (wa * a[2] + wb * b[2] + wc * c[2]) / cols
2143
+ let len = Math.hypot(x, y, z)
2144
+ dirs.push(x / len, y / len, z / len)
2145
+ }
2146
+ // Rows climb from the a-b edge to c, a row runs from a's side to b's;
2147
+ // row r holds span + 1 points with weights (span - j, j, r). Per cell
2148
+ // an upright triangle, and below the last an inverted one - Three's
2149
+ // order, so the two agree vertex for vertex.
2150
+ for (let r = 0; r < cols; r++) {
2151
+ let span = cols - r
2152
+ for (let j = 0; j < span; j++) {
2153
+ point(span - j - 1, j + 1, r)
2154
+ point(span - j - 1, j, r + 1)
2155
+ point(span - j, j, r)
2156
+ if (j < span - 1) {
2157
+ point(span - j - 1, j + 1, r)
2158
+ point(span - j - 2, j + 1, r + 1)
2159
+ point(span - j - 1, j, r + 1)
2160
+ }
2161
+ }
2162
+ }
2163
+ }
2164
+ let count = dirs.length / 3
2165
+ let verts = new Float32Array(count * BASE_FLOATS)
2166
+ for (let i = 0; i < count; i++) {
2167
+ let d = i * 3
2168
+ let x = dirs[d]!
2169
+ let y = dirs[d + 1]!
2170
+ let z = dirs[d + 2]!
2171
+ let o = i * BASE_FLOATS
2172
+ verts[o] = x * radius
2173
+ verts[o + 1] = y * radius
2174
+ verts[o + 2] = z * radius
2175
+ verts[o + 3] = x
2176
+ verts[o + 4] = y
2177
+ verts[o + 5] = z
2178
+ // The spherical map: u the azimuth around y (from -x, counter-clockwise
2179
+ // seen from above), v the inclination, 0 at the top like sphere().
2180
+ verts[o + 6] = Math.atan2(z, -x) / (2 * Math.PI) + 0.5
2181
+ verts[o + 7] = Math.atan2(-y, Math.hypot(x, z)) / Math.PI + 0.5
2182
+ }
2183
+ for (let t = 0; t < count; t += 3) {
2184
+ let o0 = t * BASE_FLOATS
2185
+ let o1 = o0 + BASE_FLOATS
2186
+ let o2 = o1 + BASE_FLOATS
2187
+ // Three's UV fixes, keyed on the triangle's own azimuth: a corner on
2188
+ // the y axis (atan2(0, 0) says nothing) takes it, and on the -x side
2189
+ // of the seam a u of exactly 1 becomes 0 so the triangle stays whole.
2190
+ let azimuth = Math.atan2(verts[o0 + 5]! + verts[o1 + 5]! + verts[o2 + 5]!, -(verts[o0 + 3]! + verts[o1 + 3]! + verts[o2 + 3]!))
2191
+ let centerU = azimuth / (2 * Math.PI) + 0.5
2192
+ for (let o of [o0, o1, o2]) {
2193
+ if (azimuth < 0 && verts[o + 6] === 1) verts[o + 6] = 0
2194
+ if (verts[o + 3] === 0 && verts[o + 5] === 0) verts[o + 6] = centerU
2195
+ }
2196
+ let u0 = verts[o0 + 6]!
2197
+ let u1 = verts[o1 + 6]!
2198
+ let u2 = verts[o2 + 6]!
2199
+ if (Math.max(u0, u1, u2) > UV_SEAM_HIGH && Math.min(u0, u1, u2) < UV_SEAM_LOW) {
2200
+ for (let o of [o0, o1, o2]) if (verts[o + 6]! < UV_SEAM_WRAP) verts[o + 6] = verts[o + 6]! + 1
2201
+ }
2202
+ if (detail === 0) {
2203
+ let p0: Vec3 = [verts[o0]!, verts[o0 + 1]!, verts[o0 + 2]!]
2204
+ let p1: Vec3 = [verts[o1]!, verts[o1 + 1]!, verts[o1 + 2]!]
2205
+ let p2: Vec3 = [verts[o2]!, verts[o2 + 1]!, verts[o2 + 2]!]
2206
+ let n = normalize(cross(sub(p1, p0), sub(p2, p0)))
2207
+ for (let o of [o0, o1, o2]) {
2208
+ verts[o + 3] = n[0]
2209
+ verts[o + 4] = n[1]
2210
+ verts[o + 5] = n[2]
2211
+ }
2212
+ }
2213
+ }
2214
+ let order: number[] = []
2215
+ for (let i = 0; i < count; i++) order.push(i)
2216
+ return packGeometry(verts, order, options)
2217
+ }
2218
+
2219
+ // The golden ratio: the corner coordinate of the icosahedron and the
2220
+ // dodecahedron.
2221
+ const PHI = (1 + Math.sqrt(5)) / 2
2222
+ const PHI_INV = 1 / PHI
2223
+
2224
+ // Three's corner tables: the regular solids centered on the origin, CCW
2225
+ // triangles seen from outside (the dodecahedron's twelve pentagons fanned
2226
+ // into 36).
2227
+ const TETRAHEDRON_VERTICES = [1, 1, 1, -1, -1, 1, -1, 1, -1, 1, -1, -1]
2228
+ const TETRAHEDRON_INDICES = [2, 1, 0, 0, 3, 2, 1, 3, 0, 2, 3, 1]
2229
+ const OCTAHEDRON_VERTICES = [1, 0, 0, -1, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 1, 0, 0, -1]
2230
+ const OCTAHEDRON_INDICES = [0, 2, 4, 0, 4, 3, 0, 3, 5, 0, 5, 2, 1, 2, 5, 1, 5, 3, 1, 3, 4, 1, 4, 2]
2231
+ // prettier-ignore
2232
+ const ICOSAHEDRON_VERTICES = [
2233
+ -1, PHI, 0, 1, PHI, 0, -1, -PHI, 0, 1, -PHI, 0,
2234
+ 0, -1, PHI, 0, 1, PHI, 0, -1, -PHI, 0, 1, -PHI,
2235
+ PHI, 0, -1, PHI, 0, 1, -PHI, 0, -1, -PHI, 0, 1,
2236
+ ]
2237
+ // prettier-ignore
2238
+ const ICOSAHEDRON_INDICES = [
2239
+ 0, 11, 5, 0, 5, 1, 0, 1, 7, 0, 7, 10, 0, 10, 11,
2240
+ 1, 5, 9, 5, 11, 4, 11, 10, 2, 10, 7, 6, 7, 1, 8,
2241
+ 3, 9, 4, 3, 4, 2, 3, 2, 6, 3, 6, 8, 3, 8, 9,
2242
+ 4, 9, 5, 2, 4, 11, 6, 2, 10, 8, 6, 7, 9, 8, 1,
2243
+ ]
2244
+ // prettier-ignore
2245
+ const DODECAHEDRON_VERTICES = [
2246
+ -1, -1, -1, -1, -1, 1, -1, 1, -1, -1, 1, 1,
2247
+ 1, -1, -1, 1, -1, 1, 1, 1, -1, 1, 1, 1,
2248
+ 0, -PHI_INV, -PHI, 0, -PHI_INV, PHI, 0, PHI_INV, -PHI, 0, PHI_INV, PHI,
2249
+ -PHI_INV, -PHI, 0, -PHI_INV, PHI, 0, PHI_INV, -PHI, 0, PHI_INV, PHI, 0,
2250
+ -PHI, 0, -PHI_INV, PHI, 0, -PHI_INV, -PHI, 0, PHI_INV, PHI, 0, PHI_INV,
2251
+ ]
2252
+ // prettier-ignore
2253
+ const DODECAHEDRON_INDICES = [
2254
+ 3, 11, 7, 3, 7, 15, 3, 15, 13,
2255
+ 7, 19, 17, 7, 17, 6, 7, 6, 15,
2256
+ 17, 4, 8, 17, 8, 10, 17, 10, 6,
2257
+ 8, 0, 16, 8, 16, 2, 8, 2, 10,
2258
+ 0, 12, 1, 0, 1, 18, 0, 18, 16,
2259
+ 6, 10, 2, 6, 2, 13, 6, 13, 15,
2260
+ 2, 16, 18, 2, 18, 3, 2, 3, 13,
2261
+ 18, 1, 9, 18, 9, 11, 18, 11, 3,
2262
+ 4, 14, 12, 4, 12, 0, 4, 0, 8,
2263
+ 11, 9, 5, 11, 5, 19, 11, 19, 7,
2264
+ 19, 5, 14, 19, 14, 4, 19, 4, 17,
2265
+ 1, 12, 14, 1, 14, 5, 1, 5, 9,
2266
+ ]
2267
+
2268
+ /** The regular tetrahedron: 4 faces. */
2269
+ export function tetrahedron(options: PolyhedronOptions = {}): Geometry {
2270
+ return polyhedron(TETRAHEDRON_VERTICES, TETRAHEDRON_INDICES, options)
2271
+ }
2272
+
2273
+ /** The regular octahedron: 8 faces, corners on the axes. */
2274
+ export function octahedron(options: PolyhedronOptions = {}): Geometry {
2275
+ return polyhedron(OCTAHEDRON_VERTICES, OCTAHEDRON_INDICES, options)
2276
+ }
2277
+
2278
+ /** The regular icosahedron: 20 faces; with `detail` above 0 the
2279
+ * icosphere. */
2280
+ export function icosahedron(options: PolyhedronOptions = {}): Geometry {
2281
+ return polyhedron(ICOSAHEDRON_VERTICES, ICOSAHEDRON_INDICES, options)
2282
+ }
2283
+
2284
+ /** The regular dodecahedron: 12 pentagons, 36 triangles. */
2285
+ export function dodecahedron(options: PolyhedronOptions = {}): Geometry {
2286
+ return polyhedron(DODECAHEDRON_VERTICES, DODECAHEDRON_INDICES, options)
2287
+ }