@expofp/renderer 3.0.0 → 3.1.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.
package/dist/index.d.ts CHANGED
@@ -274,8 +274,14 @@ export declare interface LayerDef extends SharedDef {
274
274
  interactive?: boolean;
275
275
  /** Collection of layer's children. Mixing different types of children is not supported. */
276
276
  children: RenderableDefCollection;
277
- /** Layer's rendering mode, 2d or 3d. Optional, 2d by default. */
278
- mode?: "2d" | "3d";
277
+ /**
278
+ * Layer's rendering mode. Propagates to children: child layers inherit the parent's mode
279
+ * unless they specify their own. Defaults to `"2D"` at the root.
280
+ * Resolved once during scene build and should not be changed afterwards.
281
+ * - `"2D"`: painter's algorithm (AlwaysDepth). Layer order determines overlap.
282
+ * - `"3D"`: depth testing (LessDepth). Closer geometry occludes farther geometry.
283
+ */
284
+ readonly mode?: "2D" | "3D";
279
285
  }
280
286
 
281
287
  /** Line definition */
@@ -338,7 +344,11 @@ export declare class Polygon {
338
344
  get vertices(): readonly Vector3Like[];
339
345
  /** Array of polygon indices. Each index is a triplet of vertex indices forming a triangle. */
340
346
  get indices(): readonly Index[];
341
- /** Bounding rectangle of the polygon. */
347
+ /**
348
+ * Bounding rectangle of the polygon.
349
+ * This is a 2D axis-aligned bounding box computed from the X/Y projection of vertices.
350
+ * The Z extent of the polygon is not tracked.
351
+ */
342
352
  get bounds(): Rect;
343
353
  /**
344
354
  * Converts a {@link Rect} to a {@link Polygon}.
@@ -367,7 +377,8 @@ export declare class Polygon {
367
377
  rotate(rotation: number, center?: Vector2Like): Polygon;
368
378
  /**
369
379
  * Scales the polygon around the given origin.
370
- * @param scaleFactor Can be a single number or a 2D vector. If a single number is provided, both horizontal and vertical axes will be scaled by the same factor.
380
+ * @param scaleFactor Can be a single number or a 2D vector. If a single number is provided, both horizontal and
381
+ * vertical axes will be scaled by the same factor. Z is unchanged in both cases.
371
382
  * @param origin Origin of the scaling. If omitted, defaults to the bounding rectangle center.
372
383
  * @returns this {@link Polygon} instance
373
384
  */
@@ -387,6 +398,8 @@ export declare interface PtScaleEventData {
387
398
  export declare class Rect {
388
399
  /** Optional rotation of the rectangle. In radians, around center. Positive values rotate clockwise. */
389
400
  rotation: number;
401
+ /** Optional elevation of the rectangle. Positive values raise geometry toward the viewer. Defaults to 0. */
402
+ elevation: number;
390
403
  private _min;
391
404
  private _max;
392
405
  private _center;
@@ -395,8 +408,9 @@ export declare class Rect {
395
408
  * @param min Top left corner of the rectangle.
396
409
  * @param max Bottom right corner of the rectangle.
397
410
  * @param rotation Optional rotation of the rectangle. In radians, around center. Positive values rotate clockwise.
411
+ * @param elevation Optional elevation. Positive values raise geometry toward the viewer. Defaults to 0.
398
412
  */
399
- constructor(min: IVector2, max: IVector2, rotation?: number);
413
+ constructor(min: IVector2, max: IVector2, rotation?: number, elevation?: number);
400
414
  /** Top left corner of the rectangle. */
401
415
  get min(): Vector2Like;
402
416
  /** Set top left corner of the rectangle. */
@@ -417,9 +431,10 @@ export declare class Rect {
417
431
  * Creates a rectangle from an SVG rectangle element.
418
432
  * @param rect {@link SVGRectElement} or {@link SVGImageElement}
419
433
  * @param rotation Optional rotation of the rectangle. In radians, around center. Positive values rotate clockwise.
434
+ * @param elevation Optional elevation. Positive values raise geometry toward the viewer. Defaults to 0.
420
435
  * @returns new {@link Rect} instance
421
436
  */
422
- static fromSvg(rect: SVGRectElement | SVGImageElement, rotation?: number): Rect;
437
+ static fromSvg(rect: SVGRectElement | SVGImageElement, rotation?: number, elevation?: number): Rect;
423
438
  /**
424
439
  * Moves the rectangle by the given offset.
425
440
  * @param offset Offset to move the rectangle by.
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ var __defProp = Object.defineProperty;
2
2
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
4
  var _a;
5
- import { DataTexture, FloatType, UnsignedIntType, IntType, RGBAFormat, RGBAIntegerFormat, RGFormat, RGIntegerFormat, RedFormat, RedIntegerFormat, BatchedMesh as BatchedMesh$1, BufferAttribute, StreamDrawUsage, Color, Matrix4, Vector3, Frustum, Sphere, Box3, DynamicDrawUsage, Vector4, AlwaysDepth, DoubleSide, MeshBasicMaterial, Texture, Group, PlaneGeometry, SRGBColorSpace, Vector2, Quaternion, Mesh, LessEqualDepth, BufferGeometry, LinearSRGBColorSpace, Camera, Spherical, MathUtils, Plane, Raycaster, PerspectiveCamera, Scene, Clock, WebGLRenderer } from "three";
5
+ import { DataTexture, FloatType, UnsignedIntType, IntType, RGBAFormat, RGBAIntegerFormat, RGFormat, RGIntegerFormat, RedFormat, RedIntegerFormat, BatchedMesh as BatchedMesh$1, BufferAttribute, StreamDrawUsage, Color, Matrix4, Vector3, Frustum, Sphere, Box3, DynamicDrawUsage, Vector4, AlwaysDepth, DoubleSide, MeshBasicMaterial, LessEqualDepth, Texture, Group, PlaneGeometry, SRGBColorSpace, Vector2, Quaternion, BufferGeometry, LinearSRGBColorSpace, Mesh, Camera, Spherical, MathUtils, Plane, Raycaster, PerspectiveCamera, Scene, Clock, WebGLRenderer } from "three";
6
6
  import { traverseAncestorsGenerator } from "three/examples/jsm/utils/SceneUtils.js";
7
7
  import createLog from "debug";
8
8
  import { BatchedText as BatchedText$1, Text as Text$1 } from "troika-three-text";
@@ -867,8 +867,6 @@ class Text extends Text$1 {
867
867
  if (!this._batchParent) this.geometry.applyClipRect(uniforms.uTroikaClipRect.value);
868
868
  }
869
869
  uniforms.uTroikaSDFDebug.value = !!this.debugSDF;
870
- material.polygonOffset = !!this.depthOffset;
871
- material.polygonOffsetFactor = material.polygonOffsetUnits = this.depthOffset || 0;
872
870
  const color = isOutline ? this.outlineColor || 0 : this.color;
873
871
  if (color == null) {
874
872
  delete material.color;
@@ -1050,6 +1048,7 @@ const dimColorFrag = (
1050
1048
  return vec4(m * col.a, col.a);
1051
1049
  }`
1052
1050
  );
1051
+ const POLYGON_OFFSET_MULTIPLIER = 12;
1053
1052
  const sharedParameters = {
1054
1053
  side: DoubleSide,
1055
1054
  transparent: true,
@@ -1094,47 +1093,47 @@ class MaterialSystem {
1094
1093
  }
1095
1094
  };
1096
1095
  addDimToMaterial(material);
1096
+ this.addPolygonOffset(material);
1097
1097
  return material;
1098
1098
  }
1099
1099
  /**
1100
1100
  * Creates a color material.
1101
- * @param partialParams {@link MaterialColorParams}
1101
+ * @param params {@link MaterialColorParams}
1102
1102
  * @returns MeshBasicMaterial instance
1103
1103
  */
1104
- createColorMaterial(partialParams = {}) {
1105
- const params = {
1106
- color: partialParams.color ?? 16777215,
1107
- opacity: partialParams.opacity ?? 1
1108
- };
1104
+ createColorMaterial(params = {}) {
1109
1105
  const material = new MeshBasicMaterial({
1110
1106
  ...sharedParameters,
1111
- color: params.color,
1112
- opacity: params.opacity
1107
+ color: params.color ?? 16777215,
1108
+ opacity: params.opacity ?? 1
1113
1109
  });
1110
+ if (params.is3D) material.depthFunc = LessEqualDepth;
1114
1111
  addDimToMaterial(material);
1112
+ this.addPolygonOffset(material);
1115
1113
  return material;
1116
1114
  }
1117
1115
  /**
1118
1116
  * Creates a texture material.
1119
- * @param map {@link Texture}
1120
- * @param uvOffset whether to enable uv offset with per instance uniforms (for texture atlases)
1117
+ * @param params {@link MaterialTextureParams}
1121
1118
  * @returns MeshBasicMaterial instance
1122
1119
  */
1123
- createTextureMaterial(map, uvOffset = false) {
1124
- const material = new MeshBasicMaterial({ ...sharedParameters, map });
1125
- if (uvOffset) {
1120
+ createTextureMaterial(params) {
1121
+ const material = new MeshBasicMaterial({ ...sharedParameters, map: params.map });
1122
+ if (params.is3D) material.depthFunc = LessEqualDepth;
1123
+ if (params.uvOffset) {
1126
1124
  material.onBeforeCompile = (shader) => {
1127
1125
  shader.vertexShader = shader.vertexShader.replace(
1128
1126
  "#include <uv_vertex>",
1129
1127
  /*glsl*/
1130
1128
  `
1131
1129
  #include <uv_vertex>
1132
- vMapUv = uv * uvOffset.zw + uvOffset.xy;
1130
+ vMapUv = uv * uvOffset.zw + uvOffset.xy;
1133
1131
  `
1134
1132
  );
1135
1133
  };
1136
1134
  }
1137
1135
  addDimToMaterial(material);
1136
+ this.addPolygonOffset(material);
1138
1137
  return material;
1139
1138
  }
1140
1139
  /**
@@ -1158,6 +1157,40 @@ class MaterialSystem {
1158
1157
  }
1159
1158
  return this.backgroundMaterial;
1160
1159
  }
1160
+ /**
1161
+ * Patches a material's onBeforeRender to dynamically set polygonOffset based on the
1162
+ * object's renderOrder. Later layers (higher renderOrder) get a larger negative offset,
1163
+ * giving them a slight depth buffer advantage over earlier layers at the same elevation.
1164
+ * This prevents z-fighting between coplanar 3D content across different layers.
1165
+ *
1166
+ * The units multiplier is derived from {@link CameraControls.maxPolarAngle}:
1167
+ *
1168
+ * multiplier = ⌈tan(maxPolarAngle)⌉
1169
+ *
1170
+ * At steep camera pitch, coplanar surfaces from different draw calls (different
1171
+ * BatchedMesh instances) compute slightly different depth values at the same pixel
1172
+ * due to floating-point interpolation differences in the vertex shader. These errors
1173
+ * are proportional to the depth gradient across the surface, which for a horizontal
1174
+ * plane at pitch angle θ is proportional to tan(θ). The multiplier ensures the
1175
+ * per-layer polygon offset exceeds these errors at the configured maximum pitch.
1176
+ *
1177
+ * For elevated content the effective viewing angle can exceed maxPolarAngle, but this
1178
+ * is self-correcting: the screen-space height of the surface shrinks as cos(angle),
1179
+ * so the total visible z-fighting (pixels × severity ∝ cos × tan = sin) is bounded
1180
+ * and becomes subpixel as the effective angle approaches 90°.
1181
+ *
1182
+ * With maxPolarAngle = 85°: ⌈tan(85°)⌉ = ⌈11.43⌉ = 12.
1183
+ * @param material Material to patch
1184
+ */
1185
+ addPolygonOffset(material) {
1186
+ const onBeforeRender = material.onBeforeRender.bind(material);
1187
+ material.onBeforeRender = (renderer, scene, camera, geometry, object, group) => {
1188
+ onBeforeRender(renderer, scene, camera, geometry, object, group);
1189
+ material.polygonOffset = true;
1190
+ material.polygonOffsetFactor = 0;
1191
+ material.polygonOffsetUnits = -object.renderOrder * POLYGON_OFFSET_MULTIPLIER;
1192
+ };
1193
+ }
1161
1194
  }
1162
1195
  function isShapeDef(def) {
1163
1196
  return def.shape !== void 0;
@@ -1189,36 +1222,14 @@ function isLineLayer(layer) {
1189
1222
  function isLayerLayer(layer) {
1190
1223
  return layer.children[0] && isLayerDef(layer.children[0]);
1191
1224
  }
1192
- function groupBy(list, keyGetter) {
1193
- const map = /* @__PURE__ */ new Map();
1194
- list.forEach((item) => {
1195
- const key = keyGetter(item);
1196
- const collection = map.get(key);
1197
- if (!collection) {
1198
- map.set(key, [item]);
1199
- } else {
1200
- collection.push(item);
1201
- }
1202
- });
1203
- return map;
1204
- }
1205
- function partition(list, pred) {
1206
- const truthy = [];
1207
- const falsy = [];
1208
- for (const item of list) {
1209
- if (pred(item)) {
1210
- truthy.push(item);
1211
- } else {
1212
- falsy.push(item);
1213
- }
1214
- }
1215
- return [truthy, falsy];
1216
- }
1217
1225
  const INTERACTIVE_LAYER = 1;
1218
- function setInteractive(object, isInteractive) {
1219
- if (isInteractive) object.layers.enable(INTERACTIVE_LAYER);
1226
+ function isInteractive(object) {
1227
+ return object.layers.isEnabled(INTERACTIVE_LAYER);
1228
+ }
1229
+ function setInteractive(object, isInteractive2) {
1230
+ if (isInteractive2) object.layers.enable(INTERACTIVE_LAYER);
1220
1231
  else object.layers.disable(INTERACTIVE_LAYER);
1221
- object.children.forEach((child) => setInteractive(child, isInteractive));
1232
+ object.children.forEach((child) => setInteractive(child, isInteractive2));
1222
1233
  }
1223
1234
  function isVisible(object) {
1224
1235
  if (!object.visible) return false;
@@ -1462,6 +1473,7 @@ class ImageSystem extends RenderableSystem {
1462
1473
  /** Textures memory limit in megabytes */
1463
1474
  __publicField(this, "memoryLimitMb");
1464
1475
  __publicField(this, "packer");
1476
+ __publicField(this, "originalAtlases", /* @__PURE__ */ new Map());
1465
1477
  __publicField(this, "globalTranslationMatrix", new Matrix4());
1466
1478
  __publicField(this, "originTranslationMatrix", new Matrix4());
1467
1479
  __publicField(this, "rotationMatrix", new Matrix4());
@@ -1472,12 +1484,17 @@ class ImageSystem extends RenderableSystem {
1472
1484
  const padding = 1;
1473
1485
  this.packer = new MaxRectsPacker(atlasTextureSize, atlasTextureSize, padding, { pot: false });
1474
1486
  }
1487
+ dispose() {
1488
+ super.dispose();
1489
+ this.originalAtlases.clear();
1490
+ }
1475
1491
  updateLayerImpl(group, layerDef) {
1476
1492
  super.updateLayerImpl(group, layerDef);
1477
1493
  if (this.memoryLimitMb) this.resizeTextures();
1478
1494
  }
1479
1495
  buildLayer(layer) {
1480
1496
  var _a2;
1497
+ const is3D = layer.mode === "3D";
1481
1498
  const group = new Group();
1482
1499
  const images = layer.children;
1483
1500
  const bins = this.packImages(images);
@@ -1492,7 +1509,7 @@ class ImageSystem extends RenderableSystem {
1492
1509
  ).sort((a, b) => a.originalIndex - b.originalIndex);
1493
1510
  const instanceCount = rectsWithDef.length;
1494
1511
  const texture = createAtlas(bin);
1495
- const instanceMaterial = this.materialSystem.createTextureMaterial(texture, true);
1512
+ const instanceMaterial = this.materialSystem.createTextureMaterial({ map: texture, uvOffset: true, is3D });
1496
1513
  const instanceGeometry = new PlaneGeometry();
1497
1514
  const vertexCount = instanceGeometry.attributes["position"].count;
1498
1515
  const indexCount = ((_a2 = instanceGeometry.index) == null ? void 0 : _a2.count) ?? 0;
@@ -1507,13 +1524,15 @@ class ImageSystem extends RenderableSystem {
1507
1524
  this.registerDefObject(def, batchedMesh, instanceId);
1508
1525
  }
1509
1526
  const nonResizable = rectsWithDef.some(({ def }) => def.source instanceof HTMLCanvasElement);
1510
- batchedMesh.userData["nonResizable"] = nonResizable;
1527
+ if (!nonResizable) this.originalAtlases.set(batchedMesh, texture.image);
1511
1528
  group.add(batchedMesh);
1512
1529
  }
1513
1530
  return group;
1514
1531
  }
1515
1532
  /**
1516
1533
  * Resize textures to fit the memory limit.
1534
+ * Uses stored original atlases so that every call computes a globally proportional
1535
+ * resize factor, regardless of how many layers have been loaded so far.
1517
1536
  */
1518
1537
  resizeTextures() {
1519
1538
  var _a2;
@@ -1522,53 +1541,60 @@ class ImageSystem extends RenderableSystem {
1522
1541
  return;
1523
1542
  }
1524
1543
  logger$c.debug(`Resizing textures to fit memory limit: ${this.memoryLimitMb} MB`);
1525
- const texturesToResize = [];
1526
- let totalResizable = 0;
1544
+ const resizableMeshes = [];
1545
+ let totalOriginal = 0;
1527
1546
  let totalNonResizable = 0;
1528
1547
  for (const mesh of this.getAllObjects()) {
1548
+ const originalCanvas = this.originalAtlases.get(mesh);
1529
1549
  const texture = mesh.material.map;
1530
- const imageBytes = getTextureSizeBytes(texture);
1531
- const nonResizable = mesh.userData["nonResizable"];
1532
- if (nonResizable) {
1533
- totalNonResizable += imageBytes;
1550
+ if (originalCanvas) {
1551
+ totalOriginal += getCanvasSizeBytes(originalCanvas, texture.generateMipmaps);
1552
+ resizableMeshes.push(mesh);
1534
1553
  } else {
1535
- totalResizable += imageBytes;
1536
- texturesToResize.push(mesh);
1554
+ totalNonResizable += getCanvasSizeBytes(texture.image, texture.generateMipmaps);
1537
1555
  }
1538
1556
  }
1557
+ if (resizableMeshes.length === 0) {
1558
+ logger$c.debug("No resizable meshes found, no need to resize textures.");
1559
+ return;
1560
+ }
1539
1561
  const budget = this.memoryLimitMb * 1024 * 1024 - totalNonResizable;
1540
1562
  if (budget < 0) {
1541
1563
  logger$c.debug("Memory limit is too low, unable to resize textures.");
1542
1564
  return;
1543
1565
  }
1544
- const resizeFactor = Math.sqrt(budget / totalResizable);
1566
+ const resizeFactor = Math.sqrt(budget / totalOriginal);
1545
1567
  if (resizeFactor >= 1) {
1546
1568
  logger$c.debug("Textures are already within the memory limit, no need to resize");
1547
1569
  return;
1548
1570
  }
1549
1571
  logger$c.debug(`Resize factor: ${resizeFactor}`);
1550
1572
  let newTotal = totalNonResizable;
1551
- for (const mesh of texturesToResize) {
1573
+ for (const mesh of resizableMeshes) {
1552
1574
  const material = mesh.material;
1553
- const texture = material.map;
1554
- const resizedTexture = resizeTexture(texture, resizeFactor);
1555
- const textureDim = `${texture.image.width}x${texture.image.height}`;
1575
+ const currentTexture = material.map;
1576
+ const originalCanvas = this.originalAtlases.get(mesh);
1577
+ const resizedTexture = resizeCanvas(originalCanvas, resizeFactor);
1578
+ const originalDim = `${originalCanvas.width}x${originalCanvas.height}`;
1556
1579
  const resizedDim = `${resizedTexture.image.width}x${resizedTexture.image.height}`;
1557
- logger$c.debug(`Resized atlas for ${mesh.name || ((_a2 = mesh.parent) == null ? void 0 : _a2.name)}, from ${textureDim} to ${resizedDim}`);
1558
- newTotal += getTextureSizeBytes(resizedTexture);
1580
+ logger$c.debug(`Resized atlas for ${mesh.name || ((_a2 = mesh.parent) == null ? void 0 : _a2.name)}, from ${originalDim} to ${resizedDim}`);
1581
+ newTotal += getCanvasSizeBytes(resizedTexture.image, resizedTexture.generateMipmaps);
1559
1582
  material.map = resizedTexture;
1560
1583
  material.needsUpdate = true;
1561
- texture.dispose();
1562
- mesh.userData["nonResizable"] = true;
1584
+ currentTexture.dispose();
1563
1585
  }
1564
1586
  logger$c.debug(`New memory usage after resizing: ${newTotal} bytes`);
1565
1587
  }
1588
+ disposeObject(object) {
1589
+ this.originalAtlases.delete(object);
1590
+ super.disposeObject(object);
1591
+ }
1566
1592
  updateDefImpl(imageDef, mesh, instanceIds) {
1567
1593
  const instanceId = instanceIds[0];
1568
1594
  const bounds = imageDef.bounds;
1569
1595
  const origin2 = imageDef.origin ?? [0.5, 0.5];
1570
1596
  this.originTranslationMatrix.makeTranslation(0.5 - origin2[0], 0.5 - origin2[1], 0);
1571
- this.globalTranslationMatrix.makeTranslation(bounds.center.x, bounds.center.y, 0);
1597
+ this.globalTranslationMatrix.makeTranslation(bounds.center.x, bounds.center.y, bounds.elevation);
1572
1598
  this.scaleMatrix.makeScale(bounds.size.x, bounds.size.y, 1);
1573
1599
  this.rotationMatrix.makeRotationZ(bounds.rotation);
1574
1600
  const matrix = this.originTranslationMatrix.premultiply(this.scaleMatrix).premultiply(this.rotationMatrix).premultiply(this.globalTranslationMatrix);
@@ -1620,12 +1646,12 @@ function createAtlas(bin) {
1620
1646
  logger$c.debug(`Create atlas took ${(t1 - t0).toFixed(2)} milliseconds.`);
1621
1647
  return createTexture(canvas);
1622
1648
  }
1623
- function resizeTexture(texture, resizeFactor) {
1649
+ function resizeCanvas(source, resizeFactor) {
1624
1650
  const canvas = document.createElement("canvas");
1625
- canvas.width = Math.floor(texture.image.width * resizeFactor);
1626
- canvas.height = Math.floor(texture.image.height * resizeFactor);
1651
+ canvas.width = Math.floor(source.width * resizeFactor);
1652
+ canvas.height = Math.floor(source.height * resizeFactor);
1627
1653
  const ctx = canvas.getContext("2d");
1628
- ctx.drawImage(texture.image, 0, 0, canvas.width, canvas.height);
1654
+ ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
1629
1655
  return createTexture(canvas);
1630
1656
  }
1631
1657
  function createTexture(source) {
@@ -1636,9 +1662,8 @@ function createTexture(source) {
1636
1662
  texture.needsUpdate = true;
1637
1663
  return texture;
1638
1664
  }
1639
- function getTextureSizeBytes(texture) {
1640
- const imageBytes = texture.image.width * texture.image.height * 4 * (texture.generateMipmaps ? 1.33 : 1);
1641
- return Math.ceil(imageBytes);
1665
+ function getCanvasSizeBytes(canvas, useMipmaps) {
1666
+ return Math.ceil(canvas.width * canvas.height * 4 * (useMipmaps ? 1.33 : 1));
1642
1667
  }
1643
1668
  const logger$b = createLogger("line");
1644
1669
  class LineSystem extends RenderableSystem {
@@ -1719,10 +1744,13 @@ class Rect {
1719
1744
  * @param min Top left corner of the rectangle.
1720
1745
  * @param max Bottom right corner of the rectangle.
1721
1746
  * @param rotation Optional rotation of the rectangle. In radians, around center. Positive values rotate clockwise.
1747
+ * @param elevation Optional elevation. Positive values raise geometry toward the viewer. Defaults to 0.
1722
1748
  */
1723
- constructor(min, max, rotation) {
1749
+ constructor(min, max, rotation, elevation) {
1724
1750
  /** Optional rotation of the rectangle. In radians, around center. Positive values rotate clockwise. */
1725
1751
  __publicField(this, "rotation");
1752
+ /** Optional elevation of the rectangle. Positive values raise geometry toward the viewer. Defaults to 0. */
1753
+ __publicField(this, "elevation");
1726
1754
  __publicField(this, "_min");
1727
1755
  __publicField(this, "_max");
1728
1756
  __publicField(this, "_center", new Vector2());
@@ -1730,6 +1758,7 @@ class Rect {
1730
1758
  this._min = createVector2(min);
1731
1759
  this._max = createVector2(max);
1732
1760
  this.rotation = rotation ?? 0;
1761
+ this.elevation = elevation ?? 0;
1733
1762
  this.updateCenterAndSize();
1734
1763
  }
1735
1764
  /** Top left corner of the rectangle. */
@@ -1772,14 +1801,15 @@ class Rect {
1772
1801
  * Creates a rectangle from an SVG rectangle element.
1773
1802
  * @param rect {@link SVGRectElement} or {@link SVGImageElement}
1774
1803
  * @param rotation Optional rotation of the rectangle. In radians, around center. Positive values rotate clockwise.
1804
+ * @param elevation Optional elevation. Positive values raise geometry toward the viewer. Defaults to 0.
1775
1805
  * @returns new {@link Rect} instance
1776
1806
  */
1777
- static fromSvg(rect, rotation) {
1807
+ static fromSvg(rect, rotation, elevation) {
1778
1808
  const x = rect.x.baseVal.value;
1779
1809
  const y = rect.y.baseVal.value;
1780
1810
  const width = rect.width.baseVal.value;
1781
1811
  const height = rect.height.baseVal.value;
1782
- return new Rect([x, y], [x + width, y + height], rotation);
1812
+ return new Rect([x, y], [x + width, y + height], rotation, elevation);
1783
1813
  }
1784
1814
  /**
1785
1815
  * Moves the rectangle by the given offset.
@@ -1835,7 +1865,11 @@ class Polygon {
1835
1865
  get indices() {
1836
1866
  return this._indices;
1837
1867
  }
1838
- /** Bounding rectangle of the polygon. */
1868
+ /**
1869
+ * Bounding rectangle of the polygon.
1870
+ * This is a 2D axis-aligned bounding box computed from the X/Y projection of vertices.
1871
+ * The Z extent of the polygon is not tracked.
1872
+ */
1839
1873
  get bounds() {
1840
1874
  return this._bbox;
1841
1875
  }
@@ -1845,11 +1879,12 @@ class Polygon {
1845
1879
  * @returns new {@link Polygon} instance
1846
1880
  */
1847
1881
  static fromRect(rect) {
1882
+ const z = rect.elevation;
1848
1883
  const vertices = [
1849
- { x: rect.min.x, y: rect.min.y },
1850
- { x: rect.max.x, y: rect.min.y },
1851
- { x: rect.max.x, y: rect.max.y },
1852
- { x: rect.min.x, y: rect.max.y }
1884
+ { x: rect.min.x, y: rect.min.y, z },
1885
+ { x: rect.max.x, y: rect.min.y, z },
1886
+ { x: rect.max.x, y: rect.max.y, z },
1887
+ { x: rect.min.x, y: rect.max.y, z }
1853
1888
  ];
1854
1889
  const indices = [
1855
1890
  [0, 1, 2],
@@ -1901,7 +1936,8 @@ class Polygon {
1901
1936
  }
1902
1937
  /**
1903
1938
  * Scales the polygon around the given origin.
1904
- * @param scaleFactor Can be a single number or a 2D vector. If a single number is provided, both horizontal and vertical axes will be scaled by the same factor.
1939
+ * @param scaleFactor Can be a single number or a 2D vector. If a single number is provided, both horizontal and
1940
+ * vertical axes will be scaled by the same factor. Z is unchanged in both cases.
1905
1941
  * @param origin Origin of the scaling. If omitted, defaults to the bounding rectangle center.
1906
1942
  * @returns this {@link Polygon} instance
1907
1943
  */
@@ -1929,6 +1965,31 @@ class Polygon {
1929
1965
  }
1930
1966
  }
1931
1967
  const tempVector2 = new Vector2();
1968
+ function groupBy(list, keyGetter) {
1969
+ const map = /* @__PURE__ */ new Map();
1970
+ list.forEach((item) => {
1971
+ const key = keyGetter(item);
1972
+ const collection = map.get(key);
1973
+ if (!collection) {
1974
+ map.set(key, [item]);
1975
+ } else {
1976
+ collection.push(item);
1977
+ }
1978
+ });
1979
+ return map;
1980
+ }
1981
+ function partition(list, pred) {
1982
+ const truthy = [];
1983
+ const falsy = [];
1984
+ for (const item of list) {
1985
+ if (pred(item)) {
1986
+ truthy.push(item);
1987
+ } else {
1988
+ falsy.push(item);
1989
+ }
1990
+ }
1991
+ return [truthy, falsy];
1992
+ }
1932
1993
  function countGeometry(geometry) {
1933
1994
  var _a2;
1934
1995
  if (geometry.index == null) return { vertices: geometry.getAttribute("position").count, indices: 0 };
@@ -1937,6 +1998,18 @@ function countGeometry(geometry) {
1937
1998
  indices: ((_a2 = geometry.index) == null ? void 0 : _a2.count) ?? 0
1938
1999
  };
1939
2000
  }
2001
+ const sphereCenter = new Vector3();
2002
+ function computeBoundingSphere(bounds, origin2, out = new Sphere()) {
2003
+ const ox = (origin2 == null ? void 0 : origin2[0]) ?? 0.5;
2004
+ const oy = (origin2 == null ? void 0 : origin2[1]) ?? 0.5;
2005
+ const w = bounds.size.x;
2006
+ const h = bounds.size.y;
2007
+ const dx = Math.max(ox, 1 - ox) * w;
2008
+ const dy = Math.max(oy, 1 - oy) * h;
2009
+ sphereCenter.set(bounds.center.x, bounds.center.y, bounds.elevation);
2010
+ out.set(sphereCenter, Math.hypot(dx, dy));
2011
+ return out;
2012
+ }
1940
2013
  const logger$a = createLogger("mesh");
1941
2014
  extend([namesPlugin]);
1942
2015
  class MeshSystem extends RenderableSystem {
@@ -1958,6 +2031,7 @@ class MeshSystem extends RenderableSystem {
1958
2031
  this.rectGeometry.deleteAttribute("uv");
1959
2032
  }
1960
2033
  buildLayer(layer) {
2034
+ const is3D = layer.mode === "3D";
1961
2035
  const shapes = layer.children;
1962
2036
  const mapShapeToNormColor = /* @__PURE__ */ new Map();
1963
2037
  for (const shapeDef of shapes) {
@@ -1974,21 +2048,15 @@ class MeshSystem extends RenderableSystem {
1974
2048
  const transparentShapesGrouped = groupBy(transparentShapes, (shapeDef) => mapShapeToNormColor.get(shapeDef).a);
1975
2049
  const group = new Group();
1976
2050
  for (const [opacity, shapes2] of transparentShapesGrouped) {
1977
- const transparentMesh = this.buildBatchedMesh(shapes2, opacity);
2051
+ const transparentMesh = this.buildBatchedMesh(shapes2, is3D, opacity);
1978
2052
  transparentMesh.name = "transparent";
1979
2053
  group.add(transparentMesh);
1980
2054
  }
1981
2055
  if (opaqueShapes.length) {
1982
- const opaqueMesh = this.buildBatchedMesh(opaqueShapes);
2056
+ const opaqueMesh = this.buildBatchedMesh(opaqueShapes, is3D);
1983
2057
  opaqueMesh.name = "opaque";
1984
2058
  group.add(opaqueMesh);
1985
2059
  }
1986
- if (layer.mode === "3d") {
1987
- group.children.filter((child) => child instanceof Mesh).forEach((mesh) => {
1988
- const material = mesh.material;
1989
- material.depthFunc = LessEqualDepth;
1990
- });
1991
- }
1992
2060
  return group;
1993
2061
  }
1994
2062
  updateDefImpl(shapeDef, mesh, instanceIds, firstUpdate) {
@@ -2027,13 +2095,13 @@ class MeshSystem extends RenderableSystem {
2027
2095
  mesh.setColorAt(instanceId, this.color.setRGB(color.r / 255, color.g / 255, color.b / 255, SRGBColorSpace));
2028
2096
  }
2029
2097
  updateRect(shape, mesh, instanceId) {
2030
- this.position.set(shape.center.x, shape.center.y, 0);
2031
- this.rotation.setFromAxisAngle(new Vector3(0, 0, 1), shape.rotation ?? 0);
2098
+ this.position.set(shape.center.x, shape.center.y, shape.elevation);
2099
+ this.rotation.setFromAxisAngle(new Vector3(0, 0, 1), shape.rotation);
2032
2100
  this.scale.set(shape.size.x, shape.size.y, 1);
2033
2101
  this.matrix.compose(this.position, this.rotation, this.scale);
2034
2102
  mesh.setMatrixAt(instanceId, this.matrix);
2035
2103
  }
2036
- buildBatchedMesh(shapes, opacity = 1) {
2104
+ buildBatchedMesh(shapes, is3D, opacity = 1) {
2037
2105
  let vertexCount = 0;
2038
2106
  let indexCount = 0;
2039
2107
  let rectAdded = false;
@@ -2052,7 +2120,7 @@ class MeshSystem extends RenderableSystem {
2052
2120
  vertexCount += vertices;
2053
2121
  indexCount += indices;
2054
2122
  }
2055
- const material = this.materialSystem.createColorMaterial({ opacity });
2123
+ const material = this.materialSystem.createColorMaterial({ opacity, is3D });
2056
2124
  const batchedMesh = new BatchedMesh(shapes.length, vertexCount, indexCount, material);
2057
2125
  const rectGeometryId = rectAdded ? batchedMesh.addGeometry(this.rectGeometry) : void 0;
2058
2126
  batchedMesh.setCustomSort((list) => this.sortInstances(batchedMesh, list));
@@ -2188,9 +2256,10 @@ class TextSystem extends RenderableSystem {
2188
2256
  }
2189
2257
  }
2190
2258
  buildBatchedText(layer) {
2259
+ const is3D = layer.mode === "3D";
2191
2260
  const textDefs = layer.children;
2192
2261
  const batchedText = new BatchedText();
2193
- batchedText.material = this.materialSystem.createColorMaterial();
2262
+ batchedText.material = this.materialSystem.createColorMaterial({ is3D });
2194
2263
  const mappingData = [];
2195
2264
  let instanceId = 0;
2196
2265
  for (const textDef of textDefs) {
@@ -2234,7 +2303,7 @@ class TextSystem extends RenderableSystem {
2234
2303
  this.worldPosition.copy(this.localPosition).rotateAround({ x: 0, y: 0 }, textDef.bounds.rotation).add(textDef.bounds.center);
2235
2304
  this.textScale.copy(this.initialTextScale).multiplyScalar(fontSize * dpr);
2236
2305
  text.scale.set(this.textScale.x, this.textScale.y, 1);
2237
- text.position.set(this.worldPosition.x, this.worldPosition.y, 0);
2306
+ text.position.set(this.worldPosition.x, this.worldPosition.y, textDef.bounds.elevation);
2238
2307
  text.rotation.set(0, 0, textDef.bounds.rotation);
2239
2308
  this.calculateClipRect(text, textDef, this.localPosition, this.textScale, this.localToMin, this.localToMax);
2240
2309
  this.localPosition.y += height * dpr;
@@ -2469,22 +2538,24 @@ class LayerSystem {
2469
2538
  * @returns sorted leaf layers for debug logging
2470
2539
  */
2471
2540
  initRenderOrder(rootLayer) {
2472
- const leafLayers = [];
2473
- const stack = [rootLayer];
2541
+ const twoDLayers = [];
2542
+ const threeDLayers = [];
2543
+ const stack = [
2544
+ { layer: rootLayer, inheritedMode: rootLayer.mode ?? "2D" }
2545
+ ];
2474
2546
  while (stack.length) {
2475
- const layer = stack.pop();
2547
+ const { layer, inheritedMode } = stack.pop();
2476
2548
  if (isLayerLayer(layer)) {
2477
- const children = [...layer.children];
2478
- children.reverse();
2479
- for (const child of children) {
2549
+ for (let i = layer.children.length - 1; i >= 0; i--) {
2550
+ const child = layer.children[i];
2480
2551
  this.mapLayerDefToParent.set(child, layer);
2481
- stack.push(child);
2552
+ stack.push({ layer: child, inheritedMode: child.mode ?? inheritedMode });
2482
2553
  }
2483
2554
  } else {
2484
- leafLayers.push(layer);
2555
+ layer.mode ?? (layer.mode = inheritedMode);
2556
+ (layer.mode === "3D" ? threeDLayers : twoDLayers).push(layer);
2485
2557
  }
2486
2558
  }
2487
- const [threeDLayers, twoDLayers] = partition(leafLayers, (layer) => layer.mode === "3d");
2488
2559
  const sorted = [...twoDLayers, ...threeDLayers];
2489
2560
  for (let i = 0; i < sorted.length; i++) {
2490
2561
  this.renderOrderMap.set(sorted[i], i + 1);
@@ -2599,10 +2670,12 @@ class ExternalCameraSystem {
2599
2670
  }
2600
2671
  }
2601
2672
  const originalProjectionMatrix = new Matrix4();
2673
+ const patchedMeshes = /* @__PURE__ */ new WeakSet();
2602
2674
  function patchMatricesInExternalMode(scene) {
2603
2675
  scene.traverse((child) => {
2604
2676
  const mesh = child;
2605
- if (!mesh.isMesh) return;
2677
+ if (!mesh.isMesh || patchedMeshes.has(mesh)) return;
2678
+ patchedMeshes.add(mesh);
2606
2679
  const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
2607
2680
  for (const material of materials) {
2608
2681
  const onBeforeRender = material.onBeforeRender.bind(material);
@@ -2998,12 +3071,15 @@ class CoordinatesSystem {
2998
3071
  return out;
2999
3072
  }
3000
3073
  /**
3001
- * Converts a point from screen coordinates to world space.
3074
+ * Converts a point from NDC to world space by intersecting the ground plane (z=0).
3075
+ * Returns 2D world coordinates (z is always 0). This is appropriate for coordinate
3076
+ * conversions that don't need to account for 3D geometry (e.g. canvasToSvg, camera controls).
3077
+ * For pointer events that should land on 3D geometry, use scene raycasting instead.
3002
3078
  * @param ndcCoords Point in NDC (normalized device coordinates)
3003
3079
  * @param out Optional output vector
3004
- * @returns Point in world space
3080
+ * @returns Point on the ground plane in world space, or undefined if the ray is parallel to the plane
3005
3081
  */
3006
- ndcToWorld(ndcCoords, out = new Vector3()) {
3082
+ ndcToWorldPlane(ndcCoords, out = new Vector3()) {
3007
3083
  return this.pickingSystem.intersectPlane(ndcCoords, this.cameraSystem.camera, out);
3008
3084
  }
3009
3085
  /**
@@ -3033,7 +3109,7 @@ class CoordinatesSystem {
3033
3109
  const vec2 = new Vector2();
3034
3110
  const vec3 = new Vector3();
3035
3111
  const ndcPoint = this.canvasToNDC(point, vec2);
3036
- const worldPoint = this.ndcToWorld(ndcPoint, vec3);
3112
+ const worldPoint = this.ndcToWorldPlane(ndcPoint, vec3);
3037
3113
  if (!worldPoint) return [];
3038
3114
  const result = [];
3039
3115
  for (const sceneState of this.sceneSystem.sceneStates) {
@@ -5652,6 +5728,9 @@ const subsetOfTHREE = {
5652
5728
  };
5653
5729
  CameraControls.install({ THREE: subsetOfTHREE });
5654
5730
  const logger$4 = createLogger("cameraController");
5731
+ const ELEVATION_PRECISION = 0.1;
5732
+ const ELEVATION_HEADROOM_RATIO = 0.98;
5733
+ const DEPTH_BUFFER_LEVELS = 2 ** 24;
5655
5734
  class CameraController extends CameraControls {
5656
5735
  /**
5657
5736
  * @param camera {@link PerspectiveCamera} instance
@@ -5673,6 +5752,7 @@ class CameraController extends CameraControls {
5673
5752
  three: CameraController.ACTION.NONE
5674
5753
  };
5675
5754
  this.touchCancelListener = () => this.cancel();
5755
+ void this.rotatePolarTo(0, false);
5676
5756
  }
5677
5757
  /**
5678
5758
  * Get bearing angle between current camera orientation and true north (in radians).
@@ -5683,15 +5763,50 @@ class CameraController extends CameraControls {
5683
5763
  }
5684
5764
  update(delta) {
5685
5765
  const needsUpdate = super.update(delta);
5686
- if (needsUpdate && delta > 0) {
5687
- const position = this.camera.position.toArray().map((value) => +value.toFixed(2));
5688
- const target = this._target.toArray().map((value) => +value.toFixed(2));
5689
- const { theta, phi, radius } = this._spherical;
5690
- const spherical = [theta * RAD2DEG, phi * RAD2DEG, radius].map((value) => +value.toFixed(2));
5691
- logger$4.debug("camera update %O", { position, target, spherical });
5766
+ if (needsUpdate) {
5767
+ this.updateNearPlane();
5768
+ if (delta > 0) {
5769
+ const position = this.camera.position.toArray().map((value) => +value.toFixed(2));
5770
+ const target = this._target.toArray().map((value) => +value.toFixed(2));
5771
+ const { theta, phi, radius } = this._spherical;
5772
+ const spherical = [theta * RAD2DEG, phi * RAD2DEG, radius].map((value) => +value.toFixed(2));
5773
+ const clippingPlanes = [this.camera.near, this.camera.far];
5774
+ logger$4.debug("camera update %O", { position, target, spherical, clippingPlanes });
5775
+ }
5692
5776
  }
5693
5777
  return needsUpdate;
5694
5778
  }
5779
+ /**
5780
+ * Updates the near clipping plane to maintain consistent depth buffer precision
5781
+ * at the scene plane regardless of zoom level.
5782
+ *
5783
+ * Two constraints determine the near plane:
5784
+ * - **Headroom**: near = D × (1 − headroom ratio). Dominates at close/default zoom,
5785
+ * keeping headroom at exactly the target ratio. Precision exceeds the target.
5786
+ * - **Precision**: near = D² / (2²⁴ × P). Dominates at far zoom, maintaining target
5787
+ * precision at the cost of reduced headroom.
5788
+ *
5789
+ * The larger of the two is used (precision is never sacrificed for headroom).
5790
+ * The depth precision P is derived from {@link ELEVATION_PRECISION} and the current
5791
+ * {@link CameraControls.maxPolarAngle}, so it adapts when pitch limits change.
5792
+ *
5793
+ * Depth precision at the floor plane (z=0) is the guaranteed minimum. Content above
5794
+ * (closer to camera) has quadratically better precision: P × (d/D)². Content below the
5795
+ * floor degrades quadratically but remains within ~1% for practical depths (< 1% of D).
5796
+ */
5797
+ updateNearPlane() {
5798
+ const camera = this.camera;
5799
+ const D = this.distance;
5800
+ if (D <= 0) return;
5801
+ const cosPitch = Math.max(Math.cos(this.maxPolarAngle), 0.01);
5802
+ const depthPrecision = ELEVATION_PRECISION * cosPitch;
5803
+ const headroomNear = D * (1 - ELEVATION_HEADROOM_RATIO);
5804
+ const precisionNear = D * D / (DEPTH_BUFFER_LEVELS * depthPrecision);
5805
+ const near = Math.max(headroomNear, precisionNear);
5806
+ if (camera.near === near) return;
5807
+ camera.near = near;
5808
+ camera.updateProjectionMatrix();
5809
+ }
5695
5810
  connect(domElement) {
5696
5811
  super.connect(domElement);
5697
5812
  domElement.addEventListener("touchcancel", this.touchCancelListener);
@@ -6061,7 +6176,7 @@ class RollHandler extends Handler {
6061
6176
  }
6062
6177
  setPivot(e) {
6063
6178
  this.coordinatesSystem.canvasToNDC(e.offsetCenter, this.pivotNDC);
6064
- if (!this.coordinatesSystem.ndcToWorld(this.pivotNDC, this.pivotWorld)) return false;
6179
+ if (!this.coordinatesSystem.ndcToWorldPlane(this.pivotNDC, this.pivotWorld)) return false;
6065
6180
  this.controller.getPosition(this.cameraPosition);
6066
6181
  this.controller.getTarget(this.targetWorld);
6067
6182
  this.cameraForward.copy(this.targetWorld).sub(this.cameraPosition);
@@ -6147,7 +6262,7 @@ class InteractionsSystem {
6147
6262
  if (internalCameraSystem) {
6148
6263
  this.eventManager = new EventManager(this.canvas, {
6149
6264
  recognizers: [Rotate, [Pan, { event: "pitch", pointers: 2 }, "rotate"]]
6150
- // FIXME: Double click to zoom
6265
+ // TODO: Double click to zoom
6151
6266
  });
6152
6267
  this.handlers = {
6153
6268
  pan: new PanHandler(this.canvas, this.eventManager, internalCameraSystem, coordinatesSystem),
@@ -6175,8 +6290,6 @@ class InteractionsSystem {
6175
6290
  });
6176
6291
  this.handlers.pan.enable();
6177
6292
  this.handlers.zoom.enable();
6178
- this.handlers.roll.enable();
6179
- this.handlers.pitch.enable();
6180
6293
  }
6181
6294
  }
6182
6295
  /**
@@ -6238,15 +6351,18 @@ class InteractionsSystem {
6238
6351
  if (isDragging || !hasListeners) return;
6239
6352
  const mousePointer = eventToCanvas(event);
6240
6353
  coordinatesSystem.canvasToNDC(mousePointer, this.mousePointerNDC);
6241
- if (!coordinatesSystem.ndcToWorld(this.mousePointerNDC, this.mousePointerWorld)) return;
6242
- const intersectionsByScene = this.viewportAccess.getIntersectedObjectsByScene(this.mousePointerNDC);
6354
+ const raycastResults = this.viewportAccess.raycastByScene(this.mousePointerNDC);
6243
6355
  const data = [];
6244
- for (const { sceneId, intersections } of intersectionsByScene) {
6245
- const point = coordinatesSystem.worldToModel(this.mousePointerWorld, this.mousePointerModel, sceneId);
6246
- const defs = this.layerSystem.getIntersectedDefs(intersections);
6356
+ for (const { sceneId, intersections } of raycastResults) {
6357
+ const interactive = intersections.filter((i) => isInteractive(i.object));
6358
+ const closestHit = interactive[0] ?? intersections[0];
6359
+ const worldPoint = closestHit ? this.mousePointerWorld.copy(closestHit.point) : coordinatesSystem.ndcToWorldPlane(this.mousePointerNDC, this.mousePointerWorld);
6360
+ if (!worldPoint) continue;
6361
+ const point = coordinatesSystem.worldToModel(worldPoint, this.mousePointerModel, sceneId);
6362
+ const defs = this.layerSystem.getIntersectedDefs(interactive);
6247
6363
  data.push({ point: { x: point.x, y: point.y }, defs, sceneId });
6248
6364
  }
6249
- this.events.emit(eventType, { event, data });
6365
+ if (data.length > 0) this.events.emit(eventType, { event, data });
6250
6366
  };
6251
6367
  const mousemove = (event) => sharedMouseHandler("mousemove", event);
6252
6368
  const mouseout = (event) => sharedMouseHandler("mouseout", event);
@@ -6282,7 +6398,7 @@ class UpdatesSystem {
6282
6398
  constructor(ctx, layerSystem) {
6283
6399
  __publicField(this, "pendingDefs", /* @__PURE__ */ new Set());
6284
6400
  __publicField(this, "culledDefs", /* @__PURE__ */ new Set());
6285
- __publicField(this, "defBounds", new Box3());
6401
+ __publicField(this, "defSphere", new Sphere());
6286
6402
  __publicField(this, "useUpdateBuffering", true);
6287
6403
  this.ctx = ctx;
6288
6404
  this.layerSystem = layerSystem;
@@ -6323,10 +6439,9 @@ class UpdatesSystem {
6323
6439
  let culled = false;
6324
6440
  this.pendingDefs.delete(def);
6325
6441
  if (isTextDef(def) || isImageDef(def)) {
6326
- const bounds = def.bounds;
6327
- this.defBounds.min.set(bounds.min.x, bounds.min.y, 0);
6328
- this.defBounds.max.set(bounds.max.x, bounds.max.y, 0);
6329
- culled = !frustum2.intersectsBox(this.defBounds);
6442
+ const origin2 = isImageDef(def) ? def.origin : void 0;
6443
+ computeBoundingSphere(def.bounds, origin2, this.defSphere);
6444
+ culled = !frustum2.intersectsSphere(this.defSphere);
6330
6445
  }
6331
6446
  if (culled) this.culledDefs.add(def);
6332
6447
  else {
@@ -6371,8 +6486,8 @@ class InternalCameraSystem {
6371
6486
  const h = ctx.getDrawingBufferSizePx()[1];
6372
6487
  this.prevViewportHeightPx = h;
6373
6488
  this.camera = new PerspectiveCamera(this.defaultFov);
6489
+ this.camera.up.set(0, 0, -1);
6374
6490
  this.controller = new CameraController(this.camera);
6375
- void this.controller.rotatePolarTo(0, false);
6376
6491
  }
6377
6492
  /** Current camera zoom factor. */
6378
6493
  get zoomFactor() {
@@ -6405,12 +6520,8 @@ class InternalCameraSystem {
6405
6520
  initCamera(viewbox) {
6406
6521
  this.zoomBounds = [0.1, viewbox.size.x > 1e4 ? 100 : 35];
6407
6522
  this.updateCamera();
6408
- this.camera.up.set(0, -1, 0);
6409
- this.camera.position.set(0, 0, -this.zoomIdentityDistance);
6410
- this.camera.lookAt(0, 0, 0);
6523
+ this.controller.update(0);
6411
6524
  this.camera.updateMatrixWorld();
6412
- this.camera.up.set(0, 0, -1);
6413
- this.controller.updateCameraUp();
6414
6525
  }
6415
6526
  /** Updates the camera when the renderer size changes. */
6416
6527
  updateCamera() {
@@ -6422,8 +6533,7 @@ class InternalCameraSystem {
6422
6533
  const maxDistance = Math.abs(newZoomIdentity / this.zoomBounds[0]);
6423
6534
  const minDistance = Math.abs(newZoomIdentity / this.zoomBounds[1]);
6424
6535
  this.camera.aspect = w / (h || 1);
6425
- this.camera.near = 0.01;
6426
- this.camera.far = Math.max(maxDistance, this.camera.near) * 2;
6536
+ this.camera.far = Math.max(maxDistance, 0.1) * 2;
6427
6537
  this.camera.updateProjectionMatrix();
6428
6538
  this.controller.minDistance = minDistance;
6429
6539
  this.controller.maxDistance = maxDistance;
@@ -6458,21 +6568,21 @@ class InternalCameraSystem {
6458
6568
  }
6459
6569
  }
6460
6570
  class PickingSystem {
6461
- /** */
6462
6571
  constructor() {
6463
6572
  __publicField(this, "raycaster", new Raycaster());
6464
6573
  __publicField(this, "ndcPoint", new Vector2());
6465
6574
  __publicField(this, "viewboxPlane", new Plane(new Vector3(0, 0, 1), 0));
6466
- this.raycaster.layers.set(INTERACTIVE_LAYER);
6467
6575
  }
6468
6576
  /**
6469
- * Gets the objects intersected by the raycaster.
6577
+ * Raycasts the scene and returns all visible intersections sorted by distance.
6578
+ * Does not filter by interaction layer — consumers are responsible for partitioning
6579
+ * interactive vs non-interactive results using {@link isInteractive}.
6470
6580
  * @param ndcCoords raycast point in NDC (normalized device coordinates)
6471
6581
  * @param scene {@link Scene} instance
6472
6582
  * @param camera {@link Camera} instance
6473
- * @returns Array of {@link Intersection} instances
6583
+ * @returns Array of {@link Intersection} instances, nearest first
6474
6584
  */
6475
- getIntersectedObjects(ndcCoords, scene, camera) {
6585
+ raycast(ndcCoords, scene, camera) {
6476
6586
  this.setRaycasterFromCamera(ndcCoords, camera);
6477
6587
  const intersections = this.raycaster.intersectObject(scene, true);
6478
6588
  return intersections.filter((i) => isVisible(i.object));
@@ -6595,7 +6705,7 @@ class SceneSystem {
6595
6705
  const scaleFactor = Math.min(visibleRectSize.width, visibleRectSize.height);
6596
6706
  const { x: centerX, y: centerY } = viewbox.center;
6597
6707
  this.translationMatrix.makeTranslation(-centerX, -centerY, 0);
6598
- this.scaleMatrix.makeScale(scaleFactor, scaleFactor, 1);
6708
+ this.scaleMatrix.makeScale(scaleFactor, scaleFactor, -1);
6599
6709
  targetMatrix.copy(this.translationMatrix).premultiply(this.scaleMatrix);
6600
6710
  const viewportRectCenter = this.tempVector2.copy(viewportRectPx.center);
6601
6711
  const offset = viewportRectCenter.sub({ x: bufferW / 2, y: bufferH / 2 });
@@ -6667,15 +6777,16 @@ class ViewportSystem {
6667
6777
  this.visibleRectCss = rect;
6668
6778
  }
6669
6779
  /**
6670
- * Get the objects intersected by the raycaster grouped by scene.
6780
+ * Raycasts all loaded scenes and returns all visible intersections per scene.
6781
+ * Results are not filtered by interaction layer — consumers partition as needed.
6671
6782
  * @param ndcCoords raycast point in NDC (normalized device coordinates)
6672
- * @returns Array of {@link IntersectionsWithinScene} instances
6783
+ * @returns Array of {@link SceneRaycastResult} instances
6673
6784
  */
6674
- getIntersectedObjectsByScene(ndcCoords) {
6785
+ raycastByScene(ndcCoords) {
6675
6786
  const result = [];
6676
6787
  for (const sceneState of this.sceneSystem.sceneStates) {
6677
6788
  if (!sceneState.loaded) continue;
6678
- const intersections = this.pickingSystem.getIntersectedObjects(ndcCoords, sceneState.scene, this.camera);
6789
+ const intersections = this.pickingSystem.raycast(ndcCoords, sceneState.scene, this.camera);
6679
6790
  result.push({ intersections, sceneId: sceneState.id });
6680
6791
  }
6681
6792
  return result;
@@ -7033,7 +7144,6 @@ class Renderer {
7033
7144
  } else if (sceneState.scene.children.length === 0) {
7034
7145
  const root = this.layerSystem.buildScene(sceneState.sceneDef);
7035
7146
  scene.add(root);
7036
- if (this.isExternalMode) patchMatricesInExternalMode(scene);
7037
7147
  }
7038
7148
  }
7039
7149
  const justLoaded = loadedChanged && sceneState.loaded;
@@ -7041,6 +7151,7 @@ class Renderer {
7041
7151
  this.viewportSystem.updatePtScale(id);
7042
7152
  const hasDefsUpdated = this.updatesSystem.processPendingUpdates(frustum2, 3);
7043
7153
  const forceRedraw = hasControlsUpdated || hasDefsUpdated || justLoaded || this.isExternalMode || this.ui;
7154
+ if (this.isExternalMode) patchMatricesInExternalMode(scene);
7044
7155
  if (this.needsRedraw || forceRedraw) this.renderer.render(scene, camera);
7045
7156
  if (hasDefsUpdated || this.needsRedraw) this.viewportSystem.invalidateSceneBounds(sceneState);
7046
7157
  }
@@ -7077,8 +7188,7 @@ class Renderer {
7077
7188
  }
7078
7189
  initScene(sceneDef, sceneId) {
7079
7190
  const root = this.layerSystem.buildScene(sceneDef);
7080
- const scene = this.viewportSystem.initScene(sceneDef, sceneId).add(root);
7081
- if (this.isExternalMode) patchMatricesInExternalMode(scene);
7191
+ this.viewportSystem.initScene(sceneDef, sceneId).add(root);
7082
7192
  }
7083
7193
  // https://webgl2fundamentals.org/webgl/lessons/webgl-resizing-the-canvas.html
7084
7194
  resizeCanvasToDisplaySize() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expofp/renderer",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"
@@ -14,8 +14,6 @@
14
14
  "@types/object-hash": "^3.0.6",
15
15
  "@types/three": "^0.174.0",
16
16
  "stats-gl": "^3.6.0",
17
- "typescript": "^5.2.2",
18
- "vite": "^5.2.0",
19
17
  "vite-bundle-analyzer": "^1.3.2",
20
18
  "vite-plugin-dts": "^4.5.4",
21
19
  "vite-plugin-externalize-deps": "^0.9.0"