@solidrt/3d 0.0.50 → 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
@@ -12,18 +17,18 @@
12
17
  // unused by the unlit materials so the layout is ready for lights without
13
18
  // a geometry change (inactive attributes are skipped but keep the stride).
14
19
  //
15
- // GPU buffers are created lazily on first use and shared by every mesh and
16
- // scene drawing the geometry. They are app-lifetime by design - one
17
- // geometry commonly outlives the component that first drew it, so
18
- // owner-scoped auto-free would free a buffer other scenes still draw from.
19
- // disposeGeometry frees them when an app is done with a geometry for good.
20
+ // Pure module by design - geometry is data, and every function here is
21
+ // array math (the check rig checks/geometry-check.ts runs it headless on
22
+ // flux). The GPU buffer step lives in geometry-gpu.ts.
20
23
 
21
- import { createBuffer, destroyBuffer } from "@solidrt/core/gpu"
22
- import type { BufferId, IndexFormat, VertexAttribute } from "@solidrt/core/gpu"
23
- import { add, cross, normalize, sub } from "./math.ts"
24
- import type { Vec2, Vec3, Vec4 } from "./math.ts"
24
+ import type { VertexAttribute } from "@solidrt/core/gpu"
25
+ import { add, compose, cross, mat4, normalize, normalMatrix, sub, updateRotation, updateScale } from "./math.ts"
26
+ import type { Quat, TransformUpdate, Vec2, Vec3 } from "./math.ts"
25
27
 
26
- 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[]
27
32
 
28
33
  const STANDARD_ATTRIBUTES: VertexAttribute[] = [
29
34
  { name: "aPos", format: "vec3" },
@@ -31,17 +36,146 @@ const STANDARD_ATTRIBUTES: VertexAttribute[] = [
31
36
  { name: "aUV", format: "vec2" },
32
37
  ]
33
38
 
34
- /** The pipeline attribute list for each named layout. A deliberately small
35
- * set (not an open per-geometry model): every layout shares the standard
36
- * prefix, so one shader vocabulary serves all of them. */
37
- 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[]> = {
38
42
  standard: STANDARD_ATTRIBUTES,
39
43
  colored: [...STANDARD_ATTRIBUTES, { name: "aColor", format: "vec4" }],
40
44
  }
41
45
 
42
- /** Floats per vertex in the "standard" layout (what every generator emits). */
43
- export const FLOATS_PER_VERTEX = 8
44
- 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
+ }
45
179
 
46
180
  /** Uint16 indices when they fit, Uint32Array past 64k vertices - the draw
47
181
  * entry follows the array type. The tail of every unbounded generator. */
@@ -61,8 +195,6 @@ export type Geometry = {
61
195
  layout?: VertexLayout
62
196
  /** Debug name for the lazily-created GPU buffers. */
63
197
  label?: string
64
- _buffer?: BufferId
65
- _index?: BufferId
66
198
  _bounds?: Float32Array
67
199
  }
68
200
 
