@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/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
|
+
}
|