@vectojs/core 0.2.7 → 0.2.9

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.
@@ -701,6 +701,7 @@ var SVGRenderer = (_class2 = class {
701
701
  }, _class2);
702
702
 
703
703
  // src/renderer/colorParse.ts
704
+ var CACHE_MAX = 1e3;
704
705
  var cache = /* @__PURE__ */ new Map();
705
706
  var fallbackCtx;
706
707
  function fromHex(hex) {
@@ -744,10 +745,17 @@ function fromCanvas(css) {
744
745
  }
745
746
  function parseColorToRGBA(css) {
746
747
  const hit = cache.get(css);
747
- if (hit) return hit;
748
+ if (hit) {
749
+ cache.delete(css);
750
+ cache.set(css, hit);
751
+ return hit;
752
+ }
748
753
  const trimmed = css.trim();
749
754
  const rgba = _nullishCoalesce(_nullishCoalesce(_nullishCoalesce((trimmed[0] === "#" ? fromHex(trimmed) : null), () => ( fromRgbFunc(trimmed))), () => ( fromCanvas(trimmed))), () => ( [0, 0, 0, 1]));
750
755
  cache.set(css, rgba);
756
+ if (cache.size > CACHE_MAX) {
757
+ cache.delete(cache.keys().next().value);
758
+ }
751
759
  return rgba;
752
760
  }
753
761
 
@@ -825,6 +833,19 @@ void main() {
825
833
  vec4 t = texture(u_tex, v_uv);
826
834
  outColor = vec4(t.rgb * v_tint.rgb, t.a * v_tint.a);
827
835
  }`;
836
+ var CIRCLE_QUAD_FRAG = `#version 300 es
837
+ precision mediump float;
838
+ in vec2 v_uv;
839
+ in vec4 v_tint;
840
+ out vec4 outColor;
841
+ void main() {
842
+ vec2 c = v_uv - 0.5;
843
+ float d = length(c);
844
+ float aa = fwidth(d);
845
+ float alpha = 1.0 - smoothstep(0.5 - aa, 0.5, d);
846
+ if (alpha <= 0.0) discard;
847
+ outColor = vec4(v_tint.rgb, v_tint.a * alpha);
848
+ }`;
828
849
  var MSDF_FRAG = `#version 300 es
829
850
  precision mediump float;
830
851
  uniform sampler2D u_tex;
@@ -891,7 +912,9 @@ function createWebGLPointRenderer(canvas) {
891
912
  const rectProgram = link(gl, RECT_VERT, RECT_FRAG);
892
913
  const spriteProgram = link(gl, SPRITE_VERT, SPRITE_FRAG);
893
914
  const msdfProgram = link(gl, SPRITE_VERT, MSDF_FRAG);
894
- if (!pointProgram || !rectProgram || !spriteProgram || !msdfProgram) return null;
915
+ const circleQuadProgram = link(gl, SPRITE_VERT, CIRCLE_QUAD_FRAG);
916
+ if (!pointProgram || !rectProgram || !spriteProgram || !msdfProgram || !circleQuadProgram)
917
+ return null;
895
918
  gl.enable(gl.BLEND);
896
919
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
897
920
  const pAPos = gl.getAttribLocation(pointProgram, "a_pos");
@@ -951,6 +974,20 @@ function createWebGLPointRenderer(canvas) {
951
974
  gl.vertexAttribPointer(gAUv, 2, gl.FLOAT, false, SPRITE_VERT_STRIDE, 8);
952
975
  gl.enableVertexAttribArray(gATint);
953
976
  gl.vertexAttribPointer(gATint, 4, gl.FLOAT, false, SPRITE_VERT_STRIDE, 16);
977
+ const cAPos = gl.getAttribLocation(circleQuadProgram, "a_pos");
978
+ const cAUv = gl.getAttribLocation(circleQuadProgram, "a_uv");
979
+ const cATint = gl.getAttribLocation(circleQuadProgram, "a_tint");
980
+ const cURes = gl.getUniformLocation(circleQuadProgram, "u_resolution");
981
+ const circleQuadBuffer = gl.createBuffer();
982
+ const circleQuadVAO = gl.createVertexArray();
983
+ gl.bindVertexArray(circleQuadVAO);
984
+ gl.bindBuffer(gl.ARRAY_BUFFER, circleQuadBuffer);
985
+ gl.enableVertexAttribArray(cAPos);
986
+ gl.vertexAttribPointer(cAPos, 2, gl.FLOAT, false, SPRITE_VERT_STRIDE, 0);
987
+ gl.enableVertexAttribArray(cAUv);
988
+ gl.vertexAttribPointer(cAUv, 2, gl.FLOAT, false, SPRITE_VERT_STRIDE, 8);
989
+ gl.enableVertexAttribArray(cATint);
990
+ gl.vertexAttribPointer(cATint, 4, gl.FLOAT, false, SPRITE_VERT_STRIDE, 16);
954
991
  gl.bindVertexArray(null);
955
992
  let texture = null;
956
993
  let textureSource = null;
@@ -965,10 +1002,16 @@ function createWebGLPointRenderer(canvas) {
965
1002
  let spriteCount = 0;
966
1003
  let glyphData = new Float32Array(FLOATS_PER_SPRITE_VERT * VERTS_PER_SPRITE * 1024);
967
1004
  let glyphCount = 0;
1005
+ let circleQuadData = new Float32Array(
1006
+ FLOATS_PER_SPRITE_VERT * VERTS_PER_SPRITE * 64
1007
+ );
1008
+ let circleQuadCount = 0;
968
1009
  let logicalW = 0;
969
1010
  let logicalH = 0;
970
1011
  let dpr = 1;
971
1012
  let destroyed = false;
1013
+ const pointSizeRange = typeof gl.getParameter === "function" ? gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE) : null;
1014
+ const maxPointSize = pointSizeRange ? pointSizeRange[1] : Infinity;
972
1015
  const drawGlyphs = () => {
973
1016
  if (glyphCount === 0 || !msdfTexture) return;
974
1017
  const floats = glyphCount * VERTS_PER_SPRITE * FLOATS_PER_SPRITE_VERT;
@@ -1001,6 +1044,7 @@ function createWebGLPointRenderer(canvas) {
1001
1044
  rectCount = 0;
1002
1045
  spriteCount = 0;
1003
1046
  glyphCount = 0;
1047
+ circleQuadCount = 0;
1004
1048
  gl.clearColor(0, 0, 0, 0);
1005
1049
  gl.clear(gl.COLOR_BUFFER_BIT);
1006
1050
  },
@@ -1108,6 +1152,35 @@ function createWebGLPointRenderer(canvas) {
1108
1152
  glyphCount++;
1109
1153
  },
1110
1154
  addCircle(x, y, radius, color, alpha = 1) {
1155
+ const needsQuad = logicalW > 0 && (x < radius || y < radius || x > logicalW - radius || y > logicalH - radius) || radius * 2 * dpr > maxPointSize;
1156
+ if (needsQuad) {
1157
+ const stride = FLOATS_PER_SPRITE_VERT * VERTS_PER_SPRITE;
1158
+ circleQuadData = grow(circleQuadData, (circleQuadCount + 1) * stride);
1159
+ const [qr, qg, qb, qa] = parseColorToRGBA(color);
1160
+ const al = qa * alpha;
1161
+ const quad = [
1162
+ [x - radius, y - radius, 0, 0],
1163
+ [x + radius, y - radius, 1, 0],
1164
+ [x + radius, y + radius, 1, 1],
1165
+ [x - radius, y + radius, 0, 1]
1166
+ ];
1167
+ const order = [0, 1, 2, 0, 2, 3];
1168
+ let o2 = circleQuadCount * stride;
1169
+ for (const i of order) {
1170
+ const [vx, vy, vu, vv] = quad[i];
1171
+ circleQuadData[o2] = vx;
1172
+ circleQuadData[o2 + 1] = vy;
1173
+ circleQuadData[o2 + 2] = vu;
1174
+ circleQuadData[o2 + 3] = vv;
1175
+ circleQuadData[o2 + 4] = qr;
1176
+ circleQuadData[o2 + 5] = qg;
1177
+ circleQuadData[o2 + 6] = qb;
1178
+ circleQuadData[o2 + 7] = al;
1179
+ o2 += FLOATS_PER_SPRITE_VERT;
1180
+ }
1181
+ circleQuadCount++;
1182
+ return;
1183
+ }
1111
1184
  pointData = grow(pointData, (pointCount + 1) * FLOATS_PER_POINT);
1112
1185
  const [r, g, b, a] = parseColorToRGBA(color);
1113
1186
  const o = pointCount * FLOATS_PER_POINT;
@@ -1171,6 +1244,15 @@ function createWebGLPointRenderer(canvas) {
1171
1244
  gl.uniform1f(pUDpr, dpr);
1172
1245
  gl.drawArrays(gl.POINTS, 0, pointCount);
1173
1246
  }
1247
+ if (circleQuadCount > 0) {
1248
+ const floats = circleQuadCount * VERTS_PER_SPRITE * FLOATS_PER_SPRITE_VERT;
1249
+ gl.useProgram(circleQuadProgram);
1250
+ gl.bindVertexArray(circleQuadVAO);
1251
+ gl.bindBuffer(gl.ARRAY_BUFFER, circleQuadBuffer);
1252
+ gl.bufferData(gl.ARRAY_BUFFER, circleQuadData.subarray(0, floats), gl.DYNAMIC_DRAW);
1253
+ gl.uniform2f(cURes, logicalW, logicalH);
1254
+ gl.drawArrays(gl.TRIANGLES, 0, circleQuadCount * VERTS_PER_SPRITE);
1255
+ }
1174
1256
  if (spriteCount > 0 && texture) {
1175
1257
  const floats = spriteCount * VERTS_PER_SPRITE * FLOATS_PER_SPRITE_VERT;
1176
1258
  gl.useProgram(spriteProgram);
@@ -1193,14 +1275,17 @@ function createWebGLPointRenderer(canvas) {
1193
1275
  gl.deleteBuffer(rectBuffer);
1194
1276
  gl.deleteBuffer(spriteBuffer);
1195
1277
  gl.deleteBuffer(glyphBuffer);
1278
+ gl.deleteBuffer(circleQuadBuffer);
1196
1279
  gl.deleteVertexArray(pointVAO);
1197
1280
  gl.deleteVertexArray(rectVAO);
1198
1281
  gl.deleteVertexArray(spriteVAO);
1199
1282
  gl.deleteVertexArray(glyphVAO);
1283
+ gl.deleteVertexArray(circleQuadVAO);
1200
1284
  gl.deleteProgram(pointProgram);
1201
1285
  gl.deleteProgram(rectProgram);
1202
1286
  gl.deleteProgram(spriteProgram);
1203
1287
  gl.deleteProgram(msdfProgram);
1288
+ gl.deleteProgram(circleQuadProgram);
1204
1289
  if (texture) gl.deleteTexture(texture);
1205
1290
  if (msdfTexture) gl.deleteTexture(msdfTexture);
1206
1291
  }
@@ -32,6 +32,13 @@ function computeLineSegments(top, bottom, maxWidth, exclusions) {
32
32
  }
33
33
  var LayoutEngine = class {
34
34
  maxWidth;
35
+ /**
36
+ * Horizontal alignment. `'justify'` stretches inter-word spaces (or, for
37
+ * space-less CJK lines, inter-character gaps) so wrapped lines end flush at
38
+ * `maxWidth`; the last line of each paragraph stays ragged. Only applies to
39
+ * the object layout path without exclusion shapes.
40
+ */
41
+ textAlign = "left";
35
42
  maxHeight;
36
43
  preserveLeadingSpaces = false;
37
44
  wordSegmenter;
@@ -50,6 +57,22 @@ var LayoutEngine = class {
50
57
  richParagraphCache = /* @__PURE__ */ new Map();
51
58
  lastAtlas = null;
52
59
  measurer;
60
+ _hyphenate = null;
61
+ /**
62
+ * Optional hyphenator: given a word, return its break parts (e.g.
63
+ * `['hyphen', 'ation']`). Used at wrap time when a word doesn't fit; a
64
+ * visible '-' is drawn at the chosen break. Soft hyphens (U+00AD) in the
65
+ * source work without any hyphenator. Setting this clears the prepared
66
+ * caches (break opportunities are baked in during prepare()).
67
+ */
68
+ get hyphenate() {
69
+ return this._hyphenate;
70
+ }
71
+ set hyphenate(fn) {
72
+ this._hyphenate = fn;
73
+ this.paragraphCache.clear();
74
+ this.richParagraphCache.clear();
75
+ }
53
76
  constructor(maxWidth, maxHeight, measurer) {
54
77
  this.maxWidth = maxWidth;
55
78
  this.maxHeight = maxHeight;
@@ -157,7 +180,13 @@ var LayoutEngine = class {
157
180
  const word = segment.segment;
158
181
  const glyphs = [];
159
182
  let width = 0;
183
+ let breakPoints;
160
184
  for (const char of this.getGraphemes(word)) {
185
+ if (char === "\xAD") {
186
+ (breakPoints ??= []).push(glyphs.length);
187
+ shapedCharIdx += char.length;
188
+ continue;
189
+ }
161
190
  const visualStart = shapedCharIdx;
162
191
  const visualEnd = shapedCharIdx + char.length;
163
192
  const rawStart = indexMap[visualStart];
@@ -182,11 +211,23 @@ var LayoutEngine = class {
182
211
  width += w;
183
212
  shapedCharIdx += char.length;
184
213
  }
214
+ if (!breakPoints && this._hyphenate && segment.isWordLike && glyphs.length > 3) {
215
+ const parts = this._hyphenate(word);
216
+ if (parts.length > 1) {
217
+ breakPoints = [];
218
+ let count = 0;
219
+ for (let pi = 0; pi < parts.length - 1; pi++) {
220
+ for (const _g of this.getGraphemes(parts[pi])) count++;
221
+ breakPoints.push(count);
222
+ }
223
+ }
224
+ }
185
225
  words.push({
186
226
  glyphs,
187
227
  width,
188
228
  isWordLike: segment.isWordLike,
189
- isWhitespace: word.trim().length === 0
229
+ isWhitespace: word.trim().length === 0,
230
+ breakPoints
190
231
  });
191
232
  }
192
233
  const prepared = {
@@ -200,7 +241,12 @@ var LayoutEngine = class {
200
241
  paragraphs.push(prepared);
201
242
  offset += paragraph.length + 1;
202
243
  }
203
- return { paragraphs, fontSize, fallbackToCanvas: fallbackToCanvas || void 0 };
244
+ return {
245
+ paragraphs,
246
+ fontSize,
247
+ fallbackToCanvas: fallbackToCanvas || void 0,
248
+ hyphenWidth: this.glyphWidth(this.glyphKeyFor("-", fontAtlas), fontAtlas, fontSize)
249
+ };
204
250
  }
205
251
  /**
206
252
  * **Cold pass for rich text.** Like {@link prepare}, but takes an array of
@@ -274,7 +320,13 @@ var LayoutEngine = class {
274
320
  const word = segment.segment;
275
321
  const glyphs = [];
276
322
  let width = 0;
323
+ let breakPoints;
277
324
  for (const char of this.getGraphemes(word)) {
325
+ if (char === "\xAD") {
326
+ (breakPoints ??= []).push(glyphs.length);
327
+ shapedCharIdx += char.length;
328
+ continue;
329
+ }
278
330
  const visualStart = shapedCharIdx;
279
331
  const visualEnd = shapedCharIdx + char.length;
280
332
  const rawStart = indexMap[visualStart];
@@ -302,11 +354,23 @@ var LayoutEngine = class {
302
354
  width += w;
303
355
  shapedCharIdx += char.length;
304
356
  }
357
+ if (!breakPoints && this._hyphenate && segment.isWordLike && glyphs.length > 3) {
358
+ const parts = this._hyphenate(word);
359
+ if (parts.length > 1) {
360
+ breakPoints = [];
361
+ let count = 0;
362
+ for (let pi = 0; pi < parts.length - 1; pi++) {
363
+ for (const _g of this.getGraphemes(parts[pi])) count++;
364
+ breakPoints.push(count);
365
+ }
366
+ }
367
+ }
305
368
  words.push({
306
369
  glyphs,
307
370
  width,
308
371
  isWordLike: segment.isWordLike,
309
- isWhitespace: word.trim().length === 0
372
+ isWhitespace: word.trim().length === 0,
373
+ breakPoints
310
374
  });
311
375
  }
312
376
  const prepared = {
@@ -350,7 +414,7 @@ var LayoutEngine = class {
350
414
  let si = 0;
351
415
  let currentLineNodes = [];
352
416
  let paragraphBaseLevel = 0;
353
- const commitLine = () => {
417
+ const commitLine = (justifyTo) => {
354
418
  if (currentLineNodes.length === 0) return;
355
419
  const runs = [];
356
420
  let currentRun = [];
@@ -375,6 +439,37 @@ var LayoutEngine = class {
375
439
  node.isRTL = node.level % 2 === 1;
376
440
  x += node.width;
377
441
  }
442
+ if (justifyTo !== void 0 && runs.length === 1) {
443
+ let lastContent = run.length - 1;
444
+ while (lastContent >= 0 && run[lastContent].char.trim() === "") lastContent--;
445
+ if (lastContent > 0) {
446
+ const contentEnd = run[lastContent].x + run[lastContent].width;
447
+ const slack = justifyTo - contentEnd;
448
+ if (slack > 0 && slack <= (justifyTo - runStartX) * 0.5) {
449
+ const spaceIdx = [];
450
+ for (let k = 1; k < lastContent; k++) {
451
+ if (run[k].char.trim() === "") spaceIdx.push(k);
452
+ }
453
+ if (spaceIdx.length > 0) {
454
+ const extra = slack / spaceIdx.length;
455
+ let shift = 0;
456
+ let nextSpace = 0;
457
+ for (let k = 0; k <= lastContent; k++) {
458
+ run[k].x += shift;
459
+ if (nextSpace < spaceIdx.length && k === spaceIdx[nextSpace]) {
460
+ run[k].width += extra;
461
+ shift += extra;
462
+ nextSpace++;
463
+ }
464
+ }
465
+ } else {
466
+ const extra = slack / lastContent;
467
+ for (let k = 1; k <= lastContent; k++) run[k].x += extra * k;
468
+ }
469
+ if (justifyTo > maxLineWidth) maxLineWidth = justifyTo;
470
+ }
471
+ }
472
+ }
378
473
  for (const node of run) {
379
474
  layoutNodes.push(node);
380
475
  }
@@ -394,6 +489,8 @@ var LayoutEngine = class {
394
489
  }
395
490
  return false;
396
491
  };
492
+ const justifyTarget = this.textAlign === "justify" && !hasEx ? this.maxWidth : void 0;
493
+ const hyphenWidth = prepared.hyphenWidth ?? fontSize * 0.3;
397
494
  for (const paragraph of prepared.paragraphs) {
398
495
  if (paragraph.isEmpty) {
399
496
  commitLine();
@@ -411,16 +508,65 @@ var LayoutEngine = class {
411
508
  }
412
509
  const lineHeight = pMax * 1.5;
413
510
  if (!startLine(lineHeight)) break;
414
- for (const word of paragraph.words) {
415
- if (currentX + word.width > segs[si].x1 && currentX > segs[si].x0) {
416
- if (word.isWordLike === false && word.isWhitespace) continue;
417
- if (si < segs.length - 1) {
418
- si++;
419
- currentX = segs[si].x0;
420
- } else {
421
- commitLine();
422
- currentY += lineHeight;
423
- if (!startLine(lineHeight)) break;
511
+ const wordQueue = paragraph.words.slice();
512
+ for (let qi = 0; qi < wordQueue.length; qi++) {
513
+ const word = wordQueue[qi];
514
+ if (currentX + word.width > segs[si].x1) {
515
+ if (!hasEx && word.breakPoints && word.breakPoints.length > 0) {
516
+ const avail = segs[si].x1 - currentX;
517
+ let chosen = -1;
518
+ let prefixWidth = 0;
519
+ let acc = 0;
520
+ let bpIdx = 0;
521
+ for (let g = 0; g < word.glyphs.length && bpIdx < word.breakPoints.length; g++) {
522
+ acc += word.glyphs[g].width;
523
+ if (g + 1 === word.breakPoints[bpIdx]) {
524
+ if (acc + hyphenWidth <= avail) {
525
+ chosen = word.breakPoints[bpIdx];
526
+ prefixWidth = acc;
527
+ }
528
+ bpIdx++;
529
+ }
530
+ }
531
+ if (chosen > 0) {
532
+ const anchorGlyph = word.glyphs[chosen - 1];
533
+ const prefix = {
534
+ glyphs: [
535
+ ...word.glyphs.slice(0, chosen),
536
+ {
537
+ char: "-",
538
+ width: hyphenWidth,
539
+ level: anchorGlyph.level,
540
+ sourceIndex: anchorGlyph.sourceIndex,
541
+ sourceLength: 0
542
+ }
543
+ ],
544
+ width: prefixWidth + hyphenWidth,
545
+ isWordLike: true,
546
+ isWhitespace: false
547
+ };
548
+ const rest = {
549
+ glyphs: word.glyphs.slice(chosen),
550
+ width: word.width - prefixWidth,
551
+ isWordLike: true,
552
+ isWhitespace: false,
553
+ breakPoints: word.breakPoints.filter((bp) => bp > chosen).map((bp) => bp - chosen)
554
+ };
555
+ wordQueue.splice(qi, 1, prefix, rest);
556
+ qi--;
557
+ continue;
558
+ }
559
+ }
560
+ if (currentX > segs[si].x0) {
561
+ if (word.isWordLike === false && word.isWhitespace) continue;
562
+ if (si < segs.length - 1) {
563
+ si++;
564
+ currentX = segs[si].x0;
565
+ } else {
566
+ commitLine(justifyTarget);
567
+ currentY += lineHeight;
568
+ if (!startLine(lineHeight)) break;
569
+ }
424
570
  }
425
571
  }
426
572
  for (const glyph of word.glyphs) {
@@ -433,7 +579,7 @@ var LayoutEngine = class {
433
579
  si++;
434
580
  currentX = segs[si].x0;
435
581
  } else {
436
- commitLine();
582
+ commitLine(justifyTarget);
437
583
  currentY += lineHeight;
438
584
  if (!startLine(lineHeight)) break;
439
585
  }
@@ -360,10 +360,21 @@ var Entity = class {
360
360
  /**
361
361
  * Append a child entity to this node's children array.
362
362
  *
363
+ * If `child` already has a parent (including `this`), it's detached from it
364
+ * first — otherwise re-adding an already-added child duplicates it in
365
+ * `children[]` (one `remove()` call only strips the first occurrence,
366
+ * leaving a stale entry that keeps rendering/updating despite
367
+ * `child.parent` reporting `null`), and re-parenting to a different entity
368
+ * without an explicit `remove()` first leaves the old parent holding a
369
+ * stale reference whose own `.parent` disagrees with where it now lives.
370
+ * `child.parent` is only ever `null` or `this`'s ultimate owner, so this
371
+ * check is O(1) for the overwhelming common case (a brand-new entity).
372
+ *
363
373
  * @param child - The entity to add as a child.
364
374
  * @returns `this` for method chaining.
365
375
  */
366
376
  add(child) {
377
+ if (child.parent) child.parent.remove(child);
367
378
  child.parent = this;
368
379
  this.children.push(child);
369
380
  const s = this.scene;
@@ -434,6 +445,7 @@ var Entity = class {
434
445
  startTime: -1,
435
446
  startProps: {}
436
447
  });
448
+ this.scene?.markDirty();
437
449
  return this;
438
450
  }
439
451
  /** Write a driver-computed value to a backing field without re-triggering the setter. */
@@ -715,9 +727,9 @@ var Entity = class {
715
727
  * Return the exact accumulated Canvas `T * S * R` transform for this entity.
716
728
  */
717
729
  getWorldTransform() {
718
- const path = this.id === "root" ? [] : [this];
730
+ const path = [this];
719
731
  let ancestor = this.parent;
720
- while (ancestor && ancestor.id !== "root") {
732
+ while (ancestor) {
721
733
  path.push(ancestor);
722
734
  ancestor = ancestor.parent;
723
735
  }
@@ -801,8 +813,8 @@ var Entity = class {
801
813
  }
802
814
  /**
803
815
  * Accumulated world scale factors: this entity's own `scaleX`/`scaleY` times
804
- * those of every ancestor (excluding the scene root). Useful for mapping a
805
- * world-space point back into local space for hit-testing.
816
+ * those of every ancestor. Useful for mapping a world-space point back into
817
+ * local space for hit-testing.
806
818
  *
807
819
  * @returns The world scale `{ x, y }`.
808
820
  */
@@ -810,7 +822,7 @@ var Entity = class {
810
822
  let sx = this.scaleX;
811
823
  let sy = this.scaleY;
812
824
  let curr = this.parent;
813
- while (curr && curr.id !== "root") {
825
+ while (curr) {
814
826
  sx *= curr.scaleX;
815
827
  sy *= curr.scaleY;
816
828
  curr = curr.parent;
@@ -819,14 +831,14 @@ var Entity = class {
819
831
  }
820
832
  /**
821
833
  * Accumulated world rotation: this entity's own `rotation` plus
822
- * that of every ancestor (excluding the scene root).
834
+ * that of every ancestor.
823
835
  *
824
836
  * @returns The accumulated world rotation in radians.
825
837
  */
826
838
  getWorldRotation() {
827
839
  let rot = this.rotation;
828
840
  let curr = this.parent;
829
- while (curr && curr.id !== "root") {
841
+ while (curr) {
830
842
  rot += curr.rotation;
831
843
  curr = curr.parent;
832
844
  }
@@ -360,10 +360,21 @@ var Entity = (_class4 = class {
360
360
  /**
361
361
  * Append a child entity to this node's children array.
362
362
  *
363
+ * If `child` already has a parent (including `this`), it's detached from it
364
+ * first — otherwise re-adding an already-added child duplicates it in
365
+ * `children[]` (one `remove()` call only strips the first occurrence,
366
+ * leaving a stale entry that keeps rendering/updating despite
367
+ * `child.parent` reporting `null`), and re-parenting to a different entity
368
+ * without an explicit `remove()` first leaves the old parent holding a
369
+ * stale reference whose own `.parent` disagrees with where it now lives.
370
+ * `child.parent` is only ever `null` or `this`'s ultimate owner, so this
371
+ * check is O(1) for the overwhelming common case (a brand-new entity).
372
+ *
363
373
  * @param child - The entity to add as a child.
364
374
  * @returns `this` for method chaining.
365
375
  */
366
376
  add(child) {
377
+ if (child.parent) child.parent.remove(child);
367
378
  child.parent = this;
368
379
  this.children.push(child);
369
380
  const s = this.scene;
@@ -434,6 +445,7 @@ var Entity = (_class4 = class {
434
445
  startTime: -1,
435
446
  startProps: {}
436
447
  });
448
+ _optionalChain([this, 'access', _40 => _40.scene, 'optionalAccess', _41 => _41.markDirty, 'call', _42 => _42()]);
437
449
  return this;
438
450
  }
439
451
  /** Write a driver-computed value to a backing field without re-triggering the setter. */
@@ -489,10 +501,10 @@ var Entity = (_class4 = class {
489
501
  const active = driver;
490
502
  const onDone = active.onDone;
491
503
  active.onDone = void 0;
492
- _optionalChain([onDone, 'optionalCall', _40 => _40()]);
504
+ _optionalChain([onDone, 'optionalCall', _43 => _43()]);
493
505
  }
494
506
  _spawnDriver(prop, to, cfg) {
495
- if (prop !== "opacity" && _optionalChain([this, 'access', _41 => _41.scene, 'optionalAccess', _42 => _42.prefersReducedMotion])) {
507
+ if (prop !== "opacity" && _optionalChain([this, 'access', _44 => _44.scene, 'optionalAccess', _45 => _45.prefersReducedMotion])) {
496
508
  const existing2 = this._drivers.get(prop);
497
509
  if (existing2) this._settleDriver(existing2);
498
510
  this._drivers.delete(prop);
@@ -508,11 +520,11 @@ var Entity = (_class4 = class {
508
520
  const from = this._currentOf(prop);
509
521
  const driver = isTweenConfig(cfg) ? new TweenDriver(from, to, cfg) : new SpringDriver(from, to, cfg === "spring" ? {} : cfg);
510
522
  this._drivers.set(prop, driver);
511
- _optionalChain([this, 'access', _43 => _43.scene, 'optionalAccess', _44 => _44.markDirty, 'call', _45 => _45()]);
523
+ _optionalChain([this, 'access', _46 => _46.scene, 'optionalAccess', _47 => _47.markDirty, 'call', _48 => _48()]);
512
524
  }
513
525
  /** Assignment path when a declarative transition is configured for `prop`. */
514
526
  _animateProp(prop, to) {
515
- const cfg = _optionalChain([this, 'access', _46 => _46._transitions, 'optionalAccess', _47 => _47.get, 'call', _48 => _48(prop)]);
527
+ const cfg = _optionalChain([this, 'access', _49 => _49._transitions, 'optionalAccess', _50 => _50.get, 'call', _51 => _51(prop)]);
516
528
  if (!cfg) {
517
529
  this._applyAnimated(prop, to);
518
530
  return;
@@ -562,7 +574,7 @@ var Entity = (_class4 = class {
562
574
  this._applyAnimated(prop, driver.value);
563
575
  }
564
576
  }
565
- _optionalChain([this, 'access', _49 => _49.scene, 'optionalAccess', _50 => _50.markDirty, 'call', _51 => _51()]);
577
+ _optionalChain([this, 'access', _52 => _52.scene, 'optionalAccess', _53 => _53.markDirty, 'call', _54 => _54()]);
566
578
  }
567
579
  /**
568
580
  * Advance the entity's internal state for one frame.
@@ -616,7 +628,7 @@ var Entity = (_class4 = class {
616
628
  * @example entity.on('click', (e) => console.log('clicked', e));
617
629
  */
618
630
  on(event, callback, options) {
619
- const map = _optionalChain([options, 'optionalAccess', _52 => _52.capture]) ? this.captureListeners : this.listeners;
631
+ const map = _optionalChain([options, 'optionalAccess', _55 => _55.capture]) ? this.captureListeners : this.listeners;
620
632
  if (!map.has(event)) {
621
633
  map.set(event, []);
622
634
  }
@@ -632,7 +644,7 @@ var Entity = (_class4 = class {
632
644
  * @returns `this` for method chaining.
633
645
  */
634
646
  off(event, callback, options) {
635
- const handlers = (_optionalChain([options, 'optionalAccess', _53 => _53.capture]) ? this.captureListeners : this.listeners).get(event);
647
+ const handlers = (_optionalChain([options, 'optionalAccess', _56 => _56.capture]) ? this.captureListeners : this.listeners).get(event);
636
648
  if (handlers) {
637
649
  const idx = handlers.indexOf(callback);
638
650
  if (idx !== -1) handlers.splice(idx, 1);
@@ -715,9 +727,9 @@ var Entity = (_class4 = class {
715
727
  * Return the exact accumulated Canvas `T * S * R` transform for this entity.
716
728
  */
717
729
  getWorldTransform() {
718
- const path = this.id === "root" ? [] : [this];
730
+ const path = [this];
719
731
  let ancestor = this.parent;
720
- while (ancestor && ancestor.id !== "root") {
732
+ while (ancestor) {
721
733
  path.push(ancestor);
722
734
  ancestor = ancestor.parent;
723
735
  }
@@ -801,8 +813,8 @@ var Entity = (_class4 = class {
801
813
  }
802
814
  /**
803
815
  * Accumulated world scale factors: this entity's own `scaleX`/`scaleY` times
804
- * those of every ancestor (excluding the scene root). Useful for mapping a
805
- * world-space point back into local space for hit-testing.
816
+ * those of every ancestor. Useful for mapping a world-space point back into
817
+ * local space for hit-testing.
806
818
  *
807
819
  * @returns The world scale `{ x, y }`.
808
820
  */
@@ -810,7 +822,7 @@ var Entity = (_class4 = class {
810
822
  let sx = this.scaleX;
811
823
  let sy = this.scaleY;
812
824
  let curr = this.parent;
813
- while (curr && curr.id !== "root") {
825
+ while (curr) {
814
826
  sx *= curr.scaleX;
815
827
  sy *= curr.scaleY;
816
828
  curr = curr.parent;
@@ -819,14 +831,14 @@ var Entity = (_class4 = class {
819
831
  }
820
832
  /**
821
833
  * Accumulated world rotation: this entity's own `rotation` plus
822
- * that of every ancestor (excluding the scene root).
834
+ * that of every ancestor.
823
835
  *
824
836
  * @returns The accumulated world rotation in radians.
825
837
  */
826
838
  getWorldRotation() {
827
839
  let rot = this.rotation;
828
840
  let curr = this.parent;
829
- while (curr && curr.id !== "root") {
841
+ while (curr) {
830
842
  rot += curr.rotation;
831
843
  curr = curr.parent;
832
844
  }
@@ -1076,7 +1088,7 @@ var MSDFTextEntity = (_class6 = class extends Entity {
1076
1088
  if (res.seqId < this.lastRenderedSeqId) return;
1077
1089
  this.lastRenderedSeqId = res.seqId;
1078
1090
  this.layoutResult = res;
1079
- _optionalChain([this, 'access', _54 => _54.scene, 'optionalAccess', _55 => _55.markDirty, 'call', _56 => _56()]);
1091
+ _optionalChain([this, 'access', _57 => _57.scene, 'optionalAccess', _58 => _58.markDirty, 'call', _59 => _59()]);
1080
1092
  }
1081
1093
  });
1082
1094
  }