@solidrt/3d 0.0.54 → 0.0.56

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.
Files changed (54) hide show
  1. package/AGENTS.md +1121 -177
  2. package/README.md +15 -7
  3. package/examples/README.md +86 -5
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +20 -5
  6. package/examples/collision.tsx +250 -0
  7. package/examples/first-person.tsx +119 -0
  8. package/examples/fleet.tsx +194 -0
  9. package/examples/fog.tsx +173 -0
  10. package/examples/instanced.tsx +72 -32
  11. package/examples/lamps.tsx +92 -0
  12. package/examples/lit.tsx +3 -5
  13. package/examples/materials.tsx +150 -0
  14. package/examples/model-load.tsx +95 -0
  15. package/examples/model.glb +0 -0
  16. package/examples/model.tsx +32 -6
  17. package/examples/pick.tsx +2 -4
  18. package/examples/probe.tsx +101 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +32 -3
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +86 -0
  24. package/examples/skybox.tsx +128 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +132 -0
  27. package/examples/sweep-paths.tsx +2 -4
  28. package/package.json +3 -3
  29. package/src/camera.ts +148 -0
  30. package/src/clip.ts +96 -0
  31. package/src/collision.ts +199 -0
  32. package/src/color.ts +32 -0
  33. package/src/components.tsx +773 -82
  34. package/src/environment-bake.ts +492 -0
  35. package/src/environment.ts +325 -0
  36. package/src/first-person.ts +306 -0
  37. package/src/geometry.ts +6 -2
  38. package/src/glsl.ts +1390 -82
  39. package/src/gltf.ts +432 -112
  40. package/src/index.ts +57 -9
  41. package/src/light.ts +383 -0
  42. package/src/material.ts +682 -216
  43. package/src/math.ts +110 -0
  44. package/src/mesh.ts +728 -0
  45. package/src/mixer.ts +476 -0
  46. package/src/model-file.ts +80 -15
  47. package/src/model.ts +211 -34
  48. package/src/node.ts +506 -0
  49. package/src/orbit.ts +97 -48
  50. package/src/scene-pointer.ts +166 -0
  51. package/src/scene-shadows.ts +471 -0
  52. package/src/scene.ts +1304 -1532
  53. package/tools/environment.ts +69 -0
  54. package/tools/model.ts +4 -3
