@solidrt/3d 0.0.48 → 0.0.50

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/bvh.ts ADDED
@@ -0,0 +1,258 @@
1
+ // A dynamic bounding-volume hierarchy - the physics-broadphase AABB tree
2
+ // (Box2D/Bullet lineage): a binary tree over the items' world boxes, NOT a
3
+ // subdivision of space. Leaves store FAT boxes (a margin around the tight
4
+ // bounds) so an item moving a little refits nothing; an item escaping its
5
+ // fat box is removed and re-inserted along the cheapest path (least area
6
+ // growth). A ray query walks O(log n) nodes instead of testing every item,
7
+ // which is what keeps per-pointer-move picking off the O(meshes) path.
8
+ //
9
+ // The scene keeps the tree current from its own sync walk: transforms that
10
+ // changed are exactly the leaves to update, so maintenance is O(changed)
11
+ // per frame - the same delta discipline as rendering, and a static scene
12
+ // pays nothing. Storage is flat parallel arrays indexed by node id (no
13
+ // per-node objects, no allocation at steady state past tree growth).
14
+ //
15
+ // Pure module by design: no GUI imports, so the differential check rig
16
+ // (checks/pick-check.ts) runs it headless on flux against a linear oracle.
17
+
18
+ /** Fat-margin fraction of a leaf's largest extent. Bigger = fewer
19
+ * re-inserts while moving, worse query pruning; 5% is the usual trade. */
20
+ const MARGIN = 0.05
21
+
22
+ type Visit<T> = (item: T) => void
23
+
24
+ export type Bvh<T> = {
25
+ /** Insert an item with its tight world box; returns the leaf handle. */
26
+ insert(item: T, minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number): number
27
+ /** Update a leaf's tight box. Free while it stays inside the fat box;
28
+ * otherwise the leaf re-inserts. Returns true when it moved. */
29
+ update(leaf: number, minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number): boolean
30
+ /** Remove a leaf (the handle is dead afterwards). */
31
+ remove(leaf: number): void
32
+ /** Visit every item whose FAT box the ray hits (t >= 0). Broadphase
33
+ * only: the caller narrowphases against its own tight volumes. */
34
+ raycast(ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, visit: Visit<T>): void
35
+ }
36
+
37
+ /**
38
+ * Entry distance of a ray against a box: the smallest t >= 0 with
39
+ * origin + t * direction inside [min, max] (0 when the origin starts
40
+ * inside), or -1 for a miss. The direction need not be normalized - t is
41
+ * in units of its length, which is what keeps a ray transformed into a
42
+ * mesh's local space reporting world distances. Shared by the tree's
43
+ * broadphase and the scene's narrowphase.
44
+ */
45
+ export function rayBoxDistance(
46
+ ox: number, oy: number, oz: number,
47
+ dx: number, dy: number, dz: number,
48
+ minX: number, minY: number, minZ: number,
49
+ maxX: number, maxY: number, maxZ: number,
50
+ ): number {
51
+ let tNear = 0
52
+ let tFar = Infinity
53
+ // Per axis: a zero direction component never crosses the slab, so the
54
+ // origin must already be inside it (the multiply-by-inverse shortcut
55
+ // turns that case into NaN, hence the explicit branch).
56
+ if (dx === 0) {
57
+ if (ox < minX || ox > maxX) return -1
58
+ } else {
59
+ let inv = 1 / dx
60
+ let t1 = (minX - ox) * inv
61
+ let t2 = (maxX - ox) * inv
62
+ if (t1 > t2) { let t = t1; t1 = t2; t2 = t }
63
+ if (t1 > tNear) tNear = t1
64
+ if (t2 < tFar) tFar = t2
65
+ }
66
+ if (dy === 0) {
67
+ if (oy < minY || oy > maxY) return -1
68
+ } else {
69
+ let inv = 1 / dy
70
+ let t1 = (minY - oy) * inv
71
+ let t2 = (maxY - oy) * inv
72
+ if (t1 > t2) { let t = t1; t1 = t2; t2 = t }
73
+ if (t1 > tNear) tNear = t1
74
+ if (t2 < tFar) tFar = t2
75
+ }
76
+ if (dz === 0) {
77
+ if (oz < minZ || oz > maxZ) return -1
78
+ } else {
79
+ let inv = 1 / dz
80
+ let t1 = (minZ - oz) * inv
81
+ let t2 = (maxZ - oz) * inv
82
+ if (t1 > t2) { let t = t1; t1 = t2; t2 = t }
83
+ if (t1 > tNear) tNear = t1
84
+ if (t2 < tFar) tFar = t2
85
+ }
86
+ return tFar >= tNear ? tNear : -1
87
+ }
88
+
89
+ export function createBvh<T>(): Bvh<T> {
90
+ // Node storage, 6 bounds floats per node; child1 === -1 marks a leaf.
91
+ let bounds: number[] = []
92
+ let parent: number[] = []
93
+ let child1: number[] = []
94
+ let child2: number[] = []
95
+ let items: (T | undefined)[] = []
96
+ let free: number[] = []
97
+ let root = -1
98
+ // Traversal stack, reused across queries.
99
+ let stack: number[] = []
100
+
101
+ let allocate = (): number => {
102
+ let node = free.pop()
103
+ if (node === undefined) {
104
+ node = parent.length
105
+ bounds.push(0, 0, 0, 0, 0, 0)
106
+ parent.push(-1)
107
+ child1.push(-1)
108
+ child2.push(-1)
109
+ items.push(undefined)
110
+ }
111
+ return node
112
+ }
113
+
114
+ // Half the surface area - the SAH cost of a node's box. Degenerate
115
+ // (flat) boxes still cost via their other faces, which is what makes
116
+ // the heuristic sane for planes.
117
+ let area = (n: number): number => {
118
+ let i = n * 6
119
+ let w = bounds[i + 3]! - bounds[i]!
120
+ let h = bounds[i + 4]! - bounds[i + 1]!
121
+ let d = bounds[i + 5]! - bounds[i + 2]!
122
+ return w * (h + d) + h * d
123
+ }
124
+
125
+ let unionArea = (n: number, minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number): number => {
126
+ let i = n * 6
127
+ let w = Math.max(bounds[i + 3]!, maxX) - Math.min(bounds[i]!, minX)
128
+ let h = Math.max(bounds[i + 4]!, maxY) - Math.min(bounds[i + 1]!, minY)
129
+ let d = Math.max(bounds[i + 5]!, maxZ) - Math.min(bounds[i + 2]!, minZ)
130
+ return w * (h + d) + h * d
131
+ }
132
+
133
+ // Recompute an internal node's box as the union of its children.
134
+ let refit = (n: number): void => {
135
+ let a = child1[n]! * 6
136
+ let b = child2[n]! * 6
137
+ let i = n * 6
138
+ bounds[i] = Math.min(bounds[a]!, bounds[b]!)
139
+ bounds[i + 1] = Math.min(bounds[a + 1]!, bounds[b + 1]!)
140
+ bounds[i + 2] = Math.min(bounds[a + 2]!, bounds[b + 2]!)
141
+ bounds[i + 3] = Math.max(bounds[a + 3]!, bounds[b + 3]!)
142
+ bounds[i + 4] = Math.max(bounds[a + 4]!, bounds[b + 4]!)
143
+ bounds[i + 5] = Math.max(bounds[a + 5]!, bounds[b + 5]!)
144
+ }
145
+
146
+ let insertLeaf = (leaf: number): void => {
147
+ if (root === -1) {
148
+ root = leaf
149
+ parent[leaf] = -1
150
+ return
151
+ }
152
+ let i = leaf * 6
153
+ let minX = bounds[i]!, minY = bounds[i + 1]!, minZ = bounds[i + 2]!
154
+ let maxX = bounds[i + 3]!, maxY = bounds[i + 4]!, maxZ = bounds[i + 5]!
155
+ // Descend toward the sibling that grows the least (the classic
156
+ // incremental surface-area heuristic).
157
+ let sibling = root
158
+ while (child1[sibling] !== -1) {
159
+ let a = child1[sibling]!
160
+ let b = child2[sibling]!
161
+ let costA = unionArea(a, minX, minY, minZ, maxX, maxY, maxZ) - area(a)
162
+ let costB = unionArea(b, minX, minY, minZ, maxX, maxY, maxZ) - area(b)
163
+ sibling = costA < costB ? a : b
164
+ }
165
+ let oldParent = parent[sibling]!
166
+ let newParent = allocate()
167
+ parent[newParent] = oldParent
168
+ child1[newParent] = sibling
169
+ child2[newParent] = leaf
170
+ items[newParent] = undefined
171
+ parent[sibling] = newParent
172
+ parent[leaf] = newParent
173
+ if (oldParent === -1) {
174
+ root = newParent
175
+ } else if (child1[oldParent] === sibling) {
176
+ child1[oldParent] = newParent
177
+ } else {
178
+ child2[oldParent] = newParent
179
+ }
180
+ for (let n = newParent; n !== -1; n = parent[n]!) refit(n)
181
+ }
182
+
183
+ let removeLeaf = (leaf: number): void => {
184
+ if (leaf === root) {
185
+ root = -1
186
+ return
187
+ }
188
+ let p = parent[leaf]!
189
+ let sibling = child1[p] === leaf ? child2[p]! : child1[p]!
190
+ let grand = parent[p]!
191
+ parent[sibling] = grand
192
+ if (grand === -1) {
193
+ root = sibling
194
+ } else {
195
+ if (child1[grand] === p) child1[grand] = sibling
196
+ else child2[grand] = sibling
197
+ for (let n = grand; n !== -1; n = parent[n]!) refit(n)
198
+ }
199
+ child1[p] = -1
200
+ items[p] = undefined
201
+ free.push(p)
202
+ }
203
+
204
+ let setFat = (leaf: number, minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number): void => {
205
+ let m = MARGIN * Math.max(maxX - minX, maxY - minY, maxZ - minZ)
206
+ let i = leaf * 6
207
+ bounds[i] = minX - m
208
+ bounds[i + 1] = minY - m
209
+ bounds[i + 2] = minZ - m
210
+ bounds[i + 3] = maxX + m
211
+ bounds[i + 4] = maxY + m
212
+ bounds[i + 5] = maxZ + m
213
+ }
214
+
215
+ return {
216
+ insert(item, minX, minY, minZ, maxX, maxY, maxZ) {
217
+ let leaf = allocate()
218
+ items[leaf] = item
219
+ setFat(leaf, minX, minY, minZ, maxX, maxY, maxZ)
220
+ insertLeaf(leaf)
221
+ return leaf
222
+ },
223
+ update(leaf, minX, minY, minZ, maxX, maxY, maxZ) {
224
+ let i = leaf * 6
225
+ if (
226
+ bounds[i]! <= minX && bounds[i + 1]! <= minY && bounds[i + 2]! <= minZ &&
227
+ bounds[i + 3]! >= maxX && bounds[i + 4]! >= maxY && bounds[i + 5]! >= maxZ
228
+ ) {
229
+ return false
230
+ }
231
+ removeLeaf(leaf)
232
+ setFat(leaf, minX, minY, minZ, maxX, maxY, maxZ)
233
+ insertLeaf(leaf)
234
+ return true
235
+ },
236
+ remove(leaf) {
237
+ removeLeaf(leaf)
238
+ items[leaf] = undefined
239
+ free.push(leaf)
240
+ },
241
+ raycast(ox, oy, oz, dx, dy, dz, visit) {
242
+ if (root === -1) return
243
+ stack.length = 0
244
+ stack.push(root)
245
+ while (stack.length > 0) {
246
+ let n = stack.pop()!
247
+ let i = n * 6
248
+ let t = rayBoxDistance(ox, oy, oz, dx, dy, dz, bounds[i]!, bounds[i + 1]!, bounds[i + 2]!, bounds[i + 3]!, bounds[i + 4]!, bounds[i + 5]!)
249
+ if (t < 0) continue
250
+ if (child1[n] === -1) {
251
+ visit(items[n] as T)
252
+ } else {
253
+ stack.push(child1[n]!, child2[n]!)
254
+ }
255
+ }
256
+ },
257
+ }
258
+ }
@@ -17,11 +17,12 @@ import {
17
17
  setGeometry,
18
18
  setMaterial,
19
19
  setMeshParams,
20
+ setRenderOrder,
20
21
  setTransform,
21
22
  setVisible,
22
23
  } from "./scene.ts"
