@solidrt/3d 0.0.58 → 0.0.60

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 (70) hide show
  1. package/AGENTS.md +734 -264
  2. package/README.md +4 -2
  3. package/examples/README.md +50 -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 +24 -2
  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 +172 -39
  51. package/src/geometry.ts +1573 -239
  52. package/src/glsl.ts +398 -113
  53. package/src/gltf.ts +363 -141
  54. package/src/index.ts +31 -21
  55. package/src/light.ts +7 -8
  56. package/src/lod.ts +148 -0
  57. package/src/material.ts +316 -202
  58. package/src/mesh.ts +490 -256
  59. package/src/mixer.ts +27 -14
  60. package/src/model-file.ts +54 -15
  61. package/src/model.ts +86 -21
  62. package/src/motion.ts +17 -0
  63. package/src/node.ts +421 -22
  64. package/src/orbit.ts +378 -116
  65. package/src/profile.ts +4 -25
  66. package/src/resolve.ts +181 -0
  67. package/src/scene-shadows.ts +94 -93
  68. package/src/scene.ts +605 -185
  69. package/src/sweep.ts +27 -8
  70. package/tools/model.ts +3 -1
package/src/geometry.ts CHANGED
@@ -1,72 +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 { Topology, 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) }
52
111
 
53
- /** Floats per attribute format: the vertex vocabulary itself, so it is
54
- * also the list a declared format must be one of (vertex attributes and
55
- * instance attributes both). */
56
- export const FORMAT_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
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
+ }
155
+
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
+ }
57
219
 
58
220
  /** The attribute list of a layout (a preset name resolves to its list). */
59
221
  export function layoutAttributes(layout?: VertexLayout): VertexAttribute[] {
60
- if (layout === undefined || layout === "standard") return VERTEX_LAYOUTS.standard
222
+ if (layout === undefined || layout === "base") return VERTEX_LAYOUTS.base
61
223
  if (layout === "colored") return VERTEX_LAYOUTS.colored
62
224
  if (layout === "skinned") return VERTEX_LAYOUTS.skinned
63
225
  return layout
64
226
  }
65
227
 
66
- /** Floats per vertex of a layout - its interleave stride. */
228
+ /** Bytes per vertex of a layout - its interleave stride. */
67
229
  export function layoutStride(layout?: VertexLayout): number {
68
230
  let stride = 0
69
- for (let attr of layoutAttributes(layout)) stride += FORMAT_FLOATS[attr.format]
231
+ for (let attr of layoutAttributes(layout)) stride += VERTEX_FORMATS[attr.format].bytes
70
232
  return stride
71
233
  }
72
234
 
@@ -78,27 +240,31 @@ export function layoutKey(layout?: VertexLayout): string {
78
240
  .join(",")
79
241
  }
80
242
 
81
- /** Where an attribute sits in the interleave: its float offset and size.
82
- * Null when the layout does not carry that name. */
83
- 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 {
84
246
  let offset = 0
85
247
  for (let attr of layoutAttributes(layout)) {
86
- let size = FORMAT_FLOATS[attr.format]
87
- if (attr.name === name) return { offset, size, format: attr.format }
88
- 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
89
251
  }
90
252
  return null
91
253
  }
92
254
 
