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