@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/AGENTS.md +94 -12
- package/README.md +19 -4
- package/examples/README.md +8 -0
- package/examples/aim.tsx +119 -0
- package/examples/sweep-paths.tsx +79 -0
- package/package.json +2 -2
- package/src/components.tsx +22 -5
- package/src/geometry.ts +6 -0
- package/src/index.ts +8 -4
- package/src/math.ts +294 -19
- package/src/profile.ts +43 -248
- package/src/scene.ts +136 -12
- package/src/sweep.ts +460 -0
package/src/profile.ts
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
|
-
// Profile kit:
|
|
2
|
-
// polygon in the XY plane - bare [x, y] tuples
|
|
3
|
-
// tagged { p, smooth } points shade round -
|
|
4
|
-
// CCW on use, so either authoring direction
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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 {
|
|
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
|
-
|
|
24
|
+
/** A normalized profile point (normalizeProfile's output). */
|
|
25
|
+
export type Pt = { x: number; y: number; smooth: boolean }
|
|
31
26
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
let
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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) /
|
|
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 "@
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
/**
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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.
|
|
453
|
+
compose(node._local, node.position, node.quaternion, node.scale)
|
|
330
454
|
node._localDirty = false
|
|
331
455
|
changed = true
|
|
332
456
|
}
|