diff --git a/packages/core/src/canvas/shadows.ts b/packages/core/src/canvas/shadows.ts index 75812239d..08066e34f 100644 --- a/packages/core/src/canvas/shadows.ts +++ b/packages/core/src/canvas/shadows.ts @@ -1,6 +1,8 @@ import type { Canvas, Path } from 'canvaskit-wasm' +import { BLACK } from '#core/constants' import type { SceneNode } from '#core/scene-graph' +import type { Color, Vector } from '#core/types' import { figmaBlendModeToSkia } from './blend' import type { SkiaRenderer } from './renderer' @@ -11,12 +13,74 @@ import { nodeHasSmoothCorners } from './shapes' +interface RawNoiseEffect { + type: 'NOISE' + visible?: boolean + color?: Color + secondaryColor?: Color + opacity?: number + density?: number + noiseSize?: Vector + noiseType?: 'MONOTONE' | 'DUOTONE' | 'MULTITONE' +} + function resetEffectLayerPaint(r: SkiaRenderer): void { r.effectLayerPaint.setImageFilter(null) r.effectLayerPaint.setColorFilter(null) r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver) } +function rawNoiseEffects(node: SceneNode): RawNoiseEffect[] { + const source = (node as Partial).source + const effects = source?.fig.rawNodeFields.effects + if (!Array.isArray(effects)) return [] + return effects.filter( + (effect): effect is RawNoiseEffect => + effect !== null && + typeof effect === 'object' && + 'type' in effect && + effect.type === 'NOISE' && + ('visible' in effect ? effect.visible !== false : true) + ) +} + +function seededNoise(seed: number): number { + const x = Math.sin(seed * 12.9898) * 43758.5453 + return x - Math.floor(x) +} + +function renderNoiseEffect( + r: SkiaRenderer, + canvas: Canvas, + node: SceneNode, + rect: Float32Array, + hasRadius: boolean, + effect: RawNoiseEffect +): void { + const density = Math.max(0, Math.min(1, effect.density ?? 0.3)) + const step = Math.max(2, Math.round((effect.noiseSize?.x ?? 0.5) * 8)) + const color = effect.color ?? BLACK + const opacity = effect.opacity ?? color.a + const paint = new r.ck.Paint() + try { + paint.setAntiAlias(false) + canvas.save() + r.clipNodeShape(canvas, node, rect, hasRadius) + for (let y = 0; y < node.height; y += step) { + for (let x = 0; x < node.width; x += step) { + const value = seededNoise((x + 1) * 73856093 + (y + 1) * 19349663) + if (value > density) continue + const alpha = Math.max(0, Math.min(1, opacity * (0.35 + value * 0.65))) + paint.setColor(r.ck.Color4f(color.r, color.g, color.b, alpha)) + canvas.drawRect(r.ck.LTRBRect(x, y, Math.min(node.width, x + 1), Math.min(node.height, y + 1)), paint) + } + } + canvas.restore() + } finally { + paint.delete() + } +} + function drawChildTransform(canvas: Canvas, child: SceneNode, offset = { x: 0, y: 0 }): void { canvas.translate(child.x + offset.x, child.y + offset.y) if (child.rotation !== 0) { @@ -458,6 +522,12 @@ export function renderEffects( pass: 'behind' | 'front', shadowShapeChild?: SceneNode | null ): void { + if (pass === 'front') { + for (const effect of rawNoiseEffects(node)) { + renderNoiseEffect(r, canvas, node, rect, hasRadius, effect) + } + } + for (const effect of node.effects) { if (!effect.visible) continue diff --git a/tests/engine/render/canvas/effects/helpers.ts b/tests/engine/render/canvas/effects/helpers.ts index fd8af66a5..fa5cb5e69 100644 --- a/tests/engine/render/canvas/effects/helpers.ts +++ b/tests/engine/render/canvas/effects/helpers.ts @@ -33,6 +33,7 @@ export function createMockRenderer(overrides: Partial = {}): SkiaR StrokeJoin: { Round: 0 }, Matrix: { translated: mock(() => new Float32Array(9)) }, BlendMode: { SrcOver: 0, SrcIn: 1, DstOut: 2, Screen: 3, Multiply: 4 }, + PaintStyle: { Fill: 0, Stroke: 1 }, ColorType: { RGBA_8888: 0 }, AlphaType: { Premul: 0, Unpremul: 1 }, ColorSpace: { SRGB: 0 }, @@ -43,8 +44,10 @@ export function createMockRenderer(overrides: Partial = {}): SkiaR WHITE: new Float32Array([1, 1, 1, 1]), TRANSPARENT: new Float32Array([0, 0, 0, 0]), Paint: class { + setAntiAlias = mock(() => undefined) setColor = mock(() => undefined) setShader = mock(() => undefined) + setStyle = mock(() => undefined) delete = mock(() => undefined) }, ColorFilter: { @@ -117,6 +120,7 @@ export function createMockRenderer(overrides: Partial = {}): SkiaR }), renderText: mock(() => undefined), applyClippedBlur: mock(() => undefined), + clipNodeShape: mock(() => undefined), applyFill: mock(() => true), renderShape: mock(() => undefined), renderSection: mock(() => undefined), @@ -145,6 +149,7 @@ export function createMockCanvas() { scale: mock(() => undefined), drawOval: mock(() => undefined), drawRRect: mock(() => undefined), + drawCircle: mock(() => undefined), drawRect: mock(() => undefined), drawPath: mock(() => undefined), saveLayer: mock(() => undefined), diff --git a/tests/engine/render/canvas/effects/types.test.ts b/tests/engine/render/canvas/effects/types.test.ts index 38dba29fe..5eb953be2 100644 --- a/tests/engine/render/canvas/effects/types.test.ts +++ b/tests/engine/render/canvas/effects/types.test.ts @@ -132,6 +132,58 @@ describe('Renderer handles all effect types (Behavioral)', () => { expect(canvas.drawPath).toHaveBeenCalled() }) + test('renders raw Figma noise effects as a bounded visual fallback', () => { + const r = createMockRenderer() + const canvas = createMockCanvas() + const node: Partial = { + type: 'RECTANGLE', + width: 20, + height: 20, + fills: [], + childIds: [], + effects: [], + source: { + format: 'fig', + id: '1:1', + orderKey: null, + fig: { + rawSize: null, + rawTransform: null, + rawNodeFields: { + effects: [ + { + type: 'NOISE', + visible: true, + color: { r: 0, g: 0, b: 0, a: 1 }, + density: 1, + noiseSize: { x: 0.25, y: 0.25 }, + noiseType: 'MONOTONE' + } + ] + }, + layout: null, + symbolOverrides: [], + componentPropAssignments: [], + derivedSymbolData: [], + derivedSymbolDataLayoutVersion: null, + uniformScaleFactor: null + } + } + } + + renderEffects( + r, + canvas as Canvas, + node as SceneNode, + new Float32Array([0, 0, 20, 20]), + false, + 'front' + ) + + expect(r.clipNodeShape).toHaveBeenCalled() + expect(canvas.drawRect).toHaveBeenCalled() + }) + test('handles BACKGROUND_BLUR', () => { const r = createMockRenderer() const canvas = createMockCanvas()