@solidrt/3d 0.0.51 → 0.0.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/geometry.ts CHANGED
@@ -1,9 +1,14 @@
1
- // Geometry: interleaved vertex data in one of two named layouts - the
2
- // "standard" position vec3, normal vec3, uv vec2 (8 floats per vertex)
3
- // every generator emits, and "colored", which appends an aColor vec4
4
- // (12 floats, derived with withColors) as the per-vertex data channel for
5
- // custom materials (tint, baked AO, any four scalars) - plus
6
- // indices, uint16 or uint32 (the generators here emit uint16; hand-built
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
7
12
  // geometry past 64k vertices uses a Uint32Array and the draw entry follows
8
13
  // the array type). Winding is counter-clockwise seen from outside in the
9
14
  // y-up world, which the standard camera rig (perspective() with its baked
@@ -18,9 +23,12 @@
18
23
 
19
24
  import type { VertexAttribute } from "@solidrt/core/gpu"
20
25
  import { add, compose, cross, mat4, normalize, normalMatrix, sub, updateRotation, updateScale } from "./math.ts"
21
- import type { Quat, TransformUpdate, Vec2, Vec3, Vec4 } from "./math.ts"
26
+ import type { Quat, TransformUpdate, Vec2, Vec3 } from "./math.ts"
22
27
 
23
- export type VertexLayout = "standard" | "colored"
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" | VertexAttribute[]
24
32
 
