@solidrt/3d 0.0.55 → 0.0.57

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