@solidrt/3d 0.0.47 → 0.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/profile.ts CHANGED
@@ -1,20 +1,14 @@
1
- // Profile kit: 3D geometry from 2D outlines. A Profile is a closed simple
2
- // polygon in the XY plane - bare [x, y] tuples are sharp (creased) corners,
3
- // tagged { p, smooth } points shade round - and winding is normalized to
4
- // CCW on use, so either authoring direction works. extrude() sweeps a
5
- // profile along z with an optional quarter-round bevel, lathe() revolves
6
- // one about the y axis, shape() fills one as a flat face; fillet() and
7
- // roundRect() produce smooth-tagged arc corners, and triangulate() (the
8
- // ear-clipping core behind every cap) is exported for custom flat work.
9
- //
10
- // Output is the shared vertex layout of geometry.ts with real texture UVs.
11
- // Winding is CCW seen from outside like every generator, so cull: "back"
12
- // works: sharp profile points emit one vertex per adjacent edge, smooth
13
- // points one vertex with the averaged normal. Indices pick Uint16Array or
14
- // Uint32Array by vertex count - filleted profiles times bevel slices make
15
- // dense outputs routine here.
1
+ // Profile kit: the 2D outline vocabulary the solid generators build on. A
2
+ // Profile is a closed simple polygon in the XY plane - bare [x, y] tuples
3
+ // are sharp (creased) corners, tagged { p, smooth } points shade round -
4
+ // and winding is normalized to CCW on use, so either authoring direction
5
+ // works. fillet() and roundRect() produce smooth-tagged arc corners,
6
+ // shape() fills a profile as a flat +z face in the shared vertex layout,
7
+ // and triangulate() (the ear-clipping core behind every cap) is exported
8
+ // for custom flat work. The swept-solid generators consuming this
9
+ // vocabulary - extrude, lathe, sweep, tube - live in sweep.ts.
16
10
 
17
- import { FLOATS_PER_VERTEX } from "./geometry.ts"
11
+ import { packIndices } from "./geometry.ts"
18
12
  import type { Geometry } from "./geometry.ts"
19
13
  import type { Vec2 } from "./math.ts"
20
14
 
@@ -27,13 +21,14 @@ export type ProfilePoint = { p: Vec2; smooth?: boolean }
27
21
  * go either way; consumers normalize to CCW. */
28
22
  export type Profile = (Vec2 | ProfilePoint)[]
29
23
 
30
- type Pt = { x: number; y: number; smooth: boolean }
24
+ /** A normalized profile point (normalizeProfile's output). */
25
+ export type Pt = { x: number; y: number; smooth: boolean }
31
26
 
32
- // One side-strip vertex of the profile ring: position, outward 2D normal,
33
- // owning point index (for miter lookup) and normalized perimeter
34
- // parameter. Sharp points appear twice (once per edge normal); the list
35
- // ends with a copy of the first entry at t = 1, the UV seam.
36
- type RingEntry = { x: number; y: number; nx: number; ny: number; point: number; t: number }
27
+ /** One side-strip vertex of the profile ring: position, outward 2D normal,
28
+ * owning point index (for miter lookup) and normalized perimeter
29
+ * parameter. Sharp points appear twice (once per edge normal); the list
30
+ * ends with a copy of the first entry at t = 1, the UV seam. */
31
+ export type RingEntry = { x: number; y: number; nx: number; ny: number; point: number; t: number }
37
32
 
