@solidrt/3d 0.0.46 → 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/src/orbit.ts CHANGED
@@ -1,6 +1,38 @@
1
1
  // An orbit camera for a scene: azimuth/elevation/distance around a target,
2
- // with drag-to-rotate, wheel-to-zoom, optional auto-orbit, and clamps - the
3
- // standard interactive-viewer camera, extracted so apps stop rebuilding it.
2
+ // with drag-to-rotate, pinch- and wheel-to-zoom, optional auto-orbit, and
3
+ // clamps - the standard interactive-viewer camera, extracted so apps stop
4
+ // rebuilding it.
5
+ //
6
+ // Input goes through core's merged transform recognizer (createTransform), so
7
+ // the drag participates in the gesture arena: on a viewport embedded in a
8
+ // scrollable layout, an orbit drag and the ancestor scroller's pan arbitrate
9
+ // instead of both acting, and rotation only starts once the drag crosses the
10
+ // recognizer's slop. One finger (or a mouse drag) rotates; two-finger pinch
11
+ // zooms (pair rotation is ignored); wheel zooms directly - no recognizer
12
+ // needed for a discrete wheel step. What two-finger translation means
13
+ // depends on `viewport`: with it, two fingers pan - the scene slides under
14
+ // the fingers 1:1 at the target's depth, the touch convention everywhere
15
+ // (three.js DOLLY_PAN, Sketchfab, touch CAD) - which also keeps an
16
+ // imperfect pinch from smearing rotation into the zoom. Without it there
17
+ // is no pixel-to-world mapping, so focal translation falls back to
18
+ // rotating regardless of finger count.
19
+ //
20
+ // Zoom aims at the target by default. With `zoomAnchor` the app maps the
21
+ // pinch focal / wheel cursor to a world point, and the zoom scales the pose
22
+ // about that point instead - the spot under the fingers stays under the
23
+ // fingers, with the target sliding toward it. Only the app can own that
24
+ // mapping: screen-to-ray needs the projection and the element's placement
25
+ // (fov, aspect, viewBox scaling), which live in the app's camera and layout,
26
+ // not here.
27
+ //
28
+ // Anchored zoom leaves the target wherever the zoom carried it - possibly a
29
+ // point in empty air near the eye, and a drag orbiting THAT swings the scene
30
+ // wildly around nothing. `rotateAnchor` is the countermeasure: at gesture
31
+ // start the app names the world point rotation should pivot about (what the
32
+ // camera is actually looking at), and the pivot is re-seated there. The
33
+ // point is projected onto the view axis first, so re-seating moves only the
34
+ // target's depth - the eye and the picture do not change, only what a drag
35
+ // swings around.
4
36
  //
5
37
  // Pose is plain mutable state advanced by update(dt) from the app's own
6
38
  // onFrame; the control registers no frame loop of its own, so whether
@@ -8,11 +40,14 @@
8
40
  // animation costs nothing). Only `orbiting` is a signal - slow UI state
9
41
  // that HUDs read - while the pose moves at frame rate and bypasses
10
42
  // reactivity: the package's structure-vs-motion split. update() pushes the
11
- // pose to the scene camera only when it actually changed, and reports
12
- // that, so per-frame dependents (a uCamPos uniform) can follow the camera
13
- // without writing every frame.
43
+ // pose to the scene camera only when it actually changed (one setCamera:
44
+ // the scene's own shared write carries uViewProj and uCamPos), and reports
45
+ // that, so per-frame dependents (reprojecting HUD overlays via
46
+ // scene.project) can follow the camera without recomputing every frame.
14
47
 
15
48
  import { createSignal } from "@solidjs/signals"
49
+ import { createTransform } from "@solidrt/core"
50
+ import type { PointerEvent } from "@solidrt/core"
16
51
  import type { Scene } from "./scene.ts"
17
52
  import type { Vec3 } from "./math.ts"
18
53
 
