@solidrt/3d 0.0.47 → 0.0.49
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 +178 -14
- package/README.md +32 -6
- package/examples/README.md +16 -0
- package/examples/aim.tsx +119 -0
- package/examples/pick.tsx +98 -0
- package/examples/scene-background.tsx +43 -0
- package/examples/sweep-paths.tsx +79 -0
- package/package.json +3 -3
- package/src/bvh.ts +258 -0
- package/src/components.tsx +81 -10
- package/src/geometry.ts +35 -0
- package/src/index.ts +10 -6
- package/src/material.ts +37 -0
- package/src/math.ts +336 -19
- package/src/profile.ts +43 -248
- package/src/scene.ts +530 -17
- 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),
|