@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.
- package/AGENTS.md +235 -43
- package/README.md +37 -5
- package/examples/README.md +8 -0
- package/examples/aim.tsx +119 -0
- package/examples/scene-post-effect.tsx +82 -0
- package/examples/sweep-paths.tsx +79 -0
- package/package.json +4 -3
- package/src/components.tsx +42 -9
- package/src/geometry.ts +298 -37
- package/src/glsl.ts +112 -0
- package/src/index.ts +11 -5
- package/src/material.ts +43 -6
- package/src/math.ts +334 -19
- package/src/orbit.ts +187 -27
- package/src/profile.ts +315 -0
- package/src/scene.ts +224 -46
- package/src/sweep.ts +460 -0
|
@@ -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.
|
|
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.
|
|
20
|
+
"@solidrt/core": "0.0.48"
|
|
20
21
|
}
|
|
21
22
|
}
|
package/src/components.tsx
CHANGED
|
@@ -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
|
|
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
|
|
68
|
-
*
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
}
|