93
- /** The prefix check every layout must pass: standard attributes first, in
94
- * 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. */
95
264
  function checkLayout(layout: VertexAttribute[], where: string): void {
96
- for (let i = 0; i < STANDARD_ATTRIBUTES.length; i++) {
97
- let want = STANDARD_ATTRIBUTES[i]!
98
- let got = layout[i]
99
- if (got === undefined || got.name !== want.name || got.format !== want.format) {
100
- throw new Error(where + ": a layout must start with the standard prefix (aPos vec3, aNormal vec3, aUV vec2)")
101
- }
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")
102
268
  }
103
269
  let seen = new Set<string>()
104
270
  for (let attr of layout) {
@@ -107,11 +273,149 @@ function checkLayout(layout: VertexAttribute[], where: string): void {
107
273
  }
108
274
  }
109
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
+
110
413
  /**
111
- * The structural check for geometry about to draw: the layout passes the
112
- * prefix rule, the vertex float count is a whole number of its stride,
113
- * and indices are present. Throws naming the geometry. The scene runs it
114
- * 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
115
419
  * miscounted array) fails there instead of drawing garbage triangles.
116
420
  * Deliberately no max-index scan: that is O(indices) per add, and the
117
421
  * generators and merge/transform keep indices in range by construction.
@@ -120,12 +424,8 @@ export function validateGeometry(geometry: Geometry): void {
120
424
  let name = geometry.label ? "geometry '" + geometry.label + "'" : "geometry"
121
425
  let layout = geometry.layout
122
426
  if (layout !== undefined && typeof layout !== "string") checkLayout(layout, name)
123
- let stride = layoutStride(layout)
124
- if (geometry.vertices.length % stride !== 0) {
125
- throw new Error(
126
- name + ": " + geometry.vertices.length + " vertex floats is not a whole number of " + stride + "-float (" + layoutKey(layout) + ") vertices",
127
- )
128
- }
427
+ checkStreams(geometry, name)
428
+ geometryVertexCount(geometry, name)
129
429
  let topology = geometryTopology(geometry)
130
430
  let count = geometry.indices.length
131
431
  // Lines and points may be empty: a feature-edge pass over a smooth
@@ -165,9 +465,30 @@ export function geometryTopology(geometry: Geometry): Topology {
165
465
  * channel geometry is built in one pass instead of generate-then-repack. */
166
466
  export type GeometryOptions = { label?: string; layout?: VertexLayout }
167
467
 
168
- /** 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
169
490
  * vertex, or an already-written Float32Array) and indices into a Geometry
170
- * of the requested layout. A wider layout spreads the standard channels
491
+ * of the requested layout. A wider layout spreads the base channels
171
492
  * to its stride, leaving the extra slots zero. */
172
493
  export function packGeometry(
173
494
  verts: ArrayLike<number>,
@@ -175,40 +496,39 @@ export function packGeometry(
175
496
  options: GeometryOptions = {},
176
497
  ): Geometry {
177
498
  let { label, layout } = options
178
- if (verts.length % STANDARD_FLOATS !== 0) {
179
- 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")
180
501
  }
181
- let count = verts.length / STANDARD_FLOATS
502
+ let count = verts.length / BASE_FLOATS
182
503
  let packedIndices = indices instanceof Uint16Array || indices instanceof Uint32Array ? indices : packIndices(indices, count)
183
- let attrs = layoutAttributes(layout)
184
- if (layout !== undefined && typeof layout !== "string") checkLayout(attrs, "packGeometry")
185
- let stride = layoutStride(attrs)
186
- if (stride === STANDARD_FLOATS) {
504
+ let stride = generatorStride(options, "packGeometry")
505
+ if (stride === BASE_FLOATS) {
187
506
  let vertices = verts instanceof Float32Array ? verts : new Float32Array(verts)
188
507
  return layout === undefined ? { vertices, indices: packedIndices, label } : { vertices, indices: packedIndices, layout, label }
189
508
  }
190
509
  let vertices = new Float32Array(count * stride)
191
510
  for (let i = 0; i < count; i++) {
192
- let s = i * STANDARD_FLOATS
511
+ let s = i * BASE_FLOATS
193
512
  let d = i * stride
194
- 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]!
195
514
  }
196
515
  return { vertices, indices: packedIndices, layout, label }
197
516
  }
198
517
 
199
- // The stride a generator writing a Float32Array directly must use for the
200
- // requested layout, and the matching finish (no repack: the data is
201
- // already laid out).
202
- 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 {
203
523
  let { layout } = options
204
524
  let attrs = layoutAttributes(layout)
205
- if (layout !== undefined && typeof layout !== "string") checkLayout(attrs, "generator layout")
206
- return layoutStride(attrs)
525
+ if (layout !== undefined && typeof layout !== "string") checkGeneratorLayout(attrs, where)
526
+ return layoutStride(attrs) / Float32Array.BYTES_PER_ELEMENT
207
527
  }
208
528
 
209
529
  function finishGeometry(vertices: Float32Array, indices: number[], options: GeometryOptions): Geometry {
210
530
  let { label, layout } = options
211
- let packed = packIndices(indices, vertices.length / layoutStride(layout))
531
+ let packed = packIndices(indices, vertexCount(vertices, layout, "generator"))
212
532
  return layout === undefined ? { vertices, indices: packed, label } : { vertices, indices: packed, layout, label }
213
533
  }
214
534
 
@@ -219,12 +539,24 @@ export function packIndices(indices: number[], vertexCount: number): Uint16Array
219
539
  }
220
540
 
221
541
  export type Geometry = {
222
- /** Interleaved [pos.xyz, normal.xyz, uv.xy] per vertex, plus color.rgba
223
- * in the "colored" layout. */
224
- 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
225
548
  /** The array type picks the draw's index format: Uint32Array past 64k
226
549
  * vertices. */
227
550
  indices: Uint16Array | Uint32Array
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[]
228
560
  /** What the indices describe; absent means "triangles". The index
229
561
  * buffer and the primitive it lists travel together (Godot's surface
230
562
  * primitive, Unity's Mesh.SetIndices topology, Three's Line/Points
@@ -239,29 +571,161 @@ export type Geometry = {
239
571
  * wireframeGeometry/edgesGeometry build "lines" over a triangle
240
572
  * geometry's own vertices. */
241
573
  topology?: Topology
242
- /** Vertex layout; absent means "standard". Must match the material's
574
+ /** Vertex layout; absent means "base". Must match the material's
243
575
  * layout - the scene rejects a mismatched pair at add(). */
244
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
245
584
  /** Debug name for the lazily-created GPU buffers. */
246
585
  label?: string
247
586
  _bounds?: Float32Array
248
587
  }
249
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
+
250
711
  /**
251
712
  * The geometry's LOCAL axis-aligned bounds as [minX, minY, minZ, maxX,
252
- * maxY, maxZ], computed from the vertices on first use and cached (like
253
- * the GPU buffers, geometry is treated as immutable after creation).
254
- * Picking's narrowphase volume; a flat geometry legitimately has zero
255
- * 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.
256
720
  */
257
721
  export function geometryBounds(geometry: Geometry): Float32Array {
258
722
  let bounds = geometry._bounds
259
723
  if (bounds === undefined) {
260
724
  bounds = new Float32Array([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity])
261
- let v = geometry.vertices
262
- let stride = layoutStride(geometry.layout)
263
- for (let i = 0; i + 2 < v.length; i += stride) {
264
- 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)
265
729
  if (x < bounds[0]!) bounds[0] = x
266
730
  if (y < bounds[1]!) bounds[1] = y
267
731
  if (z < bounds[2]!) bounds[2] = z
@@ -270,6 +734,11 @@ export function geometryBounds(geometry: Geometry): Float32Array {
270
734
  if (z > bounds[5]!) bounds[5] = z
271
735
  }
272
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
+ }
273
742
  geometry._bounds = bounds
274
743
  }
275
744
  return bounds
@@ -277,125 +746,166 @@ export function geometryBounds(geometry: Geometry): Float32Array {
277
746
 
278
747
  /** Per-vertex values for withAttribute/fillAttribute: a flat array of the
279
748
  * attribute's size per vertex, or a callback deriving each vertex's value
280
- * 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. */
281
751
  export type AttributeFill = ArrayLike<number> | ((index: number, pos: Vec3, normal: Vec3, uv: Vec2) => ArrayLike<number>)
282
752
  /** AttributeFill for the aColor vec4 channel (4 per vertex). */
283
753
  export type ColorFill = AttributeFill
284
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
+
285
762
  /**
286
763
  * Append a named channel to a geometry: a new geometry (the source is
287
764
  * untouched, its GPU buffers stay independent) whose layout is the
288
765
  * source's plus `attr`, every existing channel copied through and the new
289
766
  * slots written from `fill`. This is Three's `geometry.setAttribute` for
290
767
  * an interleaved buffer - the one generic primitive; `withColors` is its
291
- * aColor spelling. A material reads the channel by declaring the matching
292
- * `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.
293
774
  */
294
- export function withAttribute(geometry: Geometry, attr: VertexAttribute, fill: AttributeFill, label?: string): Geometry {
295
- let srcLayout = layoutAttributes(geometry.layout)
296
- 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) {
297
777
  throw new Error("withAttribute: geometry already carries '" + attr.name + "'")
298
778
  }
299
- let srcStride = layoutStride(srcLayout)
300
- if (geometry.vertices.length % srcStride !== 0) {
301
- 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(", ") + ")")
302
781
  }
303
- let layout = [...srcLayout, { name: attr.name, format: attr.format }]
304
- checkLayout(layout, "withAttribute")
305
- let stride = layoutStride(layout)
306
- let count = geometry.vertices.length / srcStride
307
- let src = geometry.vertices
308
- let out = new Float32Array(count * stride)
309
- for (let i = 0; i < count; i++) {
310
- let s = i * srcStride
311
- let d = i * stride
312
- 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")
313
787
  }
314
- fillSlot(out, layout, attr.name, fill, 0)
315
- return {
316
- 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,
317
821
  indices: geometry.indices,
318
822
  topology: geometry.topology,
319
- layout,
320
- label: label ?? (geometry.label ? geometry.label + "-" + attr.name : undefined),
823
+ layout: target === 0 ? layout : geometry.layout,
824
+ label: options.label ?? (geometry.label ? geometry.label + "-" + attr.name : undefined),
321
825
  }
826
+ if (extra.length > 0) out.streams = extra
827
+ return out
322
828
  }