23
24
  import type { ShaderParams } from "@solidrt/core/gpu"
24
- import type { Mesh as MeshNode, Scene as SceneHandle, SceneNode } from "./scene.ts"
25
+ import type { Mesh as MeshNode, Scene as SceneHandle, SceneNode, ScenePointerEvent } from "./scene.ts"
25
26
  import type { Geometry } from "./geometry.ts"
26
27
  import type { Material } from "./material.ts"
27
28
  import type { Quat, Vec3 } from "./math.ts"
@@ -48,7 +49,24 @@ export type TransformProps = {
48
49
  visible?: boolean
49
50
  }
50
51
 
51
- function syncNode(node: SceneNode, props: TransformProps): void {
52
+ /**
53
+ * Mesh pointer events, the element vocabulary one tree deeper: the nearest
54
+ * hit mesh receives the event, down/move/up bubble to ancestor Groups
55
+ * (stopPropagation stops the walk), enter/leave pair on the mesh alone.
56
+ * Events flow while the element showing the scene carries scene.handlers -
57
+ * the built-in <Scene> leaf does (opt out with events={false}); an `output`
58
+ * leaf spreads them itself.
59
+ */
60
+ export type PointerEventProps = {
61
+ onPointerDown?: (event: ScenePointerEvent) => void
62
+ onPointerMove?: (event: ScenePointerEvent) => void
63
+ onPointerUp?: (event: ScenePointerEvent) => void
64
+ /** Meshes only: a Group never receives enter/leave. */
65
+ onPointerEnter?: (event: ScenePointerEvent) => void
66
+ onPointerLeave?: (event: ScenePointerEvent) => void
67
+ }
68
+
69
+ function syncNode(node: SceneNode, props: TransformProps & PointerEventProps): void {
52
70
  createEffect(
53
71
  () => [props.position, props.rotation, props.quaternion, props.scale, props.visible] as const,
54
72
  ([position, rotation, quaternion, scale, visible]) => {
@@ -56,6 +74,16 @@ function syncNode(node: SceneNode, props: TransformProps): void {
56
74
  setVisible(node, visible !== false)
57
75
  },
58
76
  )
77
+ createEffect(
78
+ () => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onPointerEnter, props.onPointerLeave] as const,
79
+ ([down, move, up, enter, leave]) => {
80
+ node.onPointerDown = down
81
+ node.onPointerMove = move
82
+ node.onPointerUp = up
83
+ node.onPointerEnter = enter
84
+ node.onPointerLeave = leave
85
+ },
86
+ )
59
87
  }
60
88
 
61
89
  export type SceneProps = {
@@ -64,6 +92,12 @@ export type SceneProps = {
64
92
  width: number
65
93
  height: number
66
94
  clearColor?: [number, number, number, number]
95
+ /** Fragment GLSL drawn behind the meshes, inside the scene's own pass
96
+ * (scene.setBackground): vUV/iResolution/fragColor contract, so a
97
+ * createShaderTexture backdrop ports verbatim. Reactive - swapping the
98
+ * source replaces the background; undefined removes it. Three's
99
+ * `scene.background = color` is `clearColor` here. */
100
+ background?: string
67
101
  label?: string
68
102
  ref?: (scene: SceneHandle) => void
69
103
  /**
@@ -72,8 +106,16 @@ export type SceneProps = {
72
106
  * leaf - a `<d-texture>`, a leaf carrying paint/pointer/layout props, or
73
107
  * a post-effect chain (a shader target sampling the id; created in the
74
108
  * callback it disposes with the Scene). Return null to render no leaf.
109
+ * Mesh pointer events then need the scene's handlers on your leaf:
110
+ * `<texture src={texture} {...useScene().scene.handlers} />`.
75
111
  */
76
112
  output?: (texture: TextureId) => Element
113
+ /**
114
+ * Mesh pointer events (default on): the built-in leaf carries
115
+ * scene.handlers, so Mesh/Group onPointer* props receive events. `false`
116
+ * detaches them - the leaf then costs no pointer routing at all.
117
+ */
118
+ events?: boolean
77
119
  }
78
120
 
79
121
  /**
@@ -91,14 +133,27 @@ export let Scene: ParentComponent<SceneProps> = props => {
91
133
  () => [props.width, props.height] as const,
92
134
  ([w, h]) => scene.setSize(w, h),
93
135
  )
136
+ createEffect(
137
+ () => props.background,
138
+ b => scene.setBackground(b ?? null),
139
+ )
94
140
  untrack(() => props.ref)?.(scene)
95
141
  let output = untrack(() => props.output)
142
+ let events = untrack(() => props.events) !== false
96
143
  return (
97
144
  <SceneContext value={{ scene, parent: scene.root }}>
98
145
  {output ? (
99
146
  untrack(() => output(scene.texture))
100
147
  ) : (
101
- <texture src={scene.texture} width={props.width} height={props.height} />
148
+ <texture
149
+ src={scene.texture}
150
+ width={props.width}
151
+ height={props.height}
152
+ onPointerDown={events ? scene.handlers.onPointerDown : undefined}
153
+ onPointerMove={events ? scene.handlers.onPointerMove : undefined}
154
+ onPointerUp={events ? scene.handlers.onPointerUp : undefined}
155
+ onPointerLeave={events ? scene.handlers.onPointerLeave : undefined}
156
+ />
102
157
  )}
103
158
  {props.children}
104
159
  </SceneContext>
@@ -106,7 +161,7 @@ export let Scene: ParentComponent<SceneProps> = props => {
106
161
  }
107
162
 
108
163
  /** A transform node: children inherit its position/rotation/scale. */
109
- export let Group: ParentComponent<TransformProps & { ref?: (node: SceneNode) => void }> = props => {
164
+ export let Group: ParentComponent<TransformProps & PointerEventProps & { ref?: (node: SceneNode) => void }> = props => {
110
165
  let ctx = useContext(SceneContext)
111
166
  let node = createGroup()
112
167
  add(ctx.parent, node)
@@ -116,7 +171,7 @@ export let Group: ParentComponent<TransformProps & { ref?: (node: SceneNode) =>
116
171
  return <SceneContext value={{ scene: ctx.scene, parent: node }}>{props.children}</SceneContext>
117
172
  }
118
173
 
119
- export type MeshProps = TransformProps & {
174
+ export type MeshProps = TransformProps & PointerEventProps & {
120
175
  geometry: Geometry
121
176
  material: Material
122
177
  /** Per-mesh uniforms for a custom material (setMeshParams as a prop).
@@ -125,6 +180,8 @@ export type MeshProps = TransformProps & {
125
180
  * changing every frame prefer `ref` + setMeshParams from onFrame, the
126
181
  * same split as setTransform. */
127
182
  params?: ShaderParams
183
+ /** Explicit draw-order key (setRenderOrder as a prop); default 0. */
184
+ renderOrder?: number
128
185
  ref?: (mesh: MeshNode) => void
129
186
  }
130
187
 
@@ -149,6 +206,10 @@ export let Mesh: VoidComponent<MeshProps> = props => {
149
206
  if (p !== undefined) setMeshParams(mesh, p)
150
207
  },
151
208
  )
209
+ createEffect(
210
+ () => props.renderOrder,
211
+ o => setRenderOrder(mesh, o ?? 0),
212
+ )
152
213
  syncNode(mesh, props)
153
214
  untrack(() => props.ref)?.(mesh)
154
215
  onCleanup(() => remove(mesh))
package/src/geometry.ts CHANGED
@@ -63,6 +63,35 @@ export type Geometry = {
63
63
  label?: string
64
64
  _buffer?: BufferId
65
65
  _index?: BufferId
66
+ _bounds?: Float32Array
67
+ }
68
+
69
+ /**
70
+ * The geometry's LOCAL axis-aligned bounds as [minX, minY, minZ, maxX,
71
+ * maxY, maxZ], computed from the vertices on first use and cached (like
72
+ * the GPU buffers, geometry is treated as immutable after creation).
73
+ * Picking's narrowphase volume; a flat geometry legitimately has zero
74
+ * extent on an axis. An empty geometry yields a zero box at the origin.
75
+ */
76
+ export function geometryBounds(geometry: Geometry): Float32Array {
77
+ let bounds = geometry._bounds
78
+ if (bounds === undefined) {
79
+ bounds = new Float32Array([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity])
80
+ let v = geometry.vertices
81
+ let stride = geometry.layout === "colored" ? COLORED_FLOATS : FLOATS_PER_VERTEX
82
+ for (let i = 0; i + 2 < v.length; i += stride) {
83
+ let x = v[i]!, y = v[i + 1]!, z = v[i + 2]!
84
+ if (x < bounds[0]!) bounds[0] = x
85
+ if (y < bounds[1]!) bounds[1] = y
86
+ if (z < bounds[2]!) bounds[2] = z
87
+ if (x > bounds[3]!) bounds[3] = x
88
+ if (y > bounds[4]!) bounds[4] = y
89
+ if (z > bounds[5]!) bounds[5] = z
90
+ }
91
+ if (bounds[0]! > bounds[3]!) bounds.fill(0)
92
+ geometry._bounds = bounds
93
+ }
94
+ return bounds
66
95
  }
67
96
 
68
97
  /** The geometry's GPU buffers, created on first use and cached on it,
package/src/index.ts CHANGED
@@ -5,18 +5,18 @@
5
5
  // without Solid components) and the component face (Scene/Mesh/Group/
6
6
  // PerspectiveCamera) on top. See AGENTS.md for the model and the traps.
7
7
 
8
- export { add, createGroup, createMesh, createScene, getRotation, lookAt, remove, setGeometry, setMaterial, setMeshParams, setTransform, setVisible, worldPosition } from "./scene.ts"
9
- export type { CameraUpdate, Mesh as MeshNode, Scene as SceneHandle, SceneNode, SceneOptions, TransformUpdate } from "./scene.ts"
8
+ export { add, createGroup, createMesh, createScene, getRotation, lookAt, remove, setGeometry, setMaterial, setMeshParams, setRenderOrder, setTransform, setVisible, worldPosition } from "./scene.ts"
9
+ export type { CameraUpdate, Hit, Mesh as MeshNode, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, TransformUpdate } from "./scene.ts"
10
10
  export { box, circle, cone, cylinder, disposeGeometry, fillColors, plane, ring, sphere, torus, torusKnot, withColors, FLOATS_PER_VERTEX, VERTEX_LAYOUTS } from "./geometry.ts"
11
11
  export type { ColorFill, Geometry, VertexLayout } from "./geometry.ts"
12
12
  export { fillet, roundRect, shape, triangulate } from "./profile.ts"
13
13
  export type { Profile, ProfilePoint } from "./profile.ts"
14
14
  export { extrude, lathe, pathFrames, sweep, tube } from "./sweep.ts"
15
15
  export type { PathFrames, PathPoint, SweepPath } from "./sweep.ts"
16
- export { shaderMaterial, unlit } from "./material.ts"
17
- export type { Material, ShaderMaterialOptions, UnlitOptions } from "./material.ts"
16
+ export { shaderMaterial, shaderMaterialClass, unlit } from "./material.ts"
17
+ export type { Material, ShaderMaterialClass, ShaderMaterialClassOptions, ShaderMaterialInstanceOptions, ShaderMaterialOptions, UnlitOptions } from "./material.ts"
18
18
  export { Group, Mesh, PerspectiveCamera, Scene, useScene } from "./components.tsx"
19
- export type { MeshProps, PerspectiveCameraProps, SceneProps, TransformProps } from "./components.tsx"
19
+ export type { MeshProps, PerspectiveCameraProps, PointerEventProps, SceneProps, TransformProps } from "./components.tsx"
20
20
  export { createOrbitCamera } from "./orbit.ts"
21
21
  export type { OrbitCamera, OrbitCameraOptions, OrbitPose } from "./orbit.ts"
22
22
  // math's lookAt (the camera view matrix) stays on the /math subpath: the