@octane-xplat/smooth-corners 0.0.1 → 0.10.0
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/LICENSE +21 -0
- package/dist/native/index.js +2 -0
- package/dist/native/node_modules/.pnpm/@lisse_core@0.7.2/node_modules/@lisse/core/dist/chunk-BL6ZAFLB.js +477 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/native/packages/smooth-corners/src/SmoothCorners.tsrx.js +72 -0
- package/dist/native/packages/smooth-corners/src/apply.android.js +95 -0
- package/dist/native/packages/smooth-corners/src/apply.ios.js +92 -0
- package/dist/native/packages/smooth-corners/src/path.js +424 -0
- package/dist/web/index.js +2 -0
- package/dist/web/node_modules/.pnpm/@lisse_core@0.7.2/node_modules/@lisse/core/dist/chunk-BL6ZAFLB.js +477 -0
- package/dist/web/octane-client-build.json +8 -0
- package/dist/web/packages/smooth-corners/src/SmoothCorners.web.tsrx.js +62 -0
- package/dist/web/packages/smooth-corners/src/path.js +453 -0
- package/package.json +67 -4
- package/src/SmoothCorners.macos.tsrx +177 -0
- package/src/SmoothCorners.tsrx +67 -0
- package/src/SmoothCorners.web.tsrx +71 -0
- package/src/apply.android.ts +136 -0
- package/src/apply.ios.ts +136 -0
- package/src/index.macos.ts +10 -0
- package/src/index.ts +13 -0
- package/src/index.web.ts +10 -0
- package/src/path.ts +627 -0
- package/src/props.ts +66 -0
- package/types/index.d.ts +17 -0
- package/types/props.d.ts +59 -0
- package/README.md +0 -4
package/src/path.ts
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/** Shared smooth-corner path generation — pure TS, no DOM. Emits one
|
|
2
|
+
* `PathCommand[]` (absolute coords, arcs already center-parameterized) that
|
|
3
|
+
* web stringifies to `d`, iOS feeds `CGPathAdd*`, Android feeds
|
|
4
|
+
* `android.graphics.Path`, and macOS feeds `NSBezierPath`.
|
|
5
|
+
*
|
|
6
|
+
* Lisse curves (`squircle`/`superellipse`/`clothoid`/`arc`) delegate to
|
|
7
|
+
* `@lisse/core/path`; `continuous` is our own builder — the Rosenfeld
|
|
8
|
+
* `UIBezierPath` constants reproducing Apple's fixed continuous corner
|
|
9
|
+
* (~0.25 px from SwiftUI `.continuous`; Lisse issue #103 /
|
|
10
|
+
* tools/apple-continuous-export). */
|
|
11
|
+
import {
|
|
12
|
+
distributeAndNormalize,
|
|
13
|
+
generatePath as lisseGeneratePath,
|
|
14
|
+
getCurveBuilder,
|
|
15
|
+
DEFAULT_EXPONENT,
|
|
16
|
+
DEFAULT_PRESERVE_SMOOTHING,
|
|
17
|
+
DEFAULT_SMOOTHING,
|
|
18
|
+
} from '@lisse/core/path'
|
|
19
|
+
|
|
20
|
+
import type { CurveBuilder, CurveBuilderInput } from '@lisse/core/path'
|
|
21
|
+
import type { CornerConfig, CornerCurve, CornerOptions, PerCornerConfig } from './props'
|
|
22
|
+
|
|
23
|
+
export type PathCommand =
|
|
24
|
+
| { c: 'M' | 'L'; x: number; y: number }
|
|
25
|
+
| { c: 'C'; x1: number; y1: number; x2: number; y2: number; x: number; y: number }
|
|
26
|
+
| {
|
|
27
|
+
c: 'A'
|
|
28
|
+
/** Center-parameterized circular arc: degrees, SVG y-down convention
|
|
29
|
+
* (0 = +x axis, positive sweep = visually clockwise). */
|
|
30
|
+
cx: number
|
|
31
|
+
cy: number
|
|
32
|
+
r: number
|
|
33
|
+
startDeg: number
|
|
34
|
+
sweepDeg: number
|
|
35
|
+
x: number
|
|
36
|
+
y: number
|
|
37
|
+
}
|
|
38
|
+
| { c: 'Z' }
|
|
39
|
+
|
|
40
|
+
const ORIENTS = ['TL', 'TR', 'BR', 'BL'] as const
|
|
41
|
+
type Orient = (typeof ORIENTS)[number]
|
|
42
|
+
|
|
43
|
+
interface Resolved {
|
|
44
|
+
radius: number
|
|
45
|
+
curve: CornerCurve
|
|
46
|
+
smoothing: number
|
|
47
|
+
exponent: number
|
|
48
|
+
preserveSmoothing: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Corners {
|
|
52
|
+
topLeft: Resolved
|
|
53
|
+
topRight: Resolved
|
|
54
|
+
bottomRight: Resolved
|
|
55
|
+
bottomLeft: Resolved
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveOne(c: CornerConfig | number | undefined): Resolved {
|
|
59
|
+
const cfg: CornerConfig = typeof c === 'number' ? { radius: c } : (c ?? { radius: 0 })
|
|
60
|
+
return {
|
|
61
|
+
radius: cfg.radius,
|
|
62
|
+
curve: cfg.curve ?? 'continuous',
|
|
63
|
+
smoothing: cfg.smoothing ?? DEFAULT_SMOOTHING,
|
|
64
|
+
exponent: cfg.exponent ?? DEFAULT_EXPONENT,
|
|
65
|
+
preserveSmoothing: cfg.preserveSmoothing ?? DEFAULT_PRESERVE_SMOOTHING,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveOptions(options: CornerOptions): Corners {
|
|
70
|
+
if (typeof options === 'number') {
|
|
71
|
+
const c = resolveOne(options)
|
|
72
|
+
return { topLeft: c, topRight: c, bottomRight: c, bottomLeft: c }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if ('radius' in options) {
|
|
76
|
+
const c = resolveOne(options)
|
|
77
|
+
return { topLeft: c, topRight: c, bottomRight: c, bottomLeft: c }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const p = options as PerCornerConfig
|
|
81
|
+
return {
|
|
82
|
+
topLeft: resolveOne(p.topLeft),
|
|
83
|
+
topRight: resolveOne(p.topRight),
|
|
84
|
+
bottomRight: resolveOne(p.bottomRight),
|
|
85
|
+
bottomLeft: resolveOne(p.bottomLeft),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- continuous (Rosenfeld / Apple UIBezierPath) ----------------------------
|
|
90
|
+
|
|
91
|
+
/** Shoulder extent along each adjacent edge, as a multiple of R. */
|
|
92
|
+
const CONT_P = 1.528665
|
|
93
|
+
// Cubic triple per corner, offsets as multiples of R in the corner frame:
|
|
94
|
+
// x = inward distance along the edge perpendicular to the corner's "height"
|
|
95
|
+
// axis — concretely f(x,y) = vertex + x·(inwardX) + y·(inwardY), with the
|
|
96
|
+
// sequence traversed x-descending on horizontal-entry corners (TR, BL) and
|
|
97
|
+
// y-descending on vertical-entry corners (BR, TL). See
|
|
98
|
+
// Lisse tools/apple-continuous-export/ExportRosenfeld.swift.
|
|
99
|
+
const CONT_CURVE: ReadonlyArray<readonly [number, number]> = [
|
|
100
|
+
[1.08849296, 0.0],
|
|
101
|
+
[0.86840694, 0.0],
|
|
102
|
+
[0.63149379, 0.07491139],
|
|
103
|
+
[0.37282383, 0.16905956],
|
|
104
|
+
[0.16905956, 0.37282383],
|
|
105
|
+
[0.07491139, 0.63149379],
|
|
106
|
+
[0.0, 0.86840694],
|
|
107
|
+
[0.0, 1.08849296],
|
|
108
|
+
[0.0, 1.52866498],
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
const CONT_CURVE_YX: ReadonlyArray<readonly [number, number]> = CONT_CURVE.map(
|
|
112
|
+
([x, y]) => [y, x] as const,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
/** Absolute `C` commands for one continuous corner. `p` returned separately —
|
|
116
|
+
* the tangent distance from the vertex along the incoming edge. */
|
|
117
|
+
function continuousCorner(
|
|
118
|
+
w: number,
|
|
119
|
+
h: number,
|
|
120
|
+
radius: number,
|
|
121
|
+
budget: number,
|
|
122
|
+
orient: Orient,
|
|
123
|
+
): { p: number; cmds: PathCommand[] } {
|
|
124
|
+
// The shoulder extends past R; shrink R so it fits the edge budget
|
|
125
|
+
// (mirrors how UIBezierPath scales when adjacent corners compete).
|
|
126
|
+
const r = Math.min(radius, budget / CONT_P)
|
|
127
|
+
const p = CONT_P * r
|
|
128
|
+
if (r <= 0) {
|
|
129
|
+
return { p: 0, cmds: [] }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Corner frame: (fx, fy) maps the (x,y) coefficient table to canvas
|
|
133
|
+
// coordinates; `seq` selects traversal direction.
|
|
134
|
+
let fx: (x: number, y: number) => number
|
|
135
|
+
let fy: (x: number, y: number) => number
|
|
136
|
+
let seq = CONT_CURVE
|
|
137
|
+
switch (orient) {
|
|
138
|
+
case 'TR':
|
|
139
|
+
fx = (x, _y) => w - x * r
|
|
140
|
+
fy = (_x, y) => y * r
|
|
141
|
+
break
|
|
142
|
+
case 'BR':
|
|
143
|
+
fx = (x, _y) => w - x * r
|
|
144
|
+
fy = (_x, y) => h - y * r
|
|
145
|
+
seq = CONT_CURVE_YX
|
|
146
|
+
break
|
|
147
|
+
case 'BL':
|
|
148
|
+
fx = (x, _y) => x * r
|
|
149
|
+
fy = (_x, y) => h - y * r
|
|
150
|
+
break
|
|
151
|
+
case 'TL':
|
|
152
|
+
fx = (x, _y) => x * r
|
|
153
|
+
fy = (_x, y) => 0 + y * r
|
|
154
|
+
seq = CONT_CURVE_YX
|
|
155
|
+
break
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const cmds: PathCommand[] = []
|
|
159
|
+
for (let i = 0; i < seq.length; i += 3) {
|
|
160
|
+
const [c1, c2, e] = [seq[i], seq[i + 1], seq[i + 2]]
|
|
161
|
+
cmds.push({
|
|
162
|
+
c: 'C',
|
|
163
|
+
x1: fx(c1[0], c1[1]),
|
|
164
|
+
y1: fy(c1[0], c1[1]),
|
|
165
|
+
x2: fx(c2[0], c2[1]),
|
|
166
|
+
y2: fy(c2[0], c2[1]),
|
|
167
|
+
x: fx(e[0], e[1]),
|
|
168
|
+
y: fy(e[0], e[1]),
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { p, cmds }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- lisse corner → commands -----------------------------------------------
|
|
176
|
+
|
|
177
|
+
/** Parse a curve builder's per-orient relative segment starting at `pen`.
|
|
178
|
+
* Returns absolute commands. Lisse segments only emit `c`/`a`/`l`. */
|
|
179
|
+
function segmentCommands(seg: string, pen: { x: number; y: number }): PathCommand[] {
|
|
180
|
+
const cmds = parsePath(seg, pen.x, pen.y)
|
|
181
|
+
// Drop a leading implicit M — the segment continues the pen in place.
|
|
182
|
+
return cmds.filter((c) => c.c !== 'M')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- SVG d parsing ----------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
const TOKEN = /[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g
|
|
188
|
+
const ARITY: Record<string, number> = {
|
|
189
|
+
M: 2,
|
|
190
|
+
L: 2,
|
|
191
|
+
H: 1,
|
|
192
|
+
V: 1,
|
|
193
|
+
C: 6,
|
|
194
|
+
S: 4,
|
|
195
|
+
Q: 4,
|
|
196
|
+
T: 2,
|
|
197
|
+
A: 7,
|
|
198
|
+
Z: 0,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Endpoint→center for a circular SVG arc (φ=0 — every Lisse emitter).
|
|
202
|
+
* Non-circular or rotated arcs throw: unsupported is loud, not silent. */
|
|
203
|
+
function arcToCenter(
|
|
204
|
+
x0: number,
|
|
205
|
+
y0: number,
|
|
206
|
+
rx: number,
|
|
207
|
+
ry: number,
|
|
208
|
+
rot: number,
|
|
209
|
+
largeArc: number,
|
|
210
|
+
sweep: number,
|
|
211
|
+
x1: number,
|
|
212
|
+
y1: number,
|
|
213
|
+
): Extract<PathCommand, { c: 'A' }> | null {
|
|
214
|
+
if (x0 === x1 && y0 === y1) {
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (rx <= 0 || ry <= 0) {
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (rx !== ry || rot !== 0) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`smooth-corners: only circular unrotated arcs are supported (got a ${rx} ${ry} ${rot})`,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const dx = (x0 - x1) / 2
|
|
229
|
+
const dy = (y0 - y1) / 2
|
|
230
|
+
const d2 = dx * dx + dy * dy
|
|
231
|
+
let r = rx
|
|
232
|
+
// Expand r when the endpoints don't fit (spec F.6.4 step 2).
|
|
233
|
+
const lambda = d2 / (r * r)
|
|
234
|
+
if (lambda > 1) {
|
|
235
|
+
r *= Math.sqrt(lambda)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Center offset perpendicular to the chord; sign per fa/fs
|
|
239
|
+
// (spec F.6.5: cx' = coef·y1', cy' = −coef·x1' for φ=0).
|
|
240
|
+
const h = Math.sqrt(Math.max(0, (r * r - d2) / d2))
|
|
241
|
+
const sign = largeArc !== sweep ? 1 : -1
|
|
242
|
+
const cx = (x0 + x1) / 2 + sign * h * dy
|
|
243
|
+
const cy = (y0 + y1) / 2 - sign * h * dx
|
|
244
|
+
|
|
245
|
+
const deg = (v: number) => (v * 180) / Math.PI
|
|
246
|
+
const start = deg(Math.atan2(y0 - cy, x0 - cx))
|
|
247
|
+
const end = deg(Math.atan2(y1 - cy, x1 - cx))
|
|
248
|
+
let sweepDeg = end - start
|
|
249
|
+
if (sweep && sweepDeg <= 0) {
|
|
250
|
+
sweepDeg += 360
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!sweep && sweepDeg >= 0) {
|
|
254
|
+
sweepDeg -= 360
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { c: 'A', cx, cy, r, startDeg: start, sweepDeg, x: x1, y: y1 }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Parse an SVG `d` string (or relative corner segment) into absolute
|
|
261
|
+
* `PathCommand[]`. Supports M L H V C S Q T A Z; `pen` seeds relative
|
|
262
|
+
* commands when parsing a mid-path segment. */
|
|
263
|
+
export function parsePath(d: string, penX = 0, penY = 0): PathCommand[] {
|
|
264
|
+
const tokens = d.match(TOKEN) ?? []
|
|
265
|
+
const out: PathCommand[] = []
|
|
266
|
+
let i = 0
|
|
267
|
+
let cmd = ''
|
|
268
|
+
let px = penX
|
|
269
|
+
let py = penY
|
|
270
|
+
let sx = penX // subpath start for Z
|
|
271
|
+
let sy = penY
|
|
272
|
+
|
|
273
|
+
const num = (): number => {
|
|
274
|
+
const t = tokens[i++]
|
|
275
|
+
const n = Number(t)
|
|
276
|
+
if (!Number.isFinite(n)) {
|
|
277
|
+
throw new Error(`smooth-corners: bad path token '${t}' in '${d}'`)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return n
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
while (i < tokens.length) {
|
|
284
|
+
const t = tokens[i]
|
|
285
|
+
if (/[a-zA-Z]/.test(t)) {
|
|
286
|
+
cmd = t
|
|
287
|
+
i++
|
|
288
|
+
if (cmd === 'Z' || cmd === 'z') {
|
|
289
|
+
out.push({ c: 'Z' })
|
|
290
|
+
px = sx
|
|
291
|
+
py = sy
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!cmd) {
|
|
297
|
+
throw new Error(`smooth-corners: path data missing command: '${d}'`)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const rel = cmd === cmd.toLowerCase()
|
|
301
|
+
const upper = cmd.toUpperCase()
|
|
302
|
+
const arity = ARITY[upper]
|
|
303
|
+
if (arity === undefined) {
|
|
304
|
+
throw new Error(`smooth-corners: unsupported path command '${cmd}'`)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (upper === 'S' || upper === 'Q' || upper === 'T') {
|
|
308
|
+
throw new Error(`smooth-corners: '${upper}' commands are not supported`)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
switch (upper) {
|
|
312
|
+
case 'M': {
|
|
313
|
+
const x = num() + (rel ? px : 0)
|
|
314
|
+
const y = num() + (rel ? py : 0)
|
|
315
|
+
out.push({ c: 'M', x, y })
|
|
316
|
+
px = sx = x
|
|
317
|
+
py = sy = y
|
|
318
|
+
cmd = rel ? 'l' : 'L' // subsequent pairs are lines
|
|
319
|
+
break
|
|
320
|
+
}
|
|
321
|
+
case 'L': {
|
|
322
|
+
const x = num() + (rel ? px : 0)
|
|
323
|
+
const y = num() + (rel ? py : 0)
|
|
324
|
+
out.push({ c: 'L', x, y })
|
|
325
|
+
px = x
|
|
326
|
+
py = y
|
|
327
|
+
break
|
|
328
|
+
}
|
|
329
|
+
case 'H': {
|
|
330
|
+
const x = num() + (rel ? px : 0)
|
|
331
|
+
out.push({ c: 'L', x, y: py })
|
|
332
|
+
px = x
|
|
333
|
+
break
|
|
334
|
+
}
|
|
335
|
+
case 'V': {
|
|
336
|
+
const y = num() + (rel ? py : 0)
|
|
337
|
+
out.push({ c: 'L', x: px, y })
|
|
338
|
+
py = y
|
|
339
|
+
break
|
|
340
|
+
}
|
|
341
|
+
case 'C': {
|
|
342
|
+
const x1 = num() + (rel ? px : 0)
|
|
343
|
+
const y1 = num() + (rel ? py : 0)
|
|
344
|
+
const x2 = num() + (rel ? px : 0)
|
|
345
|
+
const y2 = num() + (rel ? py : 0)
|
|
346
|
+
const x = num() + (rel ? px : 0)
|
|
347
|
+
const y = num() + (rel ? py : 0)
|
|
348
|
+
out.push({ c: 'C', x1, y1, x2, y2, x, y })
|
|
349
|
+
px = x
|
|
350
|
+
py = y
|
|
351
|
+
break
|
|
352
|
+
}
|
|
353
|
+
case 'A': {
|
|
354
|
+
const rx = num()
|
|
355
|
+
const ry = num()
|
|
356
|
+
const rot = num()
|
|
357
|
+
const fa = num()
|
|
358
|
+
const fs = num()
|
|
359
|
+
const x = num() + (rel ? px : 0)
|
|
360
|
+
const y = num() + (rel ? py : 0)
|
|
361
|
+
const arc = arcToCenter(px, py, rx, ry, rot, fa, fs, x, y)
|
|
362
|
+
if (arc) {
|
|
363
|
+
out.push(arc)
|
|
364
|
+
} else {
|
|
365
|
+
out.push({ c: 'L', x, y })
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
px = x
|
|
369
|
+
py = y
|
|
370
|
+
break
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return out
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// --- stitching ---------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
function lisseCornerSeg(
|
|
381
|
+
corner: Resolved,
|
|
382
|
+
radius: number,
|
|
383
|
+
budget: number,
|
|
384
|
+
orient: Orient,
|
|
385
|
+
pen: { x: number; y: number },
|
|
386
|
+
): { p: number; cmds: PathCommand[] } {
|
|
387
|
+
const builder: CurveBuilder = getCurveBuilder(corner.curve as never)
|
|
388
|
+
const input: CurveBuilderInput = {
|
|
389
|
+
cornerRadius: radius,
|
|
390
|
+
smoothing: corner.smoothing,
|
|
391
|
+
exponent: corner.exponent,
|
|
392
|
+
preserveSmoothing: corner.preserveSmoothing,
|
|
393
|
+
roundingAndSmoothingBudget: budget,
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const out = builder(input)
|
|
397
|
+
return { p: out.p, cmds: segmentCommands(out.pathSegment(orient as never), pen) }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function cornerCmds(
|
|
401
|
+
corner: Resolved,
|
|
402
|
+
normalized: { radius: number; roundingAndSmoothingBudget: number },
|
|
403
|
+
orient: Orient,
|
|
404
|
+
pen: { x: number; y: number },
|
|
405
|
+
w: number,
|
|
406
|
+
h: number,
|
|
407
|
+
): { p: number; cmds: PathCommand[] } {
|
|
408
|
+
if (corner.curve === 'continuous') {
|
|
409
|
+
return continuousCorner(w, h, normalized.radius, normalized.roundingAndSmoothingBudget, orient)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return lisseCornerSeg(
|
|
413
|
+
corner,
|
|
414
|
+
normalized.radius,
|
|
415
|
+
normalized.roundingAndSmoothingBudget,
|
|
416
|
+
orient,
|
|
417
|
+
pen,
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const RECT: PathCommand[] = [
|
|
422
|
+
{ c: 'M', x: 0, y: 0 },
|
|
423
|
+
{ c: 'L', x: 0, y: 0 },
|
|
424
|
+
{ c: 'L', x: 0, y: 0 },
|
|
425
|
+
{ c: 'L', x: 0, y: 0 },
|
|
426
|
+
{ c: 'Z' },
|
|
427
|
+
]
|
|
428
|
+
|
|
429
|
+
/** Absolute command list for the smooth-cornered rect. When no corner uses
|
|
430
|
+
* `continuous`, delegates to `@lisse/core` `generatePath` (keeping its
|
|
431
|
+
* capsule/blend fast paths) and parses the result; otherwise stitches
|
|
432
|
+
* per-corner segments itself (per-corner mixing with `continuous`). */
|
|
433
|
+
export function generateCommands(
|
|
434
|
+
width: number,
|
|
435
|
+
height: number,
|
|
436
|
+
options: CornerOptions,
|
|
437
|
+
): PathCommand[] {
|
|
438
|
+
if (width <= 0 || height <= 0) {
|
|
439
|
+
return RECT
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const corners = resolveOptions(options)
|
|
443
|
+
const anyContinuous =
|
|
444
|
+
corners.topLeft.curve === 'continuous' ||
|
|
445
|
+
corners.topRight.curve === 'continuous' ||
|
|
446
|
+
corners.bottomRight.curve === 'continuous' ||
|
|
447
|
+
corners.bottomLeft.curve === 'continuous'
|
|
448
|
+
|
|
449
|
+
if (!anyContinuous) {
|
|
450
|
+
return parsePath(lisseGeneratePath(width, height, lisseOptions(corners)))
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const normalized = distributeAndNormalize({
|
|
454
|
+
topLeftCornerRadius: corners.topLeft.radius,
|
|
455
|
+
topRightCornerRadius: corners.topRight.radius,
|
|
456
|
+
bottomRightCornerRadius: corners.bottomRight.radius,
|
|
457
|
+
bottomLeftCornerRadius: corners.bottomLeft.radius,
|
|
458
|
+
width,
|
|
459
|
+
height,
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
// Walk clockwise from the top edge. `pen` tracks the last emitted point.
|
|
463
|
+
const emit = (
|
|
464
|
+
corner: Resolved,
|
|
465
|
+
n: { radius: number; roundingAndSmoothingBudget: number },
|
|
466
|
+
orient: Orient,
|
|
467
|
+
entryX: number,
|
|
468
|
+
entryY: number,
|
|
469
|
+
) => cornerCmds(corner, n, orient, { x: entryX, y: entryY }, width, height)
|
|
470
|
+
|
|
471
|
+
// First pass to learn each corner's p so edge lines land correctly.
|
|
472
|
+
const pTL = previewP(corners.topLeft, normalized.topLeft, 'TL', width, height)
|
|
473
|
+
const pTR = previewP(corners.topRight, normalized.topRight, 'TR', width, height)
|
|
474
|
+
const pBR = previewP(corners.bottomRight, normalized.bottomRight, 'BR', width, height)
|
|
475
|
+
const pBL = previewP(corners.bottomLeft, normalized.bottomLeft, 'BL', width, height)
|
|
476
|
+
|
|
477
|
+
const out: PathCommand[] = [{ c: 'M', x: pTL, y: 0 }]
|
|
478
|
+
out.push({ c: 'L', x: width - pTR, y: 0 })
|
|
479
|
+
const tr = emit(corners.topRight, normalized.topRight, 'TR', width - pTR, 0)
|
|
480
|
+
out.push(...tr.cmds)
|
|
481
|
+
out.push({ c: 'L', x: width, y: height - pBR })
|
|
482
|
+
const br = emit(corners.bottomRight, normalized.bottomRight, 'BR', width, height - pBR)
|
|
483
|
+
out.push(...br.cmds)
|
|
484
|
+
out.push({ c: 'L', x: pBL, y: height })
|
|
485
|
+
const bl = emit(corners.bottomLeft, normalized.bottomLeft, 'BL', pBL, height)
|
|
486
|
+
out.push(...bl.cmds)
|
|
487
|
+
out.push({ c: 'L', x: 0, y: pTL })
|
|
488
|
+
const tl = emit(corners.topLeft, normalized.topLeft, 'TL', 0, pTL)
|
|
489
|
+
out.push(...tl.cmds)
|
|
490
|
+
out.push({ c: 'Z' })
|
|
491
|
+
return out
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** A corner's tangent distance without emitting commands (cheap for
|
|
495
|
+
* continuous; re-runs the lisse builder for other curves — called once per
|
|
496
|
+
* frame per corner, acceptable). */
|
|
497
|
+
function previewP(
|
|
498
|
+
corner: Resolved,
|
|
499
|
+
n: { radius: number; roundingAndSmoothingBudget: number },
|
|
500
|
+
orient: Orient,
|
|
501
|
+
w: number,
|
|
502
|
+
h: number,
|
|
503
|
+
): number {
|
|
504
|
+
return cornerCmds(corner, n, orient, { x: 0, y: 0 }, w, h).p
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// 'continuous' is ours, not a lisse CurveType — cast it away per field; the
|
|
508
|
+
// continuous fast-path never reaches this function.
|
|
509
|
+
function lisseOptions(corners: Corners): Parameters<typeof lisseGeneratePath>[2] {
|
|
510
|
+
const { topLeft: a, topRight: b, bottomRight: c, bottomLeft: d } = corners
|
|
511
|
+
const same =
|
|
512
|
+
a.radius === b.radius &&
|
|
513
|
+
a.radius === c.radius &&
|
|
514
|
+
a.radius === d.radius &&
|
|
515
|
+
a.curve === b.curve &&
|
|
516
|
+
a.curve === c.curve &&
|
|
517
|
+
a.curve === d.curve &&
|
|
518
|
+
a.smoothing === b.smoothing &&
|
|
519
|
+
a.smoothing === c.smoothing &&
|
|
520
|
+
a.smoothing === d.smoothing &&
|
|
521
|
+
a.exponent === b.exponent &&
|
|
522
|
+
a.exponent === c.exponent &&
|
|
523
|
+
a.exponent === d.exponent &&
|
|
524
|
+
a.preserveSmoothing === b.preserveSmoothing &&
|
|
525
|
+
a.preserveSmoothing === c.preserveSmoothing &&
|
|
526
|
+
a.preserveSmoothing === d.preserveSmoothing
|
|
527
|
+
|
|
528
|
+
if (same) {
|
|
529
|
+
return {
|
|
530
|
+
radius: a.radius,
|
|
531
|
+
curve: a.curve as never,
|
|
532
|
+
smoothing: a.smoothing,
|
|
533
|
+
exponent: a.exponent,
|
|
534
|
+
preserveSmoothing: a.preserveSmoothing,
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const per = (c: Resolved) => ({
|
|
539
|
+
radius: c.radius,
|
|
540
|
+
curve: c.curve as never,
|
|
541
|
+
smoothing: c.smoothing,
|
|
542
|
+
exponent: c.exponent,
|
|
543
|
+
preserveSmoothing: c.preserveSmoothing,
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
return { topLeft: per(a), topRight: per(b), bottomRight: per(c), bottomLeft: per(d) }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// --- emitters ----------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
const f = (n: number) => Math.round(n * 1e4) / 1e4
|
|
552
|
+
|
|
553
|
+
/** Serialize commands back to an SVG `d` (web `clip-path`, debug/tests). */
|
|
554
|
+
export function commandsToD(cmds: PathCommand[]): string {
|
|
555
|
+
const parts: string[] = []
|
|
556
|
+
for (const c of cmds) {
|
|
557
|
+
switch (c.c) {
|
|
558
|
+
case 'M':
|
|
559
|
+
parts.push(`M ${f(c.x)} ${f(c.y)}`)
|
|
560
|
+
break
|
|
561
|
+
case 'L':
|
|
562
|
+
parts.push(`L ${f(c.x)} ${f(c.y)}`)
|
|
563
|
+
break
|
|
564
|
+
case 'C':
|
|
565
|
+
parts.push(`C ${f(c.x1)} ${f(c.y1)} ${f(c.x2)} ${f(c.y2)} ${f(c.x)} ${f(c.y)}`)
|
|
566
|
+
break
|
|
567
|
+
case 'A':
|
|
568
|
+
// Re-emit as endpoint arc; largeArc when |sweep| > 180.
|
|
569
|
+
parts.push(
|
|
570
|
+
`A ${f(c.r)} ${f(c.r)} 0 ${Math.abs(c.sweepDeg) > 180 ? 1 : 0} ${c.sweepDeg >= 0 ? 1 : 0} ${f(c.x)} ${f(c.y)}`,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
break
|
|
574
|
+
case 'Z':
|
|
575
|
+
parts.push('Z')
|
|
576
|
+
break
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return parts.join(' ')
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** `d` for the smooth-cornered rect — Lisse's own string when no
|
|
584
|
+
* `continuous` corner is involved (byte-identical to upstream, incl.
|
|
585
|
+
* capsule/blend fast paths), our serialization otherwise. */
|
|
586
|
+
export function smoothPathD(width: number, height: number, options: CornerOptions): string {
|
|
587
|
+
const corners = resolveOptions(options)
|
|
588
|
+
const anyContinuous =
|
|
589
|
+
corners.topLeft.curve === 'continuous' ||
|
|
590
|
+
corners.topRight.curve === 'continuous' ||
|
|
591
|
+
corners.bottomRight.curve === 'continuous' ||
|
|
592
|
+
corners.bottomLeft.curve === 'continuous'
|
|
593
|
+
|
|
594
|
+
if (!anyContinuous && width > 0 && height > 0) {
|
|
595
|
+
return lisseGeneratePath(width, height, lisseOptions(corners))
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return commandsToD(generateCommands(width, height, options))
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Mirror a command list across the horizontal axis for a height-`h` box —
|
|
602
|
+
* AppKit's y-up layer coordinates need this (UIKit/Android Canvas are
|
|
603
|
+
* y-down like SVG). Arcs invert sweep; startDeg flips sign. */
|
|
604
|
+
export function flipCommandsY(cmds: PathCommand[], h: number): PathCommand[] {
|
|
605
|
+
return cmds.map((c) => {
|
|
606
|
+
switch (c.c) {
|
|
607
|
+
case 'M':
|
|
608
|
+
case 'L':
|
|
609
|
+
return { c: c.c, x: c.x, y: h - c.y }
|
|
610
|
+
case 'C':
|
|
611
|
+
return { c: 'C', x1: c.x1, y1: h - c.y1, x2: c.x2, y2: h - c.y2, x: c.x, y: h - c.y }
|
|
612
|
+
case 'A':
|
|
613
|
+
return {
|
|
614
|
+
c: 'A',
|
|
615
|
+
cx: c.cx,
|
|
616
|
+
cy: h - c.cy,
|
|
617
|
+
r: c.r,
|
|
618
|
+
startDeg: -c.startDeg,
|
|
619
|
+
sweepDeg: -c.sweepDeg,
|
|
620
|
+
x: c.x,
|
|
621
|
+
y: h - c.y,
|
|
622
|
+
}
|
|
623
|
+
case 'Z':
|
|
624
|
+
return c
|
|
625
|
+
}
|
|
626
|
+
})
|
|
627
|
+
}
|
package/src/props.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Shared props for `SmoothCorners` — identical on every leaf. */
|
|
2
|
+
|
|
3
|
+
/** Corner curve family. `'continuous'` is Apple's corner (the default) —
|
|
4
|
+
* reproduced via the Rosenfeld `UIBezierPath` cubic constants, ~0.25 px
|
|
5
|
+
* from live SwiftUI `.continuous`. The rest delegate to `@lisse/core`:
|
|
6
|
+
* `squircle` = Figma's corner smoothing, `superellipse` = |x|^n+|y|^n=1,
|
|
7
|
+
* `clothoid` = Euler-spiral blend, `arc` = plain circular border-radius. */
|
|
8
|
+
export type CornerCurve = 'continuous' | 'squircle' | 'superellipse' | 'clothoid' | 'arc'
|
|
9
|
+
|
|
10
|
+
export interface CornerConfig {
|
|
11
|
+
/** Nominal corner radius in dips (matches `border-radius`). */
|
|
12
|
+
radius: number
|
|
13
|
+
/** Curve family. Default `'continuous'`. */
|
|
14
|
+
curve?: CornerCurve
|
|
15
|
+
/** 0–1 smoothing — used by `squircle`/`clothoid`, ignored elsewhere.
|
|
16
|
+
* Figma's "iOS" preset is 0.6; Lisse's closest-fit is 0.65. */
|
|
17
|
+
smoothing?: number
|
|
18
|
+
/** Superellipse exponent (`n`); only `superellipse`. Default 4. */
|
|
19
|
+
exponent?: number
|
|
20
|
+
/** Preserve smoothing when space is limited. Default true. */
|
|
21
|
+
preserveSmoothing?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Per-corner configuration — each value is a CornerConfig or a radius
|
|
25
|
+
* shorthand. */
|
|
26
|
+
export interface PerCornerConfig {
|
|
27
|
+
topLeft?: CornerConfig | number
|
|
28
|
+
topRight?: CornerConfig | number
|
|
29
|
+
bottomRight?: CornerConfig | number
|
|
30
|
+
bottomLeft?: CornerConfig | number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type CornerOptions = CornerConfig | PerCornerConfig | number
|
|
34
|
+
|
|
35
|
+
export interface SmoothBorderConfig {
|
|
36
|
+
/** Stroke width in dips, painted inside the clip edge. */
|
|
37
|
+
width: number
|
|
38
|
+
/** Any color string the platform accepts (hex, rgba, named). */
|
|
39
|
+
color: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SmoothShadowConfig {
|
|
43
|
+
color?: string
|
|
44
|
+
opacity?: number
|
|
45
|
+
offsetX?: number
|
|
46
|
+
offsetY?: number
|
|
47
|
+
blur?: number
|
|
48
|
+
/** Spread is web/iOS/macOS only — Android elevation cannot express it. */
|
|
49
|
+
spread?: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SmoothCornersProps {
|
|
53
|
+
/** Radius/curve configuration — the single source of truth. `border-radius`
|
|
54
|
+
* in `style`/`className` is ignored on this element on every target. */
|
|
55
|
+
corners: CornerOptions
|
|
56
|
+
border?: SmoothBorderConfig
|
|
57
|
+
/** Drop shadow following the clip shape. Fidelity varies per target —
|
|
58
|
+
* see the design note: Android uses `elevation` (fixed direction, no
|
|
59
|
+
* offsets/spread); iOS/macOS use `shadowPath`; web uses
|
|
60
|
+
* `filter: drop-shadow` (no spread). */
|
|
61
|
+
shadow?: SmoothShadowConfig
|
|
62
|
+
className?: any
|
|
63
|
+
style?: any
|
|
64
|
+
id?: string
|
|
65
|
+
children?: any
|
|
66
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Boundary types for @octane-xplat/smooth-corners — the mechanical wrapper
|
|
2
|
+
// over props.ts (tsrx-tsc can't emit .tsrx declarations; see
|
|
3
|
+
// ui/types/index.d.ts for the same pattern).
|
|
4
|
+
import type { UniversalComponent } from 'octane/universal'
|
|
5
|
+
import type { SmoothCornersProps } from './props.js'
|
|
6
|
+
|
|
7
|
+
export declare const SmoothCorners: UniversalComponent<SmoothCornersProps>
|
|
8
|
+
|
|
9
|
+
export type {
|
|
10
|
+
CornerConfig,
|
|
11
|
+
CornerCurve,
|
|
12
|
+
CornerOptions,
|
|
13
|
+
PerCornerConfig,
|
|
14
|
+
SmoothBorderConfig,
|
|
15
|
+
SmoothCornersProps,
|
|
16
|
+
SmoothShadowConfig,
|
|
17
|
+
} from './props.js'
|