@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/sweep.ts ADDED
@@ -0,0 +1,460 @@
1
+ // Swept solids: the generators that run a Profile (profile.ts) along a
2
+ // path. extrude() sweeps along a straight line (z), lathe() around a
3
+ // circle (about y), sweep() along an arbitrary 3D polyline with mitred
4
+ // joints, and tube() is sweep() with a circular profile - the wire, rope
5
+ // and pipe primitive. Output is the shared vertex layout of geometry.ts
6
+ // with real texture UVs; winding is CCW seen from outside like every
7
+ // generator, so cull: "back" works. Indices pick Uint16Array or
8
+ // Uint32Array by vertex count - filleted profiles times many path points
9
+ // make dense outputs routine here.
10
+
11
+ import { FLOATS_PER_VERTEX, packIndices } from "./geometry.ts"
12
+ import type { Geometry } from "./geometry.ts"
13
+ import { add, cross, dot, normalize, scale, sub } from "./math.ts"
14
+ import type { Vec3 } from "./math.ts"
15
+ import { earClip, normalizeProfile, profileBounds, profileRing } from "./profile.ts"
16
+ import type { Profile, ProfileBounds, RingEntry } from "./profile.ts"
17
+
18
+ // Two CCW-outward triangles per cell between two profile rings already in
19
+ // the vertex buffer, baseA the ring nearer the sweep's start, entries
20
+ // inner. The zero-width cell between a sharp profile point's two entries
21
+ // is skipped.
22
+ function ringBand(baseA: number, baseB: number, entries: RingEntry[], out: number[]): void {
23
+ for (let k = 0; k < entries.length - 1; k++) {
24
+ let e = entries[k]!
25
+ let f = entries[k + 1]!
26
+ if (e.x === f.x && e.y === f.y) continue
27
+ let a = baseA + k
28
+ let b = a + 1
29
+ let c = baseB + k + 1
30
+ let d = baseB + k
31
+ out.push(c, b, d, b, a, d)
32
+ }
33
+ }
34
+
35
+ // A full swept grid of (outer + 1) consecutive rings: for extrude, outer
36
+ // runs down the z slices; for lathe, around the revolution. The split and
37
+ // winding reduce to box and cylinder respectively (verified against those
38
+ // generators).
39
+ function sweepIndices(outer: number, entries: RingEntry[], out: number[]): void {
40
+ let stride = entries.length
41
+ for (let o = 0; o < outer; o++) ringBand(o * stride, (o + 1) * stride, entries, out)
42
+ }
43
+
44
+ // A flat triangulated cap: place() maps a profile-space point into world,
45
+ // `n` is the outward normal, and the profile bounds map to the unit
46
+ // square. `mirrorU` flips u so a far-end cap's texture reads unmirrored
47
+ // from outside; `flip` reverses the CCW-for-a-+z-viewer triangulation for
48
+ // a cap whose outside is the profile's -z side.
49
+ function emitCap(
50
+ verts: number[],
51
+ indices: number[],
52
+ px: number[],
53
+ py: number[],
54
+ tris: number[],
55
+ b: ProfileBounds,
56
+ place: (x: number, y: number) => Vec3,
57
+ n: Vec3,
58
+ mirrorU: boolean,
59
+ flip: boolean,
60
+ ): void {
61
+ let base = verts.length / FLOATS_PER_VERTEX
62
+ for (let i = 0; i < px.length; i++) {
63
+ let p = place(px[i]!, py[i]!)
64
+ let u = mirrorU ? (b.maxX - px[i]!) / b.w : (px[i]! - b.minX) / b.w
65
+ verts.push(p[0], p[1], p[2], n[0], n[1], n[2], u, (b.maxY - py[i]!) / b.h)
66
+ }
67
+ for (let i = 0; i < tris.length; i += 3) {
68
+ if (flip) indices.push(base + tris[i]!, base + tris[i + 2]!, base + tris[i + 1]!)
69
+ else indices.push(base + tris[i]!, base + tris[i + 1]!, base + tris[i + 2]!)
70
+ }
71
+ }
72
+
73
+ /**
74
+ * The profile swept along z, centered on the origin: z runs from depth/2
75
+ * down to -depth/2, caps at both ends. `bevel` rounds both rims with a
76
+ * quarter-circle roll of that radius (clamped to just under half the
77
+ * depth), inset from the outline by miter offset so the bevel stays
78
+ * inside the silhouette. Side UVs: u = normalized distance around the
79
+ * outline (seam at the first point), v = 0 at the +z rim to 1 at the -z
80
+ * rim, bevel arcs included. Caps map the profile's bounding box to the
81
+ * unit square like plane(); the -z cap mirrors u so its texture reads
82
+ * unmirrored from outside.
83
+ */
84
+ export function extrude(
85
+ profile: Profile,
86
+ depth = 1,
87
+ bevel = 0,
88
+ bevelSegments = 4,
89
+ label?: string,
90
+ ): Geometry {
91
+ let pts = normalizeProfile(profile)
92
+ let { entries, miterX, miterY } = profileRing(pts)
93
+ let h = depth / 2
94
+ let b = Math.min(bevel, depth * 0.49)
95
+
96
+ // Slices from the +z rim down: inset from the outline, z, and the
97
+ // (radial, z) direction the entry normal tilts into. The rim slice's
98
+ // normal equals the cap's, so a bevel blends into its cap crease-free.
99
+ let slices: { inset: number; z: number; nr: number; nz: number }[] = []
100
+ if (b > 1e-9) {
101
+ let bsegs = Math.max(1, Math.round(bevelSegments))
102
+ for (let i = 0; i <= bsegs; i++) {
103
+ let a = (i / bsegs) * (Math.PI / 2)
104
+ slices.push({ inset: b * (1 - Math.sin(a)), z: h - b * (1 - Math.cos(a)), nr: Math.sin(a), nz: Math.cos(a) })
105
+ }
106
+ for (let i = bsegs; i >= 0; i--) {
107
+ let a = (i / bsegs) * (Math.PI / 2)
108
+ slices.push({ inset: b * (1 - Math.sin(a)), z: -h + b * (1 - Math.cos(a)), nr: Math.sin(a), nz: -Math.cos(a) })
109
+ }
110
+ } else {
111
+ slices.push({ inset: 0, z: h, nr: 1, nz: 0 }, { inset: 0, z: -h, nr: 1, nz: 0 })
112
+ }
113
+
114
+ // v follows the path length down the side, bevel arcs included.
115
+ let v: number[] = [0]
116
+ let path = 0
117
+ for (let i = 1; i < slices.length; i++) {
118
+ let p = slices[i - 1]!
119
+ let s = slices[i]!
120
+ path += Math.hypot(s.z - p.z, s.inset - p.inset)
121
+ v.push(path)
122
+ }
123
+ for (let i = 0; i < v.length; i++) v[i] = v[i]! / (path || 1)
124
+
125
+ let verts: number[] = []
126
+ for (let si = 0; si < slices.length; si++) {
127
+ let s = slices[si]!
128
+ for (let e of entries) {
129
+ // (e.n * nr, nz) is unit already: |e.n| = 1 and nr^2 + nz^2 = 1.
130
+ verts.push(
131
+ e.x - miterX[e.point]! * s.inset,
132
+ e.y - miterY[e.point]! * s.inset,
133
+ s.z,
134
+ e.nx * s.nr,
135
+ e.ny * s.nr,
136
+ s.nz,
137
+ e.t,
138
+ v[si]!,
139
+ )
140
+ }
141
+ }
142
+ let indices: number[] = []
143
+ sweepIndices(slices.length - 1, entries, indices)
144
+
145
+ // Caps, inset to meet the bevel rim; UVs span the base profile's box.
146
+ let bounds = profileBounds(pts)
147
+ let inset = slices[0]!.inset
148
+ let cx: number[] = []
149
+ let cy: number[] = []
150
+ for (let i = 0; i < pts.length; i++) {
151
+ cx.push(pts[i]!.x - miterX[i]! * inset)
152
+ cy.push(pts[i]!.y - miterY[i]! * inset)
153
+ }
154
+ let tris = earClip(cx, cy)
155
+ emitCap(verts, indices, cx, cy, tris, bounds, (x, y) => [x, y, h], [0, 0, 1], false, false)
156
+ emitCap(verts, indices, cx, cy, tris, bounds, (x, y) => [x, y, -h], [0, 0, -1], true, true)
157
+
158
+ return {
159
+ vertices: new Float32Array(verts),
160
+ indices: packIndices(indices, verts.length / FLOATS_PER_VERTEX),
161
+ label,
162
+ }
163
+ }
164
+
165
+ /**
166
+ * A solid of revolution: the CLOSED profile - x is the radius (>= 0), y
167
+ * the height - revolved about the y axis through `angle` radians starting
168
+ * at `start`. The closed profile (a cross-section with thickness, or run
169
+ * to the axis) keeps the output watertight and cull-correct where an open
170
+ * polyline shell would show its missing back faces; partial sweeps get
171
+ * flat triangulated end caps. UVs: u 0..1 around the sweep (seam column
172
+ * duplicated like torus), v = normalized distance around the profile;
173
+ * caps map the profile's bounding box, the end cap mirrored in u.
174
+ */
175
+ export function lathe(
176
+ profile: Profile,
177
+ segments = 32,
178
+ angle = Math.PI * 2,
179
+ start = 0,
180
+ label?: string,
181
+ ): Geometry {
182
+ if (!(angle > 0) || angle > Math.PI * 2 + 1e-9) throw new Error("Lathe angle must be in (0, 2*PI]")
183
+ let pts = normalizeProfile(profile)
184
+ let { entries } = profileRing(pts)
185
+ let full = angle > Math.PI * 2 - 1e-9
186
+
187
+ let verts: number[] = []
188
+ for (let c = 0; c <= segments; c++) {
189
+ let u = c / segments
190
+ let phi = start + angle * u
191
+ // The same radial direction as cylinder(), so winding transfers.
192
+ let dx = -Math.cos(phi)
193
+ let dz = Math.sin(phi)
194
+ for (let e of entries) {
195
+ verts.push(e.x * dx, e.y, e.x * dz, e.nx * dx, e.ny, e.nx * dz, u, e.t)
196
+ }
197
+ }
198
+ let indices: number[] = []
199
+ sweepIndices(segments, entries, indices)
200
+
201
+ if (!full) {
202
+ let bounds = profileBounds(pts)
203
+ let px = pts.map((p) => p.x)
204
+ let py = pts.map((p) => p.y)
205
+ let tris = earClip(px, py)
206
+ for (let end = 0; end < 2; end++) {
207
+ let phi = end === 0 ? start : start + angle
208
+ let dx = -Math.cos(phi)
209
+ let dz = Math.sin(phi)
210
+ // Outward along minus/plus the sweep direction; the CCW profile
211
+ // triangulation faces the start normal as-is and flips for the end.
212
+ let nx = end === 0 ? -Math.sin(phi) : Math.sin(phi)
213
+ let nz = end === 0 ? -Math.cos(phi) : Math.cos(phi)
214
+ emitCap(verts, indices, px, py, tris, bounds, (x, y) => [x * dx, y, x * dz], [nx, 0, nz], end === 1, end === 1)
215
+ }
216
+ }
217
+
218
+ return {
219
+ vertices: new Float32Array(verts),
220
+ indices: packIndices(indices, verts.length / FLOATS_PER_VERTEX),
221
+ label,
222
+ }
223
+ }
224
+
225
+ /** A sweep-path point: `p` in world space, `smooth` to share one ring
226
+ * with averaged normals at this joint instead of creasing (tag the points
227
+ * of a sampled curve; the default is sharp). Tags on the two endpoints
228
+ * are ignored - ends get caps, not joints. */
229
+ export type PathPoint = { p: Vec3; smooth?: boolean }
230
+
231
+ /** An open 3D polyline to sweep along: bare [x, y, z] points are sharp
232
+ * (creased) joints. Consecutive duplicates are dropped; the first and
233
+ * last distinct points are the capped ends. Closed loops are not
234
+ * supported yet - overlap the ends by a segment to fake one. */
235
+ export type SweepPath = (Vec3 | PathPoint)[]
236
+
237
+ type NPath = { p: Vec3; smooth: boolean }[]
238
+
239
+ function normalizePath(path: SweepPath): NPath {
240
+ let pts: NPath = []
241
+ for (let point of path) {
242
+ let p: Vec3
243
+ let smooth: boolean
244
+ if (Array.isArray(point)) {
245
+ p = [point[0], point[1], point[2]]
246
+ smooth = false
247
+ } else {
248
+ p = [point.p[0], point.p[1], point.p[2]]
249
+ smooth = point.smooth === true
250
+ }
251
+ let prev = pts[pts.length - 1]
252
+ if (prev !== undefined &&
253
+ Math.abs(p[0] - prev.p[0]) < 1e-9 && Math.abs(p[1] - prev.p[1]) < 1e-9 && Math.abs(p[2] - prev.p[2]) < 1e-9) continue
254
+ pts.push({ p, smooth })
255
+ }
256
+ if (pts.length < 2) throw new Error("Sweep path needs at least 2 distinct points")
257
+ return pts
258
+ }
259
+
260
+ /** Per-segment cross-section frames of a path (pathFrames' result). */
261
+ export type PathFrames = {
262
+ /** The deduplicated path points. */
263
+ points: Vec3[]
264
+ /** Unit direction of travel, one per segment (points.length - 1). */
265
+ tangents: Vec3[]
266
+ /** The cross-section axes profile x and y map onto, one pair per
267
+ * segment, minimally rotated from segment to segment (parallel
268
+ * transport); yAxis starts as close to world up as the first segment
269
+ * allows. */
270
+ xAxes: Vec3[]
271
+ yAxes: Vec3[]
272
+ /** Cumulative arc length at each point; the last entry is the total. */
273
+ lengths: number[]
274
+ }
275
+
276
+ // v rotated about a unit axis; cosA/sinA are the angle's cosine and sine.
277
+ function rotate(v: Vec3, axis: Vec3, cosA: number, sinA: number): Vec3 {
278
+ let c = cross(axis, v)
279
+ let k = dot(axis, v) * (1 - cosA)
280
+ return [
281
+ v[0] * cosA + c[0] * sinA + axis[0] * k,
282
+ v[1] * cosA + c[1] * sinA + axis[1] * k,
283
+ v[2] * cosA + c[2] * sinA + axis[2] * k,
284
+ ]
285
+ }
286
+
287
+ function buildFrames(pts: NPath): PathFrames {
288
+ let points = pts.map((q) => q.p)
289
+ let tangents: Vec3[] = []
290
+ let lengths: number[] = [0]
291
+ let len = 0
292
+ for (let i = 0; i < points.length - 1; i++) {
293
+ let d = sub(points[i + 1]!, points[i]!)
294
+ let l = Math.hypot(d[0], d[1], d[2])
295
+ tangents.push(scale(d, 1 / l))
296
+ len += l
297
+ lengths.push(len)
298
+ }
299
+ // Looking along the travel direction the profile reads CCW with yAxis
300
+ // up, so xAxis x yAxis = -tangent - the same handedness extrude's
301
+ // slices have, which keeps the shared band winding CCW-outward.
302
+ let t0 = tangents[0]!
303
+ let ref: Vec3 = Math.abs(t0[1]) < 0.99 ? [0, 1, 0] : [0, 0, 1]
304
+ let y0 = normalize(sub(ref, scale(t0, dot(ref, t0))))
305
+ let xAxes: Vec3[] = [cross(t0, y0)]
306
+ let yAxes: Vec3[] = [y0]
307
+ for (let i = 1; i < tangents.length; i++) {
308
+ let a = tangents[i - 1]!
309
+ let b = tangents[i]!
310
+ let axis = cross(a, b)
311
+ let sinA = Math.hypot(axis[0], axis[1], axis[2])
312
+ let x = xAxes[i - 1]!
313
+ if (sinA > 1e-9) {
314
+ x = rotate(x, scale(axis, 1 / sinA), Math.max(-1, Math.min(1, dot(a, b))), sinA)
315
+ }
316
+ // Drift guard: keep x exactly in the new cross-section plane. A full
317
+ // reversal (sinA ~ 0, dot < 0) keeps x too - it is perpendicular to
318
+ // both tangents.
319
+ x = normalize(sub(x, scale(b, dot(x, b))))
320
+ xAxes.push(x)
321
+ yAxes.push(cross(x, b))
322
+ }
323
+ return { points, tangents, xAxes, yAxes, lengths }
324
+ }
325
+
326
+ /**
327
+ * The per-segment frames sweep() places its rings with, exported for
328
+ * custom work along a path (placing objects at path points, custom swept
329
+ * surfaces): unit tangents, cross-section axes (parallel transported, so
330
+ * the frame never spins between segments), cumulative arc lengths.
331
+ * Frames are per SEGMENT; a joint's shared ring lives on the bisector
332
+ * plane of its two segments.
333
+ */
334
+ export function pathFrames(path: SweepPath): PathFrames {
335
+ return buildFrames(normalizePath(path))
336
+ }
337
+
338
+ /**
339
+ * The profile swept along an open 3D polyline - the strap, cable and
340
+ * rail generator. Joints are mitred: the cross-section sits on the
341
+ * bisector plane of its two segments, so bends never gape or overlap.
342
+ * Shading follows the path points - bare points crease (two normal sets
343
+ * share the mitre ring; right for a strap folding over an edge),
344
+ * smooth-tagged points average into one continuous surface (tag a
345
+ * sampled curve's points). Flat caps close both ends. The profile's y
346
+ * axis starts as close to world up as the first segment allows (a
347
+ * vertical start falls back to +z) and parallel-transports along the
348
+ * path without spinning. UVs: u = normalized distance around the profile
349
+ * (seam at the first point), v = normalized distance along the path;
350
+ * caps map the profile's bounding box like extrude's, the end cap
351
+ * mirrored in u. A near-reversal joint clamps its mitre stretch (4x,
352
+ * like the profile's own miter clamp) instead of flinging vertices.
353
+ */
354
+ export function sweep(profile: Profile, path: SweepPath, label?: string): Geometry {
355
+ let pts = normalizeProfile(profile)
356
+ let { entries } = profileRing(pts)
357
+ let p = normalizePath(path)
358
+ let { tangents, xAxes, yAxes, lengths } = buildFrames(p)
359
+ let n = p.length
360
+ let total = lengths[n - 1]! || 1
361
+
362
+ let verts: number[] = []
363
+ let indices: number[] = []
364
+ let emitRing = (pos: Vec3[], normals: Vec3[], v: number): number => {
365
+ let base = verts.length / FLOATS_PER_VERTEX
366
+ for (let k = 0; k < entries.length; k++) {
367
+ let q = pos[k]!
368
+ let m = normals[k]!
369
+ verts.push(q[0], q[1], q[2], m[0], m[1], m[2], entries[k]!.t, v)
370
+ }
371
+ return base
372
+ }
373
+ // The ring's world positions in segment s's cross-section plane at
374
+ // `center`, and the profile normals mapped through segment s's frame.
375
+ let planeRing = (center: Vec3, s: number): Vec3[] => {
376
+ let x = xAxes[s]!
377
+ let y = yAxes[s]!
378
+ return entries.map((e): Vec3 => [
379
+ center[0] + x[0] * e.x + y[0] * e.y,
380
+ center[1] + x[1] * e.x + y[1] * e.y,
381
+ center[2] + x[2] * e.x + y[2] * e.y,
382
+ ])
383
+ }
384
+ let frameNormals = (s: number): Vec3[] => {
385
+ let x = xAxes[s]!
386
+ let y = yAxes[s]!
387
+ return entries.map((e): Vec3 => [
388
+ x[0] * e.nx + y[0] * e.ny,
389
+ x[1] * e.nx + y[1] * e.ny,
390
+ x[2] * e.nx + y[2] * e.ny,
391
+ ])
392
+ }
393
+ // The joint ring on the bisector plane: the incoming cross-section
394
+ // projected along its own tangent onto the plane - which is exactly
395
+ // where the outgoing cross-section projects too (that is the miter
396
+ // joint fact parallel transport buys), so ONE mitred ring serves both
397
+ // bands. The projection stretch 1 / (t . m) is clamped to 4x.
398
+ let mitreRing = (i: number): Vec3[] => {
399
+ let a = tangents[i - 1]!
400
+ let m = add(a, tangents[i]!)
401
+ let ml = Math.hypot(m[0], m[1], m[2])
402
+ let bis = ml > 1e-9 ? scale(m, 1 / ml) : a
403
+ let denom = Math.max(dot(a, bis), 0.25)
404
+ return planeRing(p[i]!.p, i - 1).map((q) => {
405
+ let off = sub(q, p[i]!.p)
406
+ return sub(q, scale(a, dot(off, bis) / denom))
407
+ })
408
+ }
409
+
410
+ let prev = emitRing(planeRing(p[0]!.p, 0), frameNormals(0), 0)
411
+ for (let i = 1; i < n - 1; i++) {
412
+ let v = lengths[i]! / total
413
+ let pos = mitreRing(i)
414
+ if (p[i]!.smooth) {
415
+ let nin = frameNormals(i - 1)
416
+ let nout = frameNormals(i)
417
+ let base = emitRing(pos, nin.map((q, k) => normalize(add(q, nout[k]!))), v)
418
+ ringBand(prev, base, entries, indices)
419
+ prev = base
420
+ } else {
421
+ ringBand(prev, emitRing(pos, frameNormals(i - 1), v), entries, indices)
422
+ prev = emitRing(pos, frameNormals(i), v)
423
+ }
424
+ }
425
+ ringBand(prev, emitRing(planeRing(p[n - 1]!.p, n - 2), frameNormals(n - 2), 1), entries, indices)
426
+
427
+ let bounds = profileBounds(pts)
428
+ let px = pts.map((q) => q.x)
429
+ let py = pts.map((q) => q.y)
430
+ let tris = earClip(px, py)
431
+ let capPlace = (center: Vec3, s: number) => (x: number, y: number): Vec3 => [
432
+ center[0] + xAxes[s]![0] * x + yAxes[s]![0] * y,
433
+ center[1] + xAxes[s]![1] * x + yAxes[s]![1] * y,
434
+ center[2] + xAxes[s]![2] * x + yAxes[s]![2] * y,
435
+ ]
436
+ emitCap(verts, indices, px, py, tris, bounds, capPlace(p[0]!.p, 0), scale(tangents[0]!, -1), false, false)
437
+ emitCap(verts, indices, px, py, tris, bounds, capPlace(p[n - 1]!.p, n - 2), tangents[n - 2]!, true, true)
438
+
439
+ return {
440
+ vertices: new Float32Array(verts),
441
+ indices: packIndices(indices, verts.length / FLOATS_PER_VERTEX),
442
+ label,
443
+ }
444
+ }
445
+
446
+ /**
447
+ * A round-profile sweep - the wire, rope and pipe shorthand:
448
+ * radialSegments smooth points around a circle of `radius` swept along
449
+ * `path`. Joint shading still follows the path points (bare = creased
450
+ * bend, smooth = continuous), and both ends get flat caps. UVs: u around
451
+ * the tube, v along the path.
452
+ */
453
+ export function tube(path: SweepPath, radius = 0.5, radialSegments = 12, label?: string): Geometry {
454
+ let profile: Profile = []
455
+ for (let i = 0; i < radialSegments; i++) {
456
+ let a = (i / radialSegments) * Math.PI * 2
457
+ profile.push({ p: [Math.cos(a) * radius, Math.sin(a) * radius], smooth: true })
458
+ }
459
+ return sweep(profile, path, label)
460
+ }