25
33
  const STANDARD_ATTRIBUTES: VertexAttribute[] = [
26
34
  { name: "aPos", format: "vec3" },
@@ -28,17 +36,146 @@ const STANDARD_ATTRIBUTES: VertexAttribute[] = [
28
36
  { name: "aUV", format: "vec2" },
29
37
  ]
30
38
 
31
- /** The pipeline attribute list for each named layout. A deliberately small
32
- * set (not an open per-geometry model): every layout shares the standard
33
- * prefix, so one shader vocabulary serves all of them. */
34
- export const VERTEX_LAYOUTS: Record<VertexLayout, VertexAttribute[]> = {
39
+ /** 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", VertexAttribute[]> = {
35
42
  standard: STANDARD_ATTRIBUTES,
36
43
  colored: [...STANDARD_ATTRIBUTES, { name: "aColor", format: "vec4" }],
37
44
  }
38
45
 
39
- /** Floats per vertex in the "standard" layout (what every generator emits). */
40
- export const FLOATS_PER_VERTEX = 8
41
- const COLORED_FLOATS = 12
46
+ /** Floats per vertex in the "standard" layout (the generators' own write
47
+ * format before packing). */
48
+ export const STANDARD_FLOATS = 8
49
+
50
+ const FORMAT_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
51
+
52
+ /** The attribute list of a layout (a preset name resolves to its list). */
53
+ export function layoutAttributes(layout?: VertexLayout): VertexAttribute[] {
54
+ if (layout === undefined || layout === "standard") return VERTEX_LAYOUTS.standard
55
+ if (layout === "colored") return VERTEX_LAYOUTS.colored
56
+ return layout
57
+ }
58
+
59
+ /** Floats per vertex of a layout - its interleave stride. */
60
+ export function layoutStride(layout?: VertexLayout): number {
61
+ let stride = 0
62
+ for (let attr of layoutAttributes(layout)) stride += FORMAT_FLOATS[attr.format]
63
+ return stride
64
+ }
65
+
66
+ /** A layout's identity as a string (name:format per attribute, in order):
67
+ * two layouts with equal keys interleave identically. */
68
+ export function layoutKey(layout?: VertexLayout): string {
69
+ return layoutAttributes(layout)
70
+ .map(a => a.name + ":" + a.format)
71
+ .join(",")
72
+ }
73
+
74
+ /** Where an attribute sits in the interleave: its float offset and size.
75
+ * Null when the layout does not carry that name. */
76
+ export function layoutSlot(layout: VertexLayout | undefined, name: string): { offset: number; size: number; format: VertexAttribute["format"] } | null {
77
+ let offset = 0
78
+ for (let attr of layoutAttributes(layout)) {
79
+ let size = FORMAT_FLOATS[attr.format]
80
+ if (attr.name === name) return { offset, size, format: attr.format }
81
+ offset += size
82
+ }
83
+ return null
84
+ }
85
+
86
+ /** The prefix check every layout must pass: standard attributes first, in
87
+ * order, and no duplicate names after them. */
88
+ function checkLayout(layout: VertexAttribute[], where: string): void {
89
+ for (let i = 0; i < STANDARD_ATTRIBUTES.length; i++) {
90
+ let want = STANDARD_ATTRIBUTES[i]!
91
+ let got = layout[i]
92
+ if (got === undefined || got.name !== want.name || got.format !== want.format) {
93
+ throw new Error(where + ": a layout must start with the standard prefix (aPos vec3, aNormal vec3, aUV vec2)")
94
+ }
95
+ }
96
+ let seen = new Set<string>()
97
+ for (let attr of layout) {
98
+ if (seen.has(attr.name)) throw new Error(where + ": duplicate attribute '" + attr.name + "'")
99
+ seen.add(attr.name)
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The structural check for geometry about to draw: the layout passes the
105
+ * prefix rule, the vertex float count is a whole number of its stride,
106
+ * and indices are present. Throws naming the geometry. The scene runs it
107
+ * at add() so hand-built geometry (a bare `layout: "colored"` over a
108
+ * miscounted array) fails there instead of drawing garbage triangles.
109
+ * Deliberately no max-index scan: that is O(indices) per add, and the
110
+ * generators and merge/transform keep indices in range by construction.
111
+ */
112
+ export function validateGeometry(geometry: Geometry): void {
113
+ let name = geometry.label ? "geometry '" + geometry.label + "'" : "geometry"
114
+ let layout = geometry.layout
115
+ if (layout !== undefined && typeof layout !== "string") checkLayout(layout, name)
116
+ let stride = layoutStride(layout)
117
+ if (geometry.vertices.length % stride !== 0) {
118
+ throw new Error(
119
+ name + ": " + geometry.vertices.length + " vertex floats is not a whole number of " + stride + "-float (" + layoutKey(layout) + ") vertices",
120
+ )
121
+ }
122
+ if (geometry.indices.length === 0) throw new Error(name + ": no indices")
123
+ }
124
+
125
+ /** The options every generator shares (each generator's own options type
126
+ * extends this with its dimensions, all optional with defaults): `label`
127
+ * for the GPU buffers, and `layout` to emit vertices in a wider layout
128
+ * directly (the standard channels written, the extra slots zeroed for
129
+ * fillAttribute/fillColors to write in place), so colored or custom
130
+ * channel geometry is built in one pass instead of generate-then-repack. */
131
+ export type GeometryOptions = { label?: string; layout?: VertexLayout }
132
+
133
+ /** The generator tail: pack standard-layout vertices (number[] of 8 per
134
+ * vertex, or an already-written Float32Array) and indices into a Geometry
135
+ * of the requested layout. A wider layout spreads the standard channels
136
+ * to its stride, leaving the extra slots zero. */
137
+ export function packGeometry(
138
+ verts: ArrayLike<number>,
139
+ indices: number[] | Uint16Array | Uint32Array,
140
+ options: GeometryOptions = {},
141
+ ): Geometry {
142
+ let { label, layout } = options
143
+ if (verts.length % STANDARD_FLOATS !== 0) {
144
+ throw new Error("packGeometry: vertex data is not a whole number of standard-layout vertices")
145
+ }
146
+ let count = verts.length / STANDARD_FLOATS
147
+ let packedIndices = indices instanceof Uint16Array || indices instanceof Uint32Array ? indices : packIndices(indices, count)
148
+ let attrs = layoutAttributes(layout)
149
+ if (layout !== undefined && typeof layout !== "string") checkLayout(attrs, "packGeometry")
150
+ let stride = layoutStride(attrs)
151
+ if (stride === STANDARD_FLOATS) {
152
+ let vertices = verts instanceof Float32Array ? verts : new Float32Array(verts)
153
+ return layout === undefined ? { vertices, indices: packedIndices, label } : { vertices, indices: packedIndices, layout, label }
154
+ }
155
+ let vertices = new Float32Array(count * stride)
156
+ for (let i = 0; i < count; i++) {
157
+ let s = i * STANDARD_FLOATS
158
+ let d = i * stride
159
+ for (let k = 0; k < STANDARD_FLOATS; k++) vertices[d + k] = verts[s + k]!
160
+ }
161
+ return { vertices, indices: packedIndices, layout, label }
162
+ }
163
+
164
+ // The stride a generator writing a Float32Array directly must use for the
165
+ // requested layout, and the matching finish (no repack: the data is
166
+ // already laid out).
167
+ function generatorStride(options: GeometryOptions): number {
168
+ let { layout } = options
169
+ let attrs = layoutAttributes(layout)
170
+ if (layout !== undefined && typeof layout !== "string") checkLayout(attrs, "generator layout")
171
+ return layoutStride(attrs)
172
+ }
173
+
174
+ function finishGeometry(vertices: Float32Array, indices: number[], options: GeometryOptions): Geometry {
175
+ let { label, layout } = options
176
+ let packed = packIndices(indices, vertices.length / layoutStride(layout))
177
+ return layout === undefined ? { vertices, indices: packed, label } : { vertices, indices: packed, layout, label }
178
+ }
42
179
 
43
180
  /** Uint16 indices when they fit, Uint32Array past 64k vertices - the draw
44
181
  * entry follows the array type. The tail of every unbounded generator. */
@@ -73,7 +210,7 @@ export function geometryBounds(geometry: Geometry): Float32Array {
73
210
  if (bounds === undefined) {
74
211
  bounds = new Float32Array([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity])
75
212
  let v = geometry.vertices
76
- let stride = geometry.layout === "colored" ? COLORED_FLOATS : FLOATS_PER_VERTEX
213
+ let stride = layoutStride(geometry.layout)
77
214
  for (let i = 0; i + 2 < v.length; i += stride) {
78
215
  let x = v[i]!, y = v[i + 1]!, z = v[i + 2]!
79
216
  if (x < bounds[0]!) bounds[0] = x
@@ -89,86 +226,129 @@ export function geometryBounds(geometry: Geometry): Float32Array {
89
226
  return bounds
90
227
  }
91
228
 
92
- /** Per-vertex aColor values for withColors/fillColors: a flat 4-per-vertex
93
- * array, or a callback deriving each vertex's vec4 from the vertex data. */
94
- export type ColorFill = ArrayLike<number> | ((index: number, pos: Vec3, normal: Vec3, uv: Vec2) => Vec4)
229
+ /** Per-vertex values for withAttribute/fillAttribute: a flat array of the
230
+ * attribute's size per vertex, or a callback deriving each vertex's value
231
+ * from the standard channels (what a baker wants). */
232
+ export type AttributeFill = ArrayLike<number> | ((index: number, pos: Vec3, normal: Vec3, uv: Vec2) => ArrayLike<number>)
233
+ /** AttributeFill for the aColor vec4 channel (4 per vertex). */
234
+ export type ColorFill = AttributeFill
95
235
 
96
236
  /**
97
- * Derive a "colored"-layout geometry from a standard one: the same
98
- * positions, normals, uvs and indices, plus an aColor vec4 per vertex -
99
- * the data channel for materials whose vertex stage reads `in vec4 aColor`
100
- * (a tint, baked ambient occlusion, any four scalars; the name is the
101
- * standard vocabulary, the contents are yours). The callback form receives
102
- * each vertex's position, normal and uv - what a baker wants. The source
103
- * geometry is untouched and its GPU buffers stay independent.
237
+ * Append a named channel to a geometry: a new geometry (the source is
238
+ * untouched, its GPU buffers stay independent) whose layout is the
239
+ * source's plus `attr`, every existing channel copied through and the new
240
+ * slots written from `fill`. This is Three's `geometry.setAttribute` for
241
+ * an interleaved buffer - the one generic primitive; `withColors` is its
242
+ * aColor spelling. A material reads the channel by declaring the matching
243
+ * `in` (name and format) in its vertex stage.
104
244
  */
105
- export function withColors(geometry: Geometry, fill: ColorFill, label?: string): Geometry {
106
- if (geometry.layout === "colored") {
107
- throw new Error("withColors: geometry already carries an aColor channel")
108
- }
109
- if (geometry.vertices.length % FLOATS_PER_VERTEX !== 0) {
110
- throw new Error("withColors: vertex data is not a whole number of standard-layout vertices")
245
+ export function withAttribute(geometry: Geometry, attr: VertexAttribute, fill: AttributeFill, label?: string): Geometry {
246
+ let srcLayout = layoutAttributes(geometry.layout)
247
+ if (layoutSlot(srcLayout, attr.name) !== null) {
248
+ throw new Error("withAttribute: geometry already carries '" + attr.name + "'")
111
249
  }
112
- let count = geometry.vertices.length / FLOATS_PER_VERTEX
113
- if (typeof fill !== "function" && fill.length !== count * 4) {
114
- throw new Error("withColors: fill has " + fill.length + " floats, expected 4 per vertex (" + count * 4 + ")")
250
+ let srcStride = layoutStride(srcLayout)
251
+ if (geometry.vertices.length % srcStride !== 0) {
252
+ throw new Error("withAttribute: vertex data is not a whole number of " + layoutKey(srcLayout) + " vertices")
115
253
  }
254
+ let layout = [...srcLayout, { name: attr.name, format: attr.format }]
255
+ checkLayout(layout, "withAttribute")
256
+ let stride = layoutStride(layout)
257
+ let count = geometry.vertices.length / srcStride
116
258
  let src = geometry.vertices
117
- let out = new Float32Array(count * COLORED_FLOATS)
259
+ let out = new Float32Array(count * stride)
118
260
  for (let i = 0; i < count; i++) {
119
- let s = i * FLOATS_PER_VERTEX
120
- let d = i * COLORED_FLOATS
121
- for (let k = 0; k < FLOATS_PER_VERTEX; k++) out[d + k] = src[s + k]!
261
+ let s = i * srcStride
262
+ let d = i * stride
263
+ for (let k = 0; k < srcStride; k++) out[d + k] = src[s + k]!
122
264
  }
123
- fillColors(out, fill)
265
+ fillSlot(out, layout, attr.name, fill, 0)
124
266
  return {
125
267
  vertices: out,
126
268
  indices: geometry.indices,
127
- layout: "colored",
128
- label: label ?? (geometry.label ? geometry.label + "-colored" : undefined),
269
+ layout,
270
+ label: label ?? (geometry.label ? geometry.label + "-" + attr.name : undefined),
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Derive a "colored"-layout geometry from a standard one: the same
276
+ * positions, normals, uvs and indices, plus an aColor vec4 per vertex -
277
+ * the data channel for materials whose vertex stage reads `in vec4 aColor`
278
+ * (a tint, baked ambient occlusion, any four scalars; the name is the
279
+ * standard vocabulary, the contents are yours). `withAttribute` with the
280
+ * aColor channel; the "colored" preset name is kept on the result.
281
+ */
282
+ export function withColors(geometry: Geometry, fill: ColorFill, label?: string): Geometry {
283
+ if (layoutSlot(geometry.layout, "aColor") !== null) {
284
+ throw new Error("withColors: geometry already carries an aColor channel")
129
285
  }
286
+ let out = withAttribute(geometry, { name: "aColor", format: "vec4" }, fill, label ?? (geometry.label ? geometry.label + "-colored" : undefined))
287
+ if (layoutKey(out.layout) === layoutKey("colored")) out.layout = "colored"
288
+ return out
130
289
  }
131
290
 
132
291
  /**
133
- * The in-place primitive under withColors: write the aColor slots of a
134
- * colored-layout interleave you already own - the hook for a merging
135
- * builder baking colors over its packed buffer (the pos/normal/uv the
136
- * callback receives are read from the buffer itself, so a packer that
137
- * bakes transforms while writing hands the baker world-space vertices).
138
- * Fills vertices [first, first + count) - count defaults to the rest of
139
- * the buffer - and `fill` indexes relative to `first`, so a per-part
140
- * callback works unchanged for both APIs. Returns `vertices`.
141
- *
142
- * This trusts the buffer to BE colored-layout data - a bare array carries
143
- * no layout tag, so only the arithmetic is checked. The Geometry-level
144
- * withColors stays the checked path.
292
+ * The in-place primitive under withAttribute: write one channel the
293
+ * geometry's layout already carries (withAttribute ADDS a channel; this
294
+ * overwrites an existing one). The pos/normal/uv the callback receives
295
+ * are read from the buffer itself, so a builder baking transforms while
296
+ * writing hands the baker world-space vertices. Fills vertices
297
+ * [first, first + count) - count defaults to the rest of the buffer -
298
+ * and `fill` indexes relative to `first`, so a per-part callback works
299
+ * unchanged for both APIs. Returns `geometry.vertices`.
145
300
  */
146
- export function fillColors(vertices: Float32Array, fill: ColorFill, first = 0, count?: number): Float32Array {
147
- if (vertices.length % COLORED_FLOATS !== 0) {
148
- throw new Error("fillColors: vertex data is not a whole number of colored-layout vertices")
301
+ export function fillAttribute(geometry: Geometry, name: string, fill: AttributeFill, first = 0, count?: number): Float32Array {
302
+ return fillSlot(geometry.vertices, geometry.layout, name, fill, first, count)
303
+ }
304
+
305
+ /** The raw form behind fillAttribute (withAttribute writes its fresh
306
+ * buffer through it, before the Geometry exists): a bare array carries no
307
+ * layout tag, so the caller states the layout and only the arithmetic is
308
+ * checked. */
309
+ function fillSlot(vertices: Float32Array, layout: VertexLayout | undefined, name: string, fill: AttributeFill, first: number, count?: number): Float32Array {
310
+ let slot = layoutSlot(layout, name)
311
+ if (slot === null) throw new Error("fillAttribute: layout has no '" + name + "' attribute")
312
+ let stride = layoutStride(layout)
313
+ if (vertices.length % stride !== 0) {
314
+ throw new Error("fillAttribute: vertex data is not a whole number of " + layoutKey(layout) + " vertices")
149
315
  }
150
- let total = vertices.length / COLORED_FLOATS
316
+ let total = vertices.length / stride
151
317
  let n = count ?? total - first
152
318
  if (!Number.isInteger(first) || !Number.isInteger(n) || first < 0 || n < 0 || first + n > total) {
153
- throw new Error("fillColors: range [" + first + ", " + (first + n) + ") is outside the buffer's " + total + " vertices")
319
+ throw new Error("fillAttribute: range [" + first + ", " + (first + n) + ") is outside the buffer's " + total + " vertices")
154
320
  }
321
+ let size = slot.size
155
322
  let fn = typeof fill === "function" ? fill : null
156
- if (!fn && fill.length !== n * 4) {
157
- throw new Error("fillColors: fill has " + fill.length + " floats, expected 4 per vertex (" + n * 4 + ")")
323
+ let flat = typeof fill === "function" ? null : fill
324
+ if (flat !== null && flat.length !== n * size) {
325
+ throw new Error("fillAttribute: fill has " + flat.length + " floats, expected " + size + " per vertex (" + n * size + ")")
158
326
  }
159
327
  for (let i = 0; i < n; i++) {
160
- let d = (first + i) * COLORED_FLOATS
161
- let c: Vec4 = fn
162
- ? 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]!])
163
- : [(fill as ArrayLike<number>)[i * 4]!, (fill as ArrayLike<number>)[i * 4 + 1]!, (fill as ArrayLike<number>)[i * 4 + 2]!, (fill as ArrayLike<number>)[i * 4 + 3]!]
164
- vertices[d + 8] = c[0]
165
- vertices[d + 9] = c[1]
166
- vertices[d + 10] = c[2]
167
- vertices[d + 11] = c[3]
328
+ let d = (first + i) * stride
329
+ let value: ArrayLike<number>
330
+ let s: number
331
+ if (fn !== null) {
332
+ 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]!])
333
+ s = 0
334
+ if (value.length !== size) {
335
+ throw new Error("fillAttribute: fill callback returned " + value.length + " floats for '" + name + "', expected " + size)
336
+ }
337
+ } else {
338
+ value = flat!
339
+ s = i * size
340
+ }
341
+ for (let k = 0; k < size; k++) vertices[d + slot.offset + k] = value[s + k]!
168
342
  }
169
343
  return vertices
170
344
  }
171
345
 
346
+ /** `fillAttribute` for the aColor channel of a color-carrying geometry
347
+ * (fill is 4 per vertex). */
348
+ export function fillColors(geometry: Geometry, fill: ColorFill, first = 0, count?: number): Float32Array {
349
+ return fillAttribute(geometry, "aColor", fill, first, count)
350
+ }
351
+
172
352
  /**
173
353
  * Bake a placement (the setTransform shape: Euler XYZ radians or a
174
354
  * quaternion, not both; number = uniform scale; absent = identity) into a
@@ -187,10 +367,10 @@ export function transformGeometry(geometry: Geometry, transform: TransformUpdate
187
367
  if (transform.scale !== undefined) updateScale(scl, transform.scale)
188
368
  let m = compose(mat4(), transform.position ?? [0, 0, 0], rot, scl)
189
369
  let n = normalMatrix(mat4(), m)
190
- let stride = geometry.layout === "colored" ? COLORED_FLOATS : FLOATS_PER_VERTEX
370
+ let stride = layoutStride(geometry.layout)
191
371
  let src = geometry.vertices
192
372
  if (src.length % stride !== 0) {
193
- throw new Error("transformGeometry: vertex data is not a whole number of " + (geometry.layout ?? "standard") + "-layout vertices")
373
+ throw new Error("transformGeometry: vertex data is not a whole number of " + layoutKey(geometry.layout) + " vertices")
194
374
  }
195
375
  let out = new Float32Array(src)
196
376
  for (let i = 0; i < out.length; i += stride) {
@@ -227,16 +407,17 @@ export function transformGeometry(geometry: Geometry, transform: TransformUpdate
227
407
  */
228
408
  export function mergeGeometries(parts: Geometry[], label?: string): Geometry {
229
409
  if (parts.length === 0) throw new Error("mergeGeometries: no parts")
230
- let layout = parts[0]!.layout ?? "standard"
231
- let stride = layout === "colored" ? COLORED_FLOATS : FLOATS_PER_VERTEX
410
+ let layout = parts[0]!.layout
411
+ let key = layoutKey(layout)
412
+ let stride = layoutStride(layout)
232
413
  let floats = 0
233
414
  let indexCount = 0
234
415
  for (let part of parts) {
235
- if ((part.layout ?? "standard") !== layout) {
236
- throw new Error("mergeGeometries: mixed layouts (" + layout + " and " + (part.layout ?? "standard") + ")")
416
+ if (layoutKey(part.layout) !== key) {
417
+ throw new Error("mergeGeometries: mixed layouts (" + key + " and " + layoutKey(part.layout) + ")")
237
418
  }
238
419
  if (part.vertices.length % stride !== 0) {
239
- throw new Error("mergeGeometries: a part's vertex data is not a whole number of " + layout + "-layout vertices")
420
+ throw new Error("mergeGeometries: a part's vertex data is not a whole number of " + key + " vertices")
240
421
  }
241
422
  floats += part.vertices.length
242
423
  indexCount += part.indices.length
@@ -254,7 +435,7 @@ export function mergeGeometries(parts: Geometry[], label?: string): Geometry {
254
435
  vOffset += part.vertices.length
255
436
  iOffset += src.length
256
437
  }
257
- return { vertices, indices, layout: parts[0]!.layout, label }
438
+ return { vertices, indices, layout, label }
258
439
  }
259
440
 
260
441
  // Indices for a row-major (cellRows + 1) x (cellCols + 1) vertex grid: two
@@ -278,7 +459,10 @@ function gridIndices(cellRows: number, cellCols: number, skipFirst = false, skip
278
459
  }
279
460
 
280
461
  /** An axis-aligned box centered on the origin: 24 vertices, 36 indices. */
281
- export function box(width = 1, height = 1, depth = 1, label?: string): Geometry {
462
+ export type BoxOptions = GeometryOptions & { width?: number; height?: number; depth?: number }
463
+
464
+ export function box(options: BoxOptions = {}): Geometry {
465
+ let { width = 1, height = 1, depth = 1 } = options
282
466
  let x = width / 2
283
467
  let y = height / 2
284
468
  let z = depth / 2
@@ -287,7 +471,7 @@ export function box(width = 1, height = 1, depth = 1, label?: string): Geometry
287
471
  type P = [number, number, number]
288
472
  // Corners a (bottom-left) through d (top-left), CCW seen from outside.
289
473
  let quad = (a: P, b: P, c: P, d: P, n: P) => {
290
- let base = verts.length / FLOATS_PER_VERTEX
474
+ let base = verts.length / STANDARD_FLOATS
291
475
  let uv = [[0, 1], [1, 1], [1, 0], [0, 0]]
292
476
  let corners = [a, b, c, d]
293
477
  for (let i = 0; i < 4; i++) {
@@ -303,24 +487,27 @@ export function box(width = 1, height = 1, depth = 1, label?: string): Geometry
303
487
  quad([-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z], [-1, 0, 0]) // left
304
488
  quad([-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z], [0, 1, 0]) // top
305
489
  quad([-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z], [0, -1, 0]) // bottom
306
- return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
490
+ return packGeometry(verts, indices, options)
307
491
  }
308
492
 
309
493
  /**
310
494
  * A rectangle in the XY plane facing +z, centered on the origin. For a
311
495
  * ground plane, rotate it flat: `rotation={[-Math.PI / 2, 0, 0]}`.
312
496
  */
313
- export function plane(width = 1, height = 1, label?: string): Geometry {
497
+ export type PlaneOptions = GeometryOptions & { width?: number; height?: number }
498
+
499
+ export function plane(options: PlaneOptions = {}): Geometry {
500
+ let { width = 1, height = 1 } = options
314
501
  let x = width / 2
315
502
  let y = height / 2
316
503
  // prettier-ignore
317
- let vertices = new Float32Array([
504
+ let vertices = [
318
505
  -x, -y, 0, 0, 0, 1, 0, 1,
319
506
  x, -y, 0, 0, 0, 1, 1, 1,
320
507
  x, y, 0, 0, 0, 1, 1, 0,
321
508
  -x, y, 0, 0, 0, 1, 0, 0,
322
- ])
323
- return { vertices, indices: new Uint16Array([0, 1, 2, 0, 2, 3]), label }
509
+ ]
510
+ return packGeometry(vertices, [0, 1, 2, 0, 2, 3], options)
324
511
  }
325
512
 
326
513
  /**
@@ -333,15 +520,17 @@ export function plane(width = 1, height = 1, label?: string): Geometry {
333
520
  * coordinates, so genuinely distinct vertices). UVs: u 0..1 along the knot,
334
521
  * v 0..1 around the tube.
335
522
  */
336
- export function torusKnot(
337
- radius = 1,
338
- tube = 0.4,
339
- tubularSegments = 64,
340
- radialSegments = 8,
341
- p = 2,
342
- q = 3,
343
- label?: string,
344
- ): Geometry {
523
+ export type TorusKnotOptions = GeometryOptions & {
524
+ radius?: number
525
+ tube?: number
526
+ tubularSegments?: number
527
+ radialSegments?: number
528
+ p?: number
529
+ q?: number
530
+ }
531
+
532
+ export function torusKnot(options: TorusKnotOptions = {}): Geometry {
533
+ let { radius = 1, tube = 0.4, tubularSegments = 64, radialSegments = 8, p = 2, q = 3 } = options
345
534
  // A point on the knot curve at parameter t (0..2*PI*p).
346
535
  let point = (t: number): Vec3 => {
347
536
  let qp = (q / p) * t
@@ -351,7 +540,8 @@ export function torusKnot(
351
540
 
352
541
  let rows = tubularSegments + 1
353
542
  let cols = radialSegments + 1
354
- let vertices = new Float32Array(rows * cols * FLOATS_PER_VERTEX)
543
+ let stride = generatorStride(options)
544
+ let vertices = new Float32Array(rows * cols * stride)
355
545
  let at = 0
356
546
 
357
547
  for (let i = 0; i < rows; i++) {
@@ -381,13 +571,11 @@ export function torusKnot(
381
571
  vertices[at + 5] = n[2]
382
572
  vertices[at + 6] = i / tubularSegments
383
573
  vertices[at + 7] = j / radialSegments
384
- at += FLOATS_PER_VERTEX
574
+ at += stride
385
575
  }
386
576
  }
387
577
 
388
- let indices = new Uint16Array(gridIndices(tubularSegments, radialSegments))
389
-
390
- return { vertices, indices, label }
578
+ return finishGeometry(vertices, gridIndices(tubularSegments, radialSegments), options)
391
579
  }
392
580
 
393
581
  /**
@@ -398,13 +586,15 @@ export function torusKnot(
398
586
  * disc map. A zero radius skips that cap and the degenerate side
399
587
  * triangles at the apex.
400
588
  */
401
- export function cylinder(
402
- radiusTop = 0.5,
403
- radiusBottom = 0.5,
404
- height = 1,
405
- radialSegments = 24,
406
- label?: string,
407
- ): Geometry {
589
+ export type CylinderOptions = GeometryOptions & {
590
+ radiusTop?: number
591
+ radiusBottom?: number
592
+ height?: number
593
+ radialSegments?: number
594
+ }
595
+
596
+ export function cylinder(options: CylinderOptions = {}): Geometry {
597
+ let { radiusTop = 0.5, radiusBottom = 0.5, height = 1, radialSegments = 24 } = options
408
598
  let h = height / 2
409
599
  let cols = radialSegments + 1
410
600
  let verts: number[] = []
@@ -429,7 +619,7 @@ export function cylinder(
429
619
  // Caps fan around a center vertex; the planar UV map has no seam, so the
430
620
  // ring wraps with modulo instead of duplicating a column.
431
621
  let cap = (r: number, y: number, up: number) => {
432
- let base = verts.length / FLOATS_PER_VERTEX
622
+ let base = verts.length / STANDARD_FLOATS
433
623
  verts.push(0, y, 0, 0, up, 0, 0.5, 0.5)
434
624
  for (let i = 0; i < radialSegments; i++) {
435
625
  let phi = (i / radialSegments) * Math.PI * 2
@@ -445,34 +635,40 @@ export function cylinder(
445
635
  }
446
636
  if (radiusTop > 0) cap(radiusTop, h, 1)
447
637
  if (radiusBottom > 0) cap(radiusBottom, -h, -1)
448
- return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
638
+ return packGeometry(verts, indices, options)
449
639
  }
450
640
 
451
641
  /** A capped cone on the y axis, centered on the origin: `cylinder()` with
452
642
  * a zero top radius (each apex vertex carries its column's side normal, so
453
643
  * the surface shades smoothly around). */
454
- export function cone(radius = 0.5, height = 1, radialSegments = 24, label?: string): Geometry {
455
- return cylinder(0, radius, height, radialSegments, label)
644
+ export type ConeOptions = GeometryOptions & { radius?: number; height?: number; radialSegments?: number }
645
+
646
+ export function cone(options: ConeOptions = {}): Geometry {
647
+ let { radius = 0.5, height = 1, radialSegments = 24, ...rest } = options
648
+ return cylinder({ ...rest, radiusTop: 0, radiusBottom: radius, height, radialSegments })
456
649
  }
457
650
 
458
651
  /**
459
652
  * A torus lying flat, centered on the origin: the ring lies in the XZ
460
653
  * plane with the hole on the y axis - the y-up orientation torusKnot also
461
- * uses (Three's equivalent stands in XY). Signature order is Three's:
654
+ * uses (Three's equivalent stands in XY). Option names are Three's:
462
655
  * radialSegments subdivides the tube cross-section, tubularSegments the
463
656
  * ring. UVs: u 0..1 around the ring, v 0..1 around the tube, seam
464
657
  * row/column duplicated like torusKnot.
465
658
  */
466
- export function torus(
467
- radius = 0.5,
468
- tube = 0.2,
469
- radialSegments = 12,
470
- tubularSegments = 32,
471
- label?: string,
472
- ): Geometry {
659
+ export type TorusOptions = GeometryOptions & {
660
+ radius?: number
661
+ tube?: number
662
+ radialSegments?: number
663
+ tubularSegments?: number
664
+ }
665
+
666
+ export function torus(options: TorusOptions = {}): Geometry {
667
+ let { radius = 0.5, tube = 0.2, radialSegments = 12, tubularSegments = 32 } = options
473
668
  let rows = tubularSegments + 1
474
669
  let cols = radialSegments + 1
475
- let vertices = new Float32Array(rows * cols * FLOATS_PER_VERTEX)
670
+ let stride = generatorStride(options)
671
+ let vertices = new Float32Array(rows * cols * stride)
476
672
  let at = 0
477
673
  for (let i = 0; i < rows; i++) {
478
674
  let phi = (i / tubularSegments) * Math.PI * 2
@@ -491,11 +687,10 @@ export function torus(
491
687
  vertices[at + 5] = cp * dz
492
688
  vertices[at + 6] = i / tubularSegments
493
689
  vertices[at + 7] = j / radialSegments
494
- at += FLOATS_PER_VERTEX
690
+ at += stride
495
691
  }
496
692
  }
497
- let indices = new Uint16Array(gridIndices(tubularSegments, radialSegments))
498
- return { vertices, indices, label }
693
+ return finishGeometry(vertices, gridIndices(tubularSegments, radialSegments), options)
499
694
  }
500
695
 
501
696
  /**
@@ -503,7 +698,10 @@ export function torus(
503
698
  * like plane()). UVs are the planar map of the disc inscribed in the unit
504
699
  * square.
505
700
  */
506
- export function circle(radius = 0.5, segments = 32, label?: string): Geometry {
701
+ export type CircleOptions = GeometryOptions & { radius?: number; segments?: number }
702
+
703
+ export function circle(options: CircleOptions = {}): Geometry {
704
+ let { radius = 0.5, segments = 32 } = options
507
705
  let verts: number[] = [0, 0, 0, 0, 0, 1, 0.5, 0.5]
508
706
  let indices: number[] = []
509
707
  for (let i = 0; i < segments; i++) {
@@ -515,7 +713,7 @@ export function circle(radius = 0.5, segments = 32, label?: string): Geometry {
515
713
  for (let i = 0; i < segments; i++) {
516
714
  indices.push(0, 1 + i, 1 + ((i + 1) % segments))
517
715
  }
518
- return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
716
+ return packGeometry(verts, indices, options)
519
717
  }
520
718
 
521
719
  /**
@@ -523,7 +721,10 @@ export function circle(radius = 0.5, segments = 32, label?: string): Geometry {
523
721
  * are the planar map of the OUTER disc, so a ring textures like the
524
722
  * matching circle() with the middle cut out.
525
723
  */
526
- export function ring(innerRadius = 0.25, outerRadius = 0.5, segments = 32, label?: string): Geometry {
724
+ export type RingOptions = GeometryOptions & { innerRadius?: number; outerRadius?: number; segments?: number }
725
+
726
+ export function ring(options: RingOptions = {}): Geometry {
727
+ let { innerRadius = 0.25, outerRadius = 0.5, segments = 32 } = options
527
728
  let verts: number[] = []
528
729
  let indices: number[] = []
529
730
  for (let i = 0; i < segments; i++) {
@@ -538,11 +739,14 @@ export function ring(innerRadius = 0.25, outerRadius = 0.5, segments = 32, label
538
739
  let j = (i + 1) % segments
539
740
  indices.push(i * 2, i * 2 + 1, j * 2 + 1, i * 2, j * 2 + 1, j * 2)
540
741
  }
541
- return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
742
+ return packGeometry(verts, indices, options)
542
743
  }
543
744
 
544
745
  /** A UV sphere centered on the origin (poles on the y axis). */
545
- export function sphere(radius = 0.5, widthSegments = 24, heightSegments = 16, label?: string): Geometry {
746
+ export type SphereOptions = GeometryOptions & { radius?: number; widthSegments?: number; heightSegments?: number }
747
+
748
+ export function sphere(options: SphereOptions = {}): Geometry {
749
+ let { radius = 0.5, widthSegments = 24, heightSegments = 16 } = options
546
750
  let verts: number[] = []
547
751
  for (let iy = 0; iy <= heightSegments; iy++) {
548
752
  let v = iy / heightSegments
@@ -560,5 +764,5 @@ export function sphere(radius = 0.5, widthSegments = 24, heightSegments = 16, la
560
764
  }
561
765
  // Both pole rows are collapsed to the pole point.
562
766
  let indices = gridIndices(heightSegments, widthSegments, true, true)
563
- return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
767
+ return packGeometry(verts, indices, options)
564
768
  }