@solidrt/3d 0.0.46 → 0.0.47
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 +145 -35
- package/README.md +22 -5
- package/examples/scene-post-effect.tsx +82 -0
- package/package.json +4 -3
- package/src/components.tsx +20 -4
- package/src/geometry.ts +292 -37
- package/src/glsl.ts +112 -0
- package/src/index.ts +6 -4
- package/src/material.ts +43 -6
- package/src/math.ts +40 -0
- package/src/orbit.ts +187 -27
- package/src/profile.ts +520 -0
- package/src/scene.ts +91 -37
package/src/profile.ts
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
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.
|
|
16
|
+
|
|
17
|
+
import { FLOATS_PER_VERTEX } from "./geometry.ts"
|
|
18
|
+
import type { Geometry } from "./geometry.ts"
|
|
19
|
+
import type { Vec2 } from "./math.ts"
|
|
20
|
+
|
|
21
|
+
/** A profile point: `p` in profile space, `smooth` to share an averaged
|
|
22
|
+
* normal with its neighbours instead of creasing (arc points want this;
|
|
23
|
+
* the default is sharp). */
|
|
24
|
+
export type ProfilePoint = { p: Vec2; smooth?: boolean }
|
|
25
|
+
|
|
26
|
+
/** A closed 2D outline: bare [x, y] points are sharp corners. Winding may
|
|
27
|
+
* go either way; consumers normalize to CCW. */
|
|
28
|
+
export type Profile = (Vec2 | ProfilePoint)[]
|
|
29
|
+
|
|
30
|
+
type Pt = { x: number; y: number; smooth: boolean }
|
|
31
|
+
|
|
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 }
|
|
37
|
+
|
|
38
|
+
function signedArea(px: number[], py: number[]): number {
|
|
39
|
+
let a = 0
|
|
40
|
+
for (let i = 0; i < px.length; i++) {
|
|
41
|
+
let j = (i + 1) % px.length
|
|
42
|
+
a += px[i]! * py[j]! - px[j]! * py[i]!
|
|
43
|
+
}
|
|
44
|
+
return a / 2
|
|
45
|
+
}
|
|
46
|
+
|
|
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[] {
|
|
50
|
+
let pts: Pt[] = []
|
|
51
|
+
for (let point of profile) {
|
|
52
|
+
let x: number, y: number, smooth: boolean
|
|
53
|
+
if (Array.isArray(point)) {
|
|
54
|
+
x = point[0]; y = point[1]; smooth = false
|
|
55
|
+
} else {
|
|
56
|
+
x = point.p[0]; y = point.p[1]; smooth = point.smooth === true
|
|
57
|
+
}
|
|
58
|
+
let prev = pts[pts.length - 1]
|
|
59
|
+
if (prev !== undefined && Math.abs(x - prev.x) < 1e-9 && Math.abs(y - prev.y) < 1e-9) continue
|
|
60
|
+
pts.push({ x, y, smooth })
|
|
61
|
+
}
|
|
62
|
+
let first = pts[0]
|
|
63
|
+
let last = pts[pts.length - 1]
|
|
64
|
+
if (first !== undefined && last !== undefined && pts.length > 1 &&
|
|
65
|
+
Math.abs(first.x - last.x) < 1e-9 && Math.abs(first.y - last.y) < 1e-9) pts.pop()
|
|
66
|
+
if (pts.length < 3) throw new Error("Profile needs at least 3 distinct points")
|
|
67
|
+
if (signedArea(pts.map((p) => p.x), pts.map((p) => p.y)) < 0) pts.reverse()
|
|
68
|
+
return pts
|
|
69
|
+
}
|
|
70
|
+
|
|
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[]) {
|
|
76
|
+
let n = pts.length
|
|
77
|
+
let enx: number[] = []
|
|
78
|
+
let eny: number[] = []
|
|
79
|
+
let elen: number[] = []
|
|
80
|
+
for (let i = 0; i < n; i++) {
|
|
81
|
+
let a = pts[i]!
|
|
82
|
+
let b = pts[(i + 1) % n]!
|
|
83
|
+
let dx = b.x - a.x
|
|
84
|
+
let dy = b.y - a.y
|
|
85
|
+
let l = Math.hypot(dx, dy) || 1
|
|
86
|
+
// Outward normal of a CCW polygon's edge.
|
|
87
|
+
enx.push(dy / l)
|
|
88
|
+
eny.push(-dx / l)
|
|
89
|
+
elen.push(l)
|
|
90
|
+
}
|
|
91
|
+
let perimeter = 0
|
|
92
|
+
for (let l of elen) perimeter += l
|
|
93
|
+
let entries: RingEntry[] = []
|
|
94
|
+
let miterX: number[] = []
|
|
95
|
+
let miterY: number[] = []
|
|
96
|
+
let dist = 0
|
|
97
|
+
for (let i = 0; i < n; i++) {
|
|
98
|
+
let p = pts[i]!
|
|
99
|
+
let px = enx[(i - 1 + n) % n]!
|
|
100
|
+
let py = eny[(i - 1 + n) % n]!
|
|
101
|
+
let nx = enx[i]!
|
|
102
|
+
let ny = eny[i]!
|
|
103
|
+
let k = 1 + px * nx + py * ny
|
|
104
|
+
let mx: number
|
|
105
|
+
let my: number
|
|
106
|
+
if (k > 1e-3) {
|
|
107
|
+
mx = (px + nx) / k
|
|
108
|
+
my = (py + ny) / k
|
|
109
|
+
} else {
|
|
110
|
+
mx = px
|
|
111
|
+
my = py
|
|
112
|
+
}
|
|
113
|
+
let ml = Math.hypot(mx, my)
|
|
114
|
+
if (ml > 4) {
|
|
115
|
+
mx = (mx * 4) / ml
|
|
116
|
+
my = (my * 4) / ml
|
|
117
|
+
}
|
|
118
|
+
miterX.push(mx)
|
|
119
|
+
miterY.push(my)
|
|
120
|
+
let t = dist / perimeter
|
|
121
|
+
if (p.smooth) {
|
|
122
|
+
let sx = px + nx
|
|
123
|
+
let sy = py + ny
|
|
124
|
+
let sl = Math.hypot(sx, sy) || 1
|
|
125
|
+
entries.push({ x: p.x, y: p.y, nx: sx / sl, ny: sy / sl, point: i, t })
|
|
126
|
+
} else {
|
|
127
|
+
entries.push({ x: p.x, y: p.y, nx: px, ny: py, point: i, t })
|
|
128
|
+
entries.push({ x: p.x, y: p.y, nx, ny, point: i, t })
|
|
129
|
+
}
|
|
130
|
+
dist += elen[i]!
|
|
131
|
+
}
|
|
132
|
+
let e0 = entries[0]!
|
|
133
|
+
entries.push({ x: e0.x, y: e0.y, nx: e0.nx, ny: e0.ny, point: 0, t: 1 })
|
|
134
|
+
return { entries, miterX, miterY }
|
|
135
|
+
}
|
|
136
|
+
|
|
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
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Inside a CCW triangle, edges included: a vertex sitting exactly on an
|
|
159
|
+
// ear's chord must BLOCK the ear (a reflex corner on the chord means the
|
|
160
|
+
// ear overlaps area outside the polygon). The ear's own neighbours are
|
|
161
|
+
// excluded from the test, and a convex fillet arc's points lie strictly
|
|
162
|
+
// outside its chord, so legitimate ears still get through.
|
|
163
|
+
function pointInTriangle(
|
|
164
|
+
x: number, y: number,
|
|
165
|
+
ax: number, ay: number, bx: number, by: number, cx: number, cy: number,
|
|
166
|
+
): boolean {
|
|
167
|
+
let s1 = (bx - ax) * (y - ay) - (by - ay) * (x - ax)
|
|
168
|
+
let s2 = (cx - bx) * (y - by) - (cy - by) * (x - bx)
|
|
169
|
+
let s3 = (ax - cx) * (y - cy) - (ay - cy) * (x - cx)
|
|
170
|
+
return s1 >= 0 && s2 >= 0 && s3 >= 0
|
|
171
|
+
}
|
|
172
|
+
|
|
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[] {
|
|
177
|
+
let idx: number[] = []
|
|
178
|
+
for (let i = 0; i < px.length; i++) idx.push(i)
|
|
179
|
+
if (signedArea(px, py) < 0) idx.reverse()
|
|
180
|
+
let out: number[] = []
|
|
181
|
+
while (idx.length > 3) {
|
|
182
|
+
let clipped = false
|
|
183
|
+
for (let i = 0; i < idx.length; i++) {
|
|
184
|
+
let a = idx[(i - 1 + idx.length) % idx.length]!
|
|
185
|
+
let b = idx[i]!
|
|
186
|
+
let c = idx[(i + 1) % idx.length]!
|
|
187
|
+
let cross = (px[b]! - px[a]!) * (py[c]! - py[a]!) - (py[b]! - py[a]!) * (px[c]! - px[a]!)
|
|
188
|
+
if (cross <= 1e-12) continue
|
|
189
|
+
let ok = true
|
|
190
|
+
for (let j of idx) {
|
|
191
|
+
if (j === a || j === b || j === c) continue
|
|
192
|
+
if (pointInTriangle(px[j]!, py[j]!, px[a]!, py[a]!, px[b]!, py[b]!, px[c]!, py[c]!)) {
|
|
193
|
+
ok = false
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (!ok) continue
|
|
198
|
+
out.push(a, b, c)
|
|
199
|
+
idx.splice(i, 1)
|
|
200
|
+
clipped = true
|
|
201
|
+
break
|
|
202
|
+
}
|
|
203
|
+
if (!clipped) break
|
|
204
|
+
}
|
|
205
|
+
for (let i = 1; i < idx.length - 1; i++) out.push(idx[0]!, idx[i]!, idx[i + 1]!)
|
|
206
|
+
return out
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function packIndices(indices: number[], vertexCount: number): Uint16Array | Uint32Array {
|
|
210
|
+
return vertexCount > 65535 ? new Uint32Array(indices) : new Uint16Array(indices)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Ear-clip a simple polygon (either winding, no holes): flat index triples
|
|
215
|
+
* into `points`, wound CCW for a +z viewer. Un-clippable leftovers fall
|
|
216
|
+
* back to a fan rather than being dropped. This is the core behind every
|
|
217
|
+
* cap here, exported for custom flat geometry.
|
|
218
|
+
*/
|
|
219
|
+
export function triangulate(points: Profile): number[] {
|
|
220
|
+
let px: number[] = []
|
|
221
|
+
let py: number[] = []
|
|
222
|
+
for (let point of points) {
|
|
223
|
+
if (Array.isArray(point)) {
|
|
224
|
+
px.push(point[0])
|
|
225
|
+
py.push(point[1])
|
|
226
|
+
} else {
|
|
227
|
+
px.push(point.p[0])
|
|
228
|
+
py.push(point.p[1])
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return earClip(px, py)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Round the corners of a polygon with tangent arcs: `radius` is one radius
|
|
236
|
+
* for every corner or one per point (0 keeps that corner sharp), clamped
|
|
237
|
+
* so neighbouring fillets never overlap. Arc points come out
|
|
238
|
+
* smooth-tagged, so filleted profiles shade round; straight-through points
|
|
239
|
+
* collapse to a single smooth point.
|
|
240
|
+
*/
|
|
241
|
+
export function fillet(points: Vec2[], radius: number | number[], segments = 4): ProfilePoint[] {
|
|
242
|
+
if (Array.isArray(radius) && radius.length !== points.length) {
|
|
243
|
+
throw new Error("fillet: radius array length must match points length")
|
|
244
|
+
}
|
|
245
|
+
let segs = Math.max(1, Math.round(segments))
|
|
246
|
+
let out: ProfilePoint[] = []
|
|
247
|
+
let n = points.length
|
|
248
|
+
for (let i = 0; i < n; i++) {
|
|
249
|
+
let r = typeof radius === "number" ? radius : radius[i]!
|
|
250
|
+
let p = points[i]!
|
|
251
|
+
let a = points[(i - 1 + n) % n]!
|
|
252
|
+
let b = points[(i + 1) % n]!
|
|
253
|
+
let d0x = a[0] - p[0], d0y = a[1] - p[1]
|
|
254
|
+
let d1x = b[0] - p[0], d1y = b[1] - p[1]
|
|
255
|
+
let l0 = Math.hypot(d0x, d0y), l1 = Math.hypot(d1x, d1y)
|
|
256
|
+
if (r <= 0 || l0 < 1e-9 || l1 < 1e-9) {
|
|
257
|
+
out.push({ p: [p[0], p[1]] })
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
d0x /= l0; d0y /= l0
|
|
261
|
+
d1x /= l1; d1y /= l1
|
|
262
|
+
let cosA = Math.max(-1, Math.min(1, d0x * d1x + d0y * d1y))
|
|
263
|
+
let ang = Math.acos(cosA)
|
|
264
|
+
if (ang > Math.PI - 1e-3) {
|
|
265
|
+
out.push({ p: [p[0], p[1]], smooth: true })
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
let half = ang / 2
|
|
269
|
+
// Tangent distance along both edges, kept off the neighbours' halves
|
|
270
|
+
// so adjacent fillets never overlap; the radius follows the clamp.
|
|
271
|
+
let t = Math.min(r / Math.tan(half), l0 / 2, l1 / 2)
|
|
272
|
+
let rr = t * Math.tan(half)
|
|
273
|
+
let t0x = p[0] + d0x * t, t0y = p[1] + d0y * t
|
|
274
|
+
let t1x = p[0] + d1x * t, t1y = p[1] + d1y * t
|
|
275
|
+
let bx = d0x + d1x, by = d0y + d1y
|
|
276
|
+
let bl = Math.hypot(bx, by) || 1
|
|
277
|
+
let dist = rr / Math.sin(half)
|
|
278
|
+
let cx = p[0] + (bx / bl) * dist, cy = p[1] + (by / bl) * dist
|
|
279
|
+
let a0 = Math.atan2(t0y - cy, t0x - cx)
|
|
280
|
+
let a1 = Math.atan2(t1y - cy, t1x - cx)
|
|
281
|
+
let da = a1 - a0
|
|
282
|
+
while (da > Math.PI) da -= Math.PI * 2
|
|
283
|
+
while (da < -Math.PI) da += Math.PI * 2
|
|
284
|
+
for (let s = 0; s <= segs; s++) {
|
|
285
|
+
let th = a0 + (da * s) / segs
|
|
286
|
+
out.push({ p: [cx + Math.cos(th) * rr, cy + Math.sin(th) * rr], smooth: true })
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return out
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* A width x height rectangle centered on the origin with corners rounded
|
|
294
|
+
* by `radius` - one for all, or per corner as [bottom-left, bottom-right,
|
|
295
|
+
* top-right, top-left] - the classic extrude() input. Radii clamp to what
|
|
296
|
+
* the sides can fit (radius >= height / 2 makes a pill).
|
|
297
|
+
*/
|
|
298
|
+
export function roundRect(
|
|
299
|
+
width = 1,
|
|
300
|
+
height = 1,
|
|
301
|
+
radius: number | number[] = 0.1,
|
|
302
|
+
segments = 4,
|
|
303
|
+
): ProfilePoint[] {
|
|
304
|
+
let x = width / 2
|
|
305
|
+
let y = height / 2
|
|
306
|
+
let corners: Vec2[] = [[-x, -y], [x, -y], [x, y], [-x, y]]
|
|
307
|
+
return fillet(corners, radius, segments)
|
|
308
|
+
}
|
|
309
|
+
|
|
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
|
+
/**
|
|
495
|
+
* The profile filled as a flat face in the XY plane facing +z - the
|
|
496
|
+
* general case of circle()/ring() for arbitrary outlines. UVs map the
|
|
497
|
+
* profile's bounding box to the unit square like plane(); rotate flat the
|
|
498
|
+
* same way: `rotation={[-Math.PI / 2, 0, 0]}`.
|
|
499
|
+
*/
|
|
500
|
+
export function shape(profile: Profile, label?: string): Geometry {
|
|
501
|
+
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
|
|
509
|
+
let px = pts.map((p) => p.x)
|
|
510
|
+
let py = pts.map((p) => p.y)
|
|
511
|
+
let verts: number[] = []
|
|
512
|
+
for (let p of pts) {
|
|
513
|
+
verts.push(p.x, p.y, 0, 0, 0, 1, (p.x - minX) / bw, (maxY - p.y) / bh)
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
vertices: new Float32Array(verts),
|
|
517
|
+
indices: packIndices(earClip(px, py), pts.length),
|
|
518
|
+
label,
|
|
519
|
+
}
|
|
520
|
+
}
|