323
829
 
324
830
  /**
325
- * Derive a "colored"-layout geometry from a standard one: the same
326
- * positions, normals, uvs and indices, plus an aColor vec4 per vertex -
327
- * the data channel for materials whose vertex stage reads `in vec4 aColor`
328
- * (a tint, baked ambient occlusion, any four scalars; the name is the
329
- * standard vocabulary, the contents are yours). `withAttribute` with the
330
- * 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.
331
841
  */
332
842
  export function withColors(geometry: Geometry, fill: ColorFill, label?: string): Geometry {
333
- if (layoutSlot(geometry.layout, "aColor") !== null) {
843
+ if (geometrySlot(geometry, "aColor") !== null) {
334
844
  throw new Error("withColors: geometry already carries an aColor channel")
335
845
  }
336
- 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) })
337
847
  if (layoutKey(out.layout) === layoutKey("colored")) out.layout = "colored"
338
848
  return out
339
849
  }
340
850
 
341
851
  /**
342
852
  * The in-place primitive under withAttribute: write one channel the
343
- * geometry's layout already carries (withAttribute ADDS a channel; this
344
- * overwrites an existing one). The pos/normal/uv the callback receives
345
- * are read from the buffer itself, so a builder baking transforms while
346
- * writing hands the baker world-space vertices. Fills vertices
347
- * [first, first + count) - count defaults to the rest of the buffer -
348
- * and `fill` indexes relative to `first`, so a per-part callback works
349
- * 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).
350
861
  */