@@ -78,7 +210,7 @@ export function geometryBounds(geometry: Geometry): Float32Array {
78
210
  if (bounds === undefined) {
79
211
  bounds = new Float32Array([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity])
80
212
  let v = geometry.vertices
81
- let stride = geometry.layout === "colored" ? COLORED_FLOATS : FLOATS_PER_VERTEX
213
+ let stride = layoutStride(geometry.layout)
82
214
  for (let i = 0; i + 2 < v.length; i += stride) {
83
215
  let x = v[i]!, y = v[i + 1]!, z = v[i + 2]!
84
216
  if (x < bounds[0]!) bounds[0] = x
@@ -94,120 +226,216 @@ export function geometryBounds(geometry: Geometry): Float32Array {
94
226
  return bounds
95
227
  }
96
228
 
97
- /** The geometry's GPU buffers, created on first use and cached on it,
98
- * plus the index format the draw entry must bind them with. */
99
- export function geometryBuffers(geometry: Geometry): {
100
- buffer: BufferId
101
- index: BufferId
102
- indexFormat: IndexFormat
103
- } {
104
- let buffer = geometry._buffer
105
- let index = geometry._index
106
- if (buffer === undefined || index === undefined) {
107
- buffer = createBuffer(geometry.vertices, {
108
- autoFree: false,
109
- label: geometry.label ? geometry.label + "-verts" : undefined,
110
- })
111
- index = createBuffer(geometry.indices, {
112
- autoFree: false,
113
- label: geometry.label ? geometry.label + "-indices" : undefined,
114
- })
115
- geometry._buffer = buffer
116
- geometry._index = index
117
- }
118
- return { buffer, index, indexFormat: geometry.indices instanceof Uint32Array ? "uint32" : "uint16" }
119
- }
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
120
235
 
121
236
  /**
122
- * Free the geometry's GPU buffers. Draw entries created from them hold
123
- * their own reference, so destruction order is safe; the geometry can be
124
- * used again afterwards (fresh buffers are created on next use).
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.
125
244
  */
126
- export function disposeGeometry(geometry: Geometry): void {
127
- if (geometry._buffer !== undefined) destroyBuffer(geometry._buffer)
128
- if (geometry._index !== undefined) destroyBuffer(geometry._index)
129
- geometry._buffer = undefined
130
- geometry._index = undefined
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 + "'")
249
+ }
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")
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
258
+ let src = geometry.vertices
259
+ let out = new Float32Array(count * stride)
260
+ for (let i = 0; i < count; i++) {
261
+ let s = i * srcStride
262
+ let d = i * stride
263
+ for (let k = 0; k < srcStride; k++) out[d + k] = src[s + k]!
264
+ }
265
+ fillSlot(out, layout, attr.name, fill, 0)
266
+ return {
267
+ vertices: out,
268
+ indices: geometry.indices,
269
+ layout,
270
+ label: label ?? (geometry.label ? geometry.label + "-" + attr.name : undefined),
271
+ }
131
272
  }
132
273
 
133
- /** Per-vertex aColor values for withColors/fillColors: a flat 4-per-vertex
134
- * array, or a callback deriving each vertex's vec4 from the vertex data. */
135
- export type ColorFill = ArrayLike<number> | ((index: number, pos: Vec3, normal: Vec3, uv: Vec2) => Vec4)
136
-
137
274
  /**
138
275
  * Derive a "colored"-layout geometry from a standard one: the same
139
276
  * positions, normals, uvs and indices, plus an aColor vec4 per vertex -
140
277
  * the data channel for materials whose vertex stage reads `in vec4 aColor`
141
278
  * (a tint, baked ambient occlusion, any four scalars; the name is the
142
- * standard vocabulary, the contents are yours). The callback form receives
143
- * each vertex's position, normal and uv - what a baker wants. The source
144
- * geometry is untouched and its GPU buffers stay independent.
279
+ * standard vocabulary, the contents are yours). `withAttribute` with the
280
+ * aColor channel; the "colored" preset name is kept on the result.
145
281
  */
146
282
  export function withColors(geometry: Geometry, fill: ColorFill, label?: string): Geometry {
147
- if (geometry.layout === "colored") {
283
+ if (layoutSlot(geometry.layout, "aColor") !== null) {
148
284
  throw new Error("withColors: geometry already carries an aColor channel")
149
285
  }
150
- if (geometry.vertices.length % FLOATS_PER_VERTEX !== 0) {
151
- throw new Error("withColors: vertex data is not a whole number of standard-layout vertices")
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
289
+ }
290
+
291
+ /**
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`.
300
+ */
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")
315
+ }
316
+ let total = vertices.length / stride
317
+ let n = count ?? total - first
318
+ if (!Number.isInteger(first) || !Number.isInteger(n) || first < 0 || n < 0 || first + n > total) {
319
+ throw new Error("fillAttribute: range [" + first + ", " + (first + n) + ") is outside the buffer's " + total + " vertices")
320
+ }
321
+ let size = slot.size
322
+ let fn = typeof fill === "function" ? fill : null
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 + ")")
152
326
  }
153
- let count = geometry.vertices.length / FLOATS_PER_VERTEX
154
- if (typeof fill !== "function" && fill.length !== count * 4) {
155
- throw new Error("withColors: fill has " + fill.length + " floats, expected 4 per vertex (" + count * 4 + ")")
327
+ for (let i = 0; i < n; i++) {
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]!
156
342
  }
