@solidrt/3d 0.0.46 → 0.0.48

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.
@@ -0,0 +1,79 @@
1
+ // Swept solids along 3D polylines. sweep() runs a 2D profile along a
2
+ // path with mitred joints: bare path points crease - the strap folds
3
+ // over the crate's edges like real webbing - while smooth-tagged points
4
+ // share averaged normals, so the helix sweeps into ONE continuous coil
5
+ // (per-segment boxes with unmitred gaps are exactly what this replaces).
6
+ // tube() is the round-profile shorthand. Lit materials on purpose:
7
+ // creased vs smooth joints differ only in normals, which unlit color
8
+ // would hide.
9
+ import { createSignal, onFrame, pct, render } from "@solidrt/core"
10
+ import { box, Group, Mesh, PerspectiveCamera, plane, roundRect, Scene, shaderMaterial, sweep, tube, unlit } from "@solidrt/3d"
11
+ import type { SweepPath } from "@solidrt/3d"
12
+ import { HEMISPHERE, LAMBERT, LIT_VERTEX } from "@solidrt/3d/glsl"
13
+
14
+ const SIZE = 720
15
+
16
+ function App() {
17
+ let [spin, setSpin] = createSignal(0)
18
+ onFrame(tick => setSpin(tick / 4000))
19
+
20
+ // One hemisphere + lambert look per color (a material instance is the
21
+ // pipeline handle, so each look is created once and shared).
22
+ let lit = (r: number, g: number, b: number) =>
23
+ shaderMaterial({
24
+ vertex: LIT_VERTEX,
25
+ fragment: `
26
+ in vec3 vNormal;
27
+ ${HEMISPHERE}
28
+ ${LAMBERT}
29
+ void main() {
30
+ vec3 n = normalize(vNormal);
31
+ vec3 l = normalize(vec3(0.5, 0.8, 0.4));
32
+ vec3 base = vec3(${r}, ${g}, ${b});
33
+ fragColor = vec4(base * (hemisphere(n, vec3(0.45), vec3(0.22)) + 0.8 * lambert(n, l)), 1.0);
34
+ }`,
35
+ })
36
+
37
+ // The strap hugs the crate at half its thickness; every bend is a bare
38
+ // (sharp) point, so each fold creases exactly on a crate edge.
39
+ let o = 0.013
40
+ let strapPath: SweepPath = [
41
+ [-1.75, o, 0],
42
+ [-1.3 - o, o, 0],
43
+ [-1.3 - o, 0.6 + o, 0],
44
+ [-0.3 + o, 0.6 + o, 0],
45
+ [-0.3 + o, o, 0],
46
+ [0.15, o, 0],
47
+ ]
48
+ let strap = sweep(roundRect(0.3, 0.026, 0.008), strapPath, "strap")
49
+
50
+ // A smooth-tagged helix: one continuous tube, not a stack of segments.
51
+ let coilPath: SweepPath = []
52
+ for (let i = 0; i <= 60; i++) {
53
+ let a = (i / 60) * Math.PI * 5
54
+ coilPath.push({ p: [0.85 + Math.cos(a) * 0.35, 0.055 + i * 0.0095, Math.sin(a) * 0.35], smooth: true })
55
+ }
56
+ let coil = tube(coilPath, 0.05, 12, "coil")
57
+
58
+ return (
59
+ <window>
60
+ <view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
61
+ <Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.07, 0.1, 1]} label="sweep-paths">
62
+ <PerspectiveCamera fov={55} position={[0, 1.9, 3.9]} lookAt={[0, 0.35, 0]} />
63
+ <Mesh
64
+ geometry={plane(6, 6, "floor")}
65
+ material={unlit({ color: [0.16, 0.17, 0.22] })}
66
+ rotation={[-Math.PI / 2, 0, 0]}
67
+ />
68
+ <Group rotation={[0, spin(), 0]}>
69
+ <Mesh geometry={box(1, 0.6, 0.8)} material={lit(0.55, 0.42, 0.28)} position={[-0.8, 0.3, 0]} />
70
+ <Mesh geometry={strap} material={lit(0.9, 0.55, 0.2)} />
71
+ <Mesh geometry={coil} material={lit(0.45, 0.6, 0.8)} />
72
+ </Group>
73
+ </Scene>
74
+ </view>
75
+ </window>
76
+ )
77
+ }
78
+
79
+ render(() => <App />)
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@solidrt/3d",
3
- "version": "0.0.46",
3
+ "version": "0.0.48",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
7
7
  "main": "src/index.ts",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