351
- export function fillAttribute(geometry: Geometry, name: string, fill: AttributeFill, first = 0, count?: number): Float32Array {
352
- 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
353
868
  }
354
869
 
355
- /** The raw form behind fillAttribute (withAttribute writes its fresh
356
- * buffer through it, before the Geometry exists): a bare array carries no
357
- * layout tag, so the caller states the layout and only the arithmetic is
358
- * checked. */
359
- function fillSlot(vertices: Float32Array, layout: VertexLayout | undefined, name: string, fill: AttributeFill, first: number, count?: number): Float32Array {
360
- let slot = layoutSlot(layout, name)
361
- if (slot === null) throw new Error("fillAttribute: layout has no '" + name + "' attribute")
362
- let stride = layoutStride(layout)
363
- if (vertices.length % stride !== 0) {
364
- throw new Error("fillAttribute: vertex data is not a whole number of " + layoutKey(layout) + " vertices")
365
- }
366
- 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 {
367
875
  let n = count ?? total - first
368
876
  if (!Number.isInteger(first) || !Number.isInteger(n) || first < 0 || n < 0 || first + n > total) {
369
877
  throw new Error("fillAttribute: range [" + first + ", " + (first + n) + ") is outside the buffer's " + total + " vertices")
370
878
  }
371
- let size = slot.size
879
+ let size = slot.components
372
880
  let fn = typeof fill === "function" ? fill : null
373
881
  let flat = typeof fill === "function" ? null : fill
374
882
  if (flat !== null && flat.length !== n * size) {
375
- 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 + ")")
376
884
  }
885
+ let pos = geometryAttribute(source, "aPos")!
886
+ let nrm = geometryAttribute(source, "aNormal") ?? ZERO_ACCESS
887
+ let uv = geometryAttribute(source, "aUV") ?? ZERO_ACCESS
377
888
  for (let i = 0; i < n; i++) {
378
- let d = (first + i) * stride
889
+ let v = first + i
379
890
  let value: ArrayLike<number>
380
891
  let s: number
381
892
  if (fn !== null) {
382
- 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)])
383
894
  s = 0