@@ -35,11 +70,38 @@ export type OrbitCameraOptions = {
35
70
  minElevation?: number
36
71
  maxElevation?: number
37
72
  /** Auto-orbit rate in radians/second (default 0: none). Runs while
38
- * `orbiting()` and not dragging; toggle with set({ orbiting }). */
73
+ * `orbiting()` and no drag/pinch is in progress; toggle with
74
+ * set({ orbiting }). */
39
75
  orbitSpeed?: number
40
- /** Multipliers over the built-in drag/wheel sensitivities. */
76
+ /** Multipliers over the built-in drag and zoom (wheel + pinch) sensitivities. */
41
77
  rotateSpeed?: number
42
78
  zoomSpeed?: number
79
+ /** Multiplier over the two-finger pan (1 = the scene tracks the fingers
80
+ * exactly at the target's depth). */
81
+ panSpeed?: number
82
+ /** The viewport the pointer coordinates live in: height in the same
83
+ * logical units as clientX/clientY, and the camera's vertical fov in
84
+ * degrees. Providing it enables two-finger pan; without it two-finger
85
+ * translation rotates, as it always did. */
86
+ viewport?: () => { height: number; fov: number }
87
+ /** Constrain where a pan may put the target - return the target to use.
88
+ * The typical use: keep the pivot within a few radii of the subject so
89
+ * panning cannot strand the camera. Zoom and rotation do not consult it. */
90
+ clampTarget?: (target: Vec3) => Vec3
91
+ /** Map a screen point (window coordinates, the clientX/clientY frame) to
92
+ * the world point a zoom there should keep pinned. Called once per pinch
93
+ * gesture (at its first span change - the anchor then holds for the whole
94
+ * gesture, see the jitter note at `pinchAnchor`) and once per wheel event,
95
+ * with the pose the zoom is about to apply to. Return null to zoom toward
96
+ * the target as usual (also the default). */
97
+ zoomAnchor?: (x: number, y: number, view: { eye: Vec3; target: Vec3 }) => Vec3 | null
98
+ /** The world point rotation should pivot about - called when a drag or
99
+ * pinch starts. It is projected onto the view axis and becomes the new
100
+ * target, preserving the picture exactly (only the pivot's depth moves),
101
+ * so a drag after an anchored zoom orbits the scene under the camera
102
+ * instead of wherever the zoom left the target. Points at or behind the
103
+ * eye are ignored, as is null (both keep the current pivot). */
104
+ rotateAnchor?: (view: { eye: Vec3; target: Vec3 }) => Vec3 | null
43
105
  }
44
106
 