38
33
  function signedArea(px: number[], py: number[]): number {
39
34
  let a = 0
@@ -44,9 +39,10 @@ function signedArea(px: number[], py: number[]): number {
44
39
  return a / 2
45
40
  }
46
41
 
47
- // Tag/tuple points to one shape, consecutive duplicates (including a
48
- // closing repeat of the first point) dropped, winding forced CCW.
49
- function normalizeProfile(profile: Profile): Pt[] {
42
+ /** Tag/tuple points to one shape, consecutive duplicates (including a
43
+ * closing repeat of the first point) dropped, winding forced CCW - the
44
+ * shared entry point of every profile consumer. */
45
+ export function normalizeProfile(profile: Profile): Pt[] {
50
46
  let pts: Pt[] = []
51
47
  for (let point of profile) {
52
48
  let x: number, y: number, smooth: boolean
@@ -68,11 +64,11 @@ function normalizeProfile(profile: Profile): Pt[] {
68
64
  return pts
69
65
  }
70
66
 
71
- // Per-point miter offsets and the vertex entries a swept strip needs.
72
- // Offsetting a point by -miter * d insets the polygon by exactly d on both
73
- // adjacent edges; miter length is clamped so a spiky corner cannot fling
74
- // its inset point far into the interior.
75
- function profileRing(pts: Pt[]) {
67
+ /** Per-point miter offsets and the vertex entries a swept strip needs.
68
+ * Offsetting a point by -miter * d insets the polygon by exactly d on both
69
+ * adjacent edges; miter length is clamped so a spiky corner cannot fling
70
+ * its inset point far into the interior. */
71
+ export function profileRing(pts: Pt[]) {
76
72
  let n = pts.length
77
73
  let enx: number[] = []
78
74
  let eny: number[] = []
@@ -134,25 +130,17 @@ function profileRing(pts: Pt[]) {
134
130
  return { entries, miterX, miterY }
135
131
  }
136
132
 
137
- // Two CCW-outward triangles per cell of a swept (outer + 1) x entries
138
- // vertex grid, entries inner: for extrude, outer runs down the z slices;
139
- // for lathe, around the revolution. The split and winding reduce to box
140
- // and cylinder respectively (verified against those generators). The
141
- // zero-width cell between a sharp point's two entries is skipped.
142
- function sweepIndices(outer: number, entries: RingEntry[], out: number[]): void {
143
- let stride = entries.length
144
- for (let o = 0; o < outer; o++) {
145
- for (let k = 0; k < stride - 1; k++) {
146
- let e = entries[k]!
147
- let f = entries[k + 1]!
148
- if (e.x === f.x && e.y === f.y) continue
149
- let a = o * stride + k
150
- let b = a + 1
151
- let c = a + stride + 1
152
- let d = a + stride
153
- out.push(c, b, d, b, a, d)
154
- }
133
+ export type ProfileBounds = { minX: number; maxX: number; minY: number; maxY: number; w: number; h: number }
134
+
135
+ /** The profile's bounding box with degenerate spans widened to 1 - the
136
+ * denominator every cap UV map shares. */
137
+ export function profileBounds(pts: { x: number; y: number }[]): ProfileBounds {
138
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
139
+ for (let p of pts) {
140
+ minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x)
141
+ minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y)
155
142
  }
143
+ return { minX, maxX, minY, maxY, w: maxX - minX || 1, h: maxY - minY || 1 }
156
144
  }
157
145
 
158
146
  // Inside a CCW triangle, edges included: a vertex sitting exactly on an
@@ -170,10 +158,11 @@ function pointInTriangle(
170
158
  return s1 >= 0 && s2 >= 0 && s3 >= 0
171
159
  }
172
160
 
173
- // Ear clipping for a simple polygon, either winding; returns index triples
174
- // into the input, wound CCW for a +z viewer. Whatever remains un-clippable
175
- // becomes a fan, so a cap is never silently dropped.
176
- function earClip(px: number[], py: number[]): number[] {
161
+ /** Ear clipping for a simple polygon, either winding; returns index
162
+ * triples into the input, wound CCW for a +z viewer. Whatever remains
163
+ * un-clippable becomes a fan, so a cap is never silently dropped.
164
+ * triangulate() is the public face; the generators call this directly. */
165
+ export function earClip(px: number[], py: number[]): number[] {
177
166
  let idx: number[] = []
178
167
  for (let i = 0; i < px.length; i++) idx.push(i)
179
168
  if (signedArea(px, py) < 0) idx.reverse()
@@ -206,10 +195,6 @@ function earClip(px: number[], py: number[]): number[] {
206
195
  return out
207
196
  }
208
197
 
209
- function packIndices(indices: number[], vertexCount: number): Uint16Array | Uint32Array {
210
- return vertexCount > 65535 ? new Uint32Array(indices) : new Uint16Array(indices)
211
- }
212
-
213
198
  /**
214
199
  * Ear-clip a simple polygon (either winding, no holes): flat index triples
215
200
  * into `points`, wound CCW for a +z viewer. Un-clippable leftovers fall
@@ -307,190 +292,6 @@ export function roundRect(
307
292
  return fillet(corners, radius, segments)
308
293
  }
309
294
 
310
- /**
311
- * The profile swept along z, centered on the origin: z runs from depth/2
312
- * down to -depth/2, caps at both ends. `bevel` rounds both rims with a
313
- * quarter-circle roll of that radius (clamped to just under half the
314
- * depth), inset from the outline by miter offset so the bevel stays
315
- * inside the silhouette. Side UVs: u = normalized distance around the
316
- * outline (seam at the first point), v = 0 at the +z rim to 1 at the -z
317
- * rim, bevel arcs included. Caps map the profile's bounding box to the
318
- * unit square like plane(); the -z cap mirrors u so its texture reads
319
- * unmirrored from outside.
320
- */
321
- export function extrude(
322
- profile: Profile,
323
- depth = 1,
324
- bevel = 0,
325
- bevelSegments = 4,
326
- label?: string,
327
- ): Geometry {
328
- let pts = normalizeProfile(profile)
329
- let { entries, miterX, miterY } = profileRing(pts)
330
- let h = depth / 2
331
- let b = Math.min(bevel, depth * 0.49)
332
-
333
- // Slices from the +z rim down: inset from the outline, z, and the
334
- // (radial, z) direction the entry normal tilts into. The rim slice's
335
- // normal equals the cap's, so a bevel blends into its cap crease-free.
336
- let slices: { inset: number; z: number; nr: number; nz: number }[] = []
337
- if (b > 1e-9) {
338
- let bsegs = Math.max(1, Math.round(bevelSegments))
339
- for (let i = 0; i <= bsegs; i++) {
340
- let a = (i / bsegs) * (Math.PI / 2)
341
- slices.push({ inset: b * (1 - Math.sin(a)), z: h - b * (1 - Math.cos(a)), nr: Math.sin(a), nz: Math.cos(a) })
342
- }
343
- for (let i = bsegs; i >= 0; i--) {
344
- let a = (i / bsegs) * (Math.PI / 2)
345
- slices.push({ inset: b * (1 - Math.sin(a)), z: -h + b * (1 - Math.cos(a)), nr: Math.sin(a), nz: -Math.cos(a) })
346
- }
347
- } else {
348
- slices.push({ inset: 0, z: h, nr: 1, nz: 0 }, { inset: 0, z: -h, nr: 1, nz: 0 })
349
- }
350
-
351
- // v follows the path length down the side, bevel arcs included.
352
- let v: number[] = [0]
353
- let path = 0
354
- for (let i = 1; i < slices.length; i++) {
355
- let p = slices[i - 1]!
356
- let s = slices[i]!
357
- path += Math.hypot(s.z - p.z, s.inset - p.inset)
358
- v.push(path)
359
- }
360
- for (let i = 0; i < v.length; i++) v[i] = v[i]! / (path || 1)
361
-
362
- let verts: number[] = []
363
- for (let si = 0; si < slices.length; si++) {
364
- let s = slices[si]!
365
- for (let e of entries) {
366
- // (e.n * nr, nz) is unit already: |e.n| = 1 and nr^2 + nz^2 = 1.
367
- verts.push(
368
- e.x - miterX[e.point]! * s.inset,
369
- e.y - miterY[e.point]! * s.inset,
370
- s.z,
371
- e.nx * s.nr,
372
- e.ny * s.nr,
373
- s.nz,
374
- e.t,
375
- v[si]!,
376
- )
377
- }
378
- }
379
- let indices: number[] = []
380
- sweepIndices(slices.length - 1, entries, indices)
381
-
382
- // Caps, inset to meet the bevel rim; UVs span the base profile's box.
383
- let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
384
- for (let p of pts) {
385
- minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x)
386
- minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y)
387
- }
388
- let bw = maxX - minX || 1
389
- let bh = maxY - minY || 1
390
- let inset = slices[0]!.inset
391
- let cx: number[] = []
392
- let cy: number[] = []
393
- for (let i = 0; i < pts.length; i++) {
394
- cx.push(pts[i]!.x - miterX[i]! * inset)
395
- cy.push(pts[i]!.y - miterY[i]! * inset)
396
- }
397
- let tris = earClip(cx, cy)
398
- let base = verts.length / FLOATS_PER_VERTEX
399
- for (let i = 0; i < cx.length; i++) {
400
- verts.push(cx[i]!, cy[i]!, h, 0, 0, 1, (cx[i]! - minX) / bw, (maxY - cy[i]!) / bh)
401
- }
402
- for (let i = 0; i < tris.length; i += 3) {
403
- indices.push(base + tris[i]!, base + tris[i + 1]!, base + tris[i + 2]!)
404
- }
405
- base = verts.length / FLOATS_PER_VERTEX
406
- for (let i = 0; i < cx.length; i++) {
407
- verts.push(cx[i]!, cy[i]!, -h, 0, 0, -1, (maxX - cx[i]!) / bw, (maxY - cy[i]!) / bh)
408
- }
409
- for (let i = 0; i < tris.length; i += 3) {
410
- indices.push(base + tris[i]!, base + tris[i + 2]!, base + tris[i + 1]!)
411
- }
412
-
413
- return {
414
- vertices: new Float32Array(verts),
415
- indices: packIndices(indices, verts.length / FLOATS_PER_VERTEX),
416
- label,
417
- }
418
- }
419
-
420
- /**
421
- * A solid of revolution: the CLOSED profile - x is the radius (>= 0), y
422
- * the height - revolved about the y axis through `angle` radians starting
423
- * at `start`. The closed profile (a cross-section with thickness, or run
424
- * to the axis) keeps the output watertight and cull-correct where an open
425
- * polyline shell would show its missing back faces; partial sweeps get
426
- * flat triangulated end caps. UVs: u 0..1 around the sweep (seam column
427
- * duplicated like torus), v = normalized distance around the profile;
428
- * caps map the profile's bounding box, the end cap mirrored in u.
429
- */
430
- export function lathe(
431
- profile: Profile,
432
- segments = 32,
433
- angle = Math.PI * 2,
434
- start = 0,
435
- label?: string,
436
- ): Geometry {
437
- if (!(angle > 0) || angle > Math.PI * 2 + 1e-9) throw new Error("Lathe angle must be in (0, 2*PI]")
438
- let pts = normalizeProfile(profile)
439
- let { entries } = profileRing(pts)
440
- let full = angle > Math.PI * 2 - 1e-9
441
-
442
- let verts: number[] = []
443
- for (let c = 0; c <= segments; c++) {
444
- let u = c / segments
445
- let phi = start + angle * u
446
- // The same radial direction as cylinder(), so winding transfers.
447
- let dx = -Math.cos(phi)
448
- let dz = Math.sin(phi)
449
- for (let e of entries) {
450
- verts.push(e.x * dx, e.y, e.x * dz, e.nx * dx, e.ny, e.nx * dz, u, e.t)
451
- }
452
- }
453
- let indices: number[] = []
454
- sweepIndices(segments, entries, indices)
455
-
456
- if (!full) {
457
- let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
458
- for (let p of pts) {
459
- minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x)
460
- minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y)
461
- }
462
- let bw = maxX - minX || 1
463
- let bh = maxY - minY || 1
464
- let px = pts.map((p) => p.x)
465
- let py = pts.map((p) => p.y)
466
- let tris = earClip(px, py)
467
- for (let end = 0; end < 2; end++) {
468
- let phi = end === 0 ? start : start + angle
469
- let dx = -Math.cos(phi)
470
- let dz = Math.sin(phi)
471
- // Outward along minus/plus the sweep direction; the CCW profile
472
- // triangulation faces the start normal as-is and flips for the end.
473
- let nx = end === 0 ? -Math.sin(phi) : Math.sin(phi)
474
- let nz = end === 0 ? -Math.cos(phi) : Math.cos(phi)
475
- let base = verts.length / FLOATS_PER_VERTEX
476
- for (let p of pts) {
477
- let u = end === 0 ? (p.x - minX) / bw : (maxX - p.x) / bw
478
- verts.push(p.x * dx, p.y, p.x * dz, nx, 0, nz, u, (maxY - p.y) / bh)
479
- }
480
- for (let i = 0; i < tris.length; i += 3) {
481
- if (end === 0) indices.push(base + tris[i]!, base + tris[i + 1]!, base + tris[i + 2]!)
482
- else indices.push(base + tris[i]!, base + tris[i + 2]!, base + tris[i + 1]!)
483
- }
484
- }
485
- }
486
-
487
- return {
488
- vertices: new Float32Array(verts),
489
- indices: packIndices(indices, verts.length / FLOATS_PER_VERTEX),
490
- label,
491
- }
492
- }
493
-
494
295
  /**
495
296
  * The profile filled as a flat face in the XY plane facing +z - the
496
297
  * general case of circle()/ring() for arbitrary outlines. UVs map the
@@ -499,18 +300,12 @@ export function lathe(
499
300
  */
500
301
  export function shape(profile: Profile, label?: string): Geometry {
501
302
  let pts = normalizeProfile(profile)
502
- let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
503
- for (let p of pts) {
504
- minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x)
505
- minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y)
506
- }
507
- let bw = maxX - minX || 1
508
- let bh = maxY - minY || 1
303
+ let { minX, maxY, w, h } = profileBounds(pts)
509
304
  let px = pts.map((p) => p.x)
510
305
  let py = pts.map((p) => p.y)
511
306
  let verts: number[] = []
512
307
  for (let p of pts) {
513
- verts.push(p.x, p.y, 0, 0, 0, 1, (p.x - minX) / bw, (maxY - p.y) / bh)
308
+ verts.push(p.x, p.y, 0, 0, 0, 1, (p.x - minX) / w, (maxY - p.y) / h)
514
309
  }
515
310
  return {
516
311
  vertices: new Float32Array(verts),
package/src/scene.ts CHANGED
@@ -19,18 +19,30 @@
19
19
 
20
20
  import { addDraw, createDrawTarget, destroyTexture, removeDraw, setDrawParams, setDrawRange, setTargetParams, setTargetSize } from "@solidrt/core/gpu"
21
21
  import type { DrawId, FilterMode, ShaderParams, TextureId, WrapMode } from "@solidrt/core/gpu"
22
- import { getOwner, onCleanup } from "@solidjs/signals"
23
- import { compose, copy, lookAt, mat4, multiply, normalMatrix, perspective, transformPoint } from "./math.ts"
24
- import type { Mat4, Vec3, Vec4 } from "./math.ts"
22
+ import { getOwner, onCleanup } from "@solidrt/core"
23
+ // The scene's lookAt() aims a node; math's builds a camera's view matrix -
24
+ // the same pairing (and the same name) as Three's Object3D/Matrix4.
25
+ import { compose, copy, eulerFromQuat, identity, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quatFromEuler, quatFromFrame, quatNormalize, transformPoint } from "./math.ts"
26
+ import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
25
27
  import { geometryBuffers } from "./geometry.ts"
26
28
  import type { Geometry } from "./geometry.ts"
27
29
  import type { Material } from "./material.ts"
28
30
 
29
31
  const IDENTITY = mat4()
30
32
  const RESOLVED = Promise.resolve()
33
+ // lookAt()'s default roll reference. Read-only: quatFromFrame never
34
+ // writes its inputs, so one shared vector is safe.
35
+ const WORLD_UP: Vec3 = [0, 1, 0]
31
36
  // Param values are snapshotted at the FFI boundary (addDraw shares
32
37
  // IDENTITY the same way), so one scratch serves every uNormal write.
33
38
  let normalScratch = mat4()
39
+ // lookAt()/worldPosition() scratch: the ancestor walk recomputes worlds
40
+ // without touching node state, so nothing here outlives a single call.
41
+ let worldScratch = mat4()
42
+ let localScratch = mat4()
43
+ let pointScratch: Vec4 = [0, 0, 0, 0]
44
+ let aimScratch: Vec3 = [0, 0, 0]
45
+ let upScratch: Vec3 = [0, 0, 0]
34
46
 
35
47
  // The scene half a node needs to reach: attach/detach entries and schedule
36
48
  // a sync. Kept separate from the public Scene type so internals stay off
@@ -50,8 +62,10 @@ export type SceneNode = {
50
62
  children: SceneNode[]
51
63
  /** Read freely; write through setTransform/setVisible so changes sync. */
52
64
  position: Vec3
53
- /** Euler radians, applied x then y then z. */
54
- rotation: Vec3
65
+ /** The stored rotation, always a UNIT quaternion. Euler triples convert
66
+ * on the way in (setTransform's `rotation`) and out (getRotation) - there
67
+ * is no second rotation field to fall out of step with this one. */
68
+ quaternion: Quat
55
69
  scale: Vec3
56
70
  visible: boolean
57
71
  _localDirty: boolean
@@ -121,7 +135,7 @@ function makeNode(kind: "group" | "mesh"): SceneNode {
121
135
  parent: null,
122
136
  children: [],
123
137
  position: [0, 0, 0],
124
- rotation: [0, 0, 0],
138
+ quaternion: [0, 0, 0, 1],
125
139
  scale: [1, 1, 1],
126
140
  visible: true,
127
141
  _localDirty: true,
@@ -183,7 +197,13 @@ function leaveScene(node: SceneNode): void {
183
197
 
184
198
  export type TransformUpdate = {
185
199
  position?: Vec3
200
+ /** Euler radians in XYZ order (x first), Three's `Euler` default -
201
+ * converted to the node's quaternion on write. */
186
202
  rotation?: Vec3
203
+ /** The rotation itself. Normalized on write, so a hand-built or
204
+ * drifted quaternion cannot silently scale the geometry. Passing this
205
+ * together with `rotation` is an error, not a precedence question. */
206
+ quaternion?: Quat
187
207
  /** A number is uniform scale. */
188
208
  scale?: Vec3 | number
189
209
  }
@@ -202,11 +222,12 @@ export function setTransform(node: SceneNode, update: TransformUpdate): void {
202
222
  node.position[2] = p[2]
203
223
  }
204
224
  let r = update.rotation
205
- if (r) {
206
- node.rotation[0] = r[0]
207
- node.rotation[1] = r[1]
208
- node.rotation[2] = r[2]
225
+ let q = update.quaternion
226
+ if (r !== undefined && q !== undefined) {
227
+ throw new Error("Pass rotation or quaternion to setTransform, not both")
209
228
  }
229
+ if (r !== undefined) quatFromEuler(node.quaternion, r)
230
+ else if (q !== undefined) quatNormalize(node.quaternion, q)
210
231
  let s = update.scale
211
232
  if (s !== undefined) {
212
233
  if (typeof s === "number") {
@@ -223,6 +244,109 @@ export function setTransform(node: SceneNode, update: TransformUpdate): void {
223
244
  node._scene?._schedule()
224
245
  }
225
246
 
247
+ /**
248
+ * Aim a node at a WORLD-space point, Three's `Object3D.lookAt`: the node's
249
+ * local +z ends up pointing at `target`, with `up` (world space, default
250
+ * +y) choosing the roll about that axis. Ancestor transforms are undone,
251
+ * so the aim holds under a rotated group - the ancestor chain is brought
252
+ * up to date on the spot rather than waiting for the pending sync.
253
+ *
254
+ * +z because that is the library's own sweep axis (`extrude`, `sweep`,
255
+ * `tube` run along z), so aiming their output needs no correction. For a
256
+ * y-axis solid (`cylinder`, `cone`) reach for `quatFromTo` instead, which
257
+ * takes the axis to aim as an argument.
258
+ *
259
+ * Writes `node.quaternion` - an ordinary rotation afterwards, readable and
260
+ * overwritable by setTransform. To aim along a DIRECTION rather than at a
261
+ * point, add it to the node's world position (`worldPosition`), the same
262
+ * conversion Three asks for.
263
+ *
264
+ * Exact for rotation and uniform scale in the ancestor chain; a
265
+ * non-uniformly scaled ancestor shears the frame and the aim is
266
+ * approximate, exactly as in Three (both read the parent's upper 3x3 as
267
+ * if it were a rotation).
268
+ */
269
+ export function lookAt(node: SceneNode, target: Vec3, up: Vec3 = WORLD_UP): void {
270
+ let parent = node.parent
271
+ if (parent === null) {
272
+ // No ancestors: parent space IS world space, aim straight from the
273
+ // node's own position.
274
+ aimScratch[0] = target[0] - node.position[0]
275
+ aimScratch[1] = target[1] - node.position[1]
276
+ aimScratch[2] = target[2] - node.position[2]
277
+ quatFromFrame(node.quaternion, aimScratch, up)
278
+ } else {
279
+ let world = worldInto(worldScratch, parent)
280
+ transformPoint(pointScratch, world, node.position)
281
+ aimScratch[0] = target[0] - pointScratch[0]
282
+ aimScratch[1] = target[1] - pointScratch[1]
283
+ aimScratch[2] = target[2] - pointScratch[2]
284
+ // World -> parent space for both vectors: rotating forward and up
285
+ // rotates the frame they build, so converting the inputs is the same
286
+ // as converting the resulting rotation, and needs no matrix inverse.
287
+ unrotate(aimScratch, world, aimScratch)
288
+ unrotate(upScratch, world, up)
289
+ quatFromFrame(node.quaternion, aimScratch, upScratch)
290
+ }
291
+ node._localDirty = true
292
+ node._scene?._schedule()
293
+ }
294
+
295
+ /**
296
+ * A node's rotation as Euler radians in XYZ order, copied into `out` (or a
297
+ * fresh Vec3). A convenience for reading and debugging, NOT a peer of
298
+ * `node.quaternion`: the conversion is lossy in the sense that it cannot
299
+ * recover the triple that was written (see eulerFromQuat), only a triple
300
+ * that means the same rotation. Anything composing or interpolating
301
+ * rotations should work with the quaternion.
302
+ */
303
+ export function getRotation(node: SceneNode, out: Vec3 = [0, 0, 0]): Vec3 {
304
+ return eulerFromQuat(out, node.quaternion)
305
+ }
306
+
307
+ /**
308
+ * A node's position in world space, copied into `out` (or a fresh Vec3) -
309
+ * Three's `getWorldPosition`. Brings the ancestor chain up to date first,
310
+ * so it is exact before the pending sync has run.
311
+ */
312
+ export function worldPosition(node: SceneNode, out: Vec3 = [0, 0, 0]): Vec3 {
313
+ let world = worldInto(worldScratch, node)
314
+ out[0] = world[12]
315
+ out[1] = world[13]
316
+ out[2] = world[14]
317
+ return out
318
+ }
319
+
320
+ /**
321
+ * `out` = node's world matrix, composing any dirty locals up the chain
322
+ * WITHOUT clearing their flags: the pending sync still has to see them to
323
+ * write uModel. One shared local scratch serves any depth - each frame
324
+ * uses it only after its recursive call has returned.
325
+ */
326
+ function worldInto(out: Mat4, node: SceneNode): Mat4 {
327
+ if (node.parent === null) identity(out)
328
+ else worldInto(out, node.parent)
329
+ let local = node._localDirty
330
+ ? compose(localScratch, node.position, node.quaternion, node.scale)
331
+ : node._local
332
+ return multiply(out, out, local)
333
+ }
334
+
335
+ /**
336
+ * `out` = v with m's rotation undone: the transpose of m's upper 3x3 with
337
+ * its columns normalized, so uniform scale divides out. out may alias v.
338
+ */
339
+ function unrotate(out: Vec3, m: Mat4, v: Vec3): Vec3 {
340
+ let x = v[0], y = v[1], z = v[2]
341
+ let l0 = Math.hypot(m[0], m[1], m[2]) || 1
342
+ let l1 = Math.hypot(m[4], m[5], m[6]) || 1
343
+ let l2 = Math.hypot(m[8], m[9], m[10]) || 1
344
+ out[0] = (m[0] * x + m[1] * y + m[2] * z) / l0
345
+ out[1] = (m[4] * x + m[5] * y + m[6] * z) / l1
346
+ out[2] = (m[8] * x + m[9] * y + m[10] * z) / l2
347
+ return out
348
+ }
349
+
226
350
  /** Show or hide a node and its whole subtree (a hidden mesh costs one
227
351
  * `instanceCount: 0` draw range - the entry stays, drawing nothing). */
228
352
  export function setVisible(node: SceneNode, visible: boolean): void {
@@ -308,7 +432,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
308
432
  cameraDirty = false
309
433
  cameraPending = true
310
434
  perspective(proj, (fov * Math.PI) / 180, width / height, near, far)
311
- lookAt(view, eye, target, up)
435
+ lookAtMatrix(view, eye, target, up)
312
436
  multiply(viewProj, proj, view)
313
437
  }
314
438
 
@@ -326,7 +450,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
326
450
  let walk = (node: SceneNode, parentChanged: boolean, parentVisible: boolean) => {
327
451
  let changed = parentChanged
328
452
  if (node._localDirty) {
329
- compose(node._local, node.position, node.rotation, node.scale)
453
+ compose(node._local, node.position, node.quaternion, node.scale)
330
454
  node._localDirty = false
331
455
  changed = true
332
456
  }