384
895
  if (value.length !== size) {
385
- 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)
386
897
  }
387
898
  } else {
388
899
  value = flat!
389
900
  s = i * size
390
901
  }
391
- 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]!)
392
903
  }
393
- return vertices
394
904
  }
395
905
 
396
906
  /** `fillAttribute` for the aColor channel of a color-carrying geometry
397
907
  * (fill is 4 per vertex). */
398
- 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 {
399
909
  return fillAttribute(geometry, "aColor", fill, first, count)
400
910
  }
401
911
 
@@ -404,9 +914,11 @@ export function fillColors(geometry: Geometry, fill: ColorFill, first = 0, count
404
914
  * quaternion, not both; number = uniform scale; absent = identity) into a
405
915
  * geometry: a new geometry (the source is
406
916
  * untouched, its GPU buffers stay independent) whose positions are moved
407
- * by the transform and whose normals follow through the inverse-transpose,
408
- * renormalized - correct under non-uniform scale. UVs, colors, indices and
409
- * 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
410
922
  * half of authoring a static scene as data: transform each part into place,
411
923
  * mergeGeometries the parts, draw one mesh.
412
924
  */
@@ -417,33 +929,34 @@ export function transformGeometry(geometry: Geometry, transform: TransformUpdate
417
929
  if (transform.scale !== undefined) updateScale(scl, transform.scale)
418
930
  let m = compose(mat4(), transform.position ?? [0, 0, 0], rot, scl)
419
931
  let n = normalMatrix(mat4(), m)
420
- let stride = layoutStride(geometry.layout)
421
- let src = geometry.vertices
422
- if (src.length % stride !== 0) {
423
- throw new Error("transformGeometry: vertex data is not a whole number of " + layoutKey(geometry.layout) + " vertices")
424
- }
425
- let out = new Float32Array(src)
426
- for (let i = 0; i < out.length; i += stride) {
427
- let x = src[i]!, y = src[i + 1]!, z = src[i + 2]!
428
- out[i] = m[0] * x + m[4] * y + m[8] * z + m[12]
429
- out[i + 1] = m[1] * x + m[5] * y + m[9] * z + m[13]
430
- out[i + 2] = m[2] * x + m[6] * y + m[10] * z + m[14]
431
- 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)
432
943
  let tx = n[0] * nx + n[4] * ny + n[8] * nz
433
944
  let ty = n[1] * nx + n[5] * ny + n[9] * nz
434
945
  let tz = n[2] * nx + n[6] * ny + n[10] * nz
435
946
  let len = Math.hypot(tx, ty, tz) || 1
436
- out[i + 3] = tx / len
437
- out[i + 4] = ty / len
438
- 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)
439
950
  }
440
- return {
951
+ let result: Geometry = {
441
952
  vertices: out,
442
953
  indices: geometry.indices,
443
954
  topology: geometry.topology,
444
955
  layout: geometry.layout,
445
956
  label: label ?? (geometry.label ? geometry.label + "-transformed" : undefined),
446
957
  }
958
+ if (geometry.streams !== undefined) result.streams = geometry.streams
959
+ return result
447
960
  }
448
961
 
449
962
  /**
@@ -460,45 +973,49 @@ export function transformGeometry(geometry: Geometry, transform: TransformUpdate
460
973
  */
