@vectojs/core 1.38.1 → 1.39.1

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.
@@ -63,7 +63,8 @@ function emptyDrawCounters() {
63
63
  }
64
64
  var TWO_PI = Math.PI * 2;
65
65
  function getDevicePixelRatio() {
66
- return typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
66
+ const raw = typeof window !== "undefined" ? window.devicePixelRatio : 1;
67
+ return Number.isFinite(raw) && raw > 0 ? raw : 1;
67
68
  }
68
69
  var CanvasRenderer = class _CanvasRenderer {
69
70
  ctx;
@@ -107,6 +108,17 @@ var CanvasRenderer = class _CanvasRenderer {
107
108
  batchCount = 0;
108
109
  _cachedFont = "";
109
110
  _cachedFill = "";
111
+ // Stroke-side counterparts of the fill/font caches, read by stroke()'s
112
+ // style-elision branch. Reset wherever the context state can change behind
113
+ // our back: restore(), resize(), contextrestored, dispose().
114
+ _cachedStroke = "";
115
+ _cachedLineWidth = -1;
116
+ _cachedLineCap = "";
117
+ _cachedLineJoin = "";
118
+ // Context-loss listeners, held for removal in dispose() so a canvas that
119
+ // outlives the renderer cannot retain it through these closures.
120
+ _onContextLost;
121
+ _onContextRestored;
110
122
  /** Backend discriminator; see {@link IRenderer.kind}. */
111
123
  kind = "canvas2d";
112
124
  /**
@@ -160,11 +172,11 @@ var CanvasRenderer = class _CanvasRenderer {
160
172
  */
161
173
  setupContextLossRecovery() {
162
174
  if (typeof this.canvas.addEventListener !== "function") return;
163
- this.canvas.addEventListener("contextlost", (e) => {
175
+ this._onContextLost = (e) => {
164
176
  e.preventDefault();
165
177
  this.contextLost = true;
166
- });
167
- this.canvas.addEventListener("contextrestored", () => {
178
+ };
179
+ this._onContextRestored = () => {
168
180
  const ctx = this.canvas.getContext("2d");
169
181
  if (!ctx) return;
170
182
  this.ctx = ctx;
@@ -174,11 +186,17 @@ var CanvasRenderer = class _CanvasRenderer {
174
186
  ctx.scale(restoredDPR, restoredDPR);
175
187
  this._cachedFont = "";
176
188
  this._cachedFill = "";
189
+ this._cachedStroke = "";
190
+ this._cachedLineWidth = -1;
191
+ this._cachedLineCap = "";
192
+ this._cachedLineJoin = "";
177
193
  this.batchActive = false;
178
194
  this.batchCount = 0;
179
195
  this.contextLost = false;
180
196
  this.contextRestoredCb?.();
181
- });
197
+ };
198
+ this.canvas.addEventListener("contextlost", this._onContextLost);
199
+ this.canvas.addEventListener("contextrestored", this._onContextRestored);
182
200
  }
183
201
  /**
184
202
  * Expose the underlying `CanvasRenderingContext2D` for operations not
@@ -192,7 +210,9 @@ var CanvasRenderer = class _CanvasRenderer {
192
210
  /** Real `devicePixelRatio`, clamped to {@link maxDPR} when set. */
193
211
  effectiveDPR() {
194
212
  const real = getDevicePixelRatio();
195
- return this.maxDPR !== void 0 ? Math.min(real, this.maxDPR) : real;
213
+ if (this.maxDPR === void 0) return real;
214
+ if (!Number.isFinite(this.maxDPR) || this.maxDPR <= 0) return real;
215
+ return Math.min(real, this.maxDPR);
196
216
  }
197
217
  /**
198
218
  * @inheritdoc
@@ -223,16 +243,26 @@ var CanvasRenderer = class _CanvasRenderer {
223
243
  */
224
244
  resize(width, height) {
225
245
  const dpr = this.effectiveDPR();
246
+ const safeWidth = Number.isFinite(width) && width >= 0 ? width : this.width;
247
+ const safeHeight = Number.isFinite(height) && height >= 0 ? height : this.height;
248
+ const backingW = Number.isFinite(safeWidth * dpr) ? Math.max(1, Math.round(safeWidth * dpr)) : 1;
249
+ const backingH = Number.isFinite(safeHeight * dpr) ? Math.max(1, Math.round(safeHeight * dpr)) : 1;
226
250
  this.appliedDPR = dpr;
227
- this.width = width;
228
- this.height = height;
229
- this.ctx.canvas.width = width * dpr;
230
- this.ctx.canvas.height = height * dpr;
231
- this.ctx.canvas.style.width = `${width}px`;
232
- this.ctx.canvas.style.height = `${height}px`;
251
+ this.width = safeWidth;
252
+ this.height = safeHeight;
253
+ this.ctx.canvas.width = backingW;
254
+ this.ctx.canvas.height = backingH;
255
+ if (this.ctx.canvas.style) {
256
+ this.ctx.canvas.style.width = `${safeWidth}px`;
257
+ this.ctx.canvas.style.height = `${safeHeight}px`;
258
+ }
233
259
  this.ctx.scale(dpr, dpr);
234
260
  this._cachedFont = "";
235
261
  this._cachedFill = "";
262
+ this._cachedStroke = "";
263
+ this._cachedLineWidth = -1;
264
+ this._cachedLineCap = "";
265
+ this._cachedLineJoin = "";
236
266
  this.batchActive = false;
237
267
  this.batchCount = 0;
238
268
  }
@@ -286,6 +316,10 @@ var CanvasRenderer = class _CanvasRenderer {
286
316
  this.ctx.restore();
287
317
  this._cachedFont = "";
288
318
  this._cachedFill = "";
319
+ this._cachedStroke = "";
320
+ this._cachedLineWidth = -1;
321
+ this._cachedLineCap = "";
322
+ this._cachedLineJoin = "";
289
323
  }
290
324
  /** @inheritdoc */
291
325
  translate(x, y) {
@@ -410,10 +444,17 @@ var CanvasRenderer = class _CanvasRenderer {
410
444
  stroke(color, lineWidth = 1) {
411
445
  this.flush();
412
446
  if (this.counters) this.counters.strokes++;
413
- this.ctx.strokeStyle = color;
414
- this.ctx.lineWidth = lineWidth;
415
- this.ctx.lineCap = "round";
416
- this.ctx.lineJoin = "round";
447
+ if (this._cachedStroke !== color || this._cachedLineWidth !== lineWidth || this._cachedLineCap !== "round" || this._cachedLineJoin !== "round") {
448
+ if (this.counters) this.counters.stateSwitches++;
449
+ this.ctx.strokeStyle = color;
450
+ this.ctx.lineWidth = lineWidth;
451
+ this.ctx.lineCap = "round";
452
+ this.ctx.lineJoin = "round";
453
+ this._cachedStroke = color;
454
+ this._cachedLineWidth = lineWidth;
455
+ this._cachedLineCap = "round";
456
+ this._cachedLineJoin = "round";
457
+ }
417
458
  this.ctx.stroke();
418
459
  }
419
460
  /** @inheritdoc */
@@ -452,6 +493,17 @@ var CanvasRenderer = class _CanvasRenderer {
452
493
  this.batchActive = false;
453
494
  this._cachedFont = "";
454
495
  this._cachedFill = "";
496
+ this._cachedStroke = "";
497
+ this._cachedLineWidth = -1;
498
+ this._cachedLineCap = "";
499
+ this._cachedLineJoin = "";
500
+ if (typeof this.canvas.removeEventListener === "function") {
501
+ if (this._onContextLost) this.canvas.removeEventListener("contextlost", this._onContextLost);
502
+ if (this._onContextRestored)
503
+ this.canvas.removeEventListener("contextrestored", this._onContextRestored);
504
+ }
505
+ this._onContextLost = void 0;
506
+ this._onContextRestored = void 0;
455
507
  }
456
508
  };
457
509
 
@@ -474,9 +526,13 @@ function hasSafeSchemeOrIsRelative(url) {
474
526
  return SAFE_SCHEMES.has(`${candidate.toLowerCase()}:`);
475
527
  }
476
528
  var CHAR_REF = /&(?:#\d+|#x[\da-f]+|colon|tab|newline);/i;
529
+ var MAX_CODE_POINT = 1114111;
530
+ function decodeCodePoint(value) {
531
+ return value <= MAX_CODE_POINT ? String.fromCodePoint(value) : "\uFFFD";
532
+ }
477
533
  function decodeCharacterReferences(value) {
478
534
  if (value.indexOf("&") < 0 || !CHAR_REF.test(value)) return value;
479
- return value.replace(/&#x([\da-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16))).replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10))).replace(/&colon;/gi, ":").replace(/&tab;/gi, " ").replace(/&newline;/gi, "\n");
535
+ return value.replace(/&#x([\da-f]+);/gi, (_, hex) => decodeCodePoint(parseInt(hex, 16))).replace(/&#(\d+);/g, (_, dec) => decodeCodePoint(parseInt(dec, 10))).replace(/&colon;/gi, ":").replace(/&tab;/gi, " ").replace(/&newline;/gi, "\n");
480
536
  }
481
537
  function sanitizeUrl(href) {
482
538
  if (typeof href !== "string") return "";
@@ -578,9 +634,12 @@ var SVGRenderer = class {
578
634
  // Cache for generated gradient defs
579
635
  gradientCounter = 0;
580
636
  gradientCache = /* @__PURE__ */ new Map();
581
- constructor(width, height) {
637
+ /** Root font size used to resolve `em`/`rem` font sizes (default 16px). */
638
+ rootFontSize;
639
+ constructor(width, height, options) {
582
640
  this.width = width;
583
641
  this.height = height;
642
+ this.rootFontSize = options?.rootFontSize ?? 16;
584
643
  installRendererDevTraps(this, "SVGRenderer");
585
644
  }
586
645
  clear() {
@@ -768,12 +827,22 @@ var SVGRenderer = class {
768
827
  `<path d="${dStr}" transform="${transformStr}" fill="none" stroke="${strokeVal}" stroke-width="${lineWidth}" stroke-opacity="${this.globalAlpha}" />`
769
828
  );
770
829
  }
830
+ /**
831
+ * Emit a `<text>` element for a text run.
832
+ *
833
+ * Font-size unit handling: `px` sizes pass through; `em` and `rem` are both
834
+ * scaled against the renderer's `rootFontSize` (constructor option, default
835
+ * 16 — matching the browser default, not necessarily the host page's root,
836
+ * so pass `{ rootFontSize }` when exporting against a non-default root).
837
+ * A percentage size (`'100% Inter'`) has no absolute meaning in this
838
+ * context and silently falls back to the default 16px font size.
839
+ */
771
840
  fillText(text, x, y, font, color) {
772
841
  this.flush();
773
842
  const sizeToken = parseFontSizeToken(font);
774
843
  let fontSize = sizeToken ? sizeToken.value : 16;
775
- if (sizeToken && sizeToken.unit !== "px") {
776
- fontSize = fontSize * 16;
844
+ if (sizeToken && (sizeToken.unit === "em" || sizeToken.unit === "rem")) {
845
+ fontSize = fontSize * this.rootFontSize;
777
846
  }
778
847
  const lowerFont = font.toLowerCase();
779
848
  const fontStyle = lowerFont.includes("italic") ? "italic" : lowerFont.includes("oblique") ? "oblique" : "normal";
@@ -955,11 +1024,11 @@ var SVGRenderer = class {
955
1024
  }
956
1025
  /**
957
1026
  * SVGRenderer accumulates strings in memory; nothing external is allocated.
958
- * Drop the buffers for GC and become idempotent.
1027
+ * Drop the buffers for GC and become idempotent. `clear()` already empties
1028
+ * the gradient cache, so no second clear is needed here.
959
1029
  */
960
1030
  dispose() {
961
1031
  this.clear();
962
- this.gradientCache.clear();
963
1032
  }
964
1033
  };
965
1034
 
@@ -1331,13 +1400,22 @@ function createWebGLPointRenderer(canvas) {
1331
1400
  premultipliedAlpha: false
1332
1401
  });
1333
1402
  if (!gl) return null;
1334
- const pointProgram = link(gl, POINT_VERT, POINT_FRAG);
1335
- const rectProgram = link(gl, RECT_VERT, RECT_FRAG);
1336
- const spriteProgram = link(gl, SPRITE_VERT, SPRITE_FRAG);
1337
- const msdfProgram = link(gl, SPRITE_VERT, MSDF_FRAG);
1338
- const circleQuadProgram = link(gl, SPRITE_VERT, CIRCLE_QUAD_FRAG);
1339
- if (!pointProgram || !rectProgram || !spriteProgram || !msdfProgram || !circleQuadProgram)
1403
+ const programs = [];
1404
+ const linkTracked = (vsSrc, fsSrc) => {
1405
+ const program = link(gl, vsSrc, fsSrc);
1406
+ if (program) programs.push(program);
1407
+ return program;
1408
+ };
1409
+ const pointProgram = linkTracked(POINT_VERT, POINT_FRAG);
1410
+ const rectProgram = linkTracked(RECT_VERT, RECT_FRAG);
1411
+ const spriteProgram = linkTracked(SPRITE_VERT, SPRITE_FRAG);
1412
+ const msdfProgram = linkTracked(SPRITE_VERT, MSDF_FRAG);
1413
+ const circleQuadProgram = linkTracked(SPRITE_VERT, CIRCLE_QUAD_FRAG);
1414
+ if (!pointProgram || !rectProgram || !spriteProgram || !msdfProgram || !circleQuadProgram) {
1415
+ for (const program of programs) gl.deleteProgram(program);
1416
+ gl.getExtension("WEBGL_lose_context")?.loseContext();
1340
1417
  return null;
1418
+ }
1341
1419
  gl.enable(gl.BLEND);
1342
1420
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
1343
1421
  const pAPos = gl.getAttribLocation(pointProgram, "a_pos");
@@ -1517,6 +1595,7 @@ function createWebGLPointRenderer(canvas) {
1517
1595
  ensureQuadIndices(spriteCount);
1518
1596
  gl.drawElements(gl.TRIANGLES, spriteCount * INDICES_PER_QUAD, gl.UNSIGNED_INT, 0);
1519
1597
  frameDrawCalls++;
1598
+ gl.bindVertexArray(null);
1520
1599
  spriteCount = 0;
1521
1600
  };
1522
1601
  const drawGlyphs = () => {
@@ -1795,6 +1874,7 @@ function createWebGLPointRenderer(canvas) {
1795
1874
  }
1796
1875
 
1797
1876
  // src/renderer/WebGPUParticleSystemManager.ts
1877
+ var recordComputePassScratch = new Float32Array(20);
1798
1878
  var COMPUTE_SHADER = `
1799
1879
  struct Particle {
1800
1880
  position: vec2<f32>,
@@ -1971,14 +2051,20 @@ var WebGPUParticleSystemManager = class {
1971
2051
  device;
1972
2052
  computePipeline = null;
1973
2053
  renderPipeline = null;
2054
+ // Held so destroy() can release them — modules were previously unreachable
2055
+ // after initPipelines and lived until context loss (#684).
2056
+ computeModule = null;
2057
+ renderModule = null;
1974
2058
  computeBindGroupLayout = null;
1975
2059
  renderBindGroupLayout = null;
1976
2060
  constructor(device) {
1977
2061
  this.device = device;
1978
2062
  }
1979
2063
  initPipelines(format) {
1980
- const computeModule = this.device.createShaderModule({ code: COMPUTE_SHADER });
1981
- const renderModule = this.device.createShaderModule({ code: RENDER_SHADER });
2064
+ this.computeModule = this.device.createShaderModule({ code: COMPUTE_SHADER });
2065
+ this.renderModule = this.device.createShaderModule({ code: RENDER_SHADER });
2066
+ const computeModule = this.computeModule;
2067
+ const renderModule = this.renderModule;
1982
2068
  this.computeBindGroupLayout = this.device.createBindGroupLayout({
1983
2069
  entries: [
1984
2070
  {
@@ -2037,6 +2123,8 @@ var WebGPUParticleSystemManager = class {
2037
2123
  });
2038
2124
  }
2039
2125
  setupEntityResources(entity) {
2126
+ releaseGpuObject(entity.gpuStorageBuffer);
2127
+ releaseGpuObject(entity.gpuUniformBuffer);
2040
2128
  const storageSize = entity.maxParticles * 32;
2041
2129
  entity.gpuStorageBuffer = this.device.createBuffer({
2042
2130
  size: storageSize,
@@ -2063,7 +2151,7 @@ var WebGPUParticleSystemManager = class {
2063
2151
  }
2064
2152
  recordComputePass(pass, entity, dt, mouseX, mouseY, width, height) {
2065
2153
  if (!this.computePipeline || !entity.computeBindGroup) return;
2066
- const uniformArray = new Float32Array(20);
2154
+ const uniformArray = recordComputePassScratch;
2067
2155
  const color = parseColorToRGBA(entity.baseColor);
2068
2156
  let opacity = 1;
2069
2157
  for (let current = entity; current; current = current.parent) {
@@ -2107,12 +2195,23 @@ var WebGPUParticleSystemManager = class {
2107
2195
  pass.draw(6, entity.maxParticles);
2108
2196
  }
2109
2197
  destroy() {
2198
+ releaseGpuObject(this.computePipeline);
2199
+ releaseGpuObject(this.renderPipeline);
2200
+ releaseGpuObject(this.computeModule);
2201
+ releaseGpuObject(this.renderModule);
2110
2202
  this.computePipeline = null;
2111
2203
  this.renderPipeline = null;
2204
+ this.computeModule = null;
2205
+ this.renderModule = null;
2112
2206
  this.computeBindGroupLayout = null;
2113
2207
  this.renderBindGroupLayout = null;
2114
2208
  }
2115
2209
  };
2210
+ function releaseGpuObject(obj) {
2211
+ if (obj && typeof obj.destroy === "function") {
2212
+ obj.destroy();
2213
+ }
2214
+ }
2116
2215
 
2117
2216
  // src/renderer/GlyphRasterAtlas.ts
2118
2217
  var HARD_MAX_SIZE = 8192;
@@ -2193,7 +2292,7 @@ var GlyphRasterAtlas = class {
2193
2292
  * (headless, unrasterizable, or too large to pack).
2194
2293
  */
2195
2294
  get(font, color, glyph) {
2196
- const key = font + "\0" + color + "\0" + glyph;
2295
+ const key = `${font.length}\0${font}${color.length}\0${color}${glyph.length}\0${glyph}`;
2197
2296
  const existing = this.slots.get(key);
2198
2297
  if (existing !== void 0) {
2199
2298
  if (existing) this._hits++;
@@ -2347,7 +2446,7 @@ var TextRasterCache = class {
2347
2446
  * fall back to {@link IRenderer.fillText}).
2348
2447
  */
2349
2448
  get(font, color, text) {
2350
- const key = font + "\0" + color + "\0" + text;
2449
+ const key = `${font.length}\0${font}${color.length}\0${color}${text.length}\0${text}`;
2351
2450
  const hit = this.cache.get(key);
2352
2451
  if (hit) {
2353
2452
  this._hits++;