fix(renderer): coerce missing shadow numeric fields + isolate per-node draw

User reported "为什么画布是空的" — Bistro DeepSeek generation, layer
panel populated with the root frame but canvas fully blank mid-stream.
The MCP-side document showed children, the UI-side layer tree showed
the root, but no pixels rendered. Two structural issues converged:

1. ShadowEffect TS type marks offsetX / offsetY / blur / spread as
   required, but LLM-emitted shadows routinely omit them
   (`{type:'shadow', blur:3, color:'#0000001A'}` with no spread). The
   prior (and the new shadow-cornerRadius) code multiplied the missing
   field through cornerRadius / RRectXY math, producing NaN. CanvasKit's
   RRectXY throws on NaN inside the WASM module, the throw escapes
   drawNode (renderer.ts:326 had no try/catch), and the entire render
   loop aborts past the bad node — so even unrelated siblings stop
   drawing. User sees a fully empty canvas despite document state
   being intact.

2. The drawNode loop had zero error isolation — a single malformed
   node could blank the whole frame. Structural fragility independent
   of the NaN bug; any future renderer regression would have the same
   symptom.

Two fixes:

 - applyShadowDirect coerces missing / non-finite shadow numeric
   fields to 0 before any math (offsetX / offsetY / spread defaults
   to 0; blur defaults to 0 and clamps non-negative). NaN can't reach
   CanvasKit. The pre-existing drawRect path also benefits — the old
   code happily fed NaN to drawRect via `x + shadow.offsetX -
   shadow.spread`, just relied on Skia's tolerance for some NaN cases.
 - renderer.ts wraps drawNode in per-node try/catch with a console.error
   on failure. A bad node now logs and skips; siblings render normally.
   Defense-in-depth so the next renderer regression doesn't blank the
   canvas.
This commit is contained in:
Fini 2026-05-10 22:54:01 +08:00
parent 6966cb75c1
commit 138df33309
2 changed files with 38 additions and 9 deletions

View file

@ -458,16 +458,33 @@ export class SkiaNodeRenderer {
const shadow = effects.find((e): e is ShadowEffect => e.type === 'shadow');
if (!shadow) return false;
// LLM-emitted shadows often omit `spread` / `offsetX` / `offsetY` even
// though the TS type marks them required — coerce missing / non-numeric
// values to 0 so we never feed NaN into CanvasKit. NaN-armed RRectXY
// throws inside the WASM module, the throw escapes drawNode (no
// try/catch in the loop at renderer.ts:326), and the entire frame stops
// rendering past that node → user sees a fully blank canvas.
const offsetX =
typeof shadow.offsetX === 'number' && Number.isFinite(shadow.offsetX) ? shadow.offsetX : 0;
const offsetY =
typeof shadow.offsetY === 'number' && Number.isFinite(shadow.offsetY) ? shadow.offsetY : 0;
const blur =
typeof shadow.blur === 'number' && Number.isFinite(shadow.blur)
? Math.max(0, shadow.blur)
: 0;
const spread =
typeof shadow.spread === 'number' && Number.isFinite(shadow.spread) ? shadow.spread : 0;
const ck = this.ck;
const paint = new ck.Paint();
paint.setStyle(ck.PaintStyle.Fill);
paint.setAntiAlias(true);
paint.setColor(parseColor(ck, shadow.color));
paint.setMaskFilter(ck.MaskFilter.MakeBlur(ck.BlurStyle.Normal, shadow.blur / 2, true));
const left = x + shadow.offsetX - shadow.spread;
const top = y + shadow.offsetY - shadow.spread;
const right = x + w + shadow.offsetX + shadow.spread;
const bottom = y + h + shadow.offsetY + shadow.spread;
paint.setMaskFilter(ck.MaskFilter.MakeBlur(ck.BlurStyle.Normal, blur / 2, true));
const left = x + offsetX - spread;
const top = y + offsetY - spread;
const right = x + w + offsetX + spread;
const bottom = y + h + offsetY + spread;
const rect = ck.LTRBRect(left, top, right, bottom);
if (cornerRadiusX > 0 || cornerRadiusY > 0) {
// 2026-05-10 user-reported "圆角元素的尖角阴影" — drawRect was producing
@ -495,8 +512,8 @@ export class SkiaNodeRenderer {
const shadowH = Math.max(0, bottom - top);
const maxShadowRX = shadowW / 2;
const maxShadowRY = shadowH / 2;
const shadowRX = Math.min(maxShadowRX, Math.max(0, cornerRadiusX + shadow.spread));
const shadowRY = Math.min(maxShadowRY, Math.max(0, cornerRadiusY + shadow.spread));
const shadowRX = Math.min(maxShadowRX, Math.max(0, cornerRadiusX + spread));
const shadowRY = Math.min(maxShadowRY, Math.max(0, cornerRadiusY + spread));
canvas.drawRRect(ck.RRectXY(rect, shadowRX, shadowRY), paint);
} else {
canvas.drawRect(rect, paint);

View file

@ -322,9 +322,21 @@ export class PenRenderer {
// Pass current zoom to renderer
this.nodeRenderer.zoom = this._zoom;
// Draw all render nodes
// Draw all render nodes. Per-node try/catch isolates failures so a
// single bad node (malformed effects, NaN coordinates, missing fill)
// doesn't abort the whole render loop and blank the canvas. The
// 2026-05-10 user report (Bistro generation, layer panel populated,
// canvas empty) was traced to NaN feeding RRectXY when an LLM-emitted
// shadow omitted `spread` — the throw escaped the loop and every
// sibling stopped rendering. The shadow path now coerces missing
// numeric fields to 0; this catch is the structural backstop.
for (const rn of this.renderNodes) {
this.nodeRenderer.drawNode(canvas, rn);
try {
this.nodeRenderer.drawNode(canvas, rn);
} catch (err) {
const id = rn.node.id ?? '<no-id>';
console.error(`[pen-renderer] drawNode threw for node ${id}:`, err);
}
}
// Draw frame labels for root frames + reusable + instances