461
974
  export function mergeGeometries(parts: Geometry[], label?: string): Geometry {
462
975
  if (parts.length === 0) throw new Error("mergeGeometries: no parts")
463
- let layout = parts[0]!.layout
464
- let key = layoutKey(layout)
465
- let stride = layoutStride(layout)
466
- let topology = geometryTopology(parts[0]!)
976
+ let first = parts[0]!
977
+ let key = geometryKey(first)
978
+ let topology = geometryTopology(first)
467
979
  if (topology === "line-strip" || topology === "triangle-strip") throw new Error("mergeGeometries: cannot merge " + topology + " geometry")
468
- let floats = 0
980
+ let vertexTotal = 0
469
981
  let indexCount = 0
470
982
  for (let part of parts) {
471
- if (layoutKey(part.layout) !== key) {
472
- 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) + ")")
473
985
  }
474
986
  if (geometryTopology(part) !== topology) {
475
987
  throw new Error("mergeGeometries: mixed topologies (" + topology + " and " + geometryTopology(part) + ")")
476
988
  }
477
- if (part.vertices.length % stride !== 0) {
478
- throw new Error("mergeGeometries: a part's vertex data is not a whole number of " + key + " vertices")
479
- }
480
- floats += part.vertices.length
989
+ vertexTotal += geometryVertexCount(part, "mergeGeometries")
481
990
  indexCount += part.indices.length
482
991
  }
483
- let vertexCount = floats / stride
484
- let vertices = new Float32Array(floats)
485
- let indices = vertexCount > 65535 ? new Uint32Array(indexCount) : new Uint16Array(indexCount)
486
- 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
487
1002
  let iOffset = 0
488
1003
  for (let part of parts) {
489
- vertices.set(part.vertices, vOffset)
490
- 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)
491
1006
  let src = part.indices
492
1007
  for (let i = 0; i < src.length; i++) indices[iOffset + i] = src[i]! + base
493
- vOffset += part.vertices.length
1008
+ base += geometryVertexCount(part, "mergeGeometries")
494
1009
  iOffset += src.length
495
1010
  }
496
- return { vertices, indices, topology: parts[0]!.topology, 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
497
1014
  }
498
1015
 
499
- /** Positions closer than this (model units) are one vertex to the edge
500
- * builders: a uv seam or a per-face normal split duplicates a position,
501
- * and an edge is the same edge whichever copy a triangle names. */
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. */
502
1019
  const WELD_PRECISION = 1e-4
503
1020
 
504
1021
  /** Degrees between two faces' normals from which the edge between them
@@ -506,18 +1023,27 @@ const WELD_PRECISION = 1e-4
506
1023
  * one smooth surface and the edge stays hidden. Three's default. */
507
1024
  const EDGES_THRESHOLD_ANGLE = 1
508
1025
 
509
- /** One id per vertex, shared by every vertex at the same position (to
510
- * WELD_PRECISION) whatever its normal and uv; plus the id count. */
511
- function weldPositions(geometry: Geometry): { ids: Uint32Array; count: number } {
512
- let v = geometry.vertices
513
- let stride = layoutStride(geometry.layout)
514
- let count = v.length / stride
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)
515
1039
  let ids = new Uint32Array(count)
516
1040
  let seen = new Map<string, number>()
517
- let scale = 1 / WELD_PRECISION
1041
+ let scale = 1 / precision
518
1042
  for (let i = 0; i < count; i++) {
519
- let b = i * stride
520
- let key = Math.round(v[b]! * scale) + "," + Math.round(v[b + 1]! * scale) + "," + Math.round(v[b + 2]! * scale)
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
+ }
521
1047
  let id = seen.get(key)
522
1048
  if (id === undefined) {
523
1049
  id = seen.size
@@ -528,6 +1054,19 @@ function weldPositions(geometry: Geometry): { ids: Uint32Array; count: number }
528
1054
  return { ids, count: seen.size }
529
1055
  }
