@vectojs/core 1.20.0 → 1.22.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.
@@ -1,4 +1,19 @@
1
1
  // src/renderer/CanvasRenderer.ts
2
+ function emptyDrawCounters() {
3
+ return {
4
+ fills: 0,
5
+ strokes: 0,
6
+ texts: 0,
7
+ images: 0,
8
+ circles: 0,
9
+ flushes: 0,
10
+ saves: 0,
11
+ restores: 0,
12
+ clips: 0,
13
+ stateSwitches: 0,
14
+ overdrawRatio: 0
15
+ };
16
+ }
2
17
  var TWO_PI = Math.PI * 2;
3
18
  function getDevicePixelRatio() {
4
19
  return typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
@@ -39,6 +54,17 @@ var CanvasRenderer = class _CanvasRenderer {
39
54
  batchCount = 0;
40
55
  _cachedFont = "";
41
56
  _cachedFill = "";
57
+ /** Backend discriminator; see {@link IRenderer.kind}. */
58
+ kind = "canvas2d";
59
+ /**
60
+ * Draw counters, allocated only once counting is enabled.
61
+ *
62
+ * Null when off, so the guard on every op is a single null test and an inactive
63
+ * renderer carries no counter object at all.
64
+ */
65
+ counters = null;
66
+ /** Accumulated primitive area, kept separately so the ratio is derived on read. */
67
+ drawnArea = 0;
42
68
  /**
43
69
  * @param canvas - The target canvas. Its backing store is resized to the
44
70
  * logical size × devicePixelRatio.
@@ -135,19 +161,47 @@ var CanvasRenderer = class _CanvasRenderer {
135
161
  return this.contextLost;
136
162
  }
137
163
  /** @inheritdoc */
164
+ /** @inheritdoc */
165
+ setDrawCounters(enabled) {
166
+ if (!enabled) {
167
+ this.counters = null;
168
+ this.drawnArea = 0;
169
+ return;
170
+ }
171
+ if (!this.counters) this.counters = emptyDrawCounters();
172
+ }
173
+ /** @inheritdoc */
174
+ getDrawCounters() {
175
+ if (!this.counters) return null;
176
+ const area = this.width * this.height;
177
+ return {
178
+ ...this.counters,
179
+ // Derived on read rather than maintained: the ratio is only meaningful
180
+ // against the current surface size, which can change under resize.
181
+ overdrawRatio: area > 0 ? Math.round(this.drawnArea / area * 100) / 100 : 0
182
+ };
183
+ }
184
+ /** @inheritdoc */
185
+ clearDrawCounters() {
186
+ if (this.counters) this.counters = emptyDrawCounters();
187
+ this.drawnArea = 0;
188
+ }
138
189
  clear() {
139
190
  if (this.contextLost) return;
140
191
  this.flush();
192
+ if (this.counters) this.drawnArea = 0;
141
193
  this.ctx.clearRect(0, 0, this.width, this.height);
142
194
  }
143
195
  /** @inheritdoc */
144
196
  save() {
145
197
  this.flush();
198
+ if (this.counters) this.counters.saves++;
146
199
  this.ctx.save();
147
200
  }
148
201
  /** @inheritdoc */
149
202
  restore() {
150
203
  this.flush();
204
+ if (this.counters) this.counters.restores++;
151
205
  this.ctx.restore();
152
206
  this._cachedFont = "";
153
207
  this._cachedFill = "";
@@ -171,6 +225,7 @@ var CanvasRenderer = class _CanvasRenderer {
171
225
  /** @inheritdoc */
172
226
  clip(x, y, width, height) {
173
227
  this.flush();
228
+ if (this.counters) this.counters.clips++;
174
229
  this.ctx.beginPath();
175
230
  this.ctx.rect(x, y, width, height);
176
231
  this.ctx.clip();
@@ -207,9 +262,22 @@ var CanvasRenderer = class _CanvasRenderer {
207
262
  /** @inheritdoc */
208
263
  drawImage(source, dx, dy, dw, dh) {
209
264
  this.flush();
265
+ if (this.counters) {
266
+ this.counters.images++;
267
+ this.drawnArea += Math.abs(dw * dh);
268
+ }
210
269
  this.ctx.drawImage(source, dx, dy, dw, dh);
211
270
  }
212
271
  /** @inheritdoc */
272
+ drawImageRect(source, sx, sy, sw, sh, dx, dy, dw, dh) {
273
+ this.flush();
274
+ if (this.counters) {
275
+ this.counters.images++;
276
+ this.drawnArea += Math.abs(dw * dh);
277
+ }
278
+ this.ctx.drawImage(source, sx, sy, sw, sh, dx, dy, dw, dh);
279
+ }
280
+ /** @inheritdoc */
213
281
  fillCircle(cx, cy, radius, color, alpha = 1) {
214
282
  if (this.batchActive && (color !== this.batchColor || alpha !== this.batchAlpha)) {
215
283
  this.flush();
@@ -220,6 +288,10 @@ var CanvasRenderer = class _CanvasRenderer {
220
288
  this.batchColor = color;
221
289
  this.batchAlpha = alpha;
222
290
  }
291
+ if (this.counters) {
292
+ this.counters.circles++;
293
+ this.drawnArea += Math.PI * radius * radius;
294
+ }
223
295
  this.ctx.moveTo(cx + radius, cy);
224
296
  this.ctx.arc(cx, cy, radius, 0, TWO_PI);
225
297
  this.batchCount++;
@@ -228,6 +300,7 @@ var CanvasRenderer = class _CanvasRenderer {
228
300
  /** @inheritdoc */
229
301
  flush() {
230
302
  if (!this.batchActive) return;
303
+ if (this.counters) this.counters.flushes++;
231
304
  this.ctx.globalAlpha = this.batchAlpha;
232
305
  this.ctx.fillStyle = this.batchColor;
233
306
  this.ctx.fill();
@@ -238,7 +311,9 @@ var CanvasRenderer = class _CanvasRenderer {
238
311
  /** @inheritdoc */
239
312
  fill(color) {
240
313
  this.flush();
314
+ if (this.counters) this.counters.fills++;
241
315
  if (this._cachedFill !== color) {
316
+ if (this.counters) this.counters.stateSwitches++;
242
317
  this.ctx.fillStyle = color;
243
318
  this._cachedFill = color;
244
319
  }
@@ -247,6 +322,7 @@ var CanvasRenderer = class _CanvasRenderer {
247
322
  /** @inheritdoc */
248
323
  stroke(color, lineWidth = 1) {
249
324
  this.flush();
325
+ if (this.counters) this.counters.strokes++;
250
326
  this.ctx.strokeStyle = color;
251
327
  this.ctx.lineWidth = lineWidth;
252
328
  this.ctx.lineCap = "round";
@@ -256,11 +332,14 @@ var CanvasRenderer = class _CanvasRenderer {
256
332
  /** @inheritdoc */
257
333
  fillText(text, x, y, font, color) {
258
334
  this.flush();
335
+ if (this.counters) this.counters.texts++;
259
336
  if (this._cachedFont !== font) {
337
+ if (this.counters) this.counters.stateSwitches++;
260
338
  this.ctx.font = font;
261
339
  this._cachedFont = font;
262
340
  }
263
341
  if (this._cachedFill !== color) {
342
+ if (this.counters) this.counters.stateSwitches++;
264
343
  this.ctx.fillStyle = color;
265
344
  this._cachedFill = color;
266
345
  }
@@ -371,6 +450,8 @@ function fontFamilyFromShorthand(font, sizeToken) {
371
450
  return family || "sans-serif";
372
451
  }
373
452
  var SVGRenderer = class {
453
+ /** Backend discriminator; see {@link IRenderer.kind}. */
454
+ kind = "svg";
374
455
  width;
375
456
  height;
376
457
  buffer = [];
@@ -1223,6 +1304,12 @@ function createWebGLPointRenderer(canvas) {
1223
1304
  let spriteCount = 0;
1224
1305
  let glyphData = new Float32Array(FLOATS_PER_SPRITE_VERT * VERTS_PER_QUAD * 1024);
1225
1306
  let glyphCount = 0;
1307
+ let frameDrawCalls = 0;
1308
+ let lastFrameDrawCalls = 0;
1309
+ let totalDrawCalls = 0;
1310
+ let atlasSwitches = 0;
1311
+ let circleQuadFallbacks = 0;
1312
+ let circlePoints = 0;
1226
1313
  let circleQuadData = new Float32Array(FLOATS_PER_SPRITE_VERT * VERTS_PER_QUAD * 64);
1227
1314
  let circleQuadCount = 0;
1228
1315
  let logicalW = 0;
@@ -1245,6 +1332,7 @@ function createWebGLPointRenderer(canvas) {
1245
1332
  gl.uniform1f(gURange, distanceRange);
1246
1333
  ensureQuadIndices(glyphCount);
1247
1334
  gl.drawElements(gl.TRIANGLES, glyphCount * INDICES_PER_QUAD, gl.UNSIGNED_INT, 0);
1335
+ frameDrawCalls++;
1248
1336
  gl.bindVertexArray(null);
1249
1337
  glyphCount = 0;
1250
1338
  };
@@ -1261,7 +1349,23 @@ function createWebGLPointRenderer(canvas) {
1261
1349
  canvas.style.height = `${height}px`;
1262
1350
  gl.viewport(0, 0, canvas.width, canvas.height);
1263
1351
  },
1352
+ stats() {
1353
+ return {
1354
+ drawCalls: lastFrameDrawCalls,
1355
+ totalDrawCalls,
1356
+ atlasSwitches,
1357
+ // Fixed at creation: five programs compiled once, never per frame, so
1358
+ // these are capability facts rather than counters.
1359
+ programs: 5,
1360
+ textures: (texture ? 1 : 0) + (msdfTexture ? 1 : 0),
1361
+ circleQuadFallbacks,
1362
+ circlePoints
1363
+ };
1364
+ },
1264
1365
  begin() {
1366
+ lastFrameDrawCalls = frameDrawCalls;
1367
+ totalDrawCalls += frameDrawCalls;
1368
+ frameDrawCalls = 0;
1265
1369
  pointCount = 0;
1266
1370
  rectCount = 0;
1267
1371
  spriteCount = 0;
@@ -1314,6 +1418,7 @@ function createWebGLPointRenderer(canvas) {
1314
1418
  distanceRange = range;
1315
1419
  return;
1316
1420
  }
1421
+ atlasSwitches++;
1317
1422
  drawGlyphs();
1318
1423
  distanceRange = range;
1319
1424
  if (!msdfTexture) {
@@ -1356,6 +1461,7 @@ function createWebGLPointRenderer(canvas) {
1356
1461
  addCircle(x, y, radius, color, alpha = 1) {
1357
1462
  const needsQuad = logicalW > 0 && (x < radius || y < radius || x > logicalW - radius || y > logicalH - radius) || radius * 2 * dpr > maxPointSize;
1358
1463
  if (needsQuad) {
1464
+ circleQuadFallbacks++;
1359
1465
  const stride = FLOATS_PER_SPRITE_VERT * VERTS_PER_QUAD;
1360
1466
  circleQuadData = grow(circleQuadData, (circleQuadCount + 1) * stride);
1361
1467
  const rgba = parseColorToRGBA(color);
@@ -1380,6 +1486,7 @@ function createWebGLPointRenderer(canvas) {
1380
1486
  circleQuadCount++;
1381
1487
  return;
1382
1488
  }
1489
+ circlePoints++;
1383
1490
  pointData = grow(pointData, (pointCount + 1) * FLOATS_PER_POINT);
1384
1491
  const [r, g, b, a] = parseColorToRGBA(color);
1385
1492
  const o = pointCount * FLOATS_PER_POINT;
@@ -1421,6 +1528,7 @@ function createWebGLPointRenderer(canvas) {
1421
1528
  gl.uniform2f(rURes, logicalW, logicalH);
1422
1529
  ensureQuadIndices(rectCount);
1423
1530
  gl.drawElements(gl.TRIANGLES, rectCount * INDICES_PER_QUAD, gl.UNSIGNED_INT, 0);
1531
+ frameDrawCalls++;
1424
1532
  }
1425
1533
  if (pointCount > 0) {
1426
1534
  gl.useProgram(pointProgram);
@@ -1434,6 +1542,7 @@ function createWebGLPointRenderer(canvas) {
1434
1542
  gl.uniform2f(pURes, logicalW, logicalH);
1435
1543
  gl.uniform1f(pUDpr, dpr);
1436
1544
  gl.drawArrays(gl.POINTS, 0, pointCount);
1545
+ frameDrawCalls++;
1437
1546
  }
1438
1547
  if (circleQuadCount > 0) {
1439
1548
  const floats = circleQuadCount * VERTS_PER_QUAD * FLOATS_PER_SPRITE_VERT;
@@ -1444,6 +1553,7 @@ function createWebGLPointRenderer(canvas) {
1444
1553
  gl.uniform2f(cURes, logicalW, logicalH);
1445
1554
  ensureQuadIndices(circleQuadCount);
1446
1555
  gl.drawElements(gl.TRIANGLES, circleQuadCount * INDICES_PER_QUAD, gl.UNSIGNED_INT, 0);
1556
+ frameDrawCalls++;
1447
1557
  }
1448
1558
  if (spriteCount > 0 && texture) {
1449
1559
  const floats = spriteCount * VERTS_PER_QUAD * FLOATS_PER_SPRITE_VERT;
@@ -1457,6 +1567,7 @@ function createWebGLPointRenderer(canvas) {
1457
1567
  gl.uniform2f(sURes, logicalW, logicalH);
1458
1568
  ensureQuadIndices(spriteCount);
1459
1569
  gl.drawElements(gl.TRIANGLES, spriteCount * INDICES_PER_QUAD, gl.UNSIGNED_INT, 0);
1570
+ frameDrawCalls++;
1460
1571
  }
1461
1572
  gl.bindVertexArray(null);
1462
1573
  drawGlyphs();
@@ -1807,6 +1918,192 @@ var WebGPUParticleSystemManager = class {
1807
1918
  }
1808
1919
  };
1809
1920
 
1921
+ // src/renderer/GlyphRasterAtlas.ts
1922
+ var HARD_MAX_SIZE = 8192;
1923
+ var PAD = 2;
1924
+ var GlyphRasterAtlas = class {
1925
+ slots = /* @__PURE__ */ new Map();
1926
+ dpr;
1927
+ maxSize;
1928
+ canvas = null;
1929
+ ctx = null;
1930
+ /**
1931
+ * Shelf packing: glyphs land left-to-right on a row, then a new row starts.
1932
+ * A monospace grid produces near-uniform widths, so shelves waste very little
1933
+ * and cost one comparison per insert — a real 2D packer would buy nothing here.
1934
+ */
1935
+ penX = PAD;
1936
+ penY = PAD;
1937
+ rowHeight = 0;
1938
+ _hits = 0;
1939
+ _misses = 0;
1940
+ _resets = 0;
1941
+ constructor(options = {}) {
1942
+ this.dpr = Math.max(1, options.dpr ?? 1);
1943
+ this.maxSize = Math.min(HARD_MAX_SIZE, Math.max(256, options.maxSize ?? 2048));
1944
+ }
1945
+ /** Live instrumentation snapshot. */
1946
+ get stats() {
1947
+ return {
1948
+ hits: this._hits,
1949
+ misses: this._misses,
1950
+ size: this.slots.size,
1951
+ resets: this._resets
1952
+ };
1953
+ }
1954
+ /**
1955
+ * The atlas canvas, to pass as the blit source.
1956
+ *
1957
+ * `null` until the first successful {@link get}, and in any non-DOM context.
1958
+ */
1959
+ get source() {
1960
+ return this.canvas;
1961
+ }
1962
+ ensureCanvas() {
1963
+ if (this.ctx) return true;
1964
+ if (typeof document === "undefined") return false;
1965
+ const canvas = document.createElement("canvas");
1966
+ canvas.width = this.maxSize;
1967
+ canvas.height = this.maxSize;
1968
+ const ctx = canvas.getContext("2d");
1969
+ if (!ctx) return false;
1970
+ this.canvas = canvas;
1971
+ this.ctx = ctx;
1972
+ return true;
1973
+ }
1974
+ /**
1975
+ * Look up a glyph, rasterizing it into the atlas on first request.
1976
+ *
1977
+ * @param font - Full CSS `font` shorthand, used for measuring and painting.
1978
+ * @param color - CSS color baked into the pixels.
1979
+ * @param glyph - A single grapheme cluster. Long strings are rejected
1980
+ * (`null`): they defeat the atlas's fixed-slot packing and belong in
1981
+ * `fillText` or {@link TextRasterCache}.
1982
+ * @returns The slot, or `null` when the caller must fall back to `fillText`
1983
+ * (headless, unrasterizable, or too large to pack).
1984
+ */
1985
+ get(font, color, glyph) {
1986
+ const key = font + "\0" + color + "\0" + glyph;
1987
+ const existing = this.slots.get(key);
1988
+ if (existing !== void 0) {
1989
+ if (existing) this._hits++;
1990
+ return existing;
1991
+ }
1992
+ this._misses++;
1993
+ if (glyph.length === 0 || glyph.length > 8) {
1994
+ this.slots.set(key, null);
1995
+ return null;
1996
+ }
1997
+ if (!this.ensureCanvas()) return null;
1998
+ const ctx = this.ctx;
1999
+ ctx.font = font;
2000
+ const metrics = ctx.measureText(glyph);
2001
+ const advance = metrics.width;
2002
+ const ascent = metrics.actualBoundingBoxAscent || metrics.fontBoundingBoxAscent || advance * 0.9;
2003
+ const descent = metrics.actualBoundingBoxDescent || metrics.fontBoundingBoxDescent || advance * 0.25;
2004
+ const left = metrics.actualBoundingBoxLeft || 0;
2005
+ const right = metrics.actualBoundingBoxRight || advance;
2006
+ const offsetX = Math.ceil(left) + PAD;
2007
+ const offsetY = Math.ceil(ascent) + PAD;
2008
+ const w = offsetX + Math.ceil(right) + PAD;
2009
+ const h = offsetY + Math.ceil(descent) + PAD;
2010
+ if (!(w > 0) || !(h > 0)) {
2011
+ this.slots.set(key, null);
2012
+ return null;
2013
+ }
2014
+ const dpr = this.dpr;
2015
+ const dw = Math.ceil(w * dpr);
2016
+ const dh = Math.ceil(h * dpr);
2017
+ if (dw + PAD * 2 > this.maxSize || dh + PAD * 2 > this.maxSize) {
2018
+ this.slots.set(key, null);
2019
+ return null;
2020
+ }
2021
+ if (this.penX + dw + PAD > this.maxSize) {
2022
+ this.penX = PAD;
2023
+ this.penY += this.rowHeight + PAD;
2024
+ this.rowHeight = 0;
2025
+ }
2026
+ if (this.penY + dh + PAD > this.maxSize) {
2027
+ this.reset();
2028
+ if (!this.ensureCanvas()) return null;
2029
+ this.ctx.font = font;
2030
+ }
2031
+ const sx = this.penX;
2032
+ const sy = this.penY;
2033
+ const c = this.ctx;
2034
+ c.save();
2035
+ c.translate(sx, sy);
2036
+ if (dpr !== 1) c.scale(dpr, dpr);
2037
+ c.font = font;
2038
+ c.textBaseline = "alphabetic";
2039
+ c.fillStyle = color;
2040
+ c.fillText(glyph, offsetX, offsetY);
2041
+ c.restore();
2042
+ this.penX += dw + PAD;
2043
+ if (dh > this.rowHeight) this.rowHeight = dh;
2044
+ const slot = {
2045
+ sx,
2046
+ sy,
2047
+ sw: dw,
2048
+ sh: dh,
2049
+ w,
2050
+ h,
2051
+ offsetX,
2052
+ offsetY,
2053
+ glyph,
2054
+ font,
2055
+ advance,
2056
+ left,
2057
+ right
2058
+ };
2059
+ this.slots.set(key, slot);
2060
+ return slot;
2061
+ }
2062
+ /**
2063
+ * Find the slot occupying a source position, or `null`.
2064
+ *
2065
+ * The inverse of {@link get}: it maps a blit back to the glyph it drew. Exists
2066
+ * for instrumentation — a test or devtool that traces `drawImage` calls sees
2067
+ * only a source rect, and needs this to recover which cluster was painted and
2068
+ * with what metrics. Linear over resident slots, so it is a diagnostic, not a
2069
+ * per-frame call.
2070
+ */
2071
+ slotAt(sx, sy) {
2072
+ for (const slot of this.slots.values()) {
2073
+ if (slot && slot.sx === sx && slot.sy === sy) return slot;
2074
+ }
2075
+ return null;
2076
+ }
2077
+ /**
2078
+ * Drop every glyph and reuse the canvas.
2079
+ *
2080
+ * Call after a font or theme change: slots are keyed by `(font, color, glyph)`
2081
+ * so stale entries are never *returned* wrongly, but they do occupy space.
2082
+ */
2083
+ reset() {
2084
+ this.slots.clear();
2085
+ this.penX = PAD;
2086
+ this.penY = PAD;
2087
+ this.rowHeight = 0;
2088
+ this._resets++;
2089
+ if (this.ctx && this.canvas) {
2090
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
2091
+ }
2092
+ }
2093
+ /** Release the backing canvas and all slots. */
2094
+ destroy() {
2095
+ this.slots.clear();
2096
+ this.canvas = null;
2097
+ this.ctx = null;
2098
+ this.penX = PAD;
2099
+ this.penY = PAD;
2100
+ this.rowHeight = 0;
2101
+ this._hits = 0;
2102
+ this._misses = 0;
2103
+ this._resets = 0;
2104
+ }
2105
+ };
2106
+
1810
2107
  // src/renderer/TextRasterCache.ts
1811
2108
  var TextRasterCache = class {
1812
2109
  cache = /* @__PURE__ */ new Map();
@@ -1900,5 +2197,6 @@ export {
1900
2197
  parseColorToRGBA,
1901
2198
  createWebGLPointRenderer,
1902
2199
  WebGPUParticleSystemManager,
2200
+ GlyphRasterAtlas,
1903
2201
  TextRasterCache
1904
2202
  };
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export * from './renderer/WebGLPointRenderer';
5
5
  export * from './renderer/WebGPUParticleSystemManager';
6
6
  export * from './renderer/colorParse';
7
7
  export * from './renderer/url';
8
+ export * from './renderer/GlyphRasterAtlas';
8
9
  export * from './renderer/TextRasterCache';
9
10
  export * from './tree/Entity';
10
11
  export * from './tree/Scene';