@solidrt/3d 0.0.46

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 ADDED
@@ -0,0 +1,114 @@
1
+ # @solidrt/3d - agent notes
2
+
3
+ A retained 3D scene graph above `@solidrt/core/gpu`. Meshes, materials and
4
+ a camera compile to ONE depth-buffered draw target (`createDrawTarget` +
5
+ one `addDraw` entry per mesh); the scene's output is an ordinary texture
6
+ id composited as a `<texture>` leaf, so it takes layout, transforms,
7
+ blendMode and pointer events like any element. Design rationale:
8
+ `okf/research/scene-graph-3d.md` in the repo.
9
+
10
+ ## The model
11
+
12
+ - Two layers. The imperative core is Solid-free: `createScene`,
13
+ `createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
14
+ `setVisible` - plain objects with dirty flags, batched to a microtask,
15
+ one `setDrawParams` (the uModel matrix) per changed mesh and ONE
16
+ `setTargetParams` (the shared uViewProj) per camera change, however many
17
+ meshes. The component
18
+ face (`Scene`/`Group`/`Mesh`/`PerspectiveCamera`) syncs props into that
19
+ core over context and renders nothing itself.
20
+ - Rendering is the runtime's. The target is `render: "auto"`: it
21
+ re-renders when entries change, so a STATIC scene costs zero passes and
22
+ the library registers no frame loop. Continuous animation is the app's
23
+ own `onFrame` writing a signal (declarative) or `setTransform` on a
24
+ `ref`-grabbed node (the frame-rate escape hatch - signals carry
25
+ structure, per-frame motion goes straight to the scene).
26
+ - One vertex layout everywhere: `aPos` vec3 + `aNormal` vec3 + `aUV` vec2,
27
+ uint16-indexed. Geometry GPU buffers are lazy, shared, and app-lifetime
28
+ (owner-scoped free would break sharing); `disposeGeometry` frees them.
29
+ - Materials dedupe hard: one program + one pipeline per material CLASS
30
+ (unlit color, unlit map), `depth: true` + `cull: "back"`; an instance is
31
+ just per-entry uniforms (`uColor`) and bindings (`uMap`).
32
+
33
+ ## Components
34
+
35
+ | Component | Props |
36
+ | --- | --- |
37
+ | `Scene` | `width`, `height` (target pixels), `clearColor?`, `label?`, `ref?(scene)` |
38
+ | `Group` | `position?`, `rotation?` (Euler radians, x-y-z order), `scale?` (number = uniform), `visible?`, `ref?(node)` |
39
+ | `Mesh` | `geometry`, `material`, transforms as Group, `ref?(mesh)` |
40
+ | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
41
+
42
+ Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
43
+ distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
44
+ zoomSpeed? })` - drag-to-rotate, wheel-to-zoom, optional auto-orbit. Spread
45
+ `orbit.handlers` onto the input-owning element, call `orbit.update(dt)`
46
+ from your onFrame (no frame loop of its own), and use its return - true
47
+ when the pose changed - to gate per-frame dependents like a `uCamPos`
48
+ write. `orbiting()` is reactive (HUD-safe); the pose is plain state via
49
+ `pose()`/`set()` (also the debug-command shape). It drives position and
50
+ target only; fov/near/far stay on scene.setCamera. In a component tree,
51
+ reach the scene via `<Scene ref>` or useScene().
52
+
53
+ Geometry: `box(w?, h?, d?)`, `plane(w?, h?)` (XY, faces +z - rotate
54
+ `[-Math.PI/2, 0, 0]` for a floor), `sphere(radius?, wSeg?, hSeg?)`,
55
+ `torusKnot(radius?, tube?, tubularSeg?, radialSeg?, p?, q?)` (standing
56
+ y-up, unlike Three's z-up).
57
+ Materials:
58
+
59
+ - `unlit({ color?, map? })` - straight `[r, g, b, a?]` 0..1, premultiplied
60
+ internally.
61
+ - `shaderMaterial({ vertex, fragment, params?, textures?, depth?,
62
+ depthWrite?, blend?, cull?, topology?, label? })` - your own GLSL, the
63
+ custom-look escape hatch. The vertex stage MUST declare and use BOTH
64
+ `uniform mat4 uModel` (the mesh's world matrix, per entry) and
65
+ `uniform mat4 uViewProj` (the camera, shared target-level params) -
66
+ transform with `uViewProj * uModel * vec4(aPos, 1.0)`; attributes come
67
+ from the shared layout by name; sources without
68
+ `#version` get the standard pipeline preamble. App-driven uniforms
69
+ beyond uModel/uViewProj: seed via `params`, then write per mesh with
70
+ `setMeshParams(mesh, { name: value })` (validated names; values persist
71
+ across entry rebuilds; frame-rate-safe like setTransform).
72
+
73
+ ## Traps
74
+
75
+ - The y-down clip flip is baked into `perspective()`; scene code and
76
+ geometry are plain y-up right-handed, and CCW-outward winding culls
77
+ correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
78
+ "fix" the negated row of `perspective()` - both would mirror the winding
79
+ and show mesh interiors.
80
+ - `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
81
+ cheap off switch). Hidden meshes skip uModel writes; the fresh matrix is
82
+ written on unhide.
83
+ - Alpha does not blend in v1: pipelines are opaque (`blend: "none"`), a
84
+ translucent color overwrites. Transparency waits on blend factors +
85
+ sorting (research note, staging step 4).
86
+ - Rotation is Euler radians applied x, then y, then z. No quaternions in
87
+ v1.
88
+ - Transforms have ONE write path: `setTransform`/`setVisible` (or the
89
+ props that call them). Mutating `node.position` directly does not sync.
90
+ - A camera change is ONE `setTargetParams` write (uViewProj is target
91
+ state), independent of mesh count - never reintroduce per-mesh camera
92
+ writes. Scene scale honestly: hundreds to a
93
+ few thousand objects, bounded by the interpreter, not the GPU.
94
+ - Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
95
+ list END. Irrelevant while everything is opaque + depth-tested; revisit
96
+ when transparency lands.
97
+ - `useScene()`/`Group`/`Mesh` throw outside `<Scene>` (default-less
98
+ context).
99
+ - A `shaderMaterial` INSTANCE is the pipeline handle: identical sources
100
+ compile twice - no dedupe by source value (deliberate; hidden
101
+ content-keyed caches are the anti-pattern the GPU layer avoids). Create
102
+ one per look at app scope, share across meshes, `dispose()` when done
103
+ for good.
104
+ - A shaderMaterial vertex stage without `uniform mat4 uModel` (declared
105
+ AND used) throws at mesh attach - the scene seeds uModel on every entry
106
+ and the engine rejects unknown uniform names. One without `uViewProj`
107
+ also throws at attach when it is the scene's ONLY material class: after
108
+ the first camera sync, _attach re-issues the shared uViewProj (same
109
+ value, one write) so the coverage error lands at add() instead of
110
+ inside a later camera-sync microtask. Only the very first attach (no
111
+ camera sync yet) reports it asynchronously, from the sync that attach
112
+ schedules. With other declaring materials present it silently ignores
113
+ the camera instead (partial coverage is legal). Declare and use both,
114
+ always.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @solidrt/3d
2
+
3
+ A retained 3D scene graph for SolidRT: meshes, materials and a camera,
4
+ declared as Solid components, rendered by the runtime into an ordinary
5
+ texture in your UI tree.
6
+
7
+ ```tsx
8
+ import { createSignal, onFrame, render } from "@solidrt/core"
9
+ import { box, Mesh, PerspectiveCamera, Scene, unlit } from "@solidrt/3d"
10
+
11
+ function App() {
12
+ let [spin, setSpin] = createSignal(0)
13
+ onFrame(tick => setSpin(tick / 2000))
14
+ return (
15
+ <window>
16
+ <Scene width={720} height={720}>
17
+ <PerspectiveCamera position={[0, 1.5, 3]} lookAt={[0, 0, 0]} />
18
+ <Mesh geometry={box()} material={unlit({ color: [0.9, 0.3, 0.3] })} rotation={[0, spin(), 0]} />
19
+ </Scene>
20
+ </window>
21
+ )
22
+ }
23
+ render(() => <App />)
24
+ ```
25
+
26
+ The scene compiles to one depth-buffered GPU draw target: one draw entry
27
+ per mesh, one shared pipeline per material class, cross-mesh occlusion
28
+ from the shared depth buffer. A static scene costs zero GPU passes - the
29
+ runtime re-renders the target only when something changes - and a moved
30
+ mesh costs one uniform write.
31
+
32
+ There is also an imperative layer underneath (`createScene`, `createMesh`,
33
+ `setTransform`, ...) usable without components, plus a small math module
34
+ (`@solidrt/3d/math`: column-major mat4, perspective, lookAt).
35
+
36
+ v1 scope: unlit color/textured materials plus `shaderMaterial` (your own
37
+ GLSL as a first-class material), box/plane/sphere geometry, one
38
+ perspective camera with an orbit control (`createOrbitCamera`: drag,
39
+ zoom, auto-orbit). Lights, transparency, model loading and picking are
40
+ staged next - see `okf/research/scene-graph-3d.md` for the roadmap. Full
41
+ usage notes and traps: [AGENTS.md](AGENTS.md); runnable examples:
42
+ [examples/](examples/).
@@ -0,0 +1,9 @@
1
+ # @solidrt/3d examples
2
+
3
+ One concept per file; run with `bunx srt run <file>` from an app that
4
+ depends on `@solidrt/3d` (or in-repo from the package directory).
5
+
6
+ - `scene-basic.tsx` - the whole v1 surface: a `<Scene>` composited as a
7
+ texture leaf, `<PerspectiveCamera>`, a ground plane, a spinning
8
+ `<Group>` of unlit meshes with real depth-buffer occlusion, geometry
9
+ and pipeline sharing, and the one-signal onFrame drive.
@@ -0,0 +1,53 @@
1
+ // A minimal scene: unlit meshes with real cross-mesh occlusion (one shared
2
+ // depth buffer), a spinning group, and a fixed camera. One onFrame loop
3
+ // drives one signal; the library keeps every draw entry's uModel (and the
4
+ // scene target's shared uViewProj) in step.
5
+ // The sphere orbits with the group, crossing behind and in front of the
6
+ // tall box - that alternation is the depth buffer at work, not draw order.
7
+ //
8
+ // NOTE a registered onFrame keeps the client presenting every vsync -
9
+ // right for a continuously animating scene, wrong for a static one (a
10
+ // static scene costs zero passes; drop the onFrame and it idles).
11
+ import { createSignal, onFrame, pct, render } from "@solidrt/core"
12
+ import { box, Group, Mesh, PerspectiveCamera, plane, Scene, sphere, unlit } from "@solidrt/3d"
13
+
14
+ const SIZE = 720
15
+
16
+ function App() {
17
+ let [spin, setSpin] = createSignal(0)
18
+ onFrame(tick => setSpin(tick / 2000))
19
+
20
+ // Geometries and materials are plain values, shared freely: both boxes
21
+ // use one cube geometry (one vertex/index buffer pair on the GPU), and
22
+ // every mesh here shares the one unlit-color pipeline.
23
+ let cube = box()
24
+ let floor = plane(6, 6, "floor")
25
+ let ball = sphere(0.35)
26
+
27
+ return (
28
+ <window>
29
+ <view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
30
+ <Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.07, 0.1, 1]} label="scene-basic">
31
+ <PerspectiveCamera fov={55} position={[0, 1.6, 3.6]} lookAt={[0, 0.3, 0]} />
32
+ <Mesh
33
+ geometry={floor}
34
+ material={unlit({ color: [0.16, 0.17, 0.22] })}
35
+ rotation={[-Math.PI / 2, 0, 0]}
36
+ />
37
+ <Group rotation={[0, spin(), 0]}>
38
+ <Mesh geometry={cube} material={unlit({ color: [0.85, 0.3, 0.3] })} position={[0, 0.5, 0]} />
39
+ <Mesh
40
+ geometry={cube}
41
+ material={unlit({ color: [0.9, 0.8, 0.35] })}
42
+ position={[-1.1, 0.7, 0]}
43
+ scale={[0.5, 1.4, 0.5]}
44
+ />
45
+ <Mesh geometry={ball} material={unlit({ color: [0.35, 0.65, 0.9] })} position={[1.1, 0.35, 0]} />
46
+ </Group>
47
+ </Scene>
48
+ </view>
49
+ </window>
50
+ )
51
+ }
52
+
53
+ render(() => <App />)
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@solidrt/3d",
3
+ "version": "0.0.46",
4
+ "license": "MIT",
5
+ "author": "Antoine van Wel",
6
+ "type": "module",
7
+ "main": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./math": "./src/math.ts"
11
+ },
12
+ "files": [
13
+ "src/",
14
+ "examples/",
15
+ "AGENTS.md"
16
+ ],
17
+ "peerDependencies": {
18
+ "@solidjs/signals": "2.0.0-beta.31",
19
+ "@solidrt/core": "0.0.46"
20
+ }
21
+ }
@@ -0,0 +1,148 @@
1
+ // The Solid face: PascalCase components over context, syncing the retained
2
+ // scene (scene.ts) - no new intrinsic elements, no renderer changes. Props
3
+ // follow the Solid 2.0 model (reactive values, no destructuring); effects
4
+ // write into the retained nodes and the runtime's dirty flush renders.
5
+ // Anything moving at frame rate can bypass the declarative layer: grab the
6
+ // node with `ref` and call setTransform from onFrame - signals carry
7
+ // structure and slow state, per-frame motion goes straight to the scene.
8
+
9
+ import { createContext, createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
10
+ import type { ParentComponent, VoidComponent } from "@solidrt/core"
11
+ import {
12
+ add,
13
+ createGroup,
14
+ createMesh,
15
+ createScene,
16
+ remove,
17
+ setGeometry,
18
+ setMaterial,
19
+ setTransform,
20
+ setVisible,
21
+ } from "./scene.ts"
22
+ import type { Mesh as MeshNode, Scene as SceneHandle, SceneNode } from "./scene.ts"
23
+ import type { Geometry } from "./geometry.ts"
24
+ import type { Material } from "./material.ts"
25
+ import type { Vec3 } from "./math.ts"
26
+
27
+ type SceneCtx = { scene: SceneHandle; parent: SceneNode }
28
+ let SceneContext = createContext<SceneCtx>()
29
+
30
+ /**
31
+ * The enclosing scene and parent node - the imperative escape hatch inside
32
+ * a component subtree (throws outside a `<Scene>`).
33
+ */
34
+ export function useScene(): SceneCtx {
35
+ return useContext(SceneContext)
36
+ }
37
+
38
+ export type TransformProps = {
39
+ position?: Vec3
40
+ /** Euler radians, applied x then y then z. */
41
+ rotation?: Vec3
42
+ scale?: Vec3 | number
43
+ visible?: boolean
44
+ }
45
+
46
+ function syncNode(node: SceneNode, props: TransformProps): void {
47
+ createEffect(
48
+ () => [props.position, props.rotation, props.scale, props.visible] as const,
49
+ ([position, rotation, scale, visible]) => {
50
+ setTransform(node, { position, rotation, scale })
51
+ setVisible(node, visible !== false)
52
+ },
53
+ )
54
+ }
55
+
56
+ export type SceneProps = {
57
+ width: number
58
+ height: number
59
+ clearColor?: [number, number, number, number]
60
+ label?: string
61
+ ref?: (scene: SceneHandle) => void
62
+ }
63
+
64
+ /**
65
+ * Owns a draw target and composites it as an ordinary `<texture>` leaf, so
66
+ * 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.
69
+ */
70
+ export let Scene: ParentComponent<SceneProps> = props => {
71
+ let scene = untrack(() =>
72
+ createScene(props.width, props.height, { clearColor: props.clearColor, label: props.label }),
73
+ )
74
+ createEffect(
75
+ () => [props.width, props.height] as const,
76
+ ([w, h]) => scene.setSize(w, h),
77
+ )
78
+ untrack(() => props.ref)?.(scene)
79
+ return (
80
+ <SceneContext value={{ scene, parent: scene.root }}>
81
+ <texture src={scene.texture} width={props.width} height={props.height} />
82
+ {props.children}
83
+ </SceneContext>
84
+ )
85
+ }
86
+
87
+ /** A transform node: children inherit its position/rotation/scale. */
88
+ export let Group: ParentComponent<TransformProps & { ref?: (node: SceneNode) => void }> = props => {
89
+ let ctx = useContext(SceneContext)
90
+ let node = createGroup()
91
+ add(ctx.parent, node)
92
+ syncNode(node, props)
93
+ untrack(() => props.ref)?.(node)
94
+ onCleanup(() => remove(node))
95
+ return <SceneContext value={{ scene: ctx.scene, parent: node }}>{props.children}</SceneContext>
96
+ }
97
+
98
+ export type MeshProps = TransformProps & {
99
+ geometry: Geometry
100
+ material: Material
101
+ ref?: (mesh: MeshNode) => void
102
+ }
103
+
104
+ /** One draw entry: geometry drawn with a material at a transform. */
105
+ export let Mesh: VoidComponent<MeshProps> = props => {
106
+ let ctx = useContext(SceneContext)
107
+ let mesh = untrack(() => createMesh(props.geometry, props.material))
108
+ add(ctx.parent, mesh)
109
+ createEffect(
110
+ () => props.geometry,
111
+ g => setGeometry(mesh, g),
112
+ { defer: true },
113
+ )
114
+ createEffect(
115
+ () => props.material,
116
+ m => setMaterial(mesh, m),
117
+ { defer: true },
118
+ )
119
+ syncNode(mesh, props)
120
+ untrack(() => props.ref)?.(mesh)
121
+ onCleanup(() => remove(mesh))
122
+ return null
123
+ }
124
+
125
+ export type PerspectiveCameraProps = {
126
+ /** Vertical field of view in DEGREES (default 60). */
127
+ fov?: number
128
+ near?: number
129
+ far?: number
130
+ position?: Vec3
131
+ lookAt?: Vec3
132
+ up?: Vec3
133
+ }
134
+
135
+ /**
136
+ * Drives the scene's camera from props (the scene has a default camera, so
137
+ * this component is optional). The camera is scene state, not a tree node:
138
+ * to orbit it, update `position`/`lookAt`.
139
+ */
140
+ export let PerspectiveCamera: VoidComponent<PerspectiveCameraProps> = props => {
141
+ let ctx = useContext(SceneContext)
142
+ createEffect(
143
+ () => [props.fov, props.near, props.far, props.position, props.lookAt, props.up] as const,
144
+ ([fov, near, far, position, lookAt, up]) =>
145
+ ctx.scene.setCamera({ fov, near, far, position, target: lookAt, up }),
146
+ )
147
+ return null
148
+ }
@@ -0,0 +1,227 @@
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
4
+ // y-up world, which the standard camera rig (perspective() with its baked
5
+ // y flip) presents as the engine's displayed-CCW front faces: every
6
+ // generator here culls correctly with cull: "back". Normals ride along
7
+ // unused by the unlit materials so the layout is ready for lights without
8
+ // a geometry change (inactive attributes are skipped but keep the stride).
9
+ //
10
+ // GPU buffers are created lazily on first use and shared by every mesh and
11
+ // scene drawing the geometry. They are app-lifetime by design - one
12
+ // geometry commonly outlives the component that first drew it, so
13
+ // owner-scoped auto-free would free a buffer other scenes still draw from.
14
+ // disposeGeometry frees them when an app is done with a geometry for good.
15
+
16
+ import { createBuffer, destroyBuffer } from "@solidrt/core/gpu"
17
+ import type { BufferId, VertexAttribute } from "@solidrt/core/gpu"
18
+ import { add, cross, normalize, sub } from "./math.ts"
19
+ import type { Vec3 } from "./math.ts"
20
+
21
+ export const VERTEX_LAYOUT: VertexAttribute[] = [
22
+ { name: "aPos", format: "vec3" },
23
+ { name: "aNormal", format: "vec3" },
24
+ { name: "aUV", format: "vec2" },
25
+ ]
26
+ export const FLOATS_PER_VERTEX = 8
27
+
28
+ export type Geometry = {
29
+ /** Interleaved [pos.xyz, normal.xyz, uv.xy] per vertex. */
30
+ vertices: Float32Array
31
+ indices: Uint16Array
32
+ /** Debug name for the lazily-created GPU buffers. */
33
+ label?: string
34
+ _buffer?: BufferId
35
+ _index?: BufferId
36
+ }
37
+
38
+ /** The geometry's GPU buffers, created on first use and cached on it. */
39
+ export function geometryBuffers(geometry: Geometry): { buffer: BufferId; index: BufferId } {
40
+ let buffer = geometry._buffer
41
+ let index = geometry._index
42
+ if (buffer === undefined || index === undefined) {
43
+ buffer = createBuffer(geometry.vertices, {
44
+ autoFree: false,
45
+ label: geometry.label ? geometry.label + "-verts" : undefined,
46
+ })
47
+ index = createBuffer(geometry.indices, {
48
+ autoFree: false,
49
+ label: geometry.label ? geometry.label + "-indices" : undefined,
50
+ })
51
+ geometry._buffer = buffer
52
+ geometry._index = index
53
+ }
54
+ return { buffer, index }
55
+ }
56
+
57
+ /**
58
+ * Free the geometry's GPU buffers. Draw entries created from them hold
59
+ * their own reference, so destruction order is safe; the geometry can be
60
+ * used again afterwards (fresh buffers are created on next use).
61
+ */
62
+ export function disposeGeometry(geometry: Geometry): void {
63
+ if (geometry._buffer !== undefined) destroyBuffer(geometry._buffer)
64
+ if (geometry._index !== undefined) destroyBuffer(geometry._index)
65
+ geometry._buffer = undefined
66
+ geometry._index = undefined
67
+ }
68
+
69
+ /** An axis-aligned box centered on the origin: 24 vertices, 36 indices. */
70
+ export function box(width = 1, height = 1, depth = 1, label?: string): Geometry {
71
+ let x = width / 2
72
+ let y = height / 2
73
+ let z = depth / 2
74
+ let verts: number[] = []
75
+ let indices: number[] = []
76
+ type P = [number, number, number]
77
+ // Corners a (bottom-left) through d (top-left), CCW seen from outside.
78
+ let quad = (a: P, b: P, c: P, d: P, n: P) => {
79
+ let base = verts.length / FLOATS_PER_VERTEX
80
+ let uv = [[0, 1], [1, 1], [1, 0], [0, 0]]
81
+ let corners = [a, b, c, d]
82
+ for (let i = 0; i < 4; i++) {
83
+ let p = corners[i]!
84
+ let t = uv[i]!
85
+ verts.push(p[0], p[1], p[2], n[0], n[1], n[2], t[0]!, t[1]!)
86
+ }
87
+ indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
88
+ }
89
+ quad([-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z], [0, 0, 1]) // front
90
+ quad([x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z], [0, 0, -1]) // back
91
+ quad([x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z], [1, 0, 0]) // right
92
+ quad([-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z], [-1, 0, 0]) // left
93
+ quad([-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z], [0, 1, 0]) // top
94
+ quad([-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z], [0, -1, 0]) // bottom
95
+ return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
96
+ }
97
+
98
+ /**
99
+ * A rectangle in the XY plane facing +z, centered on the origin. For a
100
+ * ground plane, rotate it flat: `rotation={[-Math.PI / 2, 0, 0]}`.
101
+ */
102
+ export function plane(width = 1, height = 1, label?: string): Geometry {
103
+ let x = width / 2
104
+ let y = height / 2
105
+ // prettier-ignore
106
+ let vertices = new Float32Array([
107
+ -x, -y, 0, 0, 0, 1, 0, 1,
108
+ x, -y, 0, 0, 0, 1, 1, 1,
109
+ x, y, 0, 0, 0, 1, 1, 0,
110
+ -x, y, 0, 0, 0, 1, 0, 0,
111
+ ])
112
+ return { vertices, indices: new Uint16Array([0, 1, 2, 0, 2, 3]), label }
113
+ }
114
+
115
+ /**
116
+ * A (p,q) torus knot swept into a tube, centered on the origin and standing
117
+ * y-up: the knot's disc lies in the XZ plane with the weave running
118
+ * vertically - the orientation a y-up world with XZ floors wants (the
119
+ * standard-vocabulary divergence: Three's equivalent stands on z). The
120
+ * tube's (tubularSegments x radialSegments) grid stores each vertex once;
121
+ * the seam row/column duplicate the first with u/v = 1 (distinct texture
122
+ * coordinates, so genuinely distinct vertices). UVs: u 0..1 along the knot,
123
+ * v 0..1 around the tube.
124
+ */
125
+ export function torusKnot(
126
+ radius = 1,
127
+ tube = 0.4,
128
+ tubularSegments = 64,
129
+ radialSegments = 8,
130
+ p = 2,
131
+ q = 3,
132
+ label?: string,
133
+ ): Geometry {
134
+ // A point on the knot curve at parameter t (0..2*PI*p).
135
+ let point = (t: number): Vec3 => {
136
+ let qp = (q / p) * t
137
+ let r = radius * (2 + Math.cos(qp)) * 0.5
138
+ return [r * Math.cos(t), radius * Math.sin(qp) * 0.5, r * Math.sin(t)]
139
+ }
140
+
141
+ let rows = tubularSegments + 1
142
+ let cols = radialSegments + 1
143
+ let vertices = new Float32Array(rows * cols * FLOATS_PER_VERTEX)
144
+ let at = 0
145
+
146
+ for (let i = 0; i < rows; i++) {
147
+ let t = (i / tubularSegments) * Math.PI * 2 * p
148
+ // A stable frame along the curve: tangent from a finite difference, and
149
+ // a normal biased away from the axis (P1 + P2), which is well-defined
150
+ // everywhere on a torus knot and needs no parallel transport.
151
+ let p1 = point(t)
152
+ let p2 = point(t + 0.01)
153
+ let tangent = sub(p2, p1)
154
+ let bitangent = normalize(cross(tangent, add(p2, p1)))
155
+ let normal = normalize(cross(bitangent, tangent))
156
+
157
+ for (let j = 0; j < cols; j++) {
158
+ let v = (j / radialSegments) * Math.PI * 2
159
+ let cv = -Math.cos(v) * tube
160
+ let sv = Math.sin(v) * tube
161
+ let x = p1[0] + cv * normal[0] + sv * bitangent[0]
162
+ let y = p1[1] + cv * normal[1] + sv * bitangent[1]
163
+ let z = p1[2] + cv * normal[2] + sv * bitangent[2]
164
+ let n = normalize([x - p1[0], y - p1[1], z - p1[2]])
165
+ vertices[at] = x
166
+ vertices[at + 1] = y
167
+ vertices[at + 2] = z
168
+ vertices[at + 3] = n[0]
169
+ vertices[at + 4] = n[1]
170
+ vertices[at + 5] = n[2]
171
+ vertices[at + 6] = i / tubularSegments
172
+ vertices[at + 7] = j / radialSegments
173
+ at += FLOATS_PER_VERTEX
174
+ }
175
+ }
176
+
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
191
+ }
192
+ }
193
+
194
+ return { vertices, indices, label }
195
+ }
196
+
197
+ /** A UV sphere centered on the origin (poles on the y axis). */
198
+ export function sphere(radius = 0.5, widthSegments = 24, heightSegments = 16, label?: string): Geometry {
199
+ let verts: number[] = []
200
+ let indices: number[] = []
201
+ for (let iy = 0; iy <= heightSegments; iy++) {
202
+ let v = iy / heightSegments
203
+ let theta = v * Math.PI
204
+ let sinT = Math.sin(theta)
205
+ let cosT = Math.cos(theta)
206
+ for (let ix = 0; ix <= widthSegments; ix++) {
207
+ let u = ix / widthSegments
208
+ let phi = u * Math.PI * 2
209
+ let nx = -Math.cos(phi) * sinT
210
+ let ny = cosT
211
+ let nz = Math.sin(phi) * sinT
212
+ verts.push(radius * nx, radius * ny, radius * nz, nx, ny, nz, u, v)
213
+ }
214
+ }
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
+ }
226
+ return { vertices: new Float32Array(verts), indices: new Uint16Array(indices), label }
227
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ // @solidrt/3d - a retained 3D scene graph above @solidrt/core/gpu.
2
+ // Meshes, materials, and a camera compile to one depth-buffered draw
3
+ // target; the output is an ordinary texture id in the UI tree. Two layers:
4
+ // the imperative core (createScene/createMesh/setTransform - usable
5
+ // without Solid components) and the component face (Scene/Mesh/Group/
6
+ // PerspectiveCamera) on top. See AGENTS.md for the model and the traps.
7
+
8
+ export { add, createGroup, createMesh, createScene, remove, setGeometry, setMaterial, setMeshParams, setTransform, setVisible } from "./scene.ts"
9
+ export type { CameraUpdate, Mesh as MeshNode, Scene as SceneHandle, SceneNode, SceneOptions, TransformUpdate } from "./scene.ts"
10
+ export { box, disposeGeometry, plane, sphere, torusKnot, FLOATS_PER_VERTEX, VERTEX_LAYOUT } from "./geometry.ts"
11
+ export type { Geometry } from "./geometry.ts"
12
+ export { shaderMaterial, unlit } from "./material.ts"
13
+ export type { Material, ShaderMaterialOptions, UnlitOptions } from "./material.ts"
14
+ export { Group, Mesh, PerspectiveCamera, Scene, useScene } from "./components.tsx"
15
+ export type { MeshProps, PerspectiveCameraProps, SceneProps, TransformProps } from "./components.tsx"
16
+ export { createOrbitCamera } from "./orbit.ts"
17
+ export type { OrbitCamera, OrbitCameraOptions, OrbitPose } from "./orbit.ts"
18
+ export { compose, copy, identity, lookAt, mat4, multiply, perspective } from "./math.ts"
19
+ export type { Mat4, Vec3 } from "./math.ts"