530
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
+
531
1070
  /** The edge table of a triangle geometry, welded by position, as a lines
532
1071
  * index list: every edge once for `cosThreshold` null, else the feature
533
1072
  * edges - those with one face (a border) or whose two faces' normals
@@ -537,18 +1076,16 @@ function edgeIndices(geometry: Geometry, name: string, cosThreshold: number | nu
537
1076
  if (geometryTopology(geometry) !== "triangles") {
538
1077
  throw new Error(name + ": needs a triangle geometry, got " + geometryTopology(geometry))
539
1078
  }
540
- let v = geometry.vertices
541
- let stride = layoutStride(geometry.layout)
542
- let weld = weldPositions(geometry)
1079
+ let pos = geometryAttribute(geometry, "aPos")!
1080
+ let weld = weldPositions(geometry, name)
543
1081
  let src = geometry.indices
544
1082
  type Edge = { a: number; b: number; nx: number; ny: number; nz: number; faces: number; sharp: boolean }
545
1083
  let edges = new Map<number, Edge>()
546
1084
  for (let t = 0; t + 2 < src.length; t += 3) {
547
1085
  let i0 = src[t]!, i1 = src[t + 1]!, i2 = src[t + 2]!
548
- let p0 = i0 * stride, p1 = i1 * stride, p2 = i2 * stride
549
1086
  // The face normal, for the feature test.
550
- let ux = v[p1]! - v[p0]!, uy = v[p1 + 1]! - v[p0 + 1]!, uz = v[p1 + 2]! - v[p0 + 2]!
551
- let wx = v[p2]! - v[p0]!, wy = v[p2 + 1]! - v[p0 + 1]!, wz = v[p2 + 2]! - v[p0 + 2]!
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)
552
1089
  let nx = uy * wz - uz * wy, ny = uz * wx - ux * wz, nz = ux * wy - uy * wx
553
1090
  let len = Math.hypot(nx, ny, nz) || 1
554
1091
  nx /= len
@@ -573,7 +1110,7 @@ function edgeIndices(geometry: Geometry, name: string, cosThreshold: number | nu
573
1110
  for (let e of edges.values()) {
574
1111
  if (cosThreshold === null || e.faces === 1 || e.sharp) out.push(e.a, e.b)
575
1112
  }
576
- return packIndices(out, v.length / stride)
1113
+ return packIndices(out, weld.ids.length)
577
1114
  }
578
1115
 
579
1116
  /**
@@ -589,13 +1126,15 @@ function edgeIndices(geometry: Geometry, name: string, cosThreshold: number | nu
589
1126
  * geometry. Lines are one pixel wide.
590
1127
  */
591
1128
  export function wireframeGeometry(geometry: Geometry, label?: string): Geometry {
592
- return {
593
- vertices: geometry.vertices,
594
- indices: edgeIndices(geometry, "wireframeGeometry", null),
595
- topology: "lines",
596
- layout: geometry.layout,
597
- label: label ?? (geometry.label ? geometry.label + "-wireframe" : undefined),
598
- }
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
599
1138
  }
600
1139
 
601
1140
  /**
@@ -614,13 +1153,499 @@ export function wireframeGeometry(geometry: Geometry, label?: string): Geometry
614
1153
  * wireframeGeometry.
615
1154
  */
616
1155
  export function edgesGeometry(geometry: Geometry, thresholdAngle = EDGES_THRESHOLD_ANGLE, label?: string): Geometry {
617
- return {
618
- vertices: geometry.vertices,
619
- indices: edgeIndices(geometry, "edgesGeometry", Math.cos((thresholdAngle * Math.PI) / 180)),
620
- topology: "lines",
621
- layout: geometry.layout,
622
- label: label ?? (geometry.label ? geometry.label + "-edges" : undefined),
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
623
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)
624
1649
  }
625
1650
 
626
1651
  // Indices for a row-major (cellRows + 1) x (cellCols + 1) vertex grid: two
@@ -644,6 +1669,45 @@ function gridIndices(cellRows: number, cellCols: number, skipFirst = false, skip
644
1669
  }
645
1670
 
646
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
+
647
1711
  export type BoxOptions = GeometryOptions & { width?: number; height?: number; depth?: number }
648
1712
 
