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:
parent
6966cb75c1
commit
138df33309
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue