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