package/src/node.ts ADDED
@@ -0,0 +1,506 @@
1
+ // The retained node layer: the scene-graph objects (groups, and the base
2
+ // the meshes and lights extend), the add/remove tree walk binding them to
3
+ // the spatial core, and the transform write paths. Plain objects, no
4
+ // signals - scene.ts's header tells the full model. A node reaches its
5
+ // scene only through the SceneHooks seam, so this module has no runtime
6
+ // import of scene.ts (or of mesh.ts/light.ts - their imports here are
7
+ // types only).
8
+
9
+ import * as spatial from "flux:spatial"
10
+ import type { NodeId, NodeTransition } from "flux:spatial"
11
+ import { on } from "srt:events"
12
+ import type { ShaderParams, TextureId } from "@solidrt/core/gpu"
13
+ // The scene's lookAt() aims a node; math's builds a camera's view matrix -
14
+ // the same pairing (and the same name) as Three's Object3D/Matrix4.
15
+ import { compose, eulerFromQuat, identity, mat4, multiply, quat, quatFromFrame, transformPoint, updateRotation, updateScale } from "./math.ts"
16
+ import type { Mat4, Quat, TransformUpdate, Vec3, Vec4 } from "./math.ts"
17
+ import type { CastingLight, Light } from "./light.ts"
18
+ import type { InstancedMesh, InstanceNode, Mesh } from "./mesh.ts"
19
+
20
+ // lookAt()'s default roll reference. Read-only: quatFromFrame never
21
+ // writes its inputs, so one shared vector is safe.
22
+ const WORLD_UP: Vec3 = [0, 1, 0]
23
+ // The FFI carriers: one transform write (position, quaternion, scale) and
24
+ // one world-matrix read. Values are copied at the boundary, so one of each
25
+ // serves every call.
26
+ let transformScratch = new Float32Array(10)
27
+ let worldRead = new Float32Array(16)
28
+ // lookAt()/worldPosition() scratch: nothing here outlives a single call.
29
+ let worldScratch = mat4()
30
+ let localScratch = mat4()
31
+ let pointScratch: Vec4 = [0, 0, 0, 0]
32
+ let aimScratch: Vec3 = [0, 0, 0]
33
+ let upScratch: Vec3 = [0, 0, 0]
34
+ // setTransform's rotation compare happens AFTER conversion, so an euler and
35
+ // the quaternion it produces are the same write. Nothing outlives the call.
36
+ let rotScratch = quat()
37
+ let scaleScratch: Vec3 = [1, 1, 1]
38
+
39
+ // Settle routing: the core's "spatialTransitionEnd" event carries the node
40
+ // id, so nodes with a transition DECLARED (only those can settle) are
41
+ // indexed by their core id while in a scene, and one lazy subscription,
42
+ // started at the first declaration, routes to the node's onTransitionEnd.
43
+ // Target-only, like the element transitions.
44
+ let declared = new Map<NodeId, SceneNode>()
45
+ let subscribed = false
46
+
47
+ function declareTransition(id: NodeId, node: SceneNode): void {
48
+ declared.set(id, node)
49
+ if (subscribed) return
50
+ subscribed = true
51
+ on("spatialTransitionEnd", (event: { node: NodeId; component: TransitionEndEvent["component"] }) => {
52
+ let node = declared.get(event.node)
53
+ if (!node) return
54
+ try {
55
+ node.onTransitionEnd?.({ component: event.component })
56
+ } catch (err) {
57
+ console.error("Error in onTransitionEnd handler:", err)
58
+ }
59
+ })
60
+ }
61
+
62
+ // The scene half a node needs to reach: attach/detach entries and schedule
63
+ // a sync. Kept separate from the public Scene type so internals stay off
64
+ // the app-facing surface. The camera (uViewProj + uCamPos) is written
65
+ // through the shared channel only when it changes - attach never re-seeds
66
+ // it, because target state survives entry churn.
67
+ export type SceneHooks = {
68
+ _schedule(): void
69
+ _attach(mesh: Mesh): void
70
+ _detach(mesh: Mesh): void
71
+ /** An instance entered the scene (its mesh entered first): box, shape
72
+ * and record binding. */
73
+ _attachInstance(instance: InstanceNode): void
74
+ _detachInstance(instance: InstanceNode): void
75
+ _attachLight(light: Light): void
76
+ _detachLight(light: Light): void
77
+ _lightChanged(): void
78
+ _setParams(mesh: Mesh, params: ShaderParams): void
79
+ _setCount(mesh: Mesh): void
80
+ /** Re-point the mesh's entries at its (replaced) instance buffers. */
81
+ _setBuffer(mesh: Mesh): void
82
+ /** The instanced mesh's style mirror has a dirty range: publish it at
83
+ * the next sync. */
84
+ _setStyle(mesh: InstancedMesh): void
85
+ /** The mesh's castShadow flag changed: re-evaluate the filtered views. */
86
+ _setCast(mesh: Mesh): void
87
+ /** The mesh's layers bitmask changed: re-evaluate every target. */
88
+ _setLayers(mesh: Mesh): void
89
+ /** A light's castShadow/shadow options changed. */
90
+ _shadowChanged(light: CastingLight): void
91
+ _reorder(): void
92
+ /** The node's transform changed (for the sort and light bookkeeping). */
93
+ _moved(node: SceneNode): void
94
+ }
95
+
96
+ export type SceneNode = {
97
+ kind: "group" | "mesh" | "light" | "instance"
98
+ parent: SceneNode | null
99
+ children: SceneNode[]
100
+ /** Read freely; write through setTransform/setVisible so changes sync. */
101
+ position: Vec3
102
+ /** The stored rotation, always a UNIT quaternion. Euler triples convert
103
+ * on the way in (setTransform's `rotation`) and out (getRotation) - there
104
+ * is no second rotation field to fall out of step with this one. */
105
+ quaternion: Quat
106
+ scale: Vec3
107
+ visible: boolean
108
+ /** Pointer event handlers - plain fields, assign freely (they touch no
109
+ * GPU state, so they need no setTransform-style write path; components
110
+ * sync their props here). Down/move/up dispatch on the hit mesh and
111
+ * bubble through its ancestors (stopPropagation stops the walk);
112
+ * enter/leave fire on the mesh alone. Events flow once the element
113
+ * showing the scene carries `scene.handlers`. */
114
+ onPointerDown?: (event: ScenePointerEvent) => void
115
+ onPointerMove?: (event: ScenePointerEvent) => void
116
+ onPointerUp?: (event: ScenePointerEvent) => void
117
+ onPointerEnter?: (event: ScenePointerEvent) => void
118
+ onPointerLeave?: (event: ScenePointerEvent) => void
119
+ /** A declared transition (setTransition) settled naturally on one
120
+ * component; a cancel, snap or scene leave never fires. */
121
+ onTransitionEnd?: (event: TransitionEndEvent) => void
122
+ /** The core node while in a scene (created at add, freed at remove). */
123
+ _node: NodeId | null
124
+ _moved: boolean
125
+ /** The core writes this node's TRS itself (a clip player's target, a
126
+ * root-motion anchor), so the JS mirror above is stale: setTransform
127
+ * never short-circuits on it. Set once, never cleared. */
128
+ _native: boolean
129
+ _scene: SceneHooks | null
130
+ /** The declared transition, re-applied on every scene enter. */
131
+ _transition: NodeTransition | string | null
132
+ /** Skin palette rows this node feeds (a model joint carries one per
133
+ * skin): bound to the core at every scene enter, so the flush writes
134
+ * `inverse(anchorWorld) * world * post` - the model-local bone matrix -
135
+ * to the palette texture's row whenever the node moves. `anchor` is the
136
+ * model root; null for non-joints (the common case pays one null check). */
137
+ _palettes: { texture: TextureId; row: number; post: Float32Array; anchor: SceneNode }[] | null
138
+ /** A culling-only local box (a model joint's influence region, in joint
139
+ * space): bound at every scene enter so the joint's world box follows
140
+ * the pose without joining the picking index. null for the common case. */
141
+ _cullBounds: Float32Array | null
142
+ }
143
+
144
+ /** The settled component of a node transition. */
145
+ export type TransitionEndEvent = {
146
+ component: "position" | "rotation" | "scale"
147
+ }
148
+
149
+ /**
150
+ * The event a mesh, instance or ancestor group handler receives: the
151
+ * element pointer vocabulary carried over, plus the 3D fields.
152
+ * `point`/`distance` are null exactly when the ray misses the dispatch
153
+ * target - which happens only during a captured drag or on a leave.
154
+ */
155
+ export type ScenePointerEvent = {
156
+ /** The mesh the event is about (the hit, or the captured mesh during a
157
+ * drag) - constant while the event bubbles. */
158
+ mesh: Mesh
159
+ /** The instance struck when `mesh` is an instanced mesh (the walk
160
+ * starts there), null otherwise - constant while the event bubbles. */
161
+ instance: InstanceNode | null
162
+ /** Node whose handler is running; changes as the event bubbles. */
163
+ currentTarget: SceneNode
164
+ /** World-space hit point on `mesh`, or null when the ray misses it. */
165
+ point: Vec3 | null
166
+ /** Camera-ray distance to `point` in world units, or null with it. */
167
+ distance: number | null
168
+ /** Pointer position in scene pixels - project()'s coordinate space. */
169
+ x: number
170
+ y: number
171
+ pointerId: number
172
+ pointerType: string
173
+ button?: number
174
+ shiftKey: boolean
175
+ ctrlKey: boolean
176
+ altKey: boolean
177
+ metaKey: boolean
178
+ /** Stops the bubble walk after the current handler. */
179
+ stopPropagation(): void
180
+ }
181
+
182
+ /** A bare node record (internal: the mesh and light constructors build on it). */
183
+ export function makeNode(kind: SceneNode["kind"]): SceneNode {
184
+ return {
185
+ kind,
186
+ parent: null,
187
+ children: [],
188
+ position: [0, 0, 0],
189
+ quaternion: [0, 0, 0, 1],
190
+ scale: [1, 1, 1],
191
+ visible: true,
192
+ _node: null,
193
+ _native: false,
194
+ _moved: false,
195
+ _scene: null,
196
+ _transition: null,
197
+ _palettes: null,
198
+ _cullBounds: null,
199
+ }
200
+ }
201
+
202
+ export function createGroup(): SceneNode {
203
+ return makeNode("group")
204
+ }
205
+
206
+ /** Attach `child` under `parent` (re-parenting detaches it first). An
207
+ * instance is slot-bound to its mesh: addInstance places it. */
208
+ export function add(parent: SceneNode, child: SceneNode): void {
209
+ if (child.kind === "instance") throw new Error("add: an instance is slot-bound to its mesh - addInstance places it, removeInstance destroys it")
210
+ if (child.parent !== null) remove(child)
211
+ child.parent = parent
212
+ parent.children.push(child)
213
+ if (parent._scene) enterScene(child, parent._scene)
214
+ }
215
+
216
+ /** Detach `child` from its parent (and its meshes from the scene). An
217
+ * instance is destroyed instead, by removeInstance. */
218
+ export function remove(child: SceneNode): void {
219
+ if (child.kind === "instance") throw new Error("remove: an instance is slot-bound to its mesh - removeInstance destroys it")
220
+ if (child._scene) leaveScene(child)
221
+ let parent = child.parent
222
+ if (parent !== null) {
223
+ let i = parent.children.indexOf(child)
224
+ if (i >= 0) parent.children.splice(i, 1)
225
+ child.parent = null
226
+ }
227
+ }
228
+
229
+ export function enterScene(node: SceneNode, scene: SceneHooks): void {
230
+ node._scene = scene
231
+ node._node = spatial.createNode(fillTransform(node), node.visible)
232
+ if (node._transition !== null) {
233
+ spatial.setTransition(node._node, node._transition)
234
+ declareTransition(node._node, node)
235
+ }
236
+ // The parent is in the scene already (add() enters the child only then),
237
+ // and the scene root is the one node without a parent.
238
+ if (node.parent !== null && node.parent._node !== null) spatial.setParent(node._node, node.parent._node)
239
+ if (node._palettes !== null) {
240
+ // A joint's palette rows: the anchor (its model root) entered first -
241
+ // enterScene recurses parents-first - so its core node is live. The
242
+ // core drops the binding with the node at leaveScene.
243
+ for (let p of node._palettes) {
244
+ if (p.anchor._node !== null) spatial.bindTextureSlot(node._node, p.texture, p.row, p.post, p.anchor._node)
245
+ }
246
+ }
247
+ if (node._cullBounds !== null) spatial.setCullBounds(node._node, node._cullBounds)
248
+ if (node.kind === "mesh") scene._attach(node as Mesh)
249
+ else if (node.kind === "light") scene._attachLight(node as Light)
250
+ else if (node.kind === "instance") scene._attachInstance(node as InstanceNode)
251
+ for (let c of node.children) enterScene(c, scene)
252
+ scene._schedule()
253
+ }
254
+
255
+ export function leaveScene(node: SceneNode): void {
256
+ let scene = node._scene
257
+ if (scene && node.kind === "mesh") scene._detach(node as Mesh)
258
+ else if (scene && node.kind === "light") scene._detachLight(node as Light)
259
+ else if (scene && node.kind === "instance") scene._detachInstance(node as InstanceNode)
260
+ node._scene = null
261
+ for (let c of node.children) leaveScene(c)
262
+ if (node._node !== null) {
263
+ declared.delete(node._node)
264
+ spatial.destroyNode(node._node)
265
+ node._node = null
266
+ }
267
+ }
268
+
269
+ /** The node's local transform in the FFI carrier. */
270
+ export function fillTransform(node: SceneNode): Float32Array {
271
+ let t = transformScratch
272
+ t[0] = node.position[0]; t[1] = node.position[1]; t[2] = node.position[2]
273
+ t[3] = node.quaternion[0]; t[4] = node.quaternion[1]; t[5] = node.quaternion[2]; t[6] = node.quaternion[3]
274
+ t[7] = node.scale[0]; t[8] = node.scale[1]; t[9] = node.scale[2]
275
+ return t
276
+ }
277
+
278
+ /** Forward a changed local transform to the core (no-op outside a scene:
279
+ * entering pushes the whole transform). */
280
+ function pushTransform(node: SceneNode): void {
281
+ if (node._node === null || node._scene === null) return
282
+ spatial.writeTransform(node._node, fillTransform(node))
283
+ node._scene._moved(node)
284
+ }
285
+
286
+ export type { TransformUpdate } from "./math.ts"
287
+
288
+ /**
289
+ * Declare (or with null clear) how the node's transform writes animate:
290
+ * once set, setTransform writes are TARGETS the core animates toward
291
+ * (position/scale per lane, rotation along the quaternion geodesic - a
292
+ * spring keeps its velocity through retargets, the pursuit-safe shape),
293
+ * so JS writes once per target change instead of once per frame. A spec
294
+ * per component (position, rotation, scale) plus `all`; each
295
+ * `{ duration, bounce? }` (a spring, the default) / `{ duration, curve }`
296
+ * (a tween) / a shorthand string like "300ms ease-out". The declaration
297
+ * lives on the node and re-applies whenever it enters a scene; the pose
298
+ * it enters with always snaps. Clearing cancels running tracks in place
299
+ * (the node keeps its mid-flight transform) and later writes snap. Each
300
+ * natural settle calls the node's `onTransitionEnd` with the component
301
+ * (the raw "spatialTransitionEnd" engine event on srt:events stays for
302
+ * flux:spatial consumers; it carries the core id, `_node`).
303
+ */
304
+ export function setTransition(node: SceneNode, transition: NodeTransition | string | null): void {
305
+ node._transition = transition
306
+ if (node._node !== null) {
307
+ spatial.setTransition(node._node, transition)
308
+ if (transition === null) declared.delete(node._node)
309
+ else declareTransition(node._node, node)
310
+ }
311
+ }
312
+
313
+ /**
314
+ * The one write path for node transforms (so the scene knows to sync).
315
+ * Values are copied in; absent keys keep their current value. This is also
316
+ * the frame-rate escape hatch: call it from onFrame on a node grabbed via
317
+ * `ref`, bypassing signals entirely.
318
+ *
319
+ * A write that changes nothing schedules nothing, so driving every node
320
+ * unconditionally from onFrame costs only the compare for the nodes that
321
+ * did not move. Rotation is compared after conversion, so passing an euler
322
+ * equal to the node's current quaternion is also a no-op.
323
+ */
324
+ export function setTransform(node: SceneNode, update: TransformUpdate): void {
325
+ // A no-op write costs nothing: driving every node from onFrame is the
326
+ // intended shape, and most nodes did not move. Exact compares, like
327
+ // setVisible - a value that survives a float round trip unchanged is the
328
+ // same value, and an epsilon would need a scale-dependent one anyway.
329
+ // Except on a node the core poses itself: its mirror is stale, so an
330
+ // equal write may well be a real move (a character teleported back to
331
+ // where it started) and always goes through.
332
+ let changed = node._native
333
+ let p = update.position
334
+ if (p && (p[0] !== node.position[0] || p[1] !== node.position[1] || p[2] !== node.position[2])) {
335
+ node.position[0] = p[0]
336
+ node.position[1] = p[1]
337
+ node.position[2] = p[2]
338
+ changed = true
339
+ }
340
+ if (updateRotation(rotScratch, update, "setTransform")) {
341
+ let n = node.quaternion
342
+ if (rotScratch[0] !== n[0] || rotScratch[1] !== n[1] || rotScratch[2] !== n[2] || rotScratch[3] !== n[3]) {
343
+ n[0] = rotScratch[0]
344
+ n[1] = rotScratch[1]
345
+ n[2] = rotScratch[2]
346
+ n[3] = rotScratch[3]
347
+ changed = true
348
+ }
349
+ }
350
+ if (update.scale !== undefined) {
351
+ updateScale(scaleScratch, update.scale)
352
+ if (scaleScratch[0] !== node.scale[0] || scaleScratch[1] !== node.scale[1] || scaleScratch[2] !== node.scale[2]) {
353
+ node.scale[0] = scaleScratch[0]
354
+ node.scale[1] = scaleScratch[1]
355
+ node.scale[2] = scaleScratch[2]
356
+ changed = true
357
+ }
358
+ }
359
+ if (!changed) return
360
+ pushTransform(node)
361
+ }
362
+
363
+ /**
364
+ * Aim a node at a WORLD-space point, Three's `Object3D.lookAt`: the node's
365
+ * local +z ends up pointing at `target`, with `up` (world space, default
366
+ * +y) choosing the roll about that axis. Ancestor transforms are undone,
367
+ * so the aim holds under a rotated group - the ancestor chain is brought
368
+ * up to date on the spot rather than waiting for the pending sync.
369
+ *
370
+ * +z because that is the library's own sweep axis (`extrude`, `sweep`,
371
+ * `tube` run along z), so aiming their output needs no correction. For a
372
+ * y-axis solid (`cylinder`, `cone`) reach for `quatFromTo` instead, which
373
+ * takes the axis to aim as an argument.
374
+ *
375
+ * Writes `node.quaternion` - an ordinary rotation afterwards, readable and
376
+ * overwritable by setTransform. To aim along a DIRECTION rather than at a
377
+ * point, add it to the node's world position (`worldPosition`), the same
378
+ * conversion Three asks for.
379
+ *
380
+ * Exact for rotation and uniform scale in the ancestor chain; a
381
+ * non-uniformly scaled ancestor shears the frame and the aim is
382
+ * approximate, exactly as in Three (both read the parent's upper 3x3 as
383
+ * if it were a rotation).
384
+ */
385
+ export function lookAt(node: SceneNode, target: Vec3, up: Vec3 = WORLD_UP): void {
386
+ let parent = node.parent
387
+ if (parent === null) {
388
+ // No ancestors: parent space IS world space, aim straight from the
389
+ // node's own position.
390
+ aimScratch[0] = target[0] - node.position[0]
391
+ aimScratch[1] = target[1] - node.position[1]
392
+ aimScratch[2] = target[2] - node.position[2]
393
+ quatFromFrame(node.quaternion, aimScratch, up)
394
+ } else {
395
+ let world = worldInto(worldScratch, parent)
396
+ transformPoint(pointScratch, world, node.position)
397
+ aimScratch[0] = target[0] - pointScratch[0]
398
+ aimScratch[1] = target[1] - pointScratch[1]
399
+ aimScratch[2] = target[2] - pointScratch[2]
400
+ // World -> parent space for both vectors: rotating forward and up
401
+ // rotates the frame they build, so converting the inputs is the same
402
+ // as converting the resulting rotation, and needs no matrix inverse.
403
+ unrotate(aimScratch, world, aimScratch)
404
+ unrotate(upScratch, world, up)
405
+ quatFromFrame(node.quaternion, aimScratch, upScratch)
406
+ }
407
+ pushTransform(node)
408
+ }
409
+
410
+ /**
411
+ * A node's rotation as Euler radians in XYZ order, copied into `out` (or a
412
+ * fresh Vec3). A convenience for reading and debugging, NOT a peer of
413
+ * `node.quaternion`: the conversion is lossy in the sense that it cannot
414
+ * recover the triple that was written (see eulerFromQuat), only a triple
415
+ * that means the same rotation. Anything composing or interpolating
416
+ * rotations should work with the quaternion.
417
+ */
418
+ export function getRotation(node: SceneNode, out: Vec3 = [0, 0, 0]): Vec3 {
419
+ return eulerFromQuat(out, node.quaternion)
420
+ }
421
+
422
+ // readTransform's FFI carrier (position 3, quaternion 4, scale 3).
423
+ let transformRead = new Float32Array(10)
424
+
425
+ /**
426
+ * The node's CURRENT local transform as fresh arrays. In a scene this
427
+ * reads the core - which is the truth for a node a clip player animates:
428
+ * the players write core TRS directly, so the JS `position`/`quaternion`/
429
+ * `scale` fields of animated joints go stale (they hold the last JS
430
+ * write). Use this for pose reads on animated rigs (root-motion strips,
431
+ * copying a skeleton); out of a scene it copies the JS fields.
432
+ */
433
+ export function getTransform(node: SceneNode): { position: Vec3; quaternion: Quat; scale: Vec3 } {
434
+ if (node._node !== null) {
435
+ spatial.readTransform(node._node, transformRead)
436
+ let t = transformRead
437
+ return {
438
+ position: [t[0]!, t[1]!, t[2]!],
439
+ quaternion: [t[3]!, t[4]!, t[5]!, t[6]!],
440
+ scale: [t[7]!, t[8]!, t[9]!],
441
+ }
442
+ }
443
+ return {
444
+ position: [node.position[0], node.position[1], node.position[2]],
445
+ quaternion: [node.quaternion[0], node.quaternion[1], node.quaternion[2], node.quaternion[3]],
446
+ scale: [node.scale[0], node.scale[1], node.scale[2]],
447
+ }
448
+ }
449
+
450
+ /**
451
+ * A node's position in world space, copied into `out` (or a fresh Vec3) -
452
+ * Three's `getWorldPosition`. Brings the ancestor chain up to date first,
453
+ * so it is exact before the pending sync has run.
454
+ */
455
+ export function worldPosition(node: SceneNode, out: Vec3 = [0, 0, 0]): Vec3 {
456
+ let world = worldInto(worldScratch, node)
457
+ out[0] = world[12]
458
+ out[1] = world[13]
459
+ out[2] = world[14]
460
+ return out
461
+ }
462
+
463
+ /**
464
+ * `out` = node's world matrix as the tree stands now. In a scene that is
465
+ * one core read (pending writes included, nothing cleared); outside one
466
+ * the chain is composed here. Scene membership is subtree-closed, so the
467
+ * recursion meets a core node at the first in-scene ancestor at the
468
+ * latest. One shared local scratch serves any depth - each frame uses it
469
+ * only after its recursive call has returned.
470
+ */
471
+ export function worldInto(out: Mat4, node: SceneNode): Mat4 {
472
+ if (node._node !== null) {
473
+ spatial.worldMatrix(node._node, worldRead)
474
+ for (let i = 0; i < 16; i++) out[i] = worldRead[i]!
475
+ return out
476
+ }
477
+ if (node.parent === null) identity(out)
478
+ else worldInto(out, node.parent)
479
+ return multiply(out, out, compose(localScratch, node.position, node.quaternion, node.scale))
480
+ }
481
+
482
+ /**
483
+ * `out` = v with m's rotation undone: the transpose of m's upper 3x3 with
484
+ * its columns normalized, so uniform scale divides out. out may alias v.
485
+ */
486
+ function unrotate(out: Vec3, m: Mat4, v: Vec3): Vec3 {
487
+ let x = v[0], y = v[1], z = v[2]
488
+ let l0 = Math.hypot(m[0], m[1], m[2]) || 1
489
+ let l1 = Math.hypot(m[4], m[5], m[6]) || 1
490
+ let l2 = Math.hypot(m[8], m[9], m[10]) || 1
491
+ out[0] = (m[0] * x + m[1] * y + m[2] * z) / l0
492
+ out[1] = (m[4] * x + m[5] * y + m[6] * z) / l1
493
+ out[2] = (m[8] * x + m[9] * y + m[10] * z) / l2
494
+ return out
495
+ }
496
+
497
+ /** Show or hide a node and its whole subtree (a hidden mesh costs one
498
+ * `instanceCount: 0` draw range - the entry stays, drawing nothing). */
499
+ export function setVisible(node: SceneNode, visible: boolean): void {
500
+ if (node.visible === visible) return
501
+ node.visible = visible
502
+ if (node._node !== null) {
503
+ spatial.setVisible(node._node, visible)
504
+ node._scene?._schedule()
505
+ }
506
+ }