fix(renderer): RenderNode.clipRect → clipStack so each ancestor rrect is preserved

Single ClipInfo can't faithfully encode `(rrect ∩ rrect)` whenever one rect
cuts inside the other's corner. The previous fix collapsed nested clips
into one ClipInfo and dropped one side's rounded corner — which meant a
rounded modal containing rounded cards would silently lose either the
modal's rounding or the card's rounding at paint time.

Fix: replace the single `RenderNode.clipRect: ClipInfo | undefined` with
`clipStack: ClipInfo[]`. Flatten time accumulates a stack from outer-most
ancestor down to the immediate clip-introducing parent. Paint time pushes
each entry as its own canvas.save+clipRect/clipRRect — Skia's clip stack
intersects them naturally, so each level's rounded corner is enforced
independently.

Touched:
- types.ts: export ClipInfo, replace clipRect with clipStack
- document-flattener.ts: thread `clipStack: ClipInfo[]` through recursion;
  push to a copy when isRootFrame || explicitClip
- node-renderer.ts paint: loop over clipStack, push N save+clip ops, pop
  the same N at the end
- renderer.ts (root frame label loop) + skia-engine.ts (root frame label
  loop) + focus-fit.ts (auto-fit excludes clipped descendants) +
  global-export.ts (page bounds): all check clipStack.length instead of
  truthy single field
- skia-interaction.ts: drag/resize/rotate snapshots store and restore
  clipStack arrays (deep-cloned per entry)
- Tests updated + 1 new test: rounded modal containing rounded card
  preserves both rrects on the inner content's clip stack
This commit is contained in:
Fini 2026-04-29 09:50:41 +08:00
parent 220a92c501
commit 4aa59677be
11 changed files with 194 additions and 158 deletions

View file

@ -65,7 +65,7 @@ describe('focus fit helpers', () => {
absY: 60,
absW: 120,
absH: 90,
clipRect: { x: 0, y: 0, w: 1000, h: 800, rx: 0 },
clipStack: [{ x: 0, y: 0, w: 1000, h: 800, rx: 0 }],
}),
];

View file

