fix(canvas): render raw noise effect fallback
This commit is contained in:
parent
431ab2247f
commit
033326cffb
|
|
@ -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<SceneNode>).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
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export function createMockRenderer(overrides: Partial<SkiaRenderer> = {}): 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<SkiaRenderer> = {}): 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<SkiaRenderer> = {}): 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),
|
||||
|
|
|
|||
|
|
@ -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<SceneNode> = {
|
||||
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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue