@vectojs/core 1.24.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1210,6 +1210,16 @@ function grow(data, needed) {
1210
1210
  grown.set(data);
1211
1211
  return grown;
1212
1212
  }
1213
+ function isSourceReady(source) {
1214
+ const candidate = source;
1215
+ if (typeof candidate.complete === "boolean") {
1216
+ if (!candidate.complete) return false;
1217
+ if (typeof candidate.naturalWidth === "number" && candidate.naturalWidth === 0) return false;
1218
+ return true;
1219
+ }
1220
+ if (typeof candidate.readyState === "number") return candidate.readyState >= 2;
1221
+ return true;
1222
+ }
1213
1223
  function createWebGLPointRenderer(canvas) {
1214
1224
  const gl = canvas.getContext("webgl2", {
1215
1225
  premultipliedAlpha: false
@@ -1445,6 +1455,7 @@ function createWebGLPointRenderer(canvas) {
1445
1455
  },
1446
1456
  setTexture(source) {
1447
1457
  if (source === textureSource && texture) return;
1458
+ if (!isSourceReady(source)) return;
1448
1459
  if (!texture) {
1449
1460
  texture = gl.createTexture();
1450
1461
  gl.bindTexture(gl.TEXTURE_2D, texture);
@@ -1487,6 +1498,10 @@ function createWebGLPointRenderer(canvas) {
1487
1498
  distanceRange = range;
1488
1499
  return;
1489
1500
  }
1501
+ if (!isSourceReady(source)) {
1502
+ distanceRange = range;
1503
+ return;
1504
+ }
1490
1505
  atlasSwitches++;
1491
1506
  drawGlyphs();
1492
1507
  distanceRange = range;
@@ -1212,6 +1212,16 @@ function grow(data, needed) {
1212
1212
  grown.set(data);
1213
1213
  return grown;
1214
1214
  }
1215
+ function isSourceReady(source) {
1216
+ const candidate = source;
1217
+ if (typeof candidate.complete === "boolean") {
1218
+ if (!candidate.complete) return false;
1219
+ if (typeof candidate.naturalWidth === "number" && candidate.naturalWidth === 0) return false;
1220
+ return true;
1221
+ }
1222
+ if (typeof candidate.readyState === "number") return candidate.readyState >= 2;
1223
+ return true;
1224
+ }
1215
1225
  function createWebGLPointRenderer(canvas) {
1216
1226
  const gl = canvas.getContext("webgl2", {
1217
1227
  premultipliedAlpha: false
@@ -1447,6 +1457,7 @@ function createWebGLPointRenderer(canvas) {
1447
1457
  },
1448
1458
  setTexture(source) {
1449
1459
  if (source === textureSource && texture) return;
1460
+ if (!isSourceReady(source)) return;
1450
1461
  if (!texture) {
1451
1462
  texture = gl.createTexture();
1452
1463
  gl.bindTexture(gl.TEXTURE_2D, texture);
@@ -1489,6 +1500,10 @@ function createWebGLPointRenderer(canvas) {
1489
1500
  distanceRange = range;
1490
1501
  return;
1491
1502
  }
1503
+ if (!isSourceReady(source)) {
1504
+ distanceRange = range;
1505
+ return;
1506
+ }
1492
1507
  atlasSwitches++;
1493
1508
  drawGlyphs();
1494
1509
  distanceRange = range;
@@ -1093,6 +1093,11 @@ var MSDFTextEntity = class extends Entity {
1093
1093
  layoutText = "";
1094
1094
  text = "";
1095
1095
  lastRenderedSeqId = 0;
1096
+ // Atlas-decode subscription (see watchAtlasDecode). Held so `destroy()` can
1097
+ // release it: the handler closes over `this`, so leaving it attached to a
1098
+ // long-lived shared atlas image would retain the whole entity.
1099
+ atlasDecodeTarget = null;
1100
+ atlasDecodeHandler = null;
1096
1101
  rgbColorCache = /* @__PURE__ */ new Map();
1097
1102
  fontStringCache = [];
1098
1103
  layoutResult = null;
@@ -1108,8 +1113,53 @@ var MSDFTextEntity = class extends Entity {
1108
1113
  this.maxWidth = options.maxWidth ?? 1e3;
1109
1114
  this.maxHeight = options.maxHeight ?? 1e3;
1110
1115
  this.textAlign = options.textAlign ?? "left";
1116
+ this.watchAtlasDecode();
1111
1117
  this.setText(text);
1112
1118
  }
1119
+ /**
1120
+ * Repaint once the atlas raster decodes.
1121
+ *
1122
+ * The WebGL backend refuses to upload a not-yet-decoded atlas (it would pin an
1123
+ * empty texture in its identity cache forever), so the upload has to happen on
1124
+ * a LATER frame — and nothing else schedules one. Layout marks the scene dirty
1125
+ * when the worker replies, which for a network-served atlas is long before the
1126
+ * image lands, so the scene is already idle by then.
1127
+ *
1128
+ * Measured on Chromium and Firefox (2026-07-31) with a 600 ms atlas: the
1129
+ * scene's own rAF loop never uploaded a decoded atlas in EITHER render mode.
1130
+ * `onDemand` skips idle frames outright; `always` throttles to 2 FPS when
1131
+ * idle, so whether it recovers is down to whether a throttled tick happens to
1132
+ * land after the decode — Chromium got one, Firefox did not. Neither is a
1133
+ * mechanism, which is why this listener exists rather than relying on the
1134
+ * frame loop to come back around.
1135
+ *
1136
+ * Only `HTMLImageElement`-shaped sources have a decode to wait for; a canvas,
1137
+ * `ImageBitmap`, or `VideoFrame` atlas is ready on arrival.
1138
+ */
1139
+ watchAtlasDecode() {
1140
+ const source = this.texture;
1141
+ if (typeof source.complete !== "boolean" || typeof source.addEventListener !== "function" || typeof source.removeEventListener !== "function") {
1142
+ return;
1143
+ }
1144
+ if (source.complete && typeof source.naturalWidth === "number" && source.naturalWidth > 0) {
1145
+ return;
1146
+ }
1147
+ const target = this.texture;
1148
+ this.atlasDecodeHandler = () => {
1149
+ this.detachAtlasDecodeListener();
1150
+ this.scene?.markDirty();
1151
+ };
1152
+ target.addEventListener("load", this.atlasDecodeHandler);
1153
+ target.addEventListener("error", this.atlasDecodeHandler);
1154
+ this.atlasDecodeTarget = target;
1155
+ }
1156
+ detachAtlasDecodeListener() {
1157
+ if (!this.atlasDecodeTarget || !this.atlasDecodeHandler) return;
1158
+ this.atlasDecodeTarget.removeEventListener("load", this.atlasDecodeHandler);
1159
+ this.atlasDecodeTarget.removeEventListener("error", this.atlasDecodeHandler);
1160
+ this.atlasDecodeTarget = null;
1161
+ this.atlasDecodeHandler = null;
1162
+ }
1113
1163
  /** Change the wrap boundary and re-run layout for the current text. */
1114
1164
  setMaxWidth(maxWidth) {
1115
1165
  if (this.maxWidth === maxWidth) return;
@@ -1281,11 +1331,13 @@ var MSDFTextEntity = class extends Entity {
1281
1331
  }
1282
1332
  destroy() {
1283
1333
  LayoutWorkerManager.cancelLayoutForEntity(this.id);
1334
+ this.detachAtlasDecodeListener();
1284
1335
  super.destroy();
1285
1336
  }
1286
1337
  };
1287
1338
 
1288
1339
  // src/text/SVGEntity.ts
1340
+ var SVG_NAMESPACE = "http://www.w3.org/2000/svg";
1289
1341
  function isSvgWhitespace(ch) {
1290
1342
  return ch === " " || ch === " " || ch === "\n" || ch === "\r";
1291
1343
  }
@@ -1317,12 +1369,21 @@ function readSvgAttribute(source, name) {
1317
1369
  return null;
1318
1370
  }
1319
1371
  var SVGEntity = class extends Entity {
1372
+ /**
1373
+ * Stroke colour of the fallback marker drawn when the source cannot be
1374
+ * rasterized. Set to `'transparent'` to opt out and keep the box empty.
1375
+ * Default `'rgba(248,113,113,0.9)'`.
1376
+ */
1377
+ fallbackStroke = "rgba(248,113,113,0.9)";
1378
+ /** Fill behind the fallback marker. Default `'rgba(248,113,113,0.12)'`. */
1379
+ fallbackFill = "rgba(248,113,113,0.12)";
1320
1380
  svgSource = "";
1321
1381
  imageBitmap = null;
1322
1382
  imageElement = null;
1323
1383
  blobURL = null;
1324
1384
  currentImg = null;
1325
1385
  lodTimeout = null;
1386
+ rasterFailed = false;
1326
1387
  cachedDoc = null;
1327
1388
  baseWidth = 100;
1328
1389
  baseHeight = 100;
@@ -1349,6 +1410,7 @@ var SVGEntity = class extends Entity {
1349
1410
  const parserError = doc.querySelector("parsererror");
1350
1411
  if (parserError) {
1351
1412
  console.error("SVG Parsing error:", parserError.textContent);
1413
+ this.rasterFailed = true;
1352
1414
  } else {
1353
1415
  this.cachedDoc = doc;
1354
1416
  const svgEl = doc.documentElement;
@@ -1368,6 +1430,7 @@ var SVGEntity = class extends Entity {
1368
1430
  }
1369
1431
  } catch (e) {
1370
1432
  console.error("Failed parsing SVG via DOMParser, falling back to attribute scan:", e);
1433
+ this.rasterFailed = true;
1371
1434
  }
1372
1435
  } else {
1373
1436
  const wAttr = readSvgAttribute(this.svgSource, "width");
@@ -1391,6 +1454,7 @@ var SVGEntity = class extends Entity {
1391
1454
  }
1392
1455
  triggerRasterization(scale) {
1393
1456
  if (typeof window === "undefined" || typeof Blob === "undefined") return;
1457
+ this.rasterFailed = false;
1394
1458
  if (this.currentImg) {
1395
1459
  this.currentImg.onload = null;
1396
1460
  this.currentImg.onerror = null;
@@ -1414,6 +1478,7 @@ var SVGEntity = class extends Entity {
1414
1478
  "SVG Parsing validation error in triggerRasterization:",
1415
1479
  parserError.textContent
1416
1480
  );
1481
+ this.rasterFailed = true;
1417
1482
  } else {
1418
1483
  const clonedDoc = doc.cloneNode(true);
1419
1484
  const svgEl = clonedDoc.documentElement;
@@ -1427,10 +1492,14 @@ var SVGEntity = class extends Entity {
1427
1492
  }
1428
1493
  const serializer = new XMLSerializer();
1429
1494
  processedSource = serializer.serializeToString(clonedDoc);
1495
+ if (svgEl.namespaceURI === null && !/\sxmlns\s*=/.test(processedSource)) {
1496
+ processedSource = processedSource.replace(/<svg/i, `<svg xmlns="${SVG_NAMESPACE}"`);
1497
+ }
1430
1498
  }
1431
1499
  }
1432
1500
  } catch (e) {
1433
1501
  console.error("Failed to apply LOD scaling to SVG XML:", e);
1502
+ this.rasterFailed = true;
1434
1503
  }
1435
1504
  const blob = new Blob([processedSource], { type: "image/svg+xml;charset=utf-8" });
1436
1505
  this.blobURL = URL.createObjectURL(blob);
@@ -1459,15 +1528,36 @@ var SVGEntity = class extends Entity {
1459
1528
  }).catch((e) => {
1460
1529
  console.error("Failed to create ImageBitmap from SVG:", e);
1461
1530
  this.currentImg = null;
1531
+ if (!this.imageElement) this.rasterFailed = true;
1532
+ if (this.scene) this.scene.markDirty();
1462
1533
  });
1463
1534
  };
1464
1535
  img.onerror = (e) => {
1465
1536
  if (this.currentImg !== img) return;
1466
1537
  console.error("Failed to load SVG Image element:", e);
1467
1538
  this.currentImg = null;
1539
+ this.rasterFailed = true;
1540
+ if (this.scene) this.scene.markDirty();
1468
1541
  };
1469
1542
  img.src = this.blobURL;
1470
1543
  }
1544
+ /**
1545
+ * Whether the source genuinely rasterized to a bitmap.
1546
+ *
1547
+ * Distinguishes "drew the real artwork" from "drew the fallback marker",
1548
+ * which pixel counts alone cannot tell apart — both are non-blank.
1549
+ */
1550
+ hasRasterBitmap() {
1551
+ return this.imageBitmap !== null;
1552
+ }
1553
+ /**
1554
+ * Whether rasterization failed, so {@link render} draws the fallback marker.
1555
+ *
1556
+ * `false` while a raster is still in flight; only a settled failure sets it.
1557
+ */
1558
+ hasRasterFailed() {
1559
+ return this.rasterFailed;
1560
+ }
1471
1561
  isPointInside(globalX, globalY) {
1472
1562
  const local = this.worldToLocal(globalX, globalY);
1473
1563
  if (!local) return false;
@@ -1492,9 +1582,32 @@ var SVGEntity = class extends Entity {
1492
1582
  }
1493
1583
  if (this.imageBitmap) {
1494
1584
  r.drawImage(this.imageBitmap, 0, 0, this.width, this.height);
1495
- } else if (this.imageElement) {
1585
+ return;
1586
+ }
1587
+ if (this.imageElement) {
1496
1588
  r.drawImage(this.imageElement, 0, 0, this.width, this.height);
1589
+ return;
1497
1590
  }
1591
+ if (this.rasterFailed) this.drawFallback(r);
1592
+ }
1593
+ /** Box outline plus a diagonal cross — the conventional "broken image" mark. */
1594
+ drawFallback(r) {
1595
+ const w = this.width;
1596
+ const h = this.height;
1597
+ if (w <= 0 || h <= 0) return;
1598
+ r.beginPath();
1599
+ r.roundRect(0, 0, w, h, 0);
1600
+ r.fill(this.fallbackFill);
1601
+ r.beginPath();
1602
+ r.roundRect(0, 0, w, h, 0);
1603
+ r.stroke(this.fallbackStroke, 1);
1604
+ const inset = Math.min(w, h) * 0.2;
1605
+ r.beginPath();
1606
+ r.moveTo(inset, inset);
1607
+ r.lineTo(w - inset, h - inset);
1608
+ r.moveTo(w - inset, inset);
1609
+ r.lineTo(inset, h - inset);
1610
+ r.stroke(this.fallbackStroke, 1);
1498
1611
  }
1499
1612
  destroy() {
1500
1613
  if (this.lodTimeout) {
@@ -1095,11 +1095,16 @@ var MSDFTextEntity = (_class3 = class extends Entity {
1095
1095
  __init40() {this.layoutText = ""}
1096
1096
  __init41() {this.text = ""}
1097
1097
  __init42() {this.lastRenderedSeqId = 0}
1098
- __init43() {this.rgbColorCache = /* @__PURE__ */ new Map()}
1099
- __init44() {this.fontStringCache = []}
1100
- __init45() {this.layoutResult = null}
1098
+ // Atlas-decode subscription (see watchAtlasDecode). Held so `destroy()` can
1099
+ // release it: the handler closes over `this`, so leaving it attached to a
1100
+ // long-lived shared atlas image would retain the whole entity.
1101
+ __init43() {this.atlasDecodeTarget = null}
1102
+ __init44() {this.atlasDecodeHandler = null}
1103
+ __init45() {this.rgbColorCache = /* @__PURE__ */ new Map()}
1104
+ __init46() {this.fontStringCache = []}
1105
+ __init47() {this.layoutResult = null}
1101
1106
  constructor(text, options) {
1102
- super();_class3.prototype.__init39.call(this);_class3.prototype.__init40.call(this);_class3.prototype.__init41.call(this);_class3.prototype.__init42.call(this);_class3.prototype.__init43.call(this);_class3.prototype.__init44.call(this);_class3.prototype.__init45.call(this);;
1107
+ super();_class3.prototype.__init39.call(this);_class3.prototype.__init40.call(this);_class3.prototype.__init41.call(this);_class3.prototype.__init42.call(this);_class3.prototype.__init43.call(this);_class3.prototype.__init44.call(this);_class3.prototype.__init45.call(this);_class3.prototype.__init46.call(this);_class3.prototype.__init47.call(this);;
1103
1108
  this.font = options.font;
1104
1109
  this.texture = options.texture;
1105
1110
  this.fallbackFont = _nullishCoalesce(options.fallbackFont, () => ( "sans-serif"));
@@ -1110,8 +1115,53 @@ var MSDFTextEntity = (_class3 = class extends Entity {
1110
1115
  this.maxWidth = _nullishCoalesce(options.maxWidth, () => ( 1e3));
1111
1116
  this.maxHeight = _nullishCoalesce(options.maxHeight, () => ( 1e3));
1112
1117
  this.textAlign = _nullishCoalesce(options.textAlign, () => ( "left"));
1118
+ this.watchAtlasDecode();
1113
1119
  this.setText(text);
1114
1120
  }
1121
+ /**
1122
+ * Repaint once the atlas raster decodes.
1123
+ *
1124
+ * The WebGL backend refuses to upload a not-yet-decoded atlas (it would pin an
1125
+ * empty texture in its identity cache forever), so the upload has to happen on
1126
+ * a LATER frame — and nothing else schedules one. Layout marks the scene dirty
1127
+ * when the worker replies, which for a network-served atlas is long before the
1128
+ * image lands, so the scene is already idle by then.
1129
+ *
1130
+ * Measured on Chromium and Firefox (2026-07-31) with a 600 ms atlas: the
1131
+ * scene's own rAF loop never uploaded a decoded atlas in EITHER render mode.
1132
+ * `onDemand` skips idle frames outright; `always` throttles to 2 FPS when
1133
+ * idle, so whether it recovers is down to whether a throttled tick happens to
1134
+ * land after the decode — Chromium got one, Firefox did not. Neither is a
1135
+ * mechanism, which is why this listener exists rather than relying on the
1136
+ * frame loop to come back around.
1137
+ *
1138
+ * Only `HTMLImageElement`-shaped sources have a decode to wait for; a canvas,
1139
+ * `ImageBitmap`, or `VideoFrame` atlas is ready on arrival.
1140
+ */
1141
+ watchAtlasDecode() {
1142
+ const source = this.texture;
1143
+ if (typeof source.complete !== "boolean" || typeof source.addEventListener !== "function" || typeof source.removeEventListener !== "function") {
1144
+ return;
1145
+ }
1146
+ if (source.complete && typeof source.naturalWidth === "number" && source.naturalWidth > 0) {
1147
+ return;
1148
+ }
1149
+ const target = this.texture;
1150
+ this.atlasDecodeHandler = () => {
1151
+ this.detachAtlasDecodeListener();
1152
+ _optionalChain([this, 'access', _108 => _108.scene, 'optionalAccess', _109 => _109.markDirty, 'call', _110 => _110()]);
1153
+ };
1154
+ target.addEventListener("load", this.atlasDecodeHandler);
1155
+ target.addEventListener("error", this.atlasDecodeHandler);
1156
+ this.atlasDecodeTarget = target;
1157
+ }
1158
+ detachAtlasDecodeListener() {
1159
+ if (!this.atlasDecodeTarget || !this.atlasDecodeHandler) return;
1160
+ this.atlasDecodeTarget.removeEventListener("load", this.atlasDecodeHandler);
1161
+ this.atlasDecodeTarget.removeEventListener("error", this.atlasDecodeHandler);
1162
+ this.atlasDecodeTarget = null;
1163
+ this.atlasDecodeHandler = null;
1164
+ }
1115
1165
  /** Change the wrap boundary and re-run layout for the current text. */
1116
1166
  setMaxWidth(maxWidth) {
1117
1167
  if (this.maxWidth === maxWidth) return;
@@ -1177,7 +1227,7 @@ var MSDFTextEntity = (_class3 = class extends Entity {
1177
1227
  if (res.seqId < this.lastRenderedSeqId) return;
1178
1228
  this.lastRenderedSeqId = res.seqId;
1179
1229
  this.layoutResult = res;
1180
- _optionalChain([this, 'access', _108 => _108.scene, 'optionalAccess', _109 => _109.markDirty, 'call', _110 => _110()]);
1230
+ _optionalChain([this, 'access', _111 => _111.scene, 'optionalAccess', _112 => _112.markDirty, 'call', _113 => _113()]);
1181
1231
  }
1182
1232
  });
1183
1233
  }
@@ -1283,11 +1333,13 @@ var MSDFTextEntity = (_class3 = class extends Entity {
1283
1333
  }
1284
1334
  destroy() {
1285
1335
  _layout.LayoutWorkerManager.cancelLayoutForEntity(this.id);
1336
+ this.detachAtlasDecodeListener();
1286
1337
  super.destroy();
1287
1338
  }
1288
1339
  }, _class3);
1289
1340
 
1290
1341
  // src/text/SVGEntity.ts
1342
+ var SVG_NAMESPACE = "http://www.w3.org/2000/svg";
1291
1343
  function isSvgWhitespace(ch) {
1292
1344
  return ch === " " || ch === " " || ch === "\n" || ch === "\r";
1293
1345
  }
@@ -1319,19 +1371,28 @@ function readSvgAttribute(source, name) {
1319
1371
  return null;
1320
1372
  }
1321
1373
  var SVGEntity = (_class4 = class extends Entity {
1322
- __init46() {this.svgSource = ""}
1323
- __init47() {this.imageBitmap = null}
1324
- __init48() {this.imageElement = null}
1325
- __init49() {this.blobURL = null}
1326
- __init50() {this.currentImg = null}
1327
- __init51() {this.lodTimeout = null}
1328
- __init52() {this.cachedDoc = null}
1329
- __init53() {this.baseWidth = 100}
1330
- __init54() {this.baseHeight = 100}
1331
- __init55() {this.lastRasterizedScale = 1}
1332
- __init56() {this.targetScale = 1}
1374
+ /**
1375
+ * Stroke colour of the fallback marker drawn when the source cannot be
1376
+ * rasterized. Set to `'transparent'` to opt out and keep the box empty.
1377
+ * Default `'rgba(248,113,113,0.9)'`.
1378
+ */
1379
+ __init48() {this.fallbackStroke = "rgba(248,113,113,0.9)"}
1380
+ /** Fill behind the fallback marker. Default `'rgba(248,113,113,0.12)'`. */
1381
+ __init49() {this.fallbackFill = "rgba(248,113,113,0.12)"}
1382
+ __init50() {this.svgSource = ""}
1383
+ __init51() {this.imageBitmap = null}
1384
+ __init52() {this.imageElement = null}
1385
+ __init53() {this.blobURL = null}
1386
+ __init54() {this.currentImg = null}
1387
+ __init55() {this.lodTimeout = null}
1388
+ __init56() {this.rasterFailed = false}
1389
+ __init57() {this.cachedDoc = null}
1390
+ __init58() {this.baseWidth = 100}
1391
+ __init59() {this.baseHeight = 100}
1392
+ __init60() {this.lastRasterizedScale = 1}
1393
+ __init61() {this.targetScale = 1}
1333
1394
  constructor(svgSource, id) {
1334
- super(id);_class4.prototype.__init46.call(this);_class4.prototype.__init47.call(this);_class4.prototype.__init48.call(this);_class4.prototype.__init49.call(this);_class4.prototype.__init50.call(this);_class4.prototype.__init51.call(this);_class4.prototype.__init52.call(this);_class4.prototype.__init53.call(this);_class4.prototype.__init54.call(this);_class4.prototype.__init55.call(this);_class4.prototype.__init56.call(this);;
1395
+ super(id);_class4.prototype.__init48.call(this);_class4.prototype.__init49.call(this);_class4.prototype.__init50.call(this);_class4.prototype.__init51.call(this);_class4.prototype.__init52.call(this);_class4.prototype.__init53.call(this);_class4.prototype.__init54.call(this);_class4.prototype.__init55.call(this);_class4.prototype.__init56.call(this);_class4.prototype.__init57.call(this);_class4.prototype.__init58.call(this);_class4.prototype.__init59.call(this);_class4.prototype.__init60.call(this);_class4.prototype.__init61.call(this);;
1335
1396
  this.setSVGSource(svgSource);
1336
1397
  }
1337
1398
  setSVGSource(svgSource) {
@@ -1351,6 +1412,7 @@ var SVGEntity = (_class4 = class extends Entity {
1351
1412
  const parserError = doc.querySelector("parsererror");
1352
1413
  if (parserError) {
1353
1414
  console.error("SVG Parsing error:", parserError.textContent);
1415
+ this.rasterFailed = true;
1354
1416
  } else {
1355
1417
  this.cachedDoc = doc;
1356
1418
  const svgEl = doc.documentElement;
@@ -1370,6 +1432,7 @@ var SVGEntity = (_class4 = class extends Entity {
1370
1432
  }
1371
1433
  } catch (e) {
1372
1434
  console.error("Failed parsing SVG via DOMParser, falling back to attribute scan:", e);
1435
+ this.rasterFailed = true;
1373
1436
  }
1374
1437
  } else {
1375
1438
  const wAttr = readSvgAttribute(this.svgSource, "width");
@@ -1393,6 +1456,7 @@ var SVGEntity = (_class4 = class extends Entity {
1393
1456
  }
1394
1457
  triggerRasterization(scale) {
1395
1458
  if (typeof window === "undefined" || typeof Blob === "undefined") return;
1459
+ this.rasterFailed = false;
1396
1460
  if (this.currentImg) {
1397
1461
  this.currentImg.onload = null;
1398
1462
  this.currentImg.onerror = null;
@@ -1416,6 +1480,7 @@ var SVGEntity = (_class4 = class extends Entity {
1416
1480
  "SVG Parsing validation error in triggerRasterization:",
1417
1481
  parserError.textContent
1418
1482
  );
1483
+ this.rasterFailed = true;
1419
1484
  } else {
1420
1485
  const clonedDoc = doc.cloneNode(true);
1421
1486
  const svgEl = clonedDoc.documentElement;
@@ -1429,10 +1494,14 @@ var SVGEntity = (_class4 = class extends Entity {
1429
1494
  }
1430
1495
  const serializer = new XMLSerializer();
1431
1496
  processedSource = serializer.serializeToString(clonedDoc);
1497
+ if (svgEl.namespaceURI === null && !/\sxmlns\s*=/.test(processedSource)) {
1498
+ processedSource = processedSource.replace(/<svg/i, `<svg xmlns="${SVG_NAMESPACE}"`);
1499
+ }
1432
1500
  }
1433
1501
  }
1434
1502
  } catch (e) {
1435
1503
  console.error("Failed to apply LOD scaling to SVG XML:", e);
1504
+ this.rasterFailed = true;
1436
1505
  }
1437
1506
  const blob = new Blob([processedSource], { type: "image/svg+xml;charset=utf-8" });
1438
1507
  this.blobURL = URL.createObjectURL(blob);
@@ -1461,15 +1530,36 @@ var SVGEntity = (_class4 = class extends Entity {
1461
1530
  }).catch((e) => {
1462
1531
  console.error("Failed to create ImageBitmap from SVG:", e);
1463
1532
  this.currentImg = null;
1533
+ if (!this.imageElement) this.rasterFailed = true;
1534
+ if (this.scene) this.scene.markDirty();
1464
1535
  });
1465
1536
  };
1466
1537
  img.onerror = (e) => {
1467
1538
  if (this.currentImg !== img) return;
1468
1539
  console.error("Failed to load SVG Image element:", e);
1469
1540
  this.currentImg = null;
1541
+ this.rasterFailed = true;
1542
+ if (this.scene) this.scene.markDirty();
1470
1543
  };
1471
1544
  img.src = this.blobURL;
1472
1545
  }
1546
+ /**
1547
+ * Whether the source genuinely rasterized to a bitmap.
1548
+ *
1549
+ * Distinguishes "drew the real artwork" from "drew the fallback marker",
1550
+ * which pixel counts alone cannot tell apart — both are non-blank.
1551
+ */
1552
+ hasRasterBitmap() {
1553
+ return this.imageBitmap !== null;
1554
+ }
1555
+ /**
1556
+ * Whether rasterization failed, so {@link render} draws the fallback marker.
1557
+ *
1558
+ * `false` while a raster is still in flight; only a settled failure sets it.
1559
+ */
1560
+ hasRasterFailed() {
1561
+ return this.rasterFailed;
1562
+ }
1473
1563
  isPointInside(globalX, globalY) {
1474
1564
  const local = this.worldToLocal(globalX, globalY);
1475
1565
  if (!local) return false;
@@ -1494,9 +1584,32 @@ var SVGEntity = (_class4 = class extends Entity {
1494
1584
  }
1495
1585
  if (this.imageBitmap) {
1496
1586
  r.drawImage(this.imageBitmap, 0, 0, this.width, this.height);
1497
- } else if (this.imageElement) {
1587
+ return;
1588
+ }
1589
+ if (this.imageElement) {
1498
1590
  r.drawImage(this.imageElement, 0, 0, this.width, this.height);
1591
+ return;
1499
1592
  }
1593
+ if (this.rasterFailed) this.drawFallback(r);
1594
+ }
1595
+ /** Box outline plus a diagonal cross — the conventional "broken image" mark. */
1596
+ drawFallback(r) {
1597
+ const w = this.width;
1598
+ const h = this.height;
1599
+ if (w <= 0 || h <= 0) return;
1600
+ r.beginPath();
1601
+ r.roundRect(0, 0, w, h, 0);
1602
+ r.fill(this.fallbackFill);
1603
+ r.beginPath();
1604
+ r.roundRect(0, 0, w, h, 0);
1605
+ r.stroke(this.fallbackStroke, 1);
1606
+ const inset = Math.min(w, h) * 0.2;
1607
+ r.beginPath();
1608
+ r.moveTo(inset, inset);
1609
+ r.lineTo(w - inset, h - inset);
1610
+ r.moveTo(w - inset, inset);
1611
+ r.lineTo(inset, h - inset);
1612
+ r.stroke(this.fallbackStroke, 1);
1500
1613
  }
1501
1614
  destroy() {
1502
1615
  if (this.lodTimeout) {