649
1713
  export function box(options: BoxOptions = {}): Geometry {
@@ -656,7 +1720,7 @@ export function box(options: BoxOptions = {}): Geometry {
656
1720
  type P = [number, number, number]
657
1721
  // Corners a (bottom-left) through d (top-left), CCW seen from outside.
658
1722
  let quad = (a: P, b: P, c: P, d: P, n: P) => {
659
- let base = verts.length / STANDARD_FLOATS
1723
+ let base = verts.length / BASE_FLOATS
660
1724
  let uv = [[0, 1], [1, 1], [1, 0], [0, 0]]
661
1725
  let corners = [a, b, c, d]
662
1726
  for (let i = 0; i < 4; i++) {
@@ -769,17 +1833,20 @@ export function torusKnot(options: TorusKnotOptions = {}): Geometry {
769
1833
  * case); side normals tilt with the taper. Side UVs: u around the
770
1834
  * circumference, v 0 at the top to 1 at the bottom; caps get a planar
771
1835
  * disc map. A zero radius skips that cap and the degenerate side
772
- * 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.
773
1838
  */
774
1839
  export type CylinderOptions = GeometryOptions & {
775
1840
  radiusTop?: number
776
1841
  radiusBottom?: number
777
1842
  height?: number
778
1843
  radialSegments?: number
1844
+ heightSegments?: number
779
1845
  }
780
1846
 
781
1847
  export function cylinder(options: CylinderOptions = {}): Geometry {
782
- 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")
783
1850
  let h = height / 2
784
1851
  let cols = radialSegments + 1
785
1852
  let verts: number[] = []
@@ -787,24 +1854,23 @@ export function cylinder(options: CylinderOptions = {}): Geometry {
787
1854
  let slant = Math.hypot(height, radiusBottom - radiusTop) || 1
788
1855
  let nr = height / slant
789
1856
  let ny = (radiusBottom - radiusTop) / slant
790
- let rows = [
791
- { r: radiusTop, y: h, v: 0 },
792
- { r: radiusBottom, y: -h, v: 1 },
793
- ]
794
- 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
795
1861
  for (let ix = 0; ix < cols; ix++) {
796
1862
  let u = ix / radialSegments
797
1863
  let phi = u * Math.PI * 2
798
1864
  let dx = -Math.cos(phi)
799
1865
  let dz = Math.sin(phi)
800
- 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)
801
1867
  }
802
1868
  }
803
- let indices = gridIndices(1, radialSegments, radiusTop <= 0, radiusBottom <= 0)
1869
+ let indices = gridIndices(heightSegments, radialSegments, radiusTop <= 0, radiusBottom <= 0)
804
1870
  // Caps fan around a center vertex; the planar UV map has no seam, so the
805
1871
  // ring wraps with modulo instead of duplicating a column.
806
1872
  let cap = (r: number, y: number, up: number) => {
807
- let base = verts.length / STANDARD_FLOATS
1873
+ let base = verts.length / BASE_FLOATS
808
1874
  verts.push(0, y, 0, 0, up, 0, 0.5, 0.5)
809
1875
  for (let i = 0; i < radialSegments; i++) {
810
1876
  let phi = (i / radialSegments) * Math.PI * 2
@@ -826,13 +1892,75 @@ export function cylinder(options: CylinderOptions = {}): Geometry {
826
1892
  /** A capped cone on the y axis, centered on the origin: `cylinder()` with
827
1893
  * a zero top radius (each apex vertex carries its column's side normal, so
828
1894
  * the surface shades smoothly around). */
829
- export type ConeOptions = GeometryOptions & { radius?: number; height?: number; radialSegments?: number }
1895
+ export type ConeOptions = GeometryOptions & { radius?: number; height?: number; radialSegments?: number; heightSegments?: number }
830
1896
 
831
1897
  export function cone(options: ConeOptions = {}): Geometry {
832
1898
  let { radius = 0.5, height = 1, radialSegments = 24, ...rest } = options
833
1899
  return cylinder({ ...rest, radiusTop: 0, radiusBottom: radius, height, radialSegments })
834
1900
  }
835
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
+
836
1964
  /**
837
1965
  * A torus lying flat, centered on the origin: the ring lies in the XZ
838
1966
  * plane with the hole on the y axis - the y-up orientation torusKnot also
@@ -951,3 +2079,209 @@ export function sphere(options: SphereOptions = {}): Geometry {
951
2079
  let indices = gridIndices(heightSegments, widthSegments, true, true)
952
2080
  return packGeometry(verts, indices, options)
953
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
+ }