diff --git a/CHANGELOG.md b/CHANGELOG.md index 8755db272..486547283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ - 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 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 diff --git a/packages/core/src/canvas/shapes.ts b/packages/core/src/canvas/shapes.ts index 4085d6b33..dc5d18344 100644 --- a/packages/core/src/canvas/shapes.ts +++ b/packages/core/src/canvas/shapes.ts @@ -18,11 +18,180 @@ export function nodeHasRadius(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 { - return Math.max(0, Math.min(radius, width / 2, height / 2)) +type SmoothCornerKey = 'topLeft' | 'topRight' | 'bottomRight' | 'bottomLeft' + +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 { + const radius = (value: number) => Math.max(0, value + spread) + const radii: Record = 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 = { + topLeft: -1, + topRight: -1, + bottomRight: -1, + bottomLeft: -1 + } + const adjacentByCorner: Record> = { + 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( @@ -37,37 +206,38 @@ export function makeSmoothRRectPath( const top = offsetY - spread const right = offsetX + node.width + spread const bottom = offsetY + node.height + spread - const radius = clampCornerRadius(right - left, bottom - top, node.cornerRadius + spread) - - if (radius === 0) { - path.addRect(r.ck.LTRBRect(left, top, right, bottom)) + const width = right - left + const height = bottom - top + if (width <= 0 || height <= 0) { + path.addRect(r.ck.LTRBRect(left, top, Math.max(left, right), Math.max(top, bottom))) return path } const smoothing = Math.max(0, Math.min(node.cornerSmoothing, 1)) - const exponent = 2 + smoothing * 3 - const samples = 12 - const addCorner = (cx: number, cy: number, startAngle: number, endAngle: number) => { - for (let i = 1; i <= samples; i++) { - const t = i / samples - const angle = startAngle + (endAngle - startAngle) * t - const cos = Math.cos(angle) - const sin = Math.sin(angle) - const x = cx + Math.sign(cos) * radius * Math.abs(cos) ** (2 / exponent) - const y = cy + Math.sign(sin) * radius * Math.abs(sin) ** (2 / exponent) - path.lineTo(x, y) - } + const corners = smoothCornerRadii(node, width, height, spread) + const topLeftCorner = smoothCornerPathParams(corners.topLeft, smoothing) + const topRightCorner = smoothCornerPathParams(corners.topRight, smoothing) + const bottomRightCorner = smoothCornerPathParams(corners.bottomRight, smoothing) + const bottomLeftCorner = smoothCornerPathParams(corners.bottomLeft, smoothing) + + if ( + topLeftCorner.radius === 0 && + topRightCorner.radius === 0 && + bottomRightCorner.radius === 0 && + bottomLeftCorner.radius === 0 + ) { + path.addRect(r.ck.LTRBRect(left, top, right, bottom)) + return path } - path.moveTo(left + radius, top) - path.lineTo(right - radius, top) - addCorner(right - radius, top + radius, -Math.PI / 2, 0) - path.lineTo(right, bottom - radius) - addCorner(right - radius, bottom - radius, 0, Math.PI / 2) - path.lineTo(left + radius, bottom) - addCorner(left + radius, bottom - radius, Math.PI / 2, Math.PI) - path.lineTo(left, top + radius) - addCorner(left + radius, top + radius, Math.PI, (Math.PI * 3) / 2) + path.moveTo(right - topRightCorner.p, top) + drawTopRightSmoothCorner(path, topRightCorner, right - topRightCorner.p, top) + path.lineTo(right, bottom - bottomRightCorner.p) + drawBottomRightSmoothCorner(path, bottomRightCorner, right, bottom - bottomRightCorner.p) + path.lineTo(left + bottomLeftCorner.p, bottom) + drawBottomLeftSmoothCorner(path, bottomLeftCorner, left + bottomLeftCorner.p, bottom) + path.lineTo(left, top + topLeftCorner.p) + drawTopLeftSmoothCorner(path, topLeftCorner, left, top + topLeftCorner.p) path.close() return path } diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index 8742069e5..27d8ab913 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -111,7 +111,7 @@ Figma's design documentation groups features into these areas: | Frames | ✅ | ✅ | ✅ | ✅ | ✅ | Includes clipping and auto-layout container behavior. | | Groups | ✅ | ✅ | ✅ | ✅ | ✅ | Grouping preserves visual positions. | | 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. | | Lines | ✅ | ✅ | ✅ | ✅ | ✅ | Stroke caps/joins render but are not fully exposed in UI. | | 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: 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. 4. **Pattern fills/strokes** — support Figma pattern fills and their transforms. 5. **Font variations** — apply variable-font axes from imported Figma metadata. diff --git a/src/app/demo/sections/effects.ts b/src/app/demo/sections/effects.ts index 02931af51..7d29fee3f 100644 --- a/src/app/demo/sections/effects.ts +++ b/src/app/demo/sections/effects.ts @@ -5,7 +5,7 @@ import type { EditorStore } from '@/app/editor/session' export function createEffectsSection(store: EditorStore) { const { graph } = store - const effectsSectionId = store.createShape('SECTION', 60, 840, 920, 640) + const effectsSectionId = store.createShape('SECTION', 60, 840, 920, 780) graph.updateNode(effectsSectionId, { name: 'Effects', fills: [solid(DEMO_COLORS.white)] @@ -328,4 +328,100 @@ export function createEffectsSection(store: EditorStore) { rotation: -20, 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)] + }) } diff --git a/tests/engine/render/canvas/corner-smoothing.test.ts b/tests/engine/render/canvas/corner-smoothing.test.ts index 2e7681d6b..9e232c9d9 100644 --- a/tests/engine/render/canvas/corner-smoothing.test.ts +++ b/tests/engine/render/canvas/corner-smoothing.test.ts @@ -15,6 +15,8 @@ function createRenderer() { addRect: ReturnType moveTo: ReturnType lineTo: ReturnType + cubicTo: ReturnType + arcToRotated: ReturnType close: ReturnType delete: ReturnType }> = [] @@ -23,6 +25,8 @@ function createRenderer() { addRect = mock(() => undefined) moveTo = mock(() => undefined) lineTo = mock(() => undefined) + cubicTo = mock(() => undefined) + arcToRotated = mock(() => undefined) close = mock(() => undefined) delete = mock(() => undefined) @@ -51,7 +55,7 @@ function createCanvas() { } 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 node = graph.createNode('RECTANGLE', pageId(graph), { width: 120, @@ -64,12 +68,37 @@ describe('canvas corner smoothing', () => { makeSmoothRRectPath(renderer, node).delete() expect(paths).toHaveLength(1) - expect(paths[0].moveTo).toHaveBeenCalledWith(24, 0) - expect(paths[0].lineTo).toHaveBeenCalledWith(96, 0) - expect(paths[0].lineTo).toHaveBeenCalledTimes(52) + expect(paths[0].moveTo).toHaveBeenCalledWith(80, 0) + expect(paths[0].lineTo).toHaveBeenCalledWith(120, 40) + expect(paths[0].arcToRotated).toHaveBeenCalledTimes(4) + expect(paths[0].cubicTo).toHaveBeenCalledTimes(8) + expect(paths[0].lineTo).toHaveBeenCalledTimes(3) 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', () => { const graph = new SceneGraph() const node = graph.createNode('RECTANGLE', pageId(graph), {