@blorkfield/overlay-core 0.4.3 → 0.5.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.
package/dist/index.js CHANGED
@@ -464,7 +464,7 @@ function createBodyFromVertices(id, x, y, vertices, renderOptions) {
464
464
  function createBoundariesWithFloorConfig(bounds, floorConfig) {
465
465
  const width = bounds.right - bounds.left;
466
466
  const height = bounds.bottom - bounds.top;
467
- const options = { isStatic: true, render: { visible: false } };
467
+ const wallOptions = { isStatic: true, render: { visible: false } };
468
468
  const walls = [
469
469
  // Left wall
470
470
  Matter2.Bodies.rectangle(
@@ -472,7 +472,7 @@ function createBoundariesWithFloorConfig(bounds, floorConfig) {
472
472
  bounds.top + height / 2,
473
473
  BOUNDARY_THICKNESS,
474
474
  height,
475
- { ...options, label: "leftWall" }
475
+ { ...wallOptions, label: "leftWall" }
476
476
  ),
477
477
  // Right wall
478
478
  Matter2.Bodies.rectangle(
@@ -480,23 +480,44 @@ function createBoundariesWithFloorConfig(bounds, floorConfig) {
480
480
  bounds.top + height / 2,
481
481
  BOUNDARY_THICKNESS,
482
482
  height,
483
- { ...options, label: "rightWall" }
483
+ { ...wallOptions, label: "rightWall" }
484
484
  )
485
485
  ];
486
486
  const segmentCount = floorConfig?.segments ?? 1;
487
- const segmentWidth = width / segmentCount;
488
487
  const floorSegments = [];
488
+ let segmentWidths;
489
+ if (floorConfig?.segmentWidths && floorConfig.segmentWidths.length === segmentCount) {
490
+ const sum = floorConfig.segmentWidths.reduce((a, b) => a + b, 0);
491
+ segmentWidths = floorConfig.segmentWidths.map((w) => w / sum * width);
492
+ } else {
493
+ const equalWidth = width / segmentCount;
494
+ segmentWidths = Array(segmentCount).fill(equalWidth);
495
+ }
496
+ let currentX = bounds.left;
489
497
  for (let i = 0; i < segmentCount; i++) {
490
- const segmentX = bounds.left + (i + 0.5) * segmentWidth;
498
+ const segmentWidth = segmentWidths[i];
499
+ const thickness = floorConfig?.thickness !== void 0 ? Array.isArray(floorConfig.thickness) ? floorConfig.thickness[i] ?? BOUNDARY_THICKNESS : floorConfig.thickness : BOUNDARY_THICKNESS;
500
+ const color = floorConfig?.color !== void 0 ? Array.isArray(floorConfig.color) ? floorConfig.color[i] : floorConfig.color : void 0;
501
+ const segmentX = currentX + segmentWidth / 2;
502
+ const segmentY = bounds.bottom - thickness / 2;
503
+ const segmentOptions = {
504
+ isStatic: true,
505
+ label: `floor-segment-${i}`,
506
+ render: {
507
+ visible: color !== void 0,
508
+ fillStyle: color ?? "#888888"
509
+ }
510
+ };
491
511
  floorSegments.push(
492
512
  Matter2.Bodies.rectangle(
493
513
  segmentX,
494
- bounds.bottom + BOUNDARY_THICKNESS / 2,
514
+ segmentY,
495
515
  segmentWidth,
496
- BOUNDARY_THICKNESS,
497
- { ...options, label: `floor-segment-${i}` }
516
+ thickness,
517
+ segmentOptions
498
518
  )
499
519
  );
520
+ currentX += segmentWidth;
500
521
  }
501
522
  return { walls, floorSegments };
502
523
  }
@@ -1160,6 +1181,9 @@ var OverlayScene = class {
1160
1181
  if (this.config.wrapHorizontal && entry.tags.includes("falling")) {
1161
1182
  wrapHorizontal(entry.body, this.config.bounds);
1162
1183
  }
1184
+ if (entry.domElement && entry.tags.includes("falling")) {
1185
+ this.updateDOMElementTransform(entry);
1186
+ }
1163
1187
  }
1164
1188
  if (!this.config.debug) {
1165
1189
  this.drawTTFGlyphs();
@@ -1185,6 +1209,7 @@ var OverlayScene = class {
1185
1209
  this.boundaries = [...boundariesResult.walls, ...boundariesResult.floorSegments];
1186
1210
  this.floorSegments = boundariesResult.floorSegments;
1187
1211
  Matter5.Composite.add(this.engine.world, this.boundaries);
1212
+ this.checkInitialFloorIntegrity();
1188
1213
  this.mouse = Matter5.Mouse.create(canvas);
1189
1214
  this.mouseConstraint = Matter5.MouseConstraint.create(this.engine, {
1190
1215
  mouse: this.mouse,
@@ -1303,7 +1328,8 @@ var OverlayScene = class {
1303
1328
  if (onObstacles.has(dyn.id)) continue;
1304
1329
  const dynBounds = dyn.body.bounds;
1305
1330
  const horizontalOverlap = dynBounds.max.x > segmentBounds.min.x && dynBounds.min.x < segmentBounds.max.x;
1306
- if (horizontalOverlap) {
1331
+ const nearFloor = dynBounds.max.y >= segmentBounds.min.y - 10;
1332
+ if (horizontalOverlap && nearFloor) {
1307
1333
  resting.add(dyn.id);
1308
1334
  }
1309
1335
  }
@@ -1329,17 +1355,50 @@ var OverlayScene = class {
1329
1355
  const objectIds = this.floorSegmentPressure.get(i);
1330
1356
  const pressure = objectIds ? this.calculateWeightedPressure(objectIds) : 0;
1331
1357
  if (pressure >= threshold) {
1332
- this.collapseFloorSegment(i, pressure, threshold);
1358
+ this.collapseFloorSegment(i, `pressure ${pressure} >= threshold ${threshold}`);
1333
1359
  }
1334
1360
  }
1335
1361
  }
1336
1362
  /** Collapse a single floor segment */
1337
- collapseFloorSegment(index, pressure, threshold) {
1363
+ collapseFloorSegment(index, reason) {
1338
1364
  if (this.collapsedSegments.has(index)) return;
1339
1365
  this.collapsedSegments.add(index);
1340
1366
  const segment = this.floorSegments[index];
1341
1367
  Matter5.Composite.remove(this.engine.world, segment);
1342
- console.log(`[Pressure] Floor segment ${index} collapsed! (pressure: ${pressure} >= ${threshold})`);
1368
+ logger.debug("OverlayScene", `Floor segment ${index} collapsed: ${reason}`);
1369
+ this.checkFloorIntegrity();
1370
+ }
1371
+ /** Check if floor integrity requirement is violated and collapse all remaining if so */
1372
+ checkFloorIntegrity() {
1373
+ const minIntegrity = this.config.floorConfig?.minIntegrity;
1374
+ if (minIntegrity === void 0) return;
1375
+ const totalSegments = this.floorSegments.length;
1376
+ const remainingSegments = totalSegments - this.collapsedSegments.size;
1377
+ if (remainingSegments < minIntegrity && remainingSegments > 0) {
1378
+ logger.debug("OverlayScene", `Floor integrity failed: ${remainingSegments} remaining < ${minIntegrity} required. Collapsing all.`);
1379
+ for (let i = 0; i < totalSegments; i++) {
1380
+ if (!this.collapsedSegments.has(i)) {
1381
+ this.collapsedSegments.add(i);
1382
+ const segment = this.floorSegments[i];
1383
+ Matter5.Composite.remove(this.engine.world, segment);
1384
+ logger.debug("OverlayScene", `Floor segment ${i} collapsed: integrity failure cascade`);
1385
+ }
1386
+ }
1387
+ }
1388
+ }
1389
+ /** Check floor integrity on initialization (handles minIntegrity > segments) */
1390
+ checkInitialFloorIntegrity() {
1391
+ const minIntegrity = this.config.floorConfig?.minIntegrity;
1392
+ if (minIntegrity === void 0) return;
1393
+ const totalSegments = this.floorSegments.length;
1394
+ if (totalSegments < minIntegrity) {
1395
+ logger.debug("OverlayScene", `Floor integrity impossible: ${totalSegments} segments < ${minIntegrity} required. Collapsing all immediately.`);
1396
+ for (let i = 0; i < totalSegments; i++) {
1397
+ this.collapsedSegments.add(i);
1398
+ const segment = this.floorSegments[i];
1399
+ Matter5.Composite.remove(this.engine.world, segment);
1400
+ }
1401
+ }
1343
1402
  }
1344
1403
  /** Log a summary of pressure on all obstacles, grouped by word */
1345
1404
  logPressureSummary() {
@@ -1454,6 +1513,15 @@ var OverlayScene = class {
1454
1513
  if (!entry.originalPosition) return;
1455
1514
  const opacity = entry.shadow?.opacity ?? 0.3;
1456
1515
  const shadowId = `shadow-${entry.id}`;
1516
+ if (entry.domElement) {
1517
+ const shadowElement = entry.domElement.cloneNode(true);
1518
+ shadowElement.style.opacity = String(opacity);
1519
+ shadowElement.style.pointerEvents = "none";
1520
+ shadowElement.style.transform = entry.domOriginalTransform || "";
1521
+ entry.domElement.parentNode?.insertBefore(shadowElement, entry.domElement);
1522
+ entry.domShadowElement = shadowElement;
1523
+ return;
1524
+ }
1457
1525
  if (entry.ttfGlyph) {
1458
1526
  const body = Matter5.Bodies.circle(entry.originalPosition.x, entry.originalPosition.y, 1, {
1459
1527
  isStatic: true,
@@ -1586,6 +1654,7 @@ var OverlayScene = class {
1586
1654
  this.collapsedSegments.clear();
1587
1655
  this.floorSegmentPressure.clear();
1588
1656
  Matter5.Composite.add(this.engine.world, this.boundaries);
1657
+ this.checkInitialFloorIntegrity();
1589
1658
  this.render.options.width = width;
1590
1659
  this.render.options.height = height;
1591
1660
  this.render.canvas.width = width;
@@ -1602,6 +1671,21 @@ var OverlayScene = class {
1602
1671
  * Without 'falling' tag, object is static.
1603
1672
  */
1604
1673
  spawnObject(config) {
1674
+ if (config.element) {
1675
+ const result = this.addDOMObstacleInternal({
1676
+ element: config.element,
1677
+ x: config.x,
1678
+ y: config.y,
1679
+ width: config.width,
1680
+ height: config.height,
1681
+ tags: config.tags,
1682
+ pressureThreshold: config.pressureThreshold,
1683
+ weight: config.weight,
1684
+ shadow: config.shadow === true ? { opacity: 0.3 } : config.shadow || void 0,
1685
+ clickToFall: config.clickToFall
1686
+ });
1687
+ return result.id;
1688
+ }
1605
1689
  const id = crypto.randomUUID();
1606
1690
  const tags = config.tags ?? [];
1607
1691
  const isStatic = !tags.includes("falling");
@@ -1621,6 +1705,17 @@ var OverlayScene = class {
1621
1705
  } else {
1622
1706
  body = createObstacle(id, config, isStatic);
1623
1707
  }
1708
+ let pressureThreshold;
1709
+ if (config.pressureThreshold) {
1710
+ pressureThreshold = typeof config.pressureThreshold.value === "number" ? config.pressureThreshold.value : config.pressureThreshold.value[0];
1711
+ }
1712
+ let shadow;
1713
+ if (config.shadow === true) {
1714
+ shadow = { opacity: 0.3 };
1715
+ } else if (config.shadow && typeof config.shadow === "object") {
1716
+ shadow = { opacity: config.shadow.opacity ?? 0.3 };
1717
+ }
1718
+ const clicksRemaining = config.clickToFall?.clicks;
1624
1719
  const entry = {
1625
1720
  id,
1626
1721
  body,
@@ -1628,10 +1723,17 @@ var OverlayScene = class {
1628
1723
  spawnTime: performance.now(),
1629
1724
  ttl: config.ttl,
1630
1725
  despawnEffect: config.despawnEffect,
1631
- weight: config.weight ?? 1
1726
+ weight: config.weight ?? 1,
1727
+ pressureThreshold,
1728
+ shadow,
1729
+ originalPosition: shadow || clicksRemaining !== void 0 ? { x: config.x, y: config.y } : void 0,
1730
+ clicksRemaining
1632
1731
  };
1633
1732
  this.objects.set(id, entry);
1634
1733
  Matter5.Composite.add(this.engine.world, body);
1734
+ if (isStatic && pressureThreshold !== void 0) {
1735
+ this.obstaclePressure.set(id, /* @__PURE__ */ new Set());
1736
+ }
1635
1737
  return id;
1636
1738
  }
1637
1739
  /**
@@ -1658,6 +1760,17 @@ var OverlayScene = class {
1658
1760
  } else {
1659
1761
  body = await createObstacleAsync(id, config, isStatic);
1660
1762
  }
1763
+ let pressureThreshold;
1764
+ if (config.pressureThreshold) {
1765
+ pressureThreshold = typeof config.pressureThreshold.value === "number" ? config.pressureThreshold.value : config.pressureThreshold.value[0];
1766
+ }
1767
+ let shadow;
1768
+ if (config.shadow === true) {
1769
+ shadow = { opacity: 0.3 };
1770
+ } else if (config.shadow && typeof config.shadow === "object") {
1771
+ shadow = { opacity: config.shadow.opacity ?? 0.3 };
1772
+ }
1773
+ const clicksRemaining = config.clickToFall?.clicks;
1661
1774
  const entry = {
1662
1775
  id,
1663
1776
  body,
@@ -1665,10 +1778,17 @@ var OverlayScene = class {
1665
1778
  spawnTime: performance.now(),
1666
1779
  ttl: config.ttl,
1667
1780
  despawnEffect: config.despawnEffect,
1668
- weight: config.weight ?? 1
1781
+ weight: config.weight ?? 1,
1782
+ pressureThreshold,
1783
+ shadow,
1784
+ originalPosition: shadow || clicksRemaining !== void 0 ? { x: config.x, y: config.y } : void 0,
1785
+ clicksRemaining
1669
1786
  };
1670
1787
  this.objects.set(id, entry);
1671
1788
  Matter5.Composite.add(this.engine.world, body);
1789
+ if (isStatic && pressureThreshold !== void 0) {
1790
+ this.obstaclePressure.set(id, /* @__PURE__ */ new Set());
1791
+ }
1672
1792
  return id;
1673
1793
  }
1674
1794
  /**
@@ -1927,6 +2047,80 @@ var OverlayScene = class {
1927
2047
  areFontsInitialized() {
1928
2048
  return this.fontsInitialized;
1929
2049
  }
2050
+ // ==================== DOM OBSTACLE METHODS (INTERNAL) ====================
2051
+ /**
2052
+ * Internal: Attach a DOM element to physics.
2053
+ * Called by spawnObject when element is provided.
2054
+ */
2055
+ addDOMObstacleInternal(config) {
2056
+ const { element, x, y } = config;
2057
+ const width = config.width ?? element.offsetWidth;
2058
+ const height = config.height ?? element.offsetHeight;
2059
+ const tags = config.tags ?? [];
2060
+ const isStatic = !tags.includes("falling");
2061
+ const body = Matter5.Bodies.rectangle(x, y, width, height, {
2062
+ isStatic,
2063
+ label: `dom-${crypto.randomUUID().slice(0, 8)}`,
2064
+ render: { visible: false }
2065
+ // Don't render the body, DOM element is the visual
2066
+ });
2067
+ const id = body.label;
2068
+ let pressureThreshold;
2069
+ if (config.pressureThreshold) {
2070
+ pressureThreshold = typeof config.pressureThreshold.value === "number" ? config.pressureThreshold.value : config.pressureThreshold.value[0];
2071
+ }
2072
+ const shadow = config.shadow ? { opacity: config.shadow.opacity ?? 0.3 } : void 0;
2073
+ const clicksRemaining = config.clickToFall?.clicks;
2074
+ const originalTransform = element.style.transform || "";
2075
+ element.style.position = "absolute";
2076
+ element.style.transformOrigin = "center center";
2077
+ const entry = {
2078
+ id,
2079
+ body,
2080
+ tags,
2081
+ spawnTime: performance.now(),
2082
+ pressureThreshold,
2083
+ weight: config.weight ?? 1,
2084
+ shadow,
2085
+ originalPosition: shadow || clicksRemaining !== void 0 ? { x, y } : void 0,
2086
+ clicksRemaining,
2087
+ domElement: element,
2088
+ domOriginalTransform: originalTransform
2089
+ };
2090
+ this.objects.set(id, entry);
2091
+ Matter5.Composite.add(this.engine.world, body);
2092
+ this.updateDOMElementTransform(entry);
2093
+ if (isStatic && pressureThreshold !== void 0) {
2094
+ this.obstaclePressure.set(id, /* @__PURE__ */ new Set());
2095
+ }
2096
+ if (clicksRemaining !== void 0) {
2097
+ const clickHandler = () => {
2098
+ const currentEntry = this.objects.get(id);
2099
+ if (!currentEntry) return;
2100
+ if (currentEntry.tags.includes("falling")) return;
2101
+ if (currentEntry.clicksRemaining === void 0) return;
2102
+ currentEntry.clicksRemaining--;
2103
+ logger.debug("OverlayScene", `Click on DOM element: ${currentEntry.clicksRemaining} clicks remaining`);
2104
+ if (currentEntry.clicksRemaining <= 0) {
2105
+ this.collapseObstacle(currentEntry);
2106
+ element.removeEventListener("click", clickHandler);
2107
+ }
2108
+ };
2109
+ element.addEventListener("click", clickHandler);
2110
+ }
2111
+ return {
2112
+ id,
2113
+ shadowElement: null
2114
+ // Will be populated on collapse
2115
+ };
2116
+ }
2117
+ /**
2118
+ * Get the shadow element for a DOM obstacle (available after collapse).
2119
+ */
2120
+ getDOMObstacleShadow(id) {
2121
+ const entry = this.objects.get(id);
2122
+ return entry?.domShadowElement ?? null;
2123
+ }
1930
2124
  // ==================== TEXT OBSTACLE METHODS ====================
1931
2125
  /**
1932
2126
  * Create text obstacles from a string. Each character becomes an individual obstacle
@@ -2002,11 +2196,61 @@ var OverlayScene = class {
2002
2196
  maxDimension = Math.max(maxDimension, dims.width, dims.height);
2003
2197
  }
2004
2198
  if (maxDimension === 0) maxDimension = 100;
2199
+ const calculateLineWidth = (line) => {
2200
+ let width = 0;
2201
+ for (const char of line) {
2202
+ if (char === " ") {
2203
+ width += 20;
2204
+ } else if (/^[A-Za-z0-9]$/.test(char)) {
2205
+ const dims = charDimensions.get(char);
2206
+ if (dims) {
2207
+ const scale = letterSize / Math.max(dims.width, dims.height);
2208
+ const scaledWidth = dims.width * scale;
2209
+ const extraSpacing = config.letterSpacing !== void 0 ? config.letterSpacing - scaledWidth : 0;
2210
+ width += scaledWidth + Math.max(0, extraSpacing);
2211
+ }
2212
+ }
2213
+ }
2214
+ return width;
2215
+ };
2216
+ const lineWidths = lines.map((line) => calculateLineWidth(line));
2217
+ const align = config.align ?? "left";
2218
+ const maxLineWidth = Math.max(...lineWidths, 0);
2219
+ let boundsLeft;
2220
+ let boundsRight;
2221
+ switch (align) {
2222
+ case "center":
2223
+ boundsLeft = config.x - maxLineWidth / 2;
2224
+ boundsRight = config.x + maxLineWidth / 2;
2225
+ break;
2226
+ case "right":
2227
+ boundsLeft = config.x - maxLineWidth;
2228
+ boundsRight = config.x;
2229
+ break;
2230
+ default:
2231
+ boundsLeft = config.x;
2232
+ boundsRight = config.x + maxLineWidth;
2233
+ }
2234
+ const boundsTop = config.y - letterSize / 2;
2235
+ const totalHeight = lines.length > 0 ? (lines.length - 1) * lineHeight + letterSize : 0;
2236
+ const boundsBottom = boundsTop + totalHeight;
2005
2237
  let currentY = config.y;
2006
2238
  let globalCharIndex = 0;
2007
- for (const line of lines) {
2239
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
2240
+ const line = lines[lineIndex];
2008
2241
  const chars = line.split("");
2009
- let currentX = config.x;
2242
+ const lineWidth = lineWidths[lineIndex];
2243
+ let currentX;
2244
+ switch (align) {
2245
+ case "center":
2246
+ currentX = config.x - lineWidth / 2;
2247
+ break;
2248
+ case "right":
2249
+ currentX = config.x - lineWidth;
2250
+ break;
2251
+ default:
2252
+ currentX = config.x;
2253
+ }
2010
2254
  if (inWord) {
2011
2255
  currentWordIndex++;
2012
2256
  inWord = false;
@@ -2122,12 +2366,21 @@ var OverlayScene = class {
2122
2366
  letterColor,
2123
2367
  lineCount: lines.length
2124
2368
  });
2369
+ const bounds = {
2370
+ left: boundsLeft,
2371
+ right: boundsRight,
2372
+ top: boundsTop,
2373
+ bottom: boundsBottom,
2374
+ width: boundsRight - boundsLeft,
2375
+ height: boundsBottom - boundsTop
2376
+ };
2125
2377
  return {
2126
2378
  letterIds,
2127
2379
  stringTag,
2128
2380
  wordTags,
2129
2381
  letterMap,
2130
- letterDebugInfo: debugInfo
2382
+ letterDebugInfo: debugInfo,
2383
+ bounds
2131
2384
  };
2132
2385
  }
2133
2386
  /**
@@ -2200,10 +2453,43 @@ var OverlayScene = class {
2200
2453
  const fontInfo = this.fonts.find((f) => f.fontUrl === fontUrl);
2201
2454
  const fontFamily = fontInfo?.name ?? "sans-serif";
2202
2455
  const lines = text.split("\n");
2456
+ const lineWidths = lines.map((line) => measureText(loadedFont, line, fontSize));
2457
+ const align = config.align ?? "left";
2458
+ const maxLineWidth = Math.max(...lineWidths, 0);
2459
+ let boundsLeft;
2460
+ let boundsRight;
2461
+ switch (align) {
2462
+ case "center":
2463
+ boundsLeft = x - maxLineWidth / 2;
2464
+ boundsRight = x + maxLineWidth / 2;
2465
+ break;
2466
+ case "right":
2467
+ boundsLeft = x - maxLineWidth;
2468
+ boundsRight = x;
2469
+ break;
2470
+ default:
2471
+ boundsLeft = x;
2472
+ boundsRight = x + maxLineWidth;
2473
+ }
2474
+ const boundsTop = y - fontSize * 0.8;
2475
+ const totalHeight = lines.length > 0 ? (lines.length - 1) * lineHeight + fontSize : 0;
2476
+ const boundsBottom = boundsTop + totalHeight;
2203
2477
  let currentY = y;
2204
2478
  let globalCharIndex = 0;
2205
- for (const line of lines) {
2206
- let currentX = x;
2479
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
2480
+ const line = lines[lineIndex];
2481
+ const lineWidth = lineWidths[lineIndex];
2482
+ let currentX;
2483
+ switch (align) {
2484
+ case "center":
2485
+ currentX = x - lineWidth / 2;
2486
+ break;
2487
+ case "right":
2488
+ currentX = x - lineWidth;
2489
+ break;
2490
+ default:
2491
+ currentX = x;
2492
+ }
2207
2493
  if (inWord) {
2208
2494
  currentWordIndex++;
2209
2495
  inWord = false;
@@ -2313,12 +2599,21 @@ var OverlayScene = class {
2313
2599
  wordTags,
2314
2600
  lineCount: lines.length
2315
2601
  });
2602
+ const bounds = {
2603
+ left: boundsLeft,
2604
+ right: boundsRight,
2605
+ top: boundsTop,
2606
+ bottom: boundsBottom,
2607
+ width: boundsRight - boundsLeft,
2608
+ height: boundsBottom - boundsTop
2609
+ };
2316
2610
  return {
2317
2611
  letterIds,
2318
2612
  stringTag,
2319
2613
  wordTags,
2320
2614
  letterMap,
2321
- letterDebugInfo: []
2615
+ letterDebugInfo: [],
2616
+ bounds
2322
2617
  };
2323
2618
  }
2324
2619
  /**
@@ -2423,6 +2718,22 @@ var OverlayScene = class {
2423
2718
  ctx.restore();
2424
2719
  }
2425
2720
  }
2721
+ /**
2722
+ * Update a DOM element's CSS transform to match its physics body position and rotation.
2723
+ */
2724
+ updateDOMElementTransform(entry) {
2725
+ if (!entry.domElement) return;
2726
+ const body = entry.body;
2727
+ const x = body.position.x;
2728
+ const y = body.position.y;
2729
+ const angle = body.angle;
2730
+ const angleDeg = angle * (180 / Math.PI);
2731
+ const width = entry.domElement.offsetWidth;
2732
+ const height = entry.domElement.offsetHeight;
2733
+ entry.domElement.style.left = `${x - width / 2}px`;
2734
+ entry.domElement.style.top = `${y - height / 2}px`;
2735
+ entry.domElement.style.transform = `rotate(${angleDeg}deg)`;
2736
+ }
2426
2737
  checkTTLExpiration() {
2427
2738
  const now = performance.now();
2428
2739
  const expiredObjects = [];