@ -329,7 +329,7 @@ describe('SkiaInteractionManager continuous interaction commits', () => {
absY: 60,
absW: 200,
absH: 120,
clipRect: { x: 45, y: 55, w: 210, h: 130, rx: 8 },
clipStack: [{ x: 45, y: 55, w: 210, h: 130, rx: 8 }],
};
const engine = createEngineStub([renderNode]);
const manager = new SkiaInteractionManager(
@ -347,7 +347,7 @@ describe('SkiaInteractionManager continuous interaction commits', () => {
expect(renderNode.absX).toBe(70);
expect(renderNode.absY).toBe(75);
expect(renderNode.clipRect).toMatchObject({ x: 65, y: 70, w: 210, h: 130, rx: 8 });
expect(renderNode.clipStack).toEqual([{ x: 65, y: 70, w: 210, h: 130, rx: 8 }]);
expect(engine.rebuildCount).toBeGreaterThan(0);
expect(engine.dirtyCount).toBeGreaterThan(0);
});

View file

@ -23,7 +23,9 @@ export function getFocusBounds(
const selectedSet = new Set(selectedIds);
const selectedNodes = renderNodes.filter((rn) => selectedSet.has(rn.node.id));
const targetNodes =
selectedNodes.length > 0 ? selectedNodes : renderNodes.filter((rn) => !rn.clipRect);
selectedNodes.length > 0
? selectedNodes
: renderNodes.filter((rn) => rn.clipStack === undefined || rn.clipStack.length === 0);
if (targetNodes.length === 0) return null;

View file

@ -421,7 +421,8 @@ export class SkiaEngine {
canvas.scale(dpr, dpr);
for (const rn of this.renderNodes) {
if (!rn.node.name) continue;
const isRootFrame = rn.node.type === 'frame' && !rn.clipRect;
const isRootFrame =
rn.node.type === 'frame' && (rn.clipStack === undefined || rn.clipStack.length === 0);
const isReusable = this.reusableIds.has(rn.node.id);
const isInstance = this.instanceIds.has(rn.node.id);
if (!isRootFrame && !isReusable && !isInstance) continue;

View file

@ -57,7 +57,7 @@ interface RenderNodeSnapshot {
absY: number;
absW: number;
absH: number;
clipRect?: { x: number; y: number; w: number; h: number; rx: number };
clipStack?: { x: number; y: number; w: number; h: number; rx: number }[];
}
interface PreviewRect {
@ -523,7 +523,9 @@ export class SkiaInteractionManager {
absY: rn.absY,
absW: rn.absW,
absH: rn.absH,
...(rn.clipRect ? { clipRect: { ...rn.clipRect } } : {}),
...(rn.clipStack && rn.clipStack.length > 0
? { clipStack: rn.clipStack.map((c) => ({ ...c })) }
: {}),
});
}
return snapshots;
@ -647,20 +649,15 @@ export class SkiaInteractionManager {
rootRn.absY = nextRootRect.y;
rootRn.absW = nextRootRect.w;
rootRn.absH = nextRootRect.h;
rootRn.clipRect = rootSnapshot.clipRect
? {
rootRn.clipStack = rootSnapshot.clipStack
? rootSnapshot.clipStack.map((clip) => ({
...this.scalePreviewRect(
{
x: rootSnapshot.clipRect.x,
y: rootSnapshot.clipRect.y,
w: rootSnapshot.clipRect.w,
h: rootSnapshot.clipRect.h,
},
{ x: clip.x, y: clip.y, w: clip.w, h: clip.h },
sourceRect,
nextRootRect,
),
rx: rootSnapshot.clipRect.rx * Math.min(scaleX, scaleY),
}
rx: clip.rx * Math.min(scaleX, scaleY),
}))
: undefined;
for (const [id, snapshot] of previewNodes) {
@ -677,20 +674,15 @@ export class SkiaInteractionManager {
rn.absY = scaled.y;
rn.absW = scaled.w;
rn.absH = scaled.h;
rn.clipRect = snapshot.clipRect
? {
rn.clipStack = snapshot.clipStack
? snapshot.clipStack.map((clip) => ({
...this.scalePreviewRect(
{
x: snapshot.clipRect.x,
y: snapshot.clipRect.y,
w: snapshot.clipRect.w,
h: snapshot.clipRect.h,
},
{ x: clip.x, y: clip.y, w: clip.w, h: clip.h },
sourceRect,
nextRootRect,
),
rx: snapshot.clipRect.rx * Math.min(scaleX, scaleY),
}
rx: clip.rx * Math.min(scaleX, scaleY),
}))
: undefined;
}
@ -726,7 +718,9 @@ export class SkiaInteractionManager {
rootRn.absY = rootSnapshot.absY;
rootRn.absW = rootSnapshot.absW;
rootRn.absH = rootSnapshot.absH;
rootRn.clipRect = rootSnapshot.clipRect ? { ...rootSnapshot.clipRect } : undefined;
rootRn.clipStack = rootSnapshot.clipStack
? rootSnapshot.clipStack.map((c) => ({ ...c }))
: undefined;
const centerX = rootSnapshot.absX + rootSnapshot.absW / 2;
const centerY = rootSnapshot.absY + rootSnapshot.absH / 2;
@ -750,21 +744,16 @@ export class SkiaInteractionManager {
rn.absY = rotated.y;
rn.absW = rotated.w;
rn.absH = rotated.h;
rn.clipRect = snapshot.clipRect
? {
rn.clipStack = snapshot.clipStack
? snapshot.clipStack.map((clip) => ({
...this.rotatePreviewRect(
{
x: snapshot.clipRect.x,
y: snapshot.clipRect.y,
w: snapshot.clipRect.w,
h: snapshot.clipRect.h,
},
{ x: clip.x, y: clip.y, w: clip.w, h: clip.h },
centerX,
centerY,
angleDelta,
),
rx: snapshot.clipRect.rx,
}
rx: clip.rx,
}))
: undefined;
}
@ -919,12 +908,12 @@ export class SkiaInteractionManager {
if (this.dragAllIds!.has(rn.node.id)) {
rn.absX += incrDx;
rn.absY += incrDy;
if (rn.clipRect) {
rn.clipRect = {
...rn.clipRect,
x: rn.clipRect.x + incrDx,
y: rn.clipRect.y + incrDy,
};
if (rn.clipStack && rn.clipStack.length > 0) {
rn.clipStack = rn.clipStack.map((c) => ({
...c,
x: c.x + incrDx,
y: c.y + incrDy,
}));
}
rn.node = { ...rn.node, x: rn.absX, y: rn.absY };
}

View file

@ -100,7 +100,7 @@ function renderPageToImage(
maxX = -Infinity,
maxY = -Infinity;
for (const rn of renderNodes) {
if (rn.clipRect) continue;
if (rn.clipStack && rn.clipStack.length > 0) continue;
if (rn.absX < minX) minX = rn.absX;
if (rn.absY < minY) minY = rn.absY;
if (rn.absX + rn.absW > maxX) maxX = rn.absX + rn.absW;

View file

@ -223,9 +223,9 @@ describe('flattenToRenderNodes — dimension consistency', () => {
expect(t2.absY).toBeGreaterThan(t1.absY + t1.absH);
});
it('root frame clipRect matches absW/absH, not a divergent nodeW/nodeH', () => {
// Root frame (depth=0) creates a clipRect for its children.
// clipRect must use the same dimensions as the RenderNode's absW/absH.
it('root frame clip matches absW/absH, not a divergent nodeW/nodeH', () => {
// Root frame (depth=0) creates a clipStack entry for its children.
// The clip rect must use the same dimensions as the RenderNode's absW/absH.
const root = frame({
id: 'root',
width: 400,
@ -239,20 +239,21 @@ describe('flattenToRenderNodes — dimension consistency', () => {
const rootRN = nodes.find((rn) => rn.node.id === 'root')!;
const t1 = nodes.find((rn) => rn.node.id === 't1')!;
// Root frame itself has no clipRect (it IS the clip source)
expect(rootRN.clipRect).toBeUndefined();
// Root frame itself has empty clip stack (it IS the clip source for children)
expect(rootRN.clipStack).toBeUndefined();
// Child inherits root's clip — must match root's rendered dimensions
expect(t1.clipRect).toBeDefined();
expect(t1.clipRect!.w).toBe(rootRN.absW);
expect(t1.clipRect!.h).toBe(rootRN.absH);
expect(t1.clipRect!.x).toBe(rootRN.absX);
expect(t1.clipRect!.y).toBe(rootRN.absY);
// Child inherits root's clip — single entry on the stack
expect(t1.clipStack).toBeDefined();
expect(t1.clipStack!.length).toBe(1);
expect(t1.clipStack![0].w).toBe(rootRN.absW);
expect(t1.clipStack![0].h).toBe(rootRN.absH);
expect(t1.clipStack![0].x).toBe(rootRN.absX);
expect(t1.clipStack![0].y).toBe(rootRN.absY);
});
it('root frame clipRect matches absW/absH for frame without explicit height', () => {
it('root frame clip matches absW/absH for frame without explicit height', () => {
// Frame with fit_content height — getNodeHeight computes from children.
// clipRect.h must equal the RenderNode's absH, not a stale fallback.
// The artboard clip must equal the RenderNode's absH, not a stale fallback.
const root = frame({
id: 'root',
width: 375,
@ -266,16 +267,15 @@ describe('flattenToRenderNodes — dimension consistency', () => {
const rootRN = nodes.find((rn) => rn.node.id === 'root')!;
const t1 = nodes.find((rn) => rn.node.id === 't1')!;
// absH should be computed from content, not 100 fallback
expect(rootRN.absH).toBeGreaterThan(0);
// clipRect must match absH
expect(t1.clipRect).toBeDefined();
expect(t1.clipRect!.h).toBe(rootRN.absH);
expect(t1.clipRect!.w).toBe(rootRN.absW);
expect(t1.clipStack).toBeDefined();
expect(t1.clipStack!.length).toBe(1);
expect(t1.clipStack![0].h).toBe(rootRN.absH);
expect(t1.clipStack![0].w).toBe(rootRN.absW);
});
it('nested frame with clipContent clips its descendants using its own bounds/radius', () => {
it('nested frame with clipContent appends its own clip on top of ancestor stack', () => {
const root = frame({
id: 'root',
width: 400,
@ -298,20 +298,67 @@ describe('flattenToRenderNodes — dimension consistency', () => {
const card = nodes.find((rn) => rn.node.id === 'card')!;
const inner = nodes.find((rn) => rn.node.id === 'inner')!;
expect(inner.clipRect).toBeDefined();
expect(inner.clipRect!.x).toBe(card.absX);
expect(inner.clipRect!.y).toBe(card.absY);
expect(inner.clipRect!.w).toBe(card.absW);
expect(inner.clipRect!.h).toBe(card.absH);
expect(inner.clipRect!.rx).toBe(16);
// inner has 2-level stack: outer = root frame's clip, then card's own clip
expect(inner.clipStack).toBeDefined();
expect(inner.clipStack!.length).toBe(2);
expect(inner.clipStack![1].x).toBe(card.absX);
expect(inner.clipStack![1].y).toBe(card.absY);
expect(inner.clipStack![1].w).toBe(card.absW);
expect(inner.clipStack![1].h).toBe(card.absH);
expect(inner.clipStack![1].rx).toBe(16);
});
it('nested clipContent intersects with ancestor clip (overflowing horizontal scroll row)', () => {
it('preserves each ancestor rrect (rounded modal containing rounded card)', () => {
// A rounded modal contains a rounded card. Both rrects must be enforced
// on inner content — a single ClipInfo can't encode `rrect ∩ rrect` so
// each level lives independently on the clipStack.
const root = frame({
id: 'root',
width: 600,
height: 600,
children: [
frame({
id: 'modal',
x: 100,
y: 100,
width: 400,
height: 400,
cornerRadius: 24,
clipContent: true,
children: [
frame({
id: 'card',
x: 20,
y: 20,
width: 360,
height: 80,
cornerRadius: 12,
clipContent: true,
children: [text('card-inner', 'Title')],
}),
],
}),
],
});
const nodes = flattenToRenderNodes([root]);
const inner = nodes.find((rn) => rn.node.id === 'card-inner')!;
// Inner sits under [root artboard, modal rrect, card rrect]
expect(inner.clipStack).toBeDefined();
expect(inner.clipStack!.length).toBe(3);
expect(inner.clipStack![0].rx).toBe(0); // root artboard (no cornerRadius)
expect(inner.clipStack![1].rx).toBe(24); // modal preserves its rounding
expect(inner.clipStack![2].rx).toBe(12); // card preserves its rounding
});
it('overflowing horizontal scroll: 3rd card stack ends with row + card own clip', () => {
// Reproduces the food-delivery brief regression: a horizontal scroll row
// contains 3 rounded-corner cards (each `clipContent: true`) whose total
// width exceeds the row's clip. Without intersect, the third card's
// children would clip to the card's own bounds (extending past the row's
// right edge) instead of staying inside the visible scroll strip.
// width exceeds the row's visible width. The renderer enforces the row's
// rectangular clip AND the card's rounded clip independently — the 3rd
// card's children paint within `(row clip) ∩ (card3 rrect clip)` even
// though card3's bounds extend past the row's right edge.
const root = frame({
id: 'root',
width: 400,
@ -358,24 +405,26 @@ describe('flattenToRenderNodes — dimension consistency', () => {
const c3 = nodes.find((rn) => rn.node.id === 'c3')!;
const c3inner = nodes.find((rn) => rn.node.id === 'c3-inner')!;
// Layout sanity: c1 at 0, c2 at 162, c3 at 324; c3 right edge = 474 > 400
// Layout sanity: c3 at 324 with right edge 474 > 400 (root width)
expect(c3.absX).toBe(324);
expect(c3.absW).toBe(150);
// c3 itself inherits the row's clipRect (rectangular, full row width)
expect(c3.clipRect).toBeDefined();
expect(c3.clipRect!.x).toBe(0);
expect(c3.clipRect!.w).toBe(400);
// c3 RN paints under [root, row] — 2 ancestors
expect(c3.clipStack).toBeDefined();
expect(c3.clipStack!.length).toBe(2);
// Row clip is full row width (400) at row position
expect(c3.clipStack![1].w).toBe(400);
// c3-inner's clipRect MUST be intersect(c3 bounds, row clip), not c3 alone
// c3 bounds {x:324, w:150} ∩ row clip {x:0, w:400} = {x:324, w:76}
// (without intersect, this would be {x:324, w:150} and the inner text
// would paint past the row's right edge)
expect(c3inner.clipRect).toBeDefined();
expect(c3inner.clipRect!.x).toBe(324);
expect(c3inner.clipRect!.w).toBe(76);
// Shrunk on x-axis → rounded corners would no longer follow both
// boundaries faithfully, so intersect drops to rectangular clip.
expect(c3inner.clipRect!.rx).toBe(0);
// c3-inner sits under [root, row, c3] — 3 levels, every rrect preserved
expect(c3inner.clipStack).toBeDefined();
expect(c3inner.clipStack!.length).toBe(3);
// Row clip is enforced as its OWN entry — not collapsed into c3's
expect(c3inner.clipStack![1].x).toBe(0);
expect(c3inner.clipStack![1].w).toBe(400);
expect(c3inner.clipStack![1].rx).toBe(0);
// c3 clip is enforced as its OWN entry — keeps its own bounds + rrect
expect(c3inner.clipStack![2].x).toBe(324);
expect(c3inner.clipStack![2].w).toBe(150);
expect(c3inner.clipStack![2].rx).toBe(16);
});
});

View file

@ -12,7 +12,7 @@ import {
cssFontFamily,
} from '@zseven-w/pen-core';
import { wrapLine } from './paint-utils.js';
import type { RenderNode } from './types.js';
import type { ClipInfo, RenderNode } from './types.js';
// ---------------------------------------------------------------------------
// Pre-measure text widths using Canvas 2D (browser fonts)
@ -124,14 +124,6 @@ export function premeasureTextHeights(nodes: PenNode[]): PenNode[] {
// Flatten document tree -> absolute-positioned RenderNode list
// ---------------------------------------------------------------------------
interface ClipInfo {
x: number;
y: number;
w: number;
h: number;
rx: number;
}
function sizeToNumber(val: number | string | undefined, fallback: number): number {
if (typeof val === 'number') return val;
if (typeof val === 'string') {
@ -149,36 +141,13 @@ function cornerRadiusVal(cr: number | [number, number, number, number] | undefin
return cr[0];
}
/**
* Intersect two axis-aligned clip rectangles. If `inner` (the new clip we
* want to apply) gets cut by `outer` (the ancestor clip we must respect),
* drop the rounded corners — a single ClipInfo can only encode one rounded
* rect, but the true `(rrect ∩ rect)` whenever the rect cuts inside the
* rrect's corner is non-trivial. Rectangular fallback is the safe option.
*
* `outer.rx` is irrelevant here because `outer` is already in effect from
* earlier draws — its rounded shape will continue to clip transparently
* via the ambient canvas clip stack at paint time.
*/
function intersectClip(inner: ClipInfo, outer: ClipInfo): ClipInfo {
const x = Math.max(inner.x, outer.x);
const y = Math.max(inner.y, outer.y);
const x2 = Math.min(inner.x + inner.w, outer.x + outer.w);
const y2 = Math.min(inner.y + inner.h, outer.y + outer.h);
const w = Math.max(0, x2 - x);
const h = Math.max(0, y2 - y);
const innerWasCut = w < inner.w || h < inner.h;
const rx = innerWasCut ? 0 : inner.rx;
return { x, y, w, h, rx };
}
export function flattenToRenderNodes(
nodes: PenNode[],
offsetX = 0,
offsetY = 0,
parentAvailW?: number,
parentAvailH?: number,
clipCtx?: ClipInfo,
clipStack: ClipInfo[] = [],
depth = 0,
): RenderNode[] {
const result: RenderNode[] = [];
@ -247,7 +216,7 @@ export function flattenToRenderNodes(
absY,
absW,
absH,
clipRect: clipCtx,
clipStack: clipStack.length > 0 ? clipStack.slice() : undefined,
});
// Recurse into children
@ -265,20 +234,19 @@ export function flattenToRenderNodes(
layout && layout !== 'none' ? computeLayoutPositions(resolved, children) : children;
// Clipping — root frames always clip like artboards. Nested containers
// clip only when clipContent is enabled. When a nested clip is
// introduced, intersect it with the inherited ancestor clip — otherwise
// a card with `clipContent: true` (e.g. for rounded image masking) would
// let its own children paint outside the row that's supposed to clip
// the whole horizontal scroll strip, since the card's bounds extend
// past the row's right edge in an overflowing row.
let childClip = clipCtx;
// clip only when clipContent is enabled. Each level pushes its own
// ClipInfo onto the stack rather than intersecting into a single
// ClipInfo, so each ancestor's rounded corner can be enforced
// independently at paint time (a single rrect can't encode `(rrect ∩
// rrect)` faithfully when one rect cuts inside the other's corner).
let childClipStack = clipStack;
const isRootFrame = node.type === 'frame' && depth === 0;
const explicitClip = 'clipContent' in resolved && resolved.clipContent === true;
if (isRootFrame || explicitClip) {
const crRaw = 'cornerRadius' in node ? cornerRadiusVal(node.cornerRadius) : 0;
const cr = Math.min(crRaw, nodeH / 2);
const own: ClipInfo = { x: absX, y: absY, w: nodeW, h: nodeH, rx: cr };
childClip = clipCtx ? intersectClip(own, clipCtx) : own;
childClipStack = [...clipStack, own];
}
const childRNs = flattenToRenderNodes(
@ -287,7 +255,7 @@ export function flattenToRenderNodes(
absY,
childAvailW,
childAvailH,
childClip,
childClipStack,
depth + 1,
);

View file

@ -480,7 +480,7 @@ export class SkiaNodeRenderer {
// ---------------------------------------------------------------------------
drawNode(canvas: Canvas, rn: RenderNode) {
const { node, absX, absY, absW, absH, clipRect } = rn;
const { node, absX, absY, absW, absH, clipStack } = rn;
const ck = this.ck;
const opacity = typeof node.opacity === 'number' ? node.opacity : 1;
@ -490,27 +490,33 @@ export class SkiaNodeRenderer {
this.textRenderer.zoom = this.zoom;
this.textRenderer.devicePixelRatio = this.devicePixelRatio;
// Apply clipping from parent frame
let clipped = false;
if (clipRect) {
canvas.save();
clipped = true;
if (clipRect.rx > 0) {
canvas.clipRRect(
ck.RRectXY(
ck.LTRBRect(clipRect.x, clipRect.y, clipRect.x + clipRect.w, clipRect.y + clipRect.h),
clipRect.rx,
clipRect.rx,
),
ck.ClipOp.Intersect,
true,
);
} else {
canvas.clipRect(
ck.LTRBRect(clipRect.x, clipRect.y, clipRect.x + clipRect.w, clipRect.y + clipRect.h),
ck.ClipOp.Intersect,
true,
);
// Apply ancestor-to-self clip stack. Each ancestor with `clipContent: true`
// (and the root frame artboard) pushed a ClipInfo onto this stack at
// flatten time. Pushing each as a separate canvas.save+clip preserves
// every level's rounded corner — a single ClipInfo can't encode the
// intersection of two rrects when one cuts inside the other's corner.
let clipsPushed = 0;
if (clipStack && clipStack.length > 0) {
for (const clip of clipStack) {
canvas.save();
clipsPushed++;
if (clip.rx > 0) {
canvas.clipRRect(
ck.RRectXY(
ck.LTRBRect(clip.x, clip.y, clip.x + clip.w, clip.y + clip.h),
clip.rx,
clip.rx,
),
ck.ClipOp.Intersect,
true,
);
} else {
canvas.clipRect(
ck.LTRBRect(clip.x, clip.y, clip.x + clip.w, clip.y + clip.h),
ck.ClipOp.Intersect,
true,
);
}
}
}
@ -569,7 +575,7 @@ export class SkiaNodeRenderer {
if (rotation !== 0) canvas.restore();
if (flipX || flipY) canvas.restore();
if (clipped) canvas.restore();
for (let i = 0; i < clipsPushed; i++) canvas.restore();
}
// ---------------------------------------------------------------------------

View file

@ -184,7 +184,7 @@ export class PenRenderer {
maxX = -Infinity,
maxY = -Infinity;
for (const rn of this.renderNodes) {
if (rn.clipRect) continue;
if (rn.clipStack && rn.clipStack.length > 0) continue;
minX = Math.min(minX, rn.absX);
minY = Math.min(minY, rn.absY);
maxX = Math.max(maxX, rn.absX + rn.absW);
@ -330,7 +330,8 @@ export class PenRenderer {
// Draw frame labels for root frames + reusable + instances
for (const rn of this.renderNodes) {
if (!rn.node.name) continue;
const isRootFrame = rn.node.type === 'frame' && !rn.clipRect;
const isRootFrame =
rn.node.type === 'frame' && (rn.clipStack === undefined || rn.clipStack.length === 0);
const isReusable = this.reusableIds.has(rn.node.id);
const isInstance = this.instanceIds.has(rn.node.id);
if (!isRootFrame && !isReusable && !isInstance) continue;

View file

@ -2,13 +2,33 @@ import type { PenNode } from '@zseven-w/pen-types';
export type { ViewportState } from '@zseven-w/pen-types';
export interface ClipInfo {
x: number;
y: number;
w: number;
h: number;
rx: number;
}
export interface RenderNode {
node: PenNode;
absX: number;
absY: number;
absW: number;
absH: number;
clipRect?: { x: number; y: number; w: number; h: number; rx: number };
/**
* Ancestor-to-self stack of clip rectangles to apply when painting this
* node. Stored as an array (not a single intersected ClipInfo) so each
* level's rounded corner can be enforced independently — a single rrect
* can't encode `(rrect ∩ rrect)` faithfully when one rect cuts inside
* the other's corner. The renderer pushes a canvas clip per entry and
* pops them in reverse before drawing the next node.
*
* Outer-most ancestor first; the entry derived from the immediate
* `clipContent: true` parent (or the root frame artboard) is last.
* Empty array (or undefined) means the node paints unclipped.
*/
clipStack?: ClipInfo[];
}
/** Injectable icon lookup function for resolving icon names to SVG path data. */