@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/AGENTS.md +235 -43
- package/README.md +37 -5
- package/examples/README.md +8 -0
- package/examples/aim.tsx +119 -0
- package/examples/scene-post-effect.tsx +82 -0
- package/examples/sweep-paths.tsx +79 -0
- package/package.json +4 -3
- package/src/components.tsx +42 -9
- package/src/geometry.ts +298 -37
- package/src/glsl.ts +112 -0
- package/src/index.ts +11 -5
- package/src/material.ts +43 -6
- package/src/math.ts +334 -19
- package/src/orbit.ts +187 -27
- package/src/profile.ts +315 -0
- package/src/scene.ts +224 -46
- package/src/sweep.ts +460 -0
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
|
|
3
|
-
// standard interactive-viewer camera, extracted so apps stop
|
|
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
|
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
|
|
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:
|
|
72
|
-
onPointerMove(e:
|
|
73
|
-
onPointerUp(): void
|
|
74
|
-
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|