45
107
  export type OrbitPose = {
@@ -68,10 +130,12 @@ export type OrbitCamera = {
68
130
  /** Spread onto the element that receives input:
69
131
  * `<window {...orbit.handlers} />`. */
70
132
  handlers: {
71
- onPointerDown(e: { clientX: number; clientY: number }): void
72
- onPointerMove(e: { clientX: number; clientY: number }): void
73
- onPointerUp(): void
74
- onWheel(e: { deltaY: number }): void
133
+ onPointerDown(e: PointerEvent): void
134
+ onPointerMove(e: PointerEvent): void
135
+ onPointerUp(e: PointerEvent): void
136
+ /** Position is optional so a bare `{ deltaY }` still zooms; without it
137
+ * a zoomAnchor cannot apply and the zoom falls back to the target. */
138
+ onWheel(e: { deltaY: number; clientX?: number; clientY?: number }): void
75
139
  }
76
140
  }
77
141
 
@@ -98,13 +162,33 @@ export function createOrbitCamera(scene: Scene, options: OrbitCameraOptions = {}
98
162
  let wheelZoom = WHEEL_ZOOM * (options.zoomSpeed ?? 1)
99
163
 
100
164
  let [orbiting, setOrbiting] = createSignal(orbitSpeed > 0)
101
- let drag: { x: number; y: number } | null = null
165
+ let interacting = false
102
166
  let dirty = false
103
167
 
104
168
  let clampPose = () => {
105
169
  elevation = clampNum(elevation, minElevation, maxElevation)
106
170
  distance = clampNum(distance, minDistance, maxDistance)
107
171
  }
172
+ // Slide eye and target together along the camera's right/up so the scene
173
+ // tracks the fingers: dragged pixels map to world units through the
174
+ // frustum height at the target's depth. Screen +y is down, so the up-axis
175
+ // term is added (fingers down -> camera up -> scene follows down).
176
+ let pan = (dx: number, dy: number) => {
177
+ let vp = options.viewport!()
178
+ let wpp = ((2 * Math.tan((vp.fov * Math.PI) / 360) * distance) / vp.height) * (options.panSpeed ?? 1)
179
+ let sa = Math.sin(azimuth)
180
+ let ca = Math.cos(azimuth)
181
+ let se = Math.sin(elevation)
182
+ let ce = Math.cos(elevation)
183
+ // right = (ca, 0, -sa), up = (-sa*se, ce, -ca*se) for this pose - the
184
+ // camera basis written out for eye() = target + distance * (ce*sa, se, ce*ca).
185
+ let next: Vec3 = [
186
+ target[0] - dx * wpp * ca - dy * wpp * sa * se,
187
+ target[1] + dy * wpp * ce,
188
+ target[2] + dx * wpp * sa - dy * wpp * ca * se,
189
+ ]
190
+ target = options.clampTarget ? options.clampTarget(next) : next
191
+ }
108
192
  let eye = (): Vec3 => {
109
193
  let ce = Math.cos(elevation)
110
194
  return [
@@ -115,9 +199,97 @@ export function createOrbitCamera(scene: Scene, options: OrbitCameraOptions = {}
115
199
  }
116
200
  let apply = () => scene.setCamera({ position: eye(), target })
117
201
 
202
+ // Zoom by `ratio` (new distance over old, before clamping) about a world
203
+ // anchor (null zooms toward the target). Scaling eye and target about the
204
+ // anchor by the distance ratio keeps it projecting to the same pixel, so
205
+ // the shift below uses the ratio that actually applied after clamping -
206
+ // once distance pins at a clamp the target stops moving too, instead of
207
+ // sliding the view sideways under a dead zoom.
208
+ let zoomAbout = (ratio: number, anchor: Vec3 | null | undefined) => {
209
+ let prev = distance
210
+ // Bounds widen to the current distance so a pose already outside them
211
+ // (a rotateAnchor re-seat may land anywhere) zooms back toward range
212
+ // instead of snap-jumping into it.
213
+ distance = clampNum(distance * ratio, Math.min(minDistance, prev), Math.max(maxDistance, prev))
214
+ if (!anchor) return
215
+ let s = distance / prev
216
+ target = [
217
+ anchor[0] + (target[0] - anchor[0]) * s,
218
+ anchor[1] + (target[1] - anchor[1]) * s,
219
+ anchor[2] + (target[2] - anchor[2]) * s,
220
+ ]
221
+ }
222
+ let anchorAt = (x?: number, y?: number) =>
223
+ x !== undefined && y !== undefined && options.zoomAnchor
224
+ ? options.zoomAnchor(x, y, { eye: eye(), target: [target[0], target[1], target[2]] })
225
+ : null
226
+
227
+ // The pinch keeps ONE anchor for its whole gesture, taken at the first
228
+ // span change. Per-event re-derivation looks equivalent but jitters in
229
+ // practice: the two fingers' events interleave, so the measured span
230
+ // oscillates around its true value even while the fingers rest, and every
231
+ // event zooms slightly in or back out. About a fixed point those pairs
232
+ // cancel exactly (the ratios telescope); about a fresh anchor each time -
233
+ // a new raycast against a pose the previous event just moved, or the
234
+ // app's hit/fallback choice flipping between events - each pair leaves a
235
+ // residual target slide and the model visibly crawls under resting
236
+ // fingers. The wheel stays per-event: its notches are discrete, there is
237
+ // no noise to cancel, and each notch re-aiming at the cursor is the point.
238
+ let pinchAnchor: Vec3 | null = null
239
+ let pinchSeen = false
240
+
118
241
  clampPose()
119
242
  apply()
120
243
 
244
+ // One finger rotates. With two down, the span change zooms and the focal
245
+ // translation pans (or, with no viewport to map pixels through, keeps
246
+ // rotating). Pair rotation has no orbit meaning and is ignored.
247
+ let transform = createTransform({
248
+ onTransformStart: () => {
249
+ interacting = true
250
+ let anchor = options.rotateAnchor?.({ eye: eye(), target: [target[0], target[1], target[2]] })
251
+ if (anchor) {
252
+ let e = eye()
253
+ let fx = (target[0] - e[0]) / distance
254
+ let fy = (target[1] - e[1]) / distance
255
+ let fz = (target[2] - e[2]) / distance
256
+ let depth = (anchor[0] - e[0]) * fx + (anchor[1] - e[1]) * fy + (anchor[2] - e[2]) * fz
257
+ if (depth > 0) {
258
+ // Deliberately unclamped: the pose is unchanged, this only decides
259
+ // what the gesture pivots about. The zoom clamps below widen to
260
+ // the current distance, so a pivot outside [min, max] cannot make
261
+ // the next zoom snap-jump either.
262
+ distance = depth
263
+ target = [e[0] + fx * depth, e[1] + fy * depth, e[2] + fz * depth]
264
+ }
265
+ }
266
+ },
267
+ onTransformMove: (t) => {
268
+ if (t.pointers >= 2 && options.viewport) {
269
+ pan(t.dx, t.dy)
270
+ } else {
271
+ azimuth -= t.dx * dragAzimuth
272
+ elevation = clampNum(elevation + t.dy * dragElevation, minElevation, maxElevation)
273
+ }
274
+ if (t.scale !== 1) {
275
+ // Fingers spreading (scale > 1) zooms in: the distance shrinks by the
276
+ // span ratio, exponent-weighted by the zoomSpeed multiplier, about
277
+ // the gesture's anchor when the app provides one.
278
+ if (!pinchSeen) {
279
+ pinchSeen = true
280
+ pinchAnchor = anchorAt(t.x, t.y)
281
+ }
282
+ zoomAbout(1 / Math.pow(t.scale, options.zoomSpeed ?? 1), pinchAnchor)
283
+ }
284
+ dirty = true
285
+ },
286
+ onTransformEnd: () => {
287
+ interacting = false
288
+ pinchSeen = false
289
+ pinchAnchor = null
290
+ },
291
+ })
292
+
121
293
  return {
122
294
  eye,
123
295
  pose: () => ({ azimuth, elevation, distance }),
@@ -132,7 +304,7 @@ export function createOrbitCamera(scene: Scene, options: OrbitCameraOptions = {}
132
304
  dirty = true
133
305
  },
134
306
  update(dt) {
135
- if (orbitSpeed !== 0 && drag === null && orbiting()) {
307
+ if (orbitSpeed !== 0 && !interacting && orbiting()) {
136
308
  azimuth += dt * orbitSpeed
137
309
  dirty = true
138
310
  }
@@ -142,21 +314,9 @@ export function createOrbitCamera(scene: Scene, options: OrbitCameraOptions = {}
142
314
  return true
143
315
  },
144
316
  handlers: {
145
- onPointerDown(e) {
146
- drag = { x: e.clientX, y: e.clientY }
147
- },
148
- onPointerMove(e) {
149
- if (!drag) return
150
- azimuth -= (e.clientX - drag.x) * dragAzimuth
151
- elevation = clampNum(elevation + (e.clientY - drag.y) * dragElevation, minElevation, maxElevation)
152
- drag = { x: e.clientX, y: e.clientY }
153
- dirty = true
154
- },
155
- onPointerUp() {
156
- drag = null
157
- },
317
+ ...transform.handlers,
158
318
  onWheel(e) {
159
- distance = clampNum(distance * Math.exp(e.deltaY * wheelZoom), minDistance, maxDistance)
319
+ zoomAbout(Math.exp(e.deltaY * wheelZoom), anchorAt(e.clientX, e.clientY))
160
320
  dirty = true
161
321
  },
162
322
  },
package/src/profile.ts ADDED
@@ -0,0 +1,315 @@
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.
10
+
11
+ import { packIndices } from "./geometry.ts"
12
+ import type { Geometry } from "./geometry.ts"
13
+ import type { Vec2 } from "./math.ts"
14
+
15
+ /** A profile point: `p` in profile space, `smooth` to share an averaged
16
+ * normal with its neighbours instead of creasing (arc points want this;
17
+ * the default is sharp). */
18
+ export type ProfilePoint = { p: Vec2; smooth?: boolean }
19
+
20
+ /** A closed 2D outline: bare [x, y] points are sharp corners. Winding may
21
+ * go either way; consumers normalize to CCW. */
22
+ export type Profile = (Vec2 | ProfilePoint)[]
23
+
24
+ /** A normalized profile point (normalizeProfile's output). */
25
+ export type Pt = { x: number; y: number; smooth: boolean }
26
+
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 }
32
+
33
+ function signedArea(px: number[], py: number[]): number {
34
+ let a = 0
35
+ for (let i = 0; i < px.length; i++) {
36
+ let j = (i + 1) % px.length
37
+ a += px[i]! * py[j]! - px[j]! * py[i]!
38
+ }
39
+ return a / 2
40
+ }
41
+
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[] {
46
+ let pts: Pt[] = []
47
+ for (let point of profile) {
48
+ let x: number, y: number, smooth: boolean
49
+ if (Array.isArray(point)) {
50
+ x = point[0]; y = point[1]; smooth = false
51
+ } else {
52
+ x = point.p[0]; y = point.p[1]; smooth = point.smooth === true
53
+ }
54
+ let prev = pts[pts.length - 1]
55
+ if (prev !== undefined && Math.abs(x - prev.x) < 1e-9 && Math.abs(y - prev.y) < 1e-9) continue
56
+ pts.push({ x, y, smooth })
57
+ }
58
+ let first = pts[0]
59
+ let last = pts[pts.length - 1]
60
+ if (first !== undefined && last !== undefined && pts.length > 1 &&
61
+ Math.abs(first.x - last.x) < 1e-9 && Math.abs(first.y - last.y) < 1e-9) pts.pop()
62
+ if (pts.length < 3) throw new Error("Profile needs at least 3 distinct points")
63
+ if (signedArea(pts.map((p) => p.x), pts.map((p) => p.y)) < 0) pts.reverse()
64
+ return pts
65
+ }
66
+
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[]) {
72
+ let n = pts.length
73
+ let enx: number[] = []
74
+ let eny: number[] = []
75
+ let elen: number[] = []
76
+ for (let i = 0; i < n; i++) {
77
+ let a = pts[i]!
78
+ let b = pts[(i + 1) % n]!
79
+ let dx = b.x - a.x
80
+ let dy = b.y - a.y
81
+ let l = Math.hypot(dx, dy) || 1
82
+ // Outward normal of a CCW polygon's edge.
83
+ enx.push(dy / l)
84
+ eny.push(-dx / l)
85
+ elen.push(l)
86
+ }
87
+ let perimeter = 0
88
+ for (let l of elen) perimeter += l
89
+ let entries: RingEntry[] = []
90
+ let miterX: number[] = []
91
+ let miterY: number[] = []
92
+ let dist = 0
93
+ for (let i = 0; i < n; i++) {
94
+ let p = pts[i]!
95
+ let px = enx[(i - 1 + n) % n]!
96
+ let py = eny[(i - 1 + n) % n]!
97
+ let nx = enx[i]!
98
+ let ny = eny[i]!
99
+ let k = 1 + px * nx + py * ny
100
+ let mx: number
101
+ let my: number
102
+ if (k > 1e-3) {
103
+ mx = (px + nx) / k
104
+ my = (py + ny) / k
105
+ } else {
106
+ mx = px
107
+ my = py
108
+ }
109
+ let ml = Math.hypot(mx, my)
110
+ if (ml > 4) {
111
+ mx = (mx * 4) / ml
112
+ my = (my * 4) / ml
113
+ }
114
+ miterX.push(mx)
115
+ miterY.push(my)
116
+ let t = dist / perimeter
117
+ if (p.smooth) {
118
+ let sx = px + nx
119
+ let sy = py + ny
120
+ let sl = Math.hypot(sx, sy) || 1
121
+ entries.push({ x: p.x, y: p.y, nx: sx / sl, ny: sy / sl, point: i, t })
122
+ } else {
123
+ entries.push({ x: p.x, y: p.y, nx: px, ny: py, point: i, t })
124
+ entries.push({ x: p.x, y: p.y, nx, ny, point: i, t })
125
+ }
126
+ dist += elen[i]!
127
+ }
128
+ let e0 = entries[0]!
129
+ entries.push({ x: e0.x, y: e0.y, nx: e0.nx, ny: e0.ny, point: 0, t: 1 })
130
+ return { entries, miterX, miterY }
131
+ }
132
+
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)
142
+ }
143
+ return { minX, maxX, minY, maxY, w: maxX - minX || 1, h: maxY - minY || 1 }
144
+ }
145
+
146
+ // Inside a CCW triangle, edges included: a vertex sitting exactly on an
147
+ // ear's chord must BLOCK the ear (a reflex corner on the chord means the
148
+ // ear overlaps area outside the polygon). The ear's own neighbours are
149
+ // excluded from the test, and a convex fillet arc's points lie strictly
150
+ // outside its chord, so legitimate ears still get through.
151
+ function pointInTriangle(
152
+ x: number, y: number,
153
+ ax: number, ay: number, bx: number, by: number, cx: number, cy: number,
154
+ ): boolean {
155
+ let s1 = (bx - ax) * (y - ay) - (by - ay) * (x - ax)
156
+ let s2 = (cx - bx) * (y - by) - (cy - by) * (x - bx)
157
+ let s3 = (ax - cx) * (y - cy) - (ay - cy) * (x - cx)
158
+ return s1 >= 0 && s2 >= 0 && s3 >= 0
159
+ }
160
+
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[] {
166
+ let idx: number[] = []
167
+ for (let i = 0; i < px.length; i++) idx.push(i)
168
+ if (signedArea(px, py) < 0) idx.reverse()
169
+ let out: number[] = []
170
+ while (idx.length > 3) {
171
+ let clipped = false
172
+ for (let i = 0; i < idx.length; i++) {
173
+ let a = idx[(i - 1 + idx.length) % idx.length]!
174
+ let b = idx[i]!
175
+ let c = idx[(i + 1) % idx.length]!
176
+ let cross = (px[b]! - px[a]!) * (py[c]! - py[a]!) - (py[b]! - py[a]!) * (px[c]! - px[a]!)
177
+ if (cross <= 1e-12) continue
178
+ let ok = true
179
+ for (let j of idx) {
180
+ if (j === a || j === b || j === c) continue
181
+ if (pointInTriangle(px[j]!, py[j]!, px[a]!, py[a]!, px[b]!, py[b]!, px[c]!, py[c]!)) {
182
+ ok = false
183
+ break
184
+ }
185
+ }
186
+ if (!ok) continue
187
+ out.push(a, b, c)
188
+ idx.splice(i, 1)
189
+ clipped = true
190
+ break
191
+ }
192
+ if (!clipped) break
193
+ }
194
+ for (let i = 1; i < idx.length - 1; i++) out.push(idx[0]!, idx[i]!, idx[i + 1]!)
195
+ return out
196
+ }
197
+
198
+ /**
199
+ * Ear-clip a simple polygon (either winding, no holes): flat index triples
200
+ * into `points`, wound CCW for a +z viewer. Un-clippable leftovers fall
201
+ * back to a fan rather than being dropped. This is the core behind every
202
+ * cap here, exported for custom flat geometry.
203
+ */
204
+ export function triangulate(points: Profile): number[] {
205
+ let px: number[] = []
206
+ let py: number[] = []
207
+ for (let point of points) {
208
+ if (Array.isArray(point)) {
209
+ px.push(point[0])
210
+ py.push(point[1])
211
+ } else {
212
+ px.push(point.p[0])
213
+ py.push(point.p[1])
214
+ }
215
+ }
216
+ return earClip(px, py)
217
+ }
218
+
219
+ /**
220
+ * Round the corners of a polygon with tangent arcs: `radius` is one radius
221
+ * for every corner or one per point (0 keeps that corner sharp), clamped
222
+ * so neighbouring fillets never overlap. Arc points come out
223
+ * smooth-tagged, so filleted profiles shade round; straight-through points
224
+ * collapse to a single smooth point.
225
+ */
226
+ export function fillet(points: Vec2[], radius: number | number[], segments = 4): ProfilePoint[] {
227
+ if (Array.isArray(radius) && radius.length !== points.length) {
228
+ throw new Error("fillet: radius array length must match points length")
229
+ }
230
+ let segs = Math.max(1, Math.round(segments))
231
+ let out: ProfilePoint[] = []
232
+ let n = points.length
233
+ for (let i = 0; i < n; i++) {
234
+ let r = typeof radius === "number" ? radius : radius[i]!
235
+ let p = points[i]!
236
+ let a = points[(i - 1 + n) % n]!
237
+ let b = points[(i + 1) % n]!
238
+ let d0x = a[0] - p[0], d0y = a[1] - p[1]
239
+ let d1x = b[0] - p[0], d1y = b[1] - p[1]
240
+ let l0 = Math.hypot(d0x, d0y), l1 = Math.hypot(d1x, d1y)
241
+ if (r <= 0 || l0 < 1e-9 || l1 < 1e-9) {
242
+ out.push({ p: [p[0], p[1]] })
243
+ continue
244
+ }
245
+ d0x /= l0; d0y /= l0
246
+ d1x /= l1; d1y /= l1
247
+ let cosA = Math.max(-1, Math.min(1, d0x * d1x + d0y * d1y))
248
+ let ang = Math.acos(cosA)
249
+ if (ang > Math.PI - 1e-3) {
250
+ out.push({ p: [p[0], p[1]], smooth: true })
251
+ continue
252
+ }
253
+ let half = ang / 2
254
+ // Tangent distance along both edges, kept off the neighbours' halves
255
+ // so adjacent fillets never overlap; the radius follows the clamp.
256
+ let t = Math.min(r / Math.tan(half), l0 / 2, l1 / 2)
257
+ let rr = t * Math.tan(half)
258
+ let t0x = p[0] + d0x * t, t0y = p[1] + d0y * t
259
+ let t1x = p[0] + d1x * t, t1y = p[1] + d1y * t
260
+ let bx = d0x + d1x, by = d0y + d1y
261
+ let bl = Math.hypot(bx, by) || 1
262
+ let dist = rr / Math.sin(half)
263
+ let cx = p[0] + (bx / bl) * dist, cy = p[1] + (by / bl) * dist
264
+ let a0 = Math.atan2(t0y - cy, t0x - cx)
265
+ let a1 = Math.atan2(t1y - cy, t1x - cx)
266
+ let da = a1 - a0
267
+ while (da > Math.PI) da -= Math.PI * 2
268
+ while (da < -Math.PI) da += Math.PI * 2
269
+ for (let s = 0; s <= segs; s++) {
270
+ let th = a0 + (da * s) / segs
271
+ out.push({ p: [cx + Math.cos(th) * rr, cy + Math.sin(th) * rr], smooth: true })
272
+ }
273
+ }
274
+ return out
275
+ }
276
+
277
+ /**
278
+ * A width x height rectangle centered on the origin with corners rounded
279
+ * by `radius` - one for all, or per corner as [bottom-left, bottom-right,
280
+ * top-right, top-left] - the classic extrude() input. Radii clamp to what
281
+ * the sides can fit (radius >= height / 2 makes a pill).
282
+ */
283
+ export function roundRect(
284
+ width = 1,
285
+ height = 1,
286
+ radius: number | number[] = 0.1,
287
+ segments = 4,
288
+ ): ProfilePoint[] {
289
+ let x = width / 2
290
+ let y = height / 2
291
+ let corners: Vec2[] = [[-x, -y], [x, -y], [x, y], [-x, y]]
292
+ return fillet(corners, radius, segments)
293
+ }
294
+
295
+ /**
296
+ * The profile filled as a flat face in the XY plane facing +z - the
297
+ * general case of circle()/ring() for arbitrary outlines. UVs map the
298
+ * profile's bounding box to the unit square like plane(); rotate flat the
299
+ * same way: `rotation={[-Math.PI / 2, 0, 0]}`.
300
+ */
301
+ export function shape(profile: Profile, label?: string): Geometry {
302
+ let pts = normalizeProfile(profile)
303
+ let { minX, maxY, w, h } = profileBounds(pts)
304
+ let px = pts.map((p) => p.x)
305
+ let py = pts.map((p) => p.y)
306
+ let verts: number[] = []
307
+ for (let p of pts) {
308
+ verts.push(p.x, p.y, 0, 0, 0, 1, (p.x - minX) / w, (maxY - p.y) / h)
309
+ }
310
+ return {
311
+ vertices: new Float32Array(verts),
312
+ indices: packIndices(earClip(px, py), pts.length),
313
+ label,
314
+ }
315
+ }