- "./math": "./src/math.ts"
10
+ "./math": "./src/math.ts",
11
+ "./glsl": "./src/glsl.ts"
11
12
  },
12
13
  "files": [
13
14
  "src/",
@@ -16,6 +17,6 @@
16
17
  ],
17
18
  "peerDependencies": {
18
19
  "@solidjs/signals": "2.0.0-beta.31",
19
- "@solidrt/core": "0.0.46"
20
+ "@solidrt/core": "0.0.48"
20
21
  }
21
22
  }
@@ -7,7 +7,7 @@
7
7
  // structure and slow state, per-frame motion goes straight to the scene.
8
8
 
9
9
  import { createContext, createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
10
- import type { ParentComponent, VoidComponent } from "@solidrt/core"
10
+ import type { Element, ParentComponent, TextureId, VoidComponent } from "@solidrt/core"
11
11
  import {
12
12
  add,
13
13
  createGroup,
@@ -16,13 +16,15 @@ import {
16
16
  remove,
17
17
  setGeometry,
18
18
  setMaterial,
19
+ setMeshParams,
19
20
  setTransform,
20
21
  setVisible,
21
22
  } from "./scene.ts"
23
+ import type { ShaderParams } from "@solidrt/core/gpu"
22
24
  import type { Mesh as MeshNode, Scene as SceneHandle, SceneNode } from "./scene.ts"
23
25
  import type { Geometry } from "./geometry.ts"
24
26
  import type { Material } from "./material.ts"
25
- import type { Vec3 } from "./math.ts"
27
+ import type { Quat, Vec3 } from "./math.ts"
26
28
 
27
29
  type SceneCtx = { scene: SceneHandle; parent: SceneNode }
28
30
  let SceneContext = createContext<SceneCtx>()
@@ -37,35 +39,49 @@ export function useScene(): SceneCtx {
37
39
 
38
40
  export type TransformProps = {
39
41
  position?: Vec3
40
- /** Euler radians, applied x then y then z. */
42
+ /** Euler radians in XYZ order (x first), Three's `Euler` default. */
41
43
  rotation?: Vec3
44
+ /** The rotation as a quaternion - what the node stores. Pass this or
45
+ * `rotation`, not both. */
46
+ quaternion?: Quat
42
47
  scale?: Vec3 | number
43
48
  visible?: boolean
44
49
  }
45
50
 
46
51
  function syncNode(node: SceneNode, props: TransformProps): void {
47
52
  createEffect(
48
- () => [props.position, props.rotation, props.scale, props.visible] as const,
49
- ([position, rotation, scale, visible]) => {
50
- setTransform(node, { position, rotation, scale })
53
+ () => [props.position, props.rotation, props.quaternion, props.scale, props.visible] as const,
54
+ ([position, rotation, quaternion, scale, visible]) => {
55
+ setTransform(node, { position, rotation, quaternion, scale })
51
56
  setVisible(node, visible !== false)
52
57
  },
53
58
  )
54
59
  }
55
60
 
56
61
  export type SceneProps = {
62
+ /** Target pixels. With `output`, the leaf's own width/height are layout,
63
+ * so render size and display size separate (supersampling). */
57
64
  width: number
58
65
  height: number
59
66
  clearColor?: [number, number, number, number]
60
67
  label?: string
61
68
  ref?: (scene: SceneHandle) => void
69
+ /**
70
+ * Compose the output yourself: called once (untracked) with the scene's
71
+ * texture id, and its return renders in place of the built-in `<texture>`
72
+ * leaf - a `<d-texture>`, a leaf carrying paint/pointer/layout props, or
73
+ * a post-effect chain (a shader target sampling the id; created in the
74
+ * callback it disposes with the Scene). Return null to render no leaf.
75
+ */
76
+ output?: (texture: TextureId) => Element
62
77
  }
63
78
 
64
79
  /**
65
80
  * Owns a draw target and composites it as an ordinary `<texture>` leaf, so
66
81
  * the output takes layout, transforms, blendMode, and pointer events like
67
- * any element. Children (Mesh/Group/PerspectiveCamera) render nothing
68
- * themselves - they populate the retained scene through context.
82
+ * any element - or hand `output` the texture id and compose it yourself.
83
+ * Children (Mesh/Group/PerspectiveCamera) render nothing themselves - they
84
+ * populate the retained scene through context.
69
85
  */
70
86
  export let Scene: ParentComponent<SceneProps> = props => {
71
87
  let scene = untrack(() =>
@@ -76,9 +92,14 @@ export let Scene: ParentComponent<SceneProps> = props => {
76
92
  ([w, h]) => scene.setSize(w, h),
77
93
  )
78
94
  untrack(() => props.ref)?.(scene)
95
+ let output = untrack(() => props.output)
79
96
  return (
80
97
  <SceneContext value={{ scene, parent: scene.root }}>
81
- <texture src={scene.texture} width={props.width} height={props.height} />
98
+ {output ? (
99
+ untrack(() => output(scene.texture))
100
+ ) : (
101
+ <texture src={scene.texture} width={props.width} height={props.height} />
102
+ )}
82
103
  {props.children}
83
104
  </SceneContext>
84
105
  )
@@ -98,6 +119,12 @@ export let Group: ParentComponent<TransformProps & { ref?: (node: SceneNode) =>
98
119
  export type MeshProps = TransformProps & {
99
120
  geometry: Geometry
100
121
  material: Material
122
+ /** Per-mesh uniforms for a custom material (setMeshParams as a prop).
123
+ * Keys merge - a key that disappears keeps its old value; there is no
124
+ * unset. Names must be declared by the material's shaders. For values
125
+ * changing every frame prefer `ref` + setMeshParams from onFrame, the
126
+ * same split as setTransform. */
127
+ params?: ShaderParams
101
128
  ref?: (mesh: MeshNode) => void
102
129
  }
103
130
 
@@ -116,6 +143,12 @@ export let Mesh: VoidComponent<MeshProps> = props => {
116
143
  m => setMaterial(mesh, m),
117
144
  { defer: true },
118
145
  )
146
+ createEffect(
147
+ () => props.params,
148
+ p => {
149
+ if (p !== undefined) setMeshParams(mesh, p)
150
+ },
151
+ )
119
152
  syncNode(mesh, props)
120
153
  untrack(() => props.ref)?.(mesh)
121
154
  onCleanup(() => remove(mesh))
package/src/geometry.ts CHANGED
@@ -1,6 +1,11 @@
1
- // Geometry: interleaved vertex data in the one layout every scene material
2
- // shares - position vec3, normal vec3, uv vec2 (8 floats per vertex) - plus
3
- // uint16 indices. Winding is counter-clockwise seen from outside in the
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
7
+ // geometry past 64k vertices uses a Uint32Array and the draw entry follows
8
+ // the array type). Winding is counter-clockwise seen from outside in the
4
9
  // y-up world, which the standard camera rig (perspective() with its baked
5
10
  // y flip) presents as the engine's displayed-CCW front faces: every
6
11
  // generator here culls correctly with cull: "back". Normals ride along
@@ -14,29 +19,59 @@
14
19
  // disposeGeometry frees them when an app is done with a geometry for good.
15
20
 
16
21
  import { createBuffer, destroyBuffer } from "@solidrt/core/gpu"
17
- import type { BufferId, VertexAttribute } from "@solidrt/core/gpu"
22
+ import type { BufferId, IndexFormat, VertexAttribute } from "@solidrt/core/gpu"
18
23
  import { add, cross, normalize, sub } from "./math.ts"
19
- import type { Vec3 } from "./math.ts"
24
+ import type { Vec2, Vec3, Vec4 } from "./math.ts"
20
25
 
21
- export const VERTEX_LAYOUT: VertexAttribute[] = [
26
+ export type VertexLayout = "standard" | "colored"
27
+
28
+ const STANDARD_ATTRIBUTES: VertexAttribute[] = [
22
29
  { name: "aPos", format: "vec3" },
23
30
  { name: "aNormal", format: "vec3" },
24
31
  { name: "aUV", format: "vec2" },
25
32
  ]
33
+
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[]> = {
38
+ standard: STANDARD_ATTRIBUTES,
39
+ colored: [...STANDARD_ATTRIBUTES, { name: "aColor", format: "vec4" }],
40
+ }
41
+
42
+ /** Floats per vertex in the "standard" layout (what every generator emits). */
26
43
  export const FLOATS_PER_VERTEX = 8
44
+ const COLORED_FLOATS = 12
45
+
46
+ /** Uint16 indices when they fit, Uint32Array past 64k vertices - the draw
47
+ * entry follows the array type. The tail of every unbounded generator. */
48
+ export function packIndices(indices: number[], vertexCount: number): Uint16Array | Uint32Array {
49
+ return vertexCount > 65535 ? new Uint32Array(indices) : new Uint16Array(indices)
50
+ }
27
51
 
28
52
  export type Geometry = {
29
- /** Interleaved [pos.xyz, normal.xyz, uv.xy] per vertex. */
53
+ /** Interleaved [pos.xyz, normal.xyz, uv.xy] per vertex, plus color.rgba
54
+ * in the "colored" layout. */
30
55
  vertices: Float32Array
31
- indices: Uint16Array
56
+ /** The array type picks the draw's index format: Uint32Array past 64k
57
+ * vertices. */
58
+ indices: Uint16Array | Uint32Array
59
+ /** Vertex layout; absent means "standard". Must match the material's
60
+ * layout - the scene rejects a mismatched pair at add(). */
61
+ layout?: VertexLayout
32
62
  /** Debug name for the lazily-created GPU buffers. */
33
63
  label?: string
34
64
  _buffer?: BufferId
35
65
  _index?: BufferId
36
66
  }
37
67
 
38
- /** The geometry's GPU buffers, created on first use and cached on it. */
39
- export function geometryBuffers(geometry: Geometry): { buffer: BufferId; index: BufferId } {
68
+ /** The geometry's GPU buffers, created on first use and cached on it,
69
+ * plus the index format the draw entry must bind them with. */
70
+ export function geometryBuffers(geometry: Geometry): {
71
+ buffer: BufferId
72
+ index: BufferId
73
+ indexFormat: IndexFormat
74
+ } {
40
75
  let buffer = geometry._buffer
41
76
  let index = geometry._index
42
77
  if (buffer === undefined || index === undefined) {
@@ -51,7 +86,7 @@ export function geometryBuffers(geometry: Geometry): { buffer: BufferId; index:
51
86
  geometry._buffer = buffer
52
87
  geometry._index = index
53
88
  }
54
- return { buffer, index }
89
+ return { buffer, index, indexFormat: geometry.indices instanceof Uint32Array ? "uint32" : "uint16" }
55
90
  }
56
91
 
57
92
  /**
@@ -66,6 +101,106 @@ export function disposeGeometry(geometry: Geometry): void {
66
101
  geometry._index = undefined
67
102
  }
68
103
 
104
+ /** Per-vertex aColor values for withColors/fillColors: a flat 4-per-vertex
105
+ * array, or a callback deriving each vertex's vec4 from the vertex data. */
106
+ export type ColorFill = ArrayLike<number> | ((index: number, pos: Vec3, normal: Vec3, uv: Vec2) => Vec4)
107
+
108
+ /**
109
+ * Derive a "colored"-layout geometry from a standard one: the same
110
+ * positions, normals, uvs and indices, plus an aColor vec4 per vertex -
111
+ * the data channel for materials whose vertex stage reads `in vec4 aColor`
112
+ * (a tint, baked ambient occlusion, any four scalars; the name is the
113
+ * standard vocabulary, the contents are yours). The callback form receives
114
+ * each vertex's position, normal and uv - what a baker wants. The source
115
+ * geometry is untouched and its GPU buffers stay independent.
116
+ */
117
+ export function withColors(geometry: Geometry, fill: ColorFill, label?: string): Geometry {
118
+ if (geometry.layout === "colored") {
119
+ throw new Error("withColors: geometry already carries an aColor channel")
120
+ }
121
+ if (geometry.vertices.length % FLOATS_PER_VERTEX !== 0) {
122
+ throw new Error("withColors: vertex data is not a whole number of standard-layout vertices")
123
+ }
124
+ let count = geometry.vertices.length / FLOATS_PER_VERTEX
125
+ if (typeof fill !== "function" && fill.length !== count * 4) {
126
+ throw new Error("withColors: fill has " + fill.length + " floats, expected 4 per vertex (" + count * 4 + ")")
127
+ }
128
+ let src = geometry.vertices
129
+ let out = new Float32Array(count * COLORED_FLOATS)
130
+ for (let i = 0; i < count; i++) {
131
+ let s = i * FLOATS_PER_VERTEX
132
+ let d = i * COLORED_FLOATS
133
+ for (let k = 0; k < FLOATS_PER_VERTEX; k++) out[d + k] = src[s + k]!
134
+ }
135
+ fillColors(out, fill)
136
+ return {
137
+ vertices: out,
138
+ indices: geometry.indices,
139
+ layout: "colored",
140
+ label: label ?? (geometry.label ? geometry.label + "-colored" : undefined),
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The in-place primitive under withColors: write the aColor slots of a
146
+ * colored-layout interleave you already own - the hook for a merging
147
+ * builder baking colors over its packed buffer (the pos/normal/uv the
148
+ * callback receives are read from the buffer itself, so a packer that
149
+ * bakes transforms while writing hands the baker world-space vertices).
150
+ * Fills vertices [first, first + count) - count defaults to the rest of
151
+ * the buffer - and `fill` indexes relative to `first`, so a per-part
152
+ * callback works unchanged for both APIs. Returns `vertices`.
153
+ *
154
+ * This trusts the buffer to BE colored-layout data - a bare array carries
155
+ * no layout tag, so only the arithmetic is checked. The Geometry-level
156
+ * withColors stays the checked path.
157
+ */
158
+ export function fillColors(vertices: Float32Array, fill: ColorFill, first = 0, count?: number): Float32Array {
159
+ if (vertices.length % COLORED_FLOATS !== 0) {
160
+ throw new Error("fillColors: vertex data is not a whole number of colored-layout vertices")
161
+ }
162
+ let total = vertices.length / COLORED_FLOATS
163
+ let n = count ?? total - first
164
+ if (!Number.isInteger(first) || !Number.isInteger(n) || first < 0 || n < 0 || first + n > total) {
165
+ throw new Error("fillColors: range [" + first + ", " + (first + n) + ") is outside the buffer's " + total + " vertices")
166
+ }
167
+ let fn = typeof fill === "function" ? fill : null
168
+ if (!fn && fill.length !== n * 4) {
169
+ throw new Error("fillColors: fill has " + fill.length + " floats, expected 4 per vertex (" + n * 4 + ")")
170
+ }
171
+ for (let i = 0; i < n; i++) {
172
+ let d = (first + i) * COLORED_FLOATS
173
+ let c: Vec4 = fn
174
+ ? 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]!])
175
+ : [(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]!]
176
+ vertices[d + 8] = c[0]
177
+ vertices[d + 9] = c[1]
178
+ vertices[d + 10] = c[2]
179
+ vertices[d + 11] = c[3]
180
+ }
181
+ return vertices
182
+ }
183
+
184
+ // Indices for a row-major (cellRows + 1) x (cellCols + 1) vertex grid: two
185
+ // CCW triangles per cell, split across the row0col0-row1col1 diagonal -
186
+ // the one quad pattern every grid generator here shares (rows run along
187
+ // the surface, columns around, same handedness everywhere). A collapsed
188
+ // first/last vertex row (sphere pole, cone apex) skips its zero-area
189
+ // triangle per cell.
190
+ function gridIndices(cellRows: number, cellCols: number, skipFirst = false, skipLast = false): number[] {
191
+ let cols = cellCols + 1
192
+ let out: number[] = []
193
+ for (let r = 0; r < cellRows; r++) {
194
+ for (let c = 0; c < cellCols; c++) {
195
+ let r0 = r * cols + c
196
+ let r1 = r0 + cols
197
+ if (!skipFirst || r > 0) out.push(r0 + 1, r0, r1 + 1)
198
+ if (!skipLast || r < cellRows - 1) out.push(r0, r1, r1 + 1)
199
+ }
200
+ }
201
+ return out
202
+ }
203
+
69
204
  /** An axis-aligned box centered on the origin: 24 vertices, 36 indices. */
70
205
  export function box(width = 1, height = 1, depth = 1, label?: string): Geometry {
71
206
  let x = width / 2
@@ -174,30 +309,165 @@ export function torusKnot(
174
309
  }
175
310
  }
176
311
 
177
- let indices = new Uint16Array(tubularSegments * radialSegments * 6)
178
- let n = 0
179
- for (let i = 0; i < tubularSegments; i++) {
180
- for (let j = 0; j < radialSegments; j++) {
181
- let a = i * cols + j
182
- let b = (i + 1) * cols + j
183
- let c = (i + 1) * cols + j + 1
184
- let d = i * cols + j + 1
185
- indices[n++] = a
186
- indices[n++] = b
187
- indices[n++] = c
188
- indices[n++] = a
189
- indices[n++] = c
190
- indices[n++] = d
312
+ let indices = new Uint16Array(gridIndices(tubularSegments, radialSegments))
313
+
314
+ return { vertices, indices, label }
315
+ }
316
+
317
+ /**
318
+ * A capped cylinder on the y axis, centered on the origin. Different top
319
+ * and bottom radii make it a truncated cone (`cone()` is the zero-top
320
+ * case); side normals tilt with the taper. Side UVs: u around the
321
+ * circumference, v 0 at the top to 1 at the bottom; caps get a planar
322
+ * disc map. A zero radius skips that cap and the degenerate side
323
+ * triangles at the apex.
324
+ */
325
+ export function cylinder(
326
+ radiusTop = 0.5,
327
+ radiusBottom = 0.5,
328
+ height = 1,
329
+ radialSegments = 24,
330
+ label?: string,
331
+ ): Geometry {
332
+ let h = height / 2
333
+ let cols = radialSegments + 1
334
+ let verts: number[] = []
335
+ // Side normal: perpendicular to the slant line in the (radial, y) plane.
336
+ let slant = Math.hypot(height, radiusBottom - radiusTop) || 1
337
+ let nr = height / slant
338
+ let ny = (radiusBottom - radiusTop) / slant
339
+ let rows = [
340
+ { r: radiusTop, y: h, v: 0 },
341
+ { r: radiusBottom, y: -h, v: 1 },
342
+ ]
343
+ for (let row of rows) {
344
+ for (let ix = 0; ix < cols; ix++) {
345
+ let u = ix / radialSegments
346
+ let phi = u * Math.PI * 2
347
+ let dx = -Math.cos(phi)
348
+ let dz = Math.sin(phi)
349
+ verts.push(row.r * dx, row.y, row.r * dz, nr * dx, ny, nr * dz, u, row.v)
191
350
  }
192
351
  }
352
+ let indices = gridIndices(1, radialSegments, radiusTop <= 0, radiusBottom <= 0)
353
+ // Caps fan around a center vertex; the planar UV map has no seam, so the
354
+ // ring wraps with modulo instead of duplicating a column.
355
+ let cap = (r: number, y: number, up: number) => {
356
+ let base = verts.length / FLOATS_PER_VERTEX
357
+ verts.push(0, y, 0, 0, up, 0, 0.5, 0.5)
358
+ for (let i = 0; i < radialSegments; i++) {
359
+ let phi = (i / radialSegments) * Math.PI * 2
360
+ let x = -Math.cos(phi) * r
361
+ let z = Math.sin(phi) * r
362
+ verts.push(x, y, z, 0, up, 0, 0.5 + x / (2 * r), 0.5 + (up > 0 ? z : -z) / (2 * r))
363
+ }
364
+ for (let i = 0; i < radialSegments; i++) {
365
+ let j = (i + 1) % radialSegments
366
+ if (up > 0) indices.push(base, base + 1 + i, base + 1 + j)
367
+ else indices.push(base, base + 1 + j, base + 1 + i)
368
+ }
369
+ }
370
+ if (radiusTop > 0) cap(radiusTop, h, 1)
371
+ if (radiusBottom > 0) cap(radiusBottom, -h, -1)
372
+ return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
373
+ }
193
374
 
375
+ /** A capped cone on the y axis, centered on the origin: `cylinder()` with
376
+ * a zero top radius (each apex vertex carries its column's side normal, so
377
+ * the surface shades smoothly around). */
378
+ export function cone(radius = 0.5, height = 1, radialSegments = 24, label?: string): Geometry {
379
+ return cylinder(0, radius, height, radialSegments, label)
380
+ }
381
+
382
+ /**
383
+ * A torus lying flat, centered on the origin: the ring lies in the XZ
384
+ * plane with the hole on the y axis - the y-up orientation torusKnot also
385
+ * uses (Three's equivalent stands in XY). Signature order is Three's:
386
+ * radialSegments subdivides the tube cross-section, tubularSegments the
387
+ * ring. UVs: u 0..1 around the ring, v 0..1 around the tube, seam
388
+ * row/column duplicated like torusKnot.
389
+ */
390
+ export function torus(
391
+ radius = 0.5,
392
+ tube = 0.2,
393
+ radialSegments = 12,
394
+ tubularSegments = 32,
395
+ label?: string,
396
+ ): Geometry {
397
+ let rows = tubularSegments + 1
398
+ let cols = radialSegments + 1
399
+ let vertices = new Float32Array(rows * cols * FLOATS_PER_VERTEX)
400
+ let at = 0
401
+ for (let i = 0; i < rows; i++) {
402
+ let phi = (i / tubularSegments) * Math.PI * 2
403
+ let dx = -Math.cos(phi)
404
+ let dz = Math.sin(phi)
405
+ for (let j = 0; j < cols; j++) {
406
+ let psi = (j / radialSegments) * Math.PI * 2
407
+ let cp = Math.cos(psi)
408
+ let sp = Math.sin(psi)
409
+ let r = radius + tube * cp
410
+ vertices[at] = r * dx
411
+ vertices[at + 1] = tube * sp
412
+ vertices[at + 2] = r * dz
413
+ vertices[at + 3] = cp * dx
414
+ vertices[at + 4] = sp
415
+ vertices[at + 5] = cp * dz
416
+ vertices[at + 6] = i / tubularSegments
417
+ vertices[at + 7] = j / radialSegments
418
+ at += FLOATS_PER_VERTEX
419
+ }
420
+ }
421
+ let indices = new Uint16Array(gridIndices(tubularSegments, radialSegments))
194
422
  return { vertices, indices, label }
195
423
  }
196
424
 
425
+ /**
426
+ * A disc in the XY plane facing +z, centered on the origin (rotate flat
427
+ * like plane()). UVs are the planar map of the disc inscribed in the unit
428
+ * square.
429
+ */
430
+ export function circle(radius = 0.5, segments = 32, label?: string): Geometry {
431
+ let verts: number[] = [0, 0, 0, 0, 0, 1, 0.5, 0.5]
432
+ let indices: number[] = []
433
+ for (let i = 0; i < segments; i++) {
434
+ let a = (i / segments) * Math.PI * 2
435
+ let c = Math.cos(a)
436
+ let s = Math.sin(a)
437
+ verts.push(radius * c, radius * s, 0, 0, 0, 1, 0.5 + c * 0.5, 0.5 - s * 0.5)
438
+ }
439
+ for (let i = 0; i < segments; i++) {
440
+ indices.push(0, 1 + i, 1 + ((i + 1) % segments))
441
+ }
442
+ return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
443
+ }
444
+
445
+ /**
446
+ * A flat annulus in the XY plane facing +z, centered on the origin. UVs
447
+ * are the planar map of the OUTER disc, so a ring textures like the
448
+ * matching circle() with the middle cut out.
449
+ */
450
+ export function ring(innerRadius = 0.25, outerRadius = 0.5, segments = 32, label?: string): Geometry {
451
+ let verts: number[] = []
452
+ let indices: number[] = []
453
+ for (let i = 0; i < segments; i++) {
454
+ let a = (i / segments) * Math.PI * 2
455
+ let c = Math.cos(a)
456
+ let s = Math.sin(a)
457
+ for (let r of [innerRadius, outerRadius]) {
458
+ verts.push(r * c, r * s, 0, 0, 0, 1, 0.5 + (r * c) / (2 * outerRadius), 0.5 - (r * s) / (2 * outerRadius))
459
+ }
460
+ }
461
+ for (let i = 0; i < segments; i++) {
462
+ let j = (i + 1) % segments
463
+ indices.push(i * 2, i * 2 + 1, j * 2 + 1, i * 2, j * 2 + 1, j * 2)
464
+ }
465
+ return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
466
+ }
467
+
197
468
  /** A UV sphere centered on the origin (poles on the y axis). */
198
469
  export function sphere(radius = 0.5, widthSegments = 24, heightSegments = 16, label?: string): Geometry {
199
470
  let verts: number[] = []
200
- let indices: number[] = []
201
471
  for (let iy = 0; iy <= heightSegments; iy++) {
202
472
  let v = iy / heightSegments
203
473
  let theta = v * Math.PI
@@ -212,16 +482,7 @@ export function sphere(radius = 0.5, widthSegments = 24, heightSegments = 16, la
212
482
  verts.push(radius * nx, radius * ny, radius * nz, nx, ny, nz, u, v)
213
483
  }
214
484
  }
215
- let cols = widthSegments + 1
216
- for (let iy = 0; iy < heightSegments; iy++) {
217
- for (let ix = 0; ix < widthSegments; ix++) {
218
- let a = iy * cols + ix + 1
219
- let b = iy * cols + ix
220
- let c = (iy + 1) * cols + ix
221
- let d = (iy + 1) * cols + ix + 1
222
- if (iy !== 0) indices.push(a, b, d)
223
- if (iy !== heightSegments - 1) indices.push(b, c, d)
224
- }
225
- }
485
+ // Both pole rows are collapsed to the pole point.
486
+ let indices = gridIndices(heightSegments, widthSegments, true, true)
226
487
  return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
227
488
  }