343
+ return vertices
344
+ }
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
+
352
+ /**
353
+ * Bake a placement (the setTransform shape: Euler XYZ radians or a
354
+ * quaternion, not both; number = uniform scale; absent = identity) into a
355
+ * geometry: a new geometry (the source is
356
+ * untouched, its GPU buffers stay independent) whose positions are moved
357
+ * by the transform and whose normals follow through the inverse-transpose,
358
+ * renormalized - correct under non-uniform scale. UVs, colors, indices and
359
+ * layout copy through. This is Three's `geometry.applyMatrix4`, the first
360
+ * half of authoring a static scene as data: transform each part into place,
361
+ * mergeGeometries the parts, draw one mesh.
362
+ */
363
+ export function transformGeometry(geometry: Geometry, transform: TransformUpdate, label?: string): Geometry {
364
+ let rot: Quat = [0, 0, 0, 1]
365
+ updateRotation(rot, transform, "transformGeometry")
366
+ let scl: Vec3 = [1, 1, 1]
367
+ if (transform.scale !== undefined) updateScale(scl, transform.scale)
368
+ let m = compose(mat4(), transform.position ?? [0, 0, 0], rot, scl)
369
+ let n = normalMatrix(mat4(), m)
370
+ let stride = layoutStride(geometry.layout)
157
371
  let src = geometry.vertices
158
- let out = new Float32Array(count * COLORED_FLOATS)
159
- for (let i = 0; i < count; i++) {
160
- let s = i * FLOATS_PER_VERTEX
161
- let d = i * COLORED_FLOATS
162
- for (let k = 0; k < FLOATS_PER_VERTEX; k++) out[d + k] = src[s + k]!
372
+ if (src.length % stride !== 0) {
373
+ throw new Error("transformGeometry: vertex data is not a whole number of " + layoutKey(geometry.layout) + " vertices")
374
+ }
375
+ let out = new Float32Array(src)
376
+ for (let i = 0; i < out.length; i += stride) {
377
+ let x = src[i]!, y = src[i + 1]!, z = src[i + 2]!
378
+ out[i] = m[0] * x + m[4] * y + m[8] * z + m[12]
379
+ out[i + 1] = m[1] * x + m[5] * y + m[9] * z + m[13]
380
+ out[i + 2] = m[2] * x + m[6] * y + m[10] * z + m[14]
381
+ let nx = src[i + 3]!, ny = src[i + 4]!, nz = src[i + 5]!
382
+ let tx = n[0] * nx + n[4] * ny + n[8] * nz
383
+ let ty = n[1] * nx + n[5] * ny + n[9] * nz
384
+ let tz = n[2] * nx + n[6] * ny + n[10] * nz
385
+ let len = Math.hypot(tx, ty, tz) || 1
386
+ out[i + 3] = tx / len
387
+ out[i + 4] = ty / len
388
+ out[i + 5] = tz / len
163
389
  }
164
- fillColors(out, fill)
165
390
  return {
166
391
  vertices: out,
167
392
  indices: geometry.indices,
168
- layout: "colored",
169
- label: label ?? (geometry.label ? geometry.label + "-colored" : undefined),
393
+ layout: geometry.layout,
394
+ label: label ?? (geometry.label ? geometry.label + "-transformed" : undefined),
170
395
  }
171
396
  }
172
397
 
173
398
  /**
174
- * The in-place primitive under withColors: write the aColor slots of a
175
- * colored-layout interleave you already own - the hook for a merging
176
- * builder baking colors over its packed buffer (the pos/normal/uv the
177
- * callback receives are read from the buffer itself, so a packer that
178
- * bakes transforms while writing hands the baker world-space vertices).
179
- * Fills vertices [first, first + count) - count defaults to the rest of
180
- * the buffer - and `fill` indexes relative to `first`, so a per-part
181
- * callback works unchanged for both APIs. Returns `vertices`.
182
- *
183
- * This trusts the buffer to BE colored-layout data - a bare array carries
184
- * no layout tag, so only the arithmetic is checked. The Geometry-level
185
- * withColors stays the checked path.
399
+ * Concatenate geometries into one: vertices appended in order, indices
400
+ * offset to match, uint32 indices past 64k vertices. Every part must share
401
+ * one layout - a mixed list throws, because the strides differ and a merge
402
+ * that picked one would draw garbage, not a mesh missing a channel. The
403
+ * second half of authoring a static scene as data (Three's
404
+ * `BufferGeometryUtils.mergeGeometries`): the result is one draw entry and
405
+ * one uModel write however many parts went in, so only what actually moves
406
+ * keeps a node of its own.
186
407
  */
187
- export function fillColors(vertices: Float32Array, fill: ColorFill, first = 0, count?: number): Float32Array {
188
- if (vertices.length % COLORED_FLOATS !== 0) {
189
- throw new Error("fillColors: vertex data is not a whole number of colored-layout vertices")
190
- }
191
- let total = vertices.length / COLORED_FLOATS
192
- let n = count ?? total - first
193
- if (!Number.isInteger(first) || !Number.isInteger(n) || first < 0 || n < 0 || first + n > total) {
194
- throw new Error("fillColors: range [" + first + ", " + (first + n) + ") is outside the buffer's " + total + " vertices")
195
- }
196
- let fn = typeof fill === "function" ? fill : null
197
- if (!fn && fill.length !== n * 4) {
198
- throw new Error("fillColors: fill has " + fill.length + " floats, expected 4 per vertex (" + n * 4 + ")")
408
+ export function mergeGeometries(parts: Geometry[], label?: string): Geometry {
409
+ if (parts.length === 0) throw new Error("mergeGeometries: no parts")
410
+ let layout = parts[0]!.layout
411
+ let key = layoutKey(layout)
412
+ let stride = layoutStride(layout)
413
+ let floats = 0
414
+ let indexCount = 0
415
+ for (let part of parts) {
416
+ if (layoutKey(part.layout) !== key) {
417
+ throw new Error("mergeGeometries: mixed layouts (" + key + " and " + layoutKey(part.layout) + ")")
418
+ }
419
+ if (part.vertices.length % stride !== 0) {
420
+ throw new Error("mergeGeometries: a part's vertex data is not a whole number of " + key + " vertices")
421
+ }
422
+ floats += part.vertices.length
423
+ indexCount += part.indices.length
199
424
  }
200
- for (let i = 0; i < n; i++) {
201
- let d = (first + i) * COLORED_FLOATS
202
- let c: Vec4 = fn
203
- ? 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]!])
204
- : [(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]!]
205
- vertices[d + 8] = c[0]
206
- vertices[d + 9] = c[1]
207
- vertices[d + 10] = c[2]
208
- vertices[d + 11] = c[3]
425
+ let vertexCount = floats / stride
426
+ let vertices = new Float32Array(floats)
427
+ let indices = vertexCount > 65535 ? new Uint32Array(indexCount) : new Uint16Array(indexCount)
428
+ let vOffset = 0
429
+ let iOffset = 0
430
+ for (let part of parts) {
431
+ vertices.set(part.vertices, vOffset)
432
+ let base = vOffset / stride
433
+ let src = part.indices
434
+ for (let i = 0; i < src.length; i++) indices[iOffset + i] = src[i]! + base
435
+ vOffset += part.vertices.length
436
+ iOffset += src.length
209
437
  }
210
- return vertices
438
+ return { vertices, indices, layout, label }
211
439
  }
212
440
 
213
441
  // Indices for a row-major (cellRows + 1) x (cellCols + 1) vertex grid: two
@@ -231,7 +459,10 @@ function gridIndices(cellRows: number, cellCols: number, skipFirst = false, skip
231
459
  }
232
460
 
233
461
  /** An axis-aligned box centered on the origin: 24 vertices, 36 indices. */
234
- 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
235
466
  let x = width / 2
236
467
  let y = height / 2
237
468
  let z = depth / 2
@@ -240,7 +471,7 @@ export function box(width = 1, height = 1, depth = 1, label?: string): Geometry
240
471
  type P = [number, number, number]
241
472
  // Corners a (bottom-left) through d (top-left), CCW seen from outside.
242
473
  let quad = (a: P, b: P, c: P, d: P, n: P) => {
243
- let base = verts.length / FLOATS_PER_VERTEX
474
+ let base = verts.length / STANDARD_FLOATS
244
475
  let uv = [[0, 1], [1, 1], [1, 0], [0, 0]]
245
476
  let corners = [a, b, c, d]
246
477
  for (let i = 0; i < 4; i++) {
@@ -256,24 +487,27 @@ export function box(width = 1, height = 1, depth = 1, label?: string): Geometry
256
487
  quad([-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z], [-1, 0, 0]) // left
257
488
  quad([-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z], [0, 1, 0]) // top
258
489
  quad([-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z], [0, -1, 0]) // bottom
259
- return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
490
+ return packGeometry(verts, indices, options)
260
491
  }
261
492
 
262
493
  /**
263
494
  * A rectangle in the XY plane facing +z, centered on the origin. For a
264
495
  * ground plane, rotate it flat: `rotation={[-Math.PI / 2, 0, 0]}`.
265
496
  */
266
- 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
267
501
  let x = width / 2
268
502
  let y = height / 2
269
503
  // prettier-ignore
270
- let vertices = new Float32Array([
504
+ let vertices = [
271
505
  -x, -y, 0, 0, 0, 1, 0, 1,
272
506
  x, -y, 0, 0, 0, 1, 1, 1,
273
507
  x, y, 0, 0, 0, 1, 1, 0,
274
508
  -x, y, 0, 0, 0, 1, 0, 0,
275
- ])
276
- return { vertices, indices: new Uint16Array([0, 1, 2, 0, 2, 3]), label }
509
+ ]
510
+ return packGeometry(vertices, [0, 1, 2, 0, 2, 3], options)
277
511
  }
278
512
 
279
513
  /**
@@ -286,15 +520,17 @@ export function plane(width = 1, height = 1, label?: string): Geometry {
286
520
  * coordinates, so genuinely distinct vertices). UVs: u 0..1 along the knot,
287
521
  * v 0..1 around the tube.
288
522
  */
289
- export function torusKnot(
290
- radius = 1,
291
- tube = 0.4,
292
- tubularSegments = 64,
293
- radialSegments = 8,
294
- p = 2,
295
- q = 3,
296
- label?: string,
297
- ): 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
298
534
  // A point on the knot curve at parameter t (0..2*PI*p).
299
535
  let point = (t: number): Vec3 => {
300
536
  let qp = (q / p) * t
@@ -304,7 +540,8 @@ export function torusKnot(
304
540
 
305
541
  let rows = tubularSegments + 1
306
542
  let cols = radialSegments + 1
307
- let vertices = new Float32Array(rows * cols * FLOATS_PER_VERTEX)
543
+ let stride = generatorStride(options)
544
+ let vertices = new Float32Array(rows * cols * stride)
308
545
  let at = 0
309
546
 
310
547
  for (let i = 0; i < rows; i++) {
@@ -334,13 +571,11 @@ export function torusKnot(
334
571
  vertices[at + 5] = n[2]
335
572
  vertices[at + 6] = i / tubularSegments
336
573
  vertices[at + 7] = j / radialSegments
337
- at += FLOATS_PER_VERTEX
574
+ at += stride
338
575
  }
339
576
  }
340
577
 
341
- let indices = new Uint16Array(gridIndices(tubularSegments, radialSegments))
342
-
343
- return { vertices, indices, label }
578
+ return finishGeometry(vertices, gridIndices(tubularSegments, radialSegments), options)
344
579
  }
345
580
 
346
581
  /**
@@ -351,13 +586,15 @@ export function torusKnot(
351
586
  * disc map. A zero radius skips that cap and the degenerate side
352
587
  * triangles at the apex.
353
588
  */
354
- export function cylinder(
355
- radiusTop = 0.5,
356
- radiusBottom = 0.5,
357
- height = 1,
358
- radialSegments = 24,
359
- label?: string,
360
- ): 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
361
598
  let h = height / 2
362
599
  let cols = radialSegments + 1
363
600
  let verts: number[] = []
@@ -382,7 +619,7 @@ export function cylinder(
382
619
  // Caps fan around a center vertex; the planar UV map has no seam, so the
383
620
  // ring wraps with modulo instead of duplicating a column.
384
621
  let cap = (r: number, y: number, up: number) => {
385
- let base = verts.length / FLOATS_PER_VERTEX
622
+ let base = verts.length / STANDARD_FLOATS
386
623
  verts.push(0, y, 0, 0, up, 0, 0.5, 0.5)
387
624
  for (let i = 0; i < radialSegments; i++) {
388
625
  let phi = (i / radialSegments) * Math.PI * 2
@@ -398,34 +635,40 @@ export function cylinder(
398
635
  }
399
636
  if (radiusTop > 0) cap(radiusTop, h, 1)
400
637
  if (radiusBottom > 0) cap(radiusBottom, -h, -1)
401
- return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
638
+ return packGeometry(verts, indices, options)
402
639
  }
403
640
 
404
641
  /** A capped cone on the y axis, centered on the origin: `cylinder()` with
405
642
  * a zero top radius (each apex vertex carries its column's side normal, so
406
643
  * the surface shades smoothly around). */
407
- export function cone(radius = 0.5, height = 1, radialSegments = 24, label?: string): Geometry {
408
- 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 })
409
649
  }
410
650
 
411
651
  /**
412
652
  * A torus lying flat, centered on the origin: the ring lies in the XZ
413
653
  * plane with the hole on the y axis - the y-up orientation torusKnot also
414
- * 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:
415
655
  * radialSegments subdivides the tube cross-section, tubularSegments the
416
656
  * ring. UVs: u 0..1 around the ring, v 0..1 around the tube, seam
417
657
  * row/column duplicated like torusKnot.
418
658
  */
419
- export function torus(
420
- radius = 0.5,
421
- tube = 0.2,
422
- radialSegments = 12,
423
- tubularSegments = 32,
424
- label?: string,
425
- ): 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
426
668
  let rows = tubularSegments + 1
427
669
  let cols = radialSegments + 1
428
- let vertices = new Float32Array(rows * cols * FLOATS_PER_VERTEX)
670
+ let stride = generatorStride(options)
671
+ let vertices = new Float32Array(rows * cols * stride)
429
672
  let at = 0
430
673
  for (let i = 0; i < rows; i++) {
431
674
  let phi = (i / tubularSegments) * Math.PI * 2
@@ -444,11 +687,10 @@ export function torus(
444
687
  vertices[at + 5] = cp * dz
445
688
  vertices[at + 6] = i / tubularSegments
446
689
  vertices[at + 7] = j / radialSegments
447
- at += FLOATS_PER_VERTEX
690
+ at += stride
448
691
  }
449
692
  }
450
- let indices = new Uint16Array(gridIndices(tubularSegments, radialSegments))
451
- return { vertices, indices, label }
693
+ return finishGeometry(vertices, gridIndices(tubularSegments, radialSegments), options)
452
694
  }
453
695
 
454
696
  /**
@@ -456,7 +698,10 @@ export function torus(
456
698
  * like plane()). UVs are the planar map of the disc inscribed in the unit
457
699
  * square.
458
700
  */
459
- 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
460
705
  let verts: number[] = [0, 0, 0, 0, 0, 1, 0.5, 0.5]
461
706
  let indices: number[] = []
462
707
  for (let i = 0; i < segments; i++) {
@@ -468,7 +713,7 @@ export function circle(radius = 0.5, segments = 32, label?: string): Geometry {
468
713
  for (let i = 0; i < segments; i++) {
469
714
  indices.push(0, 1 + i, 1 + ((i + 1) % segments))
470
715
  }
471
- return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
716
+ return packGeometry(verts, indices, options)
472
717
  }
473
718
 
474
719
  /**
@@ -476,7 +721,10 @@ export function circle(radius = 0.5, segments = 32, label?: string): Geometry {
476
721
  * are the planar map of the OUTER disc, so a ring textures like the
477
722
  * matching circle() with the middle cut out.
478
723
  */
479
- 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
480
728
  let verts: number[] = []
481
729
  let indices: number[] = []
482
730
  for (let i = 0; i < segments; i++) {
@@ -491,11 +739,14 @@ export function ring(innerRadius = 0.25, outerRadius = 0.5, segments = 32, label
491
739
  let j = (i + 1) % segments
492
740
  indices.push(i * 2, i * 2 + 1, j * 2 + 1, i * 2, j * 2 + 1, j * 2)
493
741
  }
494
- return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
742
+ return packGeometry(verts, indices, options)
495
743
  }
496
744
 
497
745
  /** A UV sphere centered on the origin (poles on the y axis). */
498
- 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
499
750
  let verts: number[] = []
500
751
  for (let iy = 0; iy <= heightSegments; iy++) {
501
752
  let v = iy / heightSegments
@@ -513,5 +764,5 @@ export function sphere(radius = 0.5, widthSegments = 24, heightSegments = 16, la
513
764
  }
514
765
  // Both pole rows are collapsed to the pole point.
515
766
  let indices = gridIndices(heightSegments, widthSegments, true, true)
516
- return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
767
+ return packGeometry(verts, indices, options)
517
768
  }