@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/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),