From 138df33309710d604e127f8761dd101d8d39bcac Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 10 May 2026 22:54:01 +0800 Subject: [PATCH] fix(renderer): coerce missing shadow numeric fields + isolate per-node draw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/pen-renderer/src/node-renderer.ts | 31 +++++++++++++++++----- packages/pen-renderer/src/renderer.ts | 16 +++++++++-- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/packages/pen-renderer/src/node-renderer.ts b/packages/pen-renderer/src/node-renderer.ts index 10d3623c4..32116137a 100644 --- a/packages/pen-renderer/src/node-renderer.ts +++ b/packages/pen-renderer/src/node-renderer.ts @@ -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); diff --git a/packages/pen-renderer/src/renderer.ts b/packages/pen-renderer/src/renderer.ts index 7d3fe7b7d..b916fc895 100644 --- a/packages/pen-renderer/src/renderer.ts +++ b/packages/pen-renderer/src/renderer.ts @@ -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 ?? ''; + console.error(`[pen-renderer] drawNode threw for node ${id}:`, err); + } } // Draw frame labels for root frames + reusable + instances