From 4a21b28ed032ad6619f8709fda2d4894cd780c29 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 3 Jul 2026 18:00:28 +0200 Subject: [PATCH] fix(masks): render layer masks --- renderer/layers.ts | 40 +++++++++++++++++++++++++++++++++------ view/exportArtboardPng.ts | 23 +++++++++++++++------- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/renderer/layers.ts b/renderer/layers.ts index 63a835b..4cc10e4 100644 --- a/renderer/layers.ts +++ b/renderer/layers.ts @@ -14,7 +14,8 @@ export function renderLayers(context: WebGlRendererContext, document: ImageDocum for (const artboard of document.artboards) { if (!artboard.visible) continue; const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, viewport); - for (const layer of artboard.layers) renderLayer(context, document, viewport, layer, imageTextureRenderer, clipRect); + const maskLayerIds = collectMaskLayerIds(artboard.layers); + for (const layer of artboard.layers) renderLayer(context, document, viewport, layer, imageTextureRenderer, clipRect, maskLayerIds); } } @@ -25,11 +26,15 @@ function renderLayer( layer: Layer, imageTextureRenderer: ImageTextureRenderer, clipRect: ScreenRect, + maskLayerIds: ReadonlySet, ) { - if (!layer.visible) return; + if (!layer.visible || maskLayerIds.has(layer.id)) return; + + const effectiveClipRect = resolveLayerClipRect(context, document, viewport, layer, clipRect); + if (!effectiveClipRect) return; if (layer.type === "group") { - for (const child of layer.children) renderLayer(context, document, viewport, child, imageTextureRenderer, clipRect); + for (const child of layer.children) renderLayer(context, document, viewport, child, imageTextureRenderer, effectiveClipRect, maskLayerIds); return; } @@ -38,15 +43,38 @@ function renderLayer( const rect = documentRectToScreenRect(context.canvas, bounds, viewport); const asset = document.assets.find((candidate) => candidate.id === layer.assetId); - if (asset && imageTextureRenderer.render(asset, rect, clipRect)) return; + if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect)) return; - const fallbackRect = intersectScreenRects(rect, clipRect); + const fallbackRect = intersectScreenRects(rect, effectiveClipRect); if (!fallbackRect) return; clearScreenRect(context, fallbackRect, imageLayerColor); - const insetRect = intersectScreenRects({ x: rect.x + 4, y: rect.y + 4, w: Math.max(0, rect.w - 8), h: Math.max(0, rect.h - 8) }, clipRect); + const insetRect = intersectScreenRects({ x: rect.x + 4, y: rect.y + 4, w: Math.max(0, rect.w - 8), h: Math.max(0, rect.h - 8) }, effectiveClipRect); if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor); } +function resolveLayerClipRect( + context: WebGlRendererContext, + document: ImageDocument, + viewport: ViewportState, + layer: Layer, + clipRect: ScreenRect, +): ScreenRect | undefined { + if (!layer.clippingMask) return clipRect; + + const maskBounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.clippingMask.maskLayerId }); + if (!maskBounds) return undefined; + + return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport)); +} + +function collectMaskLayerIds(layers: readonly Layer[], ids = new Set()) { + for (const layer of layers) { + if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId); + if (layer.type === "group") collectMaskLayerIds(layer.children, ids); + } + return ids; +} + function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefined { const x1 = Math.max(a.x, b.x); const y1 = Math.max(a.y, b.y); diff --git a/view/exportArtboardPng.ts b/view/exportArtboardPng.ts index 473bdae..de4238b 100644 --- a/view/exportArtboardPng.ts +++ b/view/exportArtboardPng.ts @@ -18,9 +18,10 @@ export async function downloadArtboardPng(artboard: Artboard, assets: readonly A context.fillRect(0, 0, width, height); } + const maskLayerIds = collectMaskLayerIds(artboard.layers); context.save(); context.translate(-artboard.bounds.x, -artboard.bounds.y); - for (const layer of artboard.layers) await drawLayer(context, layer, artboard.layers, assets, artboard.bounds); + for (const layer of artboard.layers) await drawLayer(context, layer, artboard.layers, assets, artboard.bounds, { maskLayerIds }); context.restore(); const url = canvas.toDataURL("image/png"); @@ -36,11 +37,11 @@ async function drawLayer( layerTree: readonly Layer[], assets: readonly Asset[], artboardBounds: Rect, - ignoreOwnMask = false, + options: { maskLayerIds: ReadonlySet; ignoreOwnMask?: boolean }, ) { - if (!layer.visible) return; + if (!layer.visible || (!options.ignoreOwnMask && options.maskLayerIds.has(layer.id))) return; - if (!ignoreOwnMask && layer.clippingMask) { + if (!options.ignoreOwnMask && layer.clippingMask) { const maskLayer = findLayer(layerTree, layer.clippingMask.maskLayerId); if (!maskLayer) return; await drawMaskedLayer(context, layer, maskLayer, layerTree, assets, artboardBounds); @@ -51,7 +52,7 @@ async function drawLayer( context.globalAlpha *= layer.opacity; if (layer.type === "group") { - for (const child of layer.children) await drawLayer(context, child, layerTree, assets, artboardBounds); + for (const child of layer.children) await drawLayer(context, child, layerTree, assets, artboardBounds, options); context.restore(); return; } @@ -83,12 +84,12 @@ async function drawMaskedLayer( ) { const layerCanvas = createArtboardCanvas(artboardBounds); const layerContext = translatedContext(layerCanvas, artboardBounds); - await drawLayer(layerContext, layer, layerTree, assets, artboardBounds, true); + await drawLayer(layerContext, layer, layerTree, assets, artboardBounds, { maskLayerIds: new Set(), ignoreOwnMask: true }); layerContext.restore(); const maskCanvas = createArtboardCanvas(artboardBounds); const maskContext = translatedContext(maskCanvas, artboardBounds); - await drawLayer(maskContext, maskLayer, layerTree, assets, artboardBounds, true); + await drawLayer(maskContext, maskLayer, layerTree, assets, artboardBounds, { maskLayerIds: new Set(), ignoreOwnMask: true }); maskContext.restore(); const compositeContext = layerCanvas.getContext("2d"); @@ -115,6 +116,14 @@ function translatedContext(canvas: HTMLCanvasElement, bounds: Rect) { return context; } +function collectMaskLayerIds(layers: readonly Layer[], ids = new Set()) { + for (const layer of layers) { + if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId); + if (layer.type === "group") collectMaskLayerIds(layer.children, ids); + } + return ids; +} + function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined { for (const layer of layers) { if (layer.id === layerId) return layer;