fix(canvas): render Figma-style corner smoothing
This commit is contained in:
parent
76ee122dee
commit
98616c8610
|
|
@ -15,7 +15,7 @@
|
||||||
- Fix CanvasKit loading outside the browser when project paths contain spaces.
|
- Fix CanvasKit loading outside the browser when project paths contain spaces.
|
||||||
- Render imported Figma layer and fill blend modes such as multiply, screen, overlay, difference, hue, saturation, color, and luminosity.
|
- Render imported Figma layer and fill blend modes such as multiply, screen, overlay, difference, hue, saturation, color, and luminosity.
|
||||||
- Render common imported Figma mask stacks so visible layers above a mask are clipped by the mask shape.
|
- Render common imported Figma mask stacks so visible layers above a mask are clipped by the mask shape.
|
||||||
- Render smoothed rectangle corners and effect blend modes from imported Figma files.
|
- Render Figma-style smoothed rectangle corners, including independent corner radii, and effect blend modes from imported Figma files.
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,180 @@ export function nodeHasRadius(node: SceneNode): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nodeHasSmoothCorners(node: SceneNode): boolean {
|
export function nodeHasSmoothCorners(node: SceneNode): boolean {
|
||||||
return !node.independentCorners && node.cornerRadius > 0 && node.cornerSmoothing > 0
|
if (!(node.cornerSmoothing > 0)) return false
|
||||||
|
if (node.independentCorners) {
|
||||||
|
return (
|
||||||
|
node.topLeftRadius > 0 ||
|
||||||
|
node.topRightRadius > 0 ||
|
||||||
|
node.bottomRightRadius > 0 ||
|
||||||
|
node.bottomLeftRadius > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return node.cornerRadius > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampCornerRadius(width: number, height: number, radius: number): number {
|
type SmoothCornerKey = 'topLeft' | 'topRight' | 'bottomRight' | 'bottomLeft'
|
||||||
return Math.max(0, Math.min(radius, width / 2, height / 2))
|
|
||||||
|
type SmoothCorner = {
|
||||||
|
radius: number
|
||||||
|
budget: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmoothCornerPathParams = {
|
||||||
|
a: number
|
||||||
|
b: number
|
||||||
|
c: number
|
||||||
|
d: number
|
||||||
|
p: number
|
||||||
|
radius: number
|
||||||
|
arcSectionLength: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothCornerRadii(node: SceneNode, width: number, height: number, spread: number): Record<SmoothCornerKey, SmoothCorner> {
|
||||||
|
const radius = (value: number) => Math.max(0, value + spread)
|
||||||
|
const radii: Record<SmoothCornerKey, number> = node.independentCorners
|
||||||
|
? {
|
||||||
|
topLeft: radius(node.topLeftRadius),
|
||||||
|
topRight: radius(node.topRightRadius),
|
||||||
|
bottomRight: radius(node.bottomRightRadius),
|
||||||
|
bottomLeft: radius(node.bottomLeftRadius)
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
topLeft: radius(node.cornerRadius),
|
||||||
|
topRight: radius(node.cornerRadius),
|
||||||
|
bottomRight: radius(node.cornerRadius),
|
||||||
|
bottomLeft: radius(node.cornerRadius)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (radii.topLeft === radii.topRight && radii.topRight === radii.bottomRight && radii.bottomRight === radii.bottomLeft) {
|
||||||
|
const budget = Math.min(width, height) / 2
|
||||||
|
const clampedRadius = Math.min(radii.topLeft, budget)
|
||||||
|
return {
|
||||||
|
topLeft: { radius: clampedRadius, budget },
|
||||||
|
topRight: { radius: clampedRadius, budget },
|
||||||
|
bottomRight: { radius: clampedRadius, budget },
|
||||||
|
bottomLeft: { radius: clampedRadius, budget }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgets: Record<SmoothCornerKey, number> = {
|
||||||
|
topLeft: -1,
|
||||||
|
topRight: -1,
|
||||||
|
bottomRight: -1,
|
||||||
|
bottomLeft: -1
|
||||||
|
}
|
||||||
|
const adjacentByCorner: Record<SmoothCornerKey, Array<{ corner: SmoothCornerKey; sideLength: number }>> = {
|
||||||
|
topLeft: [
|
||||||
|
{ corner: 'topRight', sideLength: width },
|
||||||
|
{ corner: 'bottomLeft', sideLength: height }
|
||||||
|
],
|
||||||
|
topRight: [
|
||||||
|
{ corner: 'topLeft', sideLength: width },
|
||||||
|
{ corner: 'bottomRight', sideLength: height }
|
||||||
|
],
|
||||||
|
bottomRight: [
|
||||||
|
{ corner: 'bottomLeft', sideLength: width },
|
||||||
|
{ corner: 'topRight', sideLength: height }
|
||||||
|
],
|
||||||
|
bottomLeft: [
|
||||||
|
{ corner: 'bottomRight', sideLength: width },
|
||||||
|
{ corner: 'topLeft', sideLength: height }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const corner of (Object.keys(radii) as SmoothCornerKey[]).sort((a, b) => radii[b] - radii[a])) {
|
||||||
|
const cornerRadius = radii[corner]
|
||||||
|
const budget = Math.min(
|
||||||
|
...adjacentByCorner[corner].map((adjacent) => {
|
||||||
|
const adjacentRadius = radii[adjacent.corner]
|
||||||
|
if (cornerRadius === 0 && adjacentRadius === 0) return 0
|
||||||
|
if (budgets[adjacent.corner] >= 0) return adjacent.sideLength - budgets[adjacent.corner]
|
||||||
|
return (cornerRadius / (cornerRadius + adjacentRadius)) * adjacent.sideLength
|
||||||
|
})
|
||||||
|
)
|
||||||
|
budgets[corner] = budget
|
||||||
|
radii[corner] = Math.min(cornerRadius, budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
topLeft: { radius: radii.topLeft, budget: budgets.topLeft },
|
||||||
|
topRight: { radius: radii.topRight, budget: budgets.topRight },
|
||||||
|
bottomRight: { radius: radii.bottomRight, budget: budgets.bottomRight },
|
||||||
|
bottomLeft: { radius: radii.bottomLeft, budget: budgets.bottomLeft }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function degreesToRadians(degrees: number): number {
|
||||||
|
return (degrees * Math.PI) / 180
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothCornerPathParams(corner: SmoothCorner, smoothing: number): SmoothCornerPathParams {
|
||||||
|
let cornerSmoothing = smoothing
|
||||||
|
let p = (1 + cornerSmoothing) * corner.radius
|
||||||
|
if (corner.radius > 0) {
|
||||||
|
const maxSmoothing = corner.budget / corner.radius - 1
|
||||||
|
cornerSmoothing = Math.min(cornerSmoothing, maxSmoothing)
|
||||||
|
p = Math.min(p, corner.budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const arcMeasure = 90 * (1 - cornerSmoothing)
|
||||||
|
const arcSectionLength = Math.sin(degreesToRadians(arcMeasure / 2)) * corner.radius * Math.sqrt(2)
|
||||||
|
const angleAlpha = (90 - arcMeasure) / 2
|
||||||
|
const p3ToP4Distance = corner.radius * Math.tan(degreesToRadians(angleAlpha / 2))
|
||||||
|
const angleBeta = 45 * cornerSmoothing
|
||||||
|
const c = p3ToP4Distance * Math.cos(degreesToRadians(angleBeta))
|
||||||
|
const d = c * Math.tan(degreesToRadians(angleBeta))
|
||||||
|
const b = (p - arcSectionLength - c - d) / 3
|
||||||
|
|
||||||
|
return {
|
||||||
|
a: 2 * b,
|
||||||
|
b,
|
||||||
|
c,
|
||||||
|
d,
|
||||||
|
p,
|
||||||
|
radius: corner.radius,
|
||||||
|
arcSectionLength
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTopRightSmoothCorner(path: Path, corner: SmoothCornerPathParams, x: number, y: number) {
|
||||||
|
if (corner.radius === 0) {
|
||||||
|
path.lineTo(x + corner.p, y)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path.cubicTo(x + corner.a, y, x + corner.a + corner.b, y, x + corner.a + corner.b + corner.c, y + corner.d)
|
||||||
|
path.arcToRotated(corner.radius, corner.radius, 0, true, false, x + corner.p - corner.d, y + corner.p - corner.a - corner.b - corner.c)
|
||||||
|
path.cubicTo(x + corner.p, y + corner.p - corner.a - corner.b, x + corner.p, y + corner.p - corner.a, x + corner.p, y + corner.p)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBottomRightSmoothCorner(path: Path, corner: SmoothCornerPathParams, x: number, y: number) {
|
||||||
|
if (corner.radius === 0) {
|
||||||
|
path.lineTo(x, y + corner.p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path.cubicTo(x, y + corner.a, x, y + corner.a + corner.b, x - corner.d, y + corner.a + corner.b + corner.c)
|
||||||
|
path.arcToRotated(corner.radius, corner.radius, 0, true, false, x - corner.p + corner.a + corner.b + corner.c, y + corner.p - corner.d)
|
||||||
|
path.cubicTo(x - corner.p + corner.a + corner.b, y + corner.p, x - corner.p + corner.a, y + corner.p, x - corner.p, y + corner.p)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBottomLeftSmoothCorner(path: Path, corner: SmoothCornerPathParams, x: number, y: number) {
|
||||||
|
if (corner.radius === 0) {
|
||||||
|
path.lineTo(x - corner.p, y)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path.cubicTo(x - corner.a, y, x - corner.a - corner.b, y, x - corner.a - corner.b - corner.c, y - corner.d)
|
||||||
|
path.arcToRotated(corner.radius, corner.radius, 0, true, false, x - corner.p + corner.d, y - corner.p + corner.a + corner.b + corner.c)
|
||||||
|
path.cubicTo(x - corner.p, y - corner.p + corner.a + corner.b, x - corner.p, y - corner.p + corner.a, x - corner.p, y - corner.p)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTopLeftSmoothCorner(path: Path, corner: SmoothCornerPathParams, x: number, y: number) {
|
||||||
|
if (corner.radius === 0) {
|
||||||
|
path.lineTo(x, y - corner.p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path.cubicTo(x, y - corner.a, x, y - corner.a - corner.b, x + corner.d, y - corner.a - corner.b - corner.c)
|
||||||
|
path.arcToRotated(corner.radius, corner.radius, 0, true, false, x + corner.p - corner.a - corner.b - corner.c, y - corner.p + corner.d)
|
||||||
|
path.cubicTo(x + corner.p - corner.a - corner.b, y - corner.p, x + corner.p - corner.a, y - corner.p, x + corner.p, y - corner.p)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeSmoothRRectPath(
|
export function makeSmoothRRectPath(
|
||||||
|
|
@ -37,37 +206,38 @@ export function makeSmoothRRectPath(
|
||||||
const top = offsetY - spread
|
const top = offsetY - spread
|
||||||
const right = offsetX + node.width + spread
|
const right = offsetX + node.width + spread
|
||||||
const bottom = offsetY + node.height + spread
|
const bottom = offsetY + node.height + spread
|
||||||
const radius = clampCornerRadius(right - left, bottom - top, node.cornerRadius + spread)
|
const width = right - left
|
||||||
|
const height = bottom - top
|
||||||
if (radius === 0) {
|
if (width <= 0 || height <= 0) {
|
||||||
path.addRect(r.ck.LTRBRect(left, top, right, bottom))
|
path.addRect(r.ck.LTRBRect(left, top, Math.max(left, right), Math.max(top, bottom)))
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
const smoothing = Math.max(0, Math.min(node.cornerSmoothing, 1))
|
const smoothing = Math.max(0, Math.min(node.cornerSmoothing, 1))
|
||||||
const exponent = 2 + smoothing * 3
|
const corners = smoothCornerRadii(node, width, height, spread)
|
||||||
const samples = 12
|
const topLeftCorner = smoothCornerPathParams(corners.topLeft, smoothing)
|
||||||
const addCorner = (cx: number, cy: number, startAngle: number, endAngle: number) => {
|
const topRightCorner = smoothCornerPathParams(corners.topRight, smoothing)
|
||||||
for (let i = 1; i <= samples; i++) {
|
const bottomRightCorner = smoothCornerPathParams(corners.bottomRight, smoothing)
|
||||||
const t = i / samples
|
const bottomLeftCorner = smoothCornerPathParams(corners.bottomLeft, smoothing)
|
||||||
const angle = startAngle + (endAngle - startAngle) * t
|
|
||||||
const cos = Math.cos(angle)
|
if (
|
||||||
const sin = Math.sin(angle)
|
topLeftCorner.radius === 0 &&
|
||||||
const x = cx + Math.sign(cos) * radius * Math.abs(cos) ** (2 / exponent)
|
topRightCorner.radius === 0 &&
|
||||||
const y = cy + Math.sign(sin) * radius * Math.abs(sin) ** (2 / exponent)
|
bottomRightCorner.radius === 0 &&
|
||||||
path.lineTo(x, y)
|
bottomLeftCorner.radius === 0
|
||||||
}
|
) {
|
||||||
|
path.addRect(r.ck.LTRBRect(left, top, right, bottom))
|
||||||
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
path.moveTo(left + radius, top)
|
path.moveTo(right - topRightCorner.p, top)
|
||||||
path.lineTo(right - radius, top)
|
drawTopRightSmoothCorner(path, topRightCorner, right - topRightCorner.p, top)
|
||||||
addCorner(right - radius, top + radius, -Math.PI / 2, 0)
|
path.lineTo(right, bottom - bottomRightCorner.p)
|
||||||
path.lineTo(right, bottom - radius)
|
drawBottomRightSmoothCorner(path, bottomRightCorner, right, bottom - bottomRightCorner.p)
|
||||||
addCorner(right - radius, bottom - radius, 0, Math.PI / 2)
|
path.lineTo(left + bottomLeftCorner.p, bottom)
|
||||||
path.lineTo(left + radius, bottom)
|
drawBottomLeftSmoothCorner(path, bottomLeftCorner, left + bottomLeftCorner.p, bottom)
|
||||||
addCorner(left + radius, bottom - radius, Math.PI / 2, Math.PI)
|
path.lineTo(left, top + topLeftCorner.p)
|
||||||
path.lineTo(left, top + radius)
|
drawTopLeftSmoothCorner(path, topLeftCorner, left, top + topLeftCorner.p)
|
||||||
addCorner(left + radius, top + radius, Math.PI, (Math.PI * 3) / 2)
|
|
||||||
path.close()
|
path.close()
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ Figma's design documentation groups features into these areas:
|
||||||
| Frames | ✅ | ✅ | ✅ | ✅ | ✅ | Includes clipping and auto-layout container behavior. |
|
| Frames | ✅ | ✅ | ✅ | ✅ | ✅ | Includes clipping and auto-layout container behavior. |
|
||||||
| Groups | ✅ | ✅ | ✅ | ✅ | ✅ | Grouping preserves visual positions. |
|
| Groups | ✅ | ✅ | ✅ | ✅ | ✅ | Grouping preserves visual positions. |
|
||||||
| Sections | ✅ | ✅ | ✅ | ✅ | ✅ | Section rendering and title pills are OpenPencil-specific approximations. |
|
| Sections | ✅ | ✅ | ✅ | ✅ | ✅ | Section rendering and title pills are OpenPencil-specific approximations. |
|
||||||
| Rectangles / rounded rectangles | ✅ | ✅ | ✅ | ✅ | ✅ | Per-corner radii and smoothed uniform corners render; independent-corner smoothing remains approximate. |
|
| Rectangles / rounded rectangles | ✅ | ✅ | ✅ | ✅ | ✅ | Per-corner radii and smoothed corners render for fills, strokes, clips, masks, and effects. |
|
||||||
| Ellipses / arcs | ✅ | ✅ | ◐ | ✅ | ✅ | `arcData` renders/exports; no full inspector controls. |
|
| Ellipses / arcs | ✅ | ✅ | ◐ | ✅ | ✅ | `arcData` renders/exports; no full inspector controls. |
|
||||||
| Lines | ✅ | ✅ | ✅ | ✅ | ✅ | Stroke caps/joins render but are not fully exposed in UI. |
|
| Lines | ✅ | ✅ | ✅ | ✅ | ✅ | Stroke caps/joins render but are not fully exposed in UI. |
|
||||||
| Polygons / stars | ✅ | ✅ | ◐ | ✅ | ✅ | `pointCount` and `starInnerRadius` modeled. |
|
| Polygons / stars | ✅ | ✅ | ◐ | ✅ | ✅ | `pointCount` and `starInnerRadius` modeled. |
|
||||||
|
|
@ -204,7 +204,7 @@ OpenPencil deliberately preserves many Figma/Kiwi fields even when they are not
|
||||||
These are parsed or visible in Figma docs and most likely to cause visible differences in real design files:
|
These are parsed or visible in Figma docs and most likely to cause visible differences in real design files:
|
||||||
|
|
||||||
1. **Masks** — extend common sibling mask stacks with luminance masks and more exact `maskType` behavior.
|
1. **Masks** — extend common sibling mask stacks with luminance masks and more exact `maskType` behavior.
|
||||||
2. **Corner smoothing** — improve the current smoothed uniform-corner approximation for independent corners and stroke edge cases.
|
2. **Corner smoothing** — compare smoothed-corner output against Figma fixtures and tune remaining edge cases.
|
||||||
3. **Pattern fills** — render imported Figma pattern/image tiling semantics beyond current image scale modes.
|
3. **Pattern fills** — render imported Figma pattern/image tiling semantics beyond current image scale modes.
|
||||||
4. **Pattern fills/strokes** — support Figma pattern fills and their transforms.
|
4. **Pattern fills/strokes** — support Figma pattern fills and their transforms.
|
||||||
5. **Font variations** — apply variable-font axes from imported Figma metadata.
|
5. **Font variations** — apply variable-font axes from imported Figma metadata.
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type { EditorStore } from '@/app/editor/session'
|
||||||
export function createEffectsSection(store: EditorStore) {
|
export function createEffectsSection(store: EditorStore) {
|
||||||
const { graph } = store
|
const { graph } = store
|
||||||
|
|
||||||
const effectsSectionId = store.createShape('SECTION', 60, 840, 920, 640)
|
const effectsSectionId = store.createShape('SECTION', 60, 840, 920, 780)
|
||||||
graph.updateNode(effectsSectionId, {
|
graph.updateNode(effectsSectionId, {
|
||||||
name: 'Effects',
|
name: 'Effects',
|
||||||
fills: [solid(DEMO_COLORS.white)]
|
fills: [solid(DEMO_COLORS.white)]
|
||||||
|
|
@ -328,4 +328,100 @@ export function createEffectsSection(store: EditorStore) {
|
||||||
rotation: -20,
|
rotation: -20,
|
||||||
fills: [solid(DEMO_COLORS.teal)]
|
fills: [solid(DEMO_COLORS.teal)]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const smoothingLabel = store.createShape('TEXT', 32, 644, 280, 20, effectsSectionId)
|
||||||
|
graph.updateNode(smoothingLabel, {
|
||||||
|
name: 'Label',
|
||||||
|
text: 'Corner smoothing & effect blends',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
fills: [solid(DEMO_COLORS.gray500)]
|
||||||
|
})
|
||||||
|
|
||||||
|
const smoothingCard = store.createShape('FRAME', 32, 676, 256, 104, effectsSectionId)
|
||||||
|
graph.updateNode(smoothingCard, {
|
||||||
|
name: 'Smooth Corner Comparison',
|
||||||
|
cornerRadius: 16,
|
||||||
|
fills: [solid(DEMO_COLORS.gray50)],
|
||||||
|
strokes: thinStroke(DEMO_COLORS.gray200)
|
||||||
|
})
|
||||||
|
const regularCorner = store.createShape('RECTANGLE', 24, 18, 58, 58, smoothingCard)
|
||||||
|
graph.updateNode(regularCorner, {
|
||||||
|
name: 'Regular Radius',
|
||||||
|
cornerRadius: 18,
|
||||||
|
fills: [solid(DEMO_COLORS.blue)]
|
||||||
|
})
|
||||||
|
const smoothCorner = store.createShape('RECTANGLE', 102, 18, 58, 58, smoothingCard)
|
||||||
|
graph.updateNode(smoothCorner, {
|
||||||
|
name: 'Smoothed Radius',
|
||||||
|
cornerRadius: 18,
|
||||||
|
cornerSmoothing: 0.9,
|
||||||
|
fills: [solid(DEMO_COLORS.purple)]
|
||||||
|
})
|
||||||
|
const cornerLabel = store.createShape('TEXT', 176, 26, 56, 36, smoothingCard)
|
||||||
|
graph.updateNode(cornerLabel, {
|
||||||
|
name: 'Label',
|
||||||
|
text: 'Radius\n+ smooth',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 16,
|
||||||
|
fills: [solid(DEMO_COLORS.gray500)]
|
||||||
|
})
|
||||||
|
|
||||||
|
const independentCard = store.createShape('FRAME', 312, 676, 204, 104, effectsSectionId)
|
||||||
|
graph.updateNode(independentCard, {
|
||||||
|
name: 'Independent Smooth Corners',
|
||||||
|
cornerRadius: 28,
|
||||||
|
cornerSmoothing: 1,
|
||||||
|
independentCorners: true,
|
||||||
|
topLeftRadius: 36,
|
||||||
|
topRightRadius: 10,
|
||||||
|
bottomRightRadius: 36,
|
||||||
|
bottomLeftRadius: 10,
|
||||||
|
fills: [
|
||||||
|
gradient([
|
||||||
|
{ color: DEMO_COLORS.teal, position: 0 },
|
||||||
|
{ color: DEMO_COLORS.blue, position: 1 }
|
||||||
|
])
|
||||||
|
],
|
||||||
|
effects: [dropShadow(0, 8, 20, 0, { r: 0.08, g: 0.73, b: 0.73, a: 0.24 })]
|
||||||
|
})
|
||||||
|
const independentText = store.createShape('TEXT', 20, 34, 164, 24, independentCard)
|
||||||
|
graph.updateNode(independentText, {
|
||||||
|
name: 'Label',
|
||||||
|
text: 'Independent corners',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 700,
|
||||||
|
fills: [solid(DEMO_COLORS.white)]
|
||||||
|
})
|
||||||
|
|
||||||
|
const effectBlendCard = store.createShape('FRAME', 540, 676, 300, 104, effectsSectionId)
|
||||||
|
graph.updateNode(effectBlendCard, {
|
||||||
|
name: 'Effect Blend Card',
|
||||||
|
cornerRadius: 18,
|
||||||
|
fills: [solid({ r: 0.08, g: 0.1, b: 0.18, a: 1 })],
|
||||||
|
clipsContent: true
|
||||||
|
})
|
||||||
|
const glowBase = store.createShape('ELLIPSE', 22, 14, 76, 76, effectBlendCard)
|
||||||
|
graph.updateNode(glowBase, {
|
||||||
|
name: 'Glow Base',
|
||||||
|
fills: [solid({ r: 0.24, g: 0.46, b: 1, a: 1 })]
|
||||||
|
})
|
||||||
|
const glowShape = store.createShape('RECTANGLE', 70, 22, 96, 60, effectBlendCard)
|
||||||
|
graph.updateNode(glowShape, {
|
||||||
|
name: 'Screen Shadow Blend',
|
||||||
|
cornerRadius: 22,
|
||||||
|
cornerSmoothing: 0.85,
|
||||||
|
fills: [solid({ r: 0.58, g: 0.27, b: 0.95, a: 0.7 })],
|
||||||
|
effects: [{ ...dropShadow(0, 0, 28, 0, { r: 0.56, g: 0.33, b: 1, a: 0.72 }), blendMode: 'SCREEN' }]
|
||||||
|
})
|
||||||
|
const effectBlendText = store.createShape('TEXT', 184, 34, 88, 34, effectBlendCard)
|
||||||
|
graph.updateNode(effectBlendText, {
|
||||||
|
name: 'Label',
|
||||||
|
text: 'Screen\nshadow',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 17,
|
||||||
|
fills: [solid(DEMO_COLORS.white)]
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ function createRenderer() {
|
||||||
addRect: ReturnType<typeof mock>
|
addRect: ReturnType<typeof mock>
|
||||||
moveTo: ReturnType<typeof mock>
|
moveTo: ReturnType<typeof mock>
|
||||||
lineTo: ReturnType<typeof mock>
|
lineTo: ReturnType<typeof mock>
|
||||||
|
cubicTo: ReturnType<typeof mock>
|
||||||
|
arcToRotated: ReturnType<typeof mock>
|
||||||
close: ReturnType<typeof mock>
|
close: ReturnType<typeof mock>
|
||||||
delete: ReturnType<typeof mock>
|
delete: ReturnType<typeof mock>
|
||||||
}> = []
|
}> = []
|
||||||
|
|
@ -23,6 +25,8 @@ function createRenderer() {
|
||||||
addRect = mock(() => undefined)
|
addRect = mock(() => undefined)
|
||||||
moveTo = mock(() => undefined)
|
moveTo = mock(() => undefined)
|
||||||
lineTo = mock(() => undefined)
|
lineTo = mock(() => undefined)
|
||||||
|
cubicTo = mock(() => undefined)
|
||||||
|
arcToRotated = mock(() => undefined)
|
||||||
close = mock(() => undefined)
|
close = mock(() => undefined)
|
||||||
delete = mock(() => undefined)
|
delete = mock(() => undefined)
|
||||||
|
|
||||||
|
|
@ -51,7 +55,7 @@ function createCanvas() {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('canvas corner smoothing', () => {
|
describe('canvas corner smoothing', () => {
|
||||||
test('builds a superellipse-style path for smoothed rectangular corners', () => {
|
test('builds cubic paths for smoothed rectangular corners', () => {
|
||||||
const graph = new SceneGraph()
|
const graph = new SceneGraph()
|
||||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||||
width: 120,
|
width: 120,
|
||||||
|
|
@ -64,12 +68,37 @@ describe('canvas corner smoothing', () => {
|
||||||
makeSmoothRRectPath(renderer, node).delete()
|
makeSmoothRRectPath(renderer, node).delete()
|
||||||
|
|
||||||
expect(paths).toHaveLength(1)
|
expect(paths).toHaveLength(1)
|
||||||
expect(paths[0].moveTo).toHaveBeenCalledWith(24, 0)
|
expect(paths[0].moveTo).toHaveBeenCalledWith(80, 0)
|
||||||
expect(paths[0].lineTo).toHaveBeenCalledWith(96, 0)
|
expect(paths[0].lineTo).toHaveBeenCalledWith(120, 40)
|
||||||
expect(paths[0].lineTo).toHaveBeenCalledTimes(52)
|
expect(paths[0].arcToRotated).toHaveBeenCalledTimes(4)
|
||||||
|
expect(paths[0].cubicTo).toHaveBeenCalledTimes(8)
|
||||||
|
expect(paths[0].lineTo).toHaveBeenCalledTimes(3)
|
||||||
expect(paths[0].close).toHaveBeenCalled()
|
expect(paths[0].close).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('supports independent smoothed corner radii', () => {
|
||||||
|
const graph = new SceneGraph()
|
||||||
|
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||||
|
width: 120,
|
||||||
|
height: 80,
|
||||||
|
independentCorners: true,
|
||||||
|
topLeftRadius: 28,
|
||||||
|
topRightRadius: 12,
|
||||||
|
bottomRightRadius: 32,
|
||||||
|
bottomLeftRadius: 0,
|
||||||
|
cornerSmoothing: 1
|
||||||
|
})
|
||||||
|
const { renderer, paths } = createRenderer()
|
||||||
|
|
||||||
|
makeSmoothRRectPath(renderer, node).delete()
|
||||||
|
|
||||||
|
expect(paths).toHaveLength(1)
|
||||||
|
expect(paths[0].moveTo).toHaveBeenCalledWith(98.18181818181819, 0)
|
||||||
|
expect(paths[0].arcToRotated).toHaveBeenCalledTimes(3)
|
||||||
|
expect(paths[0].cubicTo).toHaveBeenCalledTimes(6)
|
||||||
|
expect(paths[0].lineTo).toHaveBeenCalledWith(0, 80)
|
||||||
|
})
|
||||||
|
|
||||||
test('draws smoothed rectangle fills as paths instead of regular rrects', () => {
|
test('draws smoothed rectangle fills as paths instead of regular rrects', () => {
|
||||||
const graph = new SceneGraph()
|
const graph = new SceneGraph()
|
||||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue