@d5techs/3dgs-lib 1.2.0 → 1.4.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/3dgs-lib.cjs CHANGED
@@ -4413,7 +4413,8 @@ function readProperty$1(dataView, baseOffset, prop, littleEndian) {
4413
4413
  function sigmoid$1(x) {
4414
4414
  return 1 / (1 + Math.exp(-x));
4415
4415
  }
4416
- async function loadPLY(url) {
4416
+ async function loadPLY(url, options = {}) {
4417
+ const { coordinateSystem = "blender" } = options;
4417
4418
  const response = await fetch(url);
4418
4419
  if (!response.ok) {
4419
4420
  throw new Error(`无法加载 PLY 文件: ${url}`);
@@ -4425,6 +4426,7 @@ async function loadPLY(url) {
4425
4426
  throw new Error("不支持 ASCII 格式的 PLY 文件,请使用 binary_little_endian 或 binary_big_endian 格式");
4426
4427
  }
4427
4428
  const littleEndian = format === "binary_little_endian";
4429
+ const swapYZ = coordinateSystem === "blender";
4428
4430
  const propMap = buildPropertyMap(properties);
4429
4431
  const props = {
4430
4432
  x: propMap.get("x"),
@@ -4490,9 +4492,14 @@ async function loadPLY(url) {
4490
4492
  shRest[dstBase + 2] = srcB < shRestProps.length ? readProperty$1(dataView, base, shRestProps[srcB], littleEndian) : 0;
4491
4493
  }
4492
4494
  splats[i] = {
4493
- mean: [x, y, z],
4494
- scale: [scale_0, scale_1, scale_2],
4495
- rotation: [
4495
+ mean: swapYZ ? [x, z, y] : [x, y, z],
4496
+ scale: swapYZ ? [scale_0, scale_2, scale_1] : [scale_0, scale_1, scale_2],
4497
+ rotation: swapYZ ? [
4498
+ -rot_0 * qnorm,
4499
+ rot_1 * qnorm,
4500
+ rot_3 * qnorm,
4501
+ rot_2 * qnorm
4502
+ ] : [
4496
4503
  rot_0 * qnorm,
4497
4504
  rot_1 * qnorm,
4498
4505
  rot_2 * qnorm,
@@ -4708,7 +4715,8 @@ async function parsePLYBuffer(buffer, options = {}) {
4708
4715
  const {
4709
4716
  maxSplats = 2e5,
4710
4717
  loadSH = false,
4711
- onProgress
4718
+ onProgress,
4719
+ coordinateSystem = "blender"
4712
4720
  } = options;
4713
4721
  const seed = options.seed ?? buffer.byteLength;
4714
4722
  const { headerText, dataOffset } = extractHeader(buffer);
@@ -4798,27 +4806,41 @@ async function parsePLYBuffer(buffer, options = {}) {
4798
4806
  const opacities = new Float32Array(actualCount);
4799
4807
  const shCoeffs = loadSH ? new Float32Array(actualCount * 45) : void 0;
4800
4808
  const dataView = new DataView(buffer, dataOffset);
4809
+ const swapYZ = coordinateSystem === "blender";
4801
4810
  let outputIdx = 0;
4802
4811
  let lastProgress = 0;
4803
4812
  for (let i = 0; i < actualCount; i++) {
4804
4813
  const srcIdx = sampleIndices ? sampleIndices[i] : i;
4805
4814
  const base = srcIdx * stride;
4806
- positions[outputIdx * 3 + 0] = offsets.x >= 0 ? readProperty(dataView, base + offsets.x, types.x, littleEndian) : 0;
4807
- positions[outputIdx * 3 + 1] = offsets.y >= 0 ? readProperty(dataView, base + offsets.y, types.y, littleEndian) : 0;
4808
- positions[outputIdx * 3 + 2] = offsets.z >= 0 ? readProperty(dataView, base + offsets.z, types.z, littleEndian) : 0;
4809
- scales[outputIdx * 3 + 0] = offsets.scale_0 >= 0 ? Math.exp(readProperty(dataView, base + offsets.scale_0, types.scale_0, littleEndian)) : 1;
4810
- scales[outputIdx * 3 + 1] = offsets.scale_1 >= 0 ? Math.exp(readProperty(dataView, base + offsets.scale_1, types.scale_1, littleEndian)) : 1;
4811
- scales[outputIdx * 3 + 2] = offsets.scale_2 >= 0 ? Math.exp(readProperty(dataView, base + offsets.scale_2, types.scale_2, littleEndian)) : 1;
4815
+ const px = offsets.x >= 0 ? readProperty(dataView, base + offsets.x, types.x, littleEndian) : 0;
4816
+ const py = offsets.y >= 0 ? readProperty(dataView, base + offsets.y, types.y, littleEndian) : 0;
4817
+ const pz = offsets.z >= 0 ? readProperty(dataView, base + offsets.z, types.z, littleEndian) : 0;
4818
+ positions[outputIdx * 3 + 0] = px;
4819
+ positions[outputIdx * 3 + 1] = swapYZ ? pz : py;
4820
+ positions[outputIdx * 3 + 2] = swapYZ ? py : pz;
4821
+ const sx = offsets.scale_0 >= 0 ? Math.exp(readProperty(dataView, base + offsets.scale_0, types.scale_0, littleEndian)) : 1;
4822
+ const sy = offsets.scale_1 >= 0 ? Math.exp(readProperty(dataView, base + offsets.scale_1, types.scale_1, littleEndian)) : 1;
4823
+ const sz = offsets.scale_2 >= 0 ? Math.exp(readProperty(dataView, base + offsets.scale_2, types.scale_2, littleEndian)) : 1;
4824
+ scales[outputIdx * 3 + 0] = sx;
4825
+ scales[outputIdx * 3 + 1] = swapYZ ? sz : sy;
4826
+ scales[outputIdx * 3 + 2] = swapYZ ? sy : sz;
4812
4827
  const rot_0 = offsets.rot_0 >= 0 ? readProperty(dataView, base + offsets.rot_0, types.rot_0, littleEndian) : 1;
4813
4828
  const rot_1 = offsets.rot_1 >= 0 ? readProperty(dataView, base + offsets.rot_1, types.rot_1, littleEndian) : 0;
4814
4829
  const rot_2 = offsets.rot_2 >= 0 ? readProperty(dataView, base + offsets.rot_2, types.rot_2, littleEndian) : 0;
4815
4830
  const rot_3 = offsets.rot_3 >= 0 ? readProperty(dataView, base + offsets.rot_3, types.rot_3, littleEndian) : 0;
4816
4831
  const qlen = Math.sqrt(rot_0 * rot_0 + rot_1 * rot_1 + rot_2 * rot_2 + rot_3 * rot_3);
4817
4832
  const qnorm = qlen > 0 ? 1 / qlen : 1;
4818
- rotations[outputIdx * 4 + 0] = rot_0 * qnorm;
4819
- rotations[outputIdx * 4 + 1] = rot_1 * qnorm;
4820
- rotations[outputIdx * 4 + 2] = rot_2 * qnorm;
4821
- rotations[outputIdx * 4 + 3] = rot_3 * qnorm;
4833
+ if (swapYZ) {
4834
+ rotations[outputIdx * 4 + 0] = -rot_0 * qnorm;
4835
+ rotations[outputIdx * 4 + 1] = rot_1 * qnorm;
4836
+ rotations[outputIdx * 4 + 2] = rot_3 * qnorm;
4837
+ rotations[outputIdx * 4 + 3] = rot_2 * qnorm;
4838
+ } else {
4839
+ rotations[outputIdx * 4 + 0] = rot_0 * qnorm;
4840
+ rotations[outputIdx * 4 + 1] = rot_1 * qnorm;
4841
+ rotations[outputIdx * 4 + 2] = rot_2 * qnorm;
4842
+ rotations[outputIdx * 4 + 3] = rot_3 * qnorm;
4843
+ }
4822
4844
  const f_dc_0 = offsets.f_dc_0 >= 0 ? readProperty(dataView, base + offsets.f_dc_0, types.f_dc_0, littleEndian) : 0;
4823
4845
  const f_dc_1 = offsets.f_dc_1 >= 0 ? readProperty(dataView, base + offsets.f_dc_1, types.f_dc_1, littleEndian) : 0;
4824
4846
  const f_dc_2 = offsets.f_dc_2 >= 0 ? readProperty(dataView, base + offsets.f_dc_2, types.f_dc_2, littleEndian) : 0;
@@ -11600,6 +11622,8 @@ class TransformGizmo {
11600
11622
  __publicField(this, "dragMode", "selected");
11601
11623
  // 平面翻转
11602
11624
  __publicField(this, "flipPlanes", true);
11625
+ /** 强制等比缩放:开启后无论拖哪个轴,缩放都作用于 xyz */
11626
+ __publicField(this, "uniformScaleOnly", false);
11603
11627
  // 目标对象
11604
11628
  __publicField(this, "_target", null);
11605
11629
  // 形状
@@ -12686,7 +12710,8 @@ class TransformGizmo {
12686
12710
  }
12687
12711
  _applyScale(delta) {
12688
12712
  if (!this._target || !this._dragStartTransform) return;
12689
- const axis = this._selectedAxis;
12713
+ let axis = this._selectedAxis;
12714
+ if (this.uniformScaleOnly) axis = "xyz";
12690
12715
  let scaleFactor = 1;
12691
12716
  if (axis === "x") scaleFactor = 1 + delta.x;
12692
12717
  else if (axis === "y") scaleFactor = 1 + delta.y;
@@ -12865,6 +12890,9 @@ class GizmoManager {
12865
12890
  setGizmoMode(mode) {
12866
12891
  this.transformGizmo.mode = mode;
12867
12892
  }
12893
+ setUniformScaleOnly(enabled) {
12894
+ this.transformGizmo.uniformScaleOnly = enabled;
12895
+ }
12868
12896
  /**
12869
12897
  * 设置 Gizmo 目标对象
12870
12898
  */
@@ -12985,9 +13013,17 @@ class HotspotManager {
12985
13013
  __publicField(this, "indicatorBaseRadius", 0.02);
12986
13014
  // 搜索邻域的屏幕空间半径(像素)
12987
13015
  __publicField(this, "pickRadiusPx", 20);
13016
+ // 标签 overlay 容器
13017
+ __publicField(this, "labelContainer", null);
13018
+ // 热点点击回调
13019
+ __publicField(this, "onHotspotClicked", null);
13020
+ // 热点点击命中半径(屏幕空间 NDC 距离)
13021
+ __publicField(this, "hotspotHitRadius", 0.05);
12988
13022
  // 回调
12989
13023
  __publicField(this, "onHotspotPlaced", null);
12990
13024
  __publicField(this, "onModeChanged", null);
13025
+ __publicField(this, "_clickListenerBound", false);
13026
+ __publicField(this, "_boundOnCanvasClick", null);
12991
13027
  // ============================================
12992
13028
  // 事件处理
12993
13029
  // ============================================
@@ -13009,6 +13045,7 @@ class HotspotManager {
13009
13045
  this.boundOnMouseDown = this.onMouseDown.bind(this);
13010
13046
  this.boundOnMouseUp = this.onMouseUp.bind(this);
13011
13047
  this.boundOnKeyDown = this.onKeyDown.bind(this);
13048
+ this.initLabelContainer();
13012
13049
  }
13013
13050
  setGSRenderer(gsRenderer) {
13014
13051
  this.gsRenderer = gsRenderer;
@@ -13074,6 +13111,175 @@ class HotspotManager {
13074
13111
  setOnModeChanged(cb) {
13075
13112
  this.onModeChanged = cb;
13076
13113
  }
13114
+ // ============================================
13115
+ // 标签管理
13116
+ // ============================================
13117
+ initLabelContainer() {
13118
+ const container = document.createElement("div");
13119
+ container.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:hidden;z-index:10";
13120
+ const parent = this.canvas.parentElement;
13121
+ if (parent) {
13122
+ const pos = getComputedStyle(parent).position;
13123
+ if (pos === "static") parent.style.position = "relative";
13124
+ parent.appendChild(container);
13125
+ }
13126
+ this.labelContainer = container;
13127
+ }
13128
+ /**
13129
+ * 设置热点标签
13130
+ */
13131
+ setHotspotLabel(hotspotIndex, text, position = "top", fontSize = 14, visible = true) {
13132
+ if (hotspotIndex < 0 || hotspotIndex >= this.hotspots.length) return false;
13133
+ const info = this.hotspots[hotspotIndex];
13134
+ info.label = { text, position, fontSize, visible };
13135
+ this.ensureLabelElement(info);
13136
+ this.applyLabelStyle(info);
13137
+ return true;
13138
+ }
13139
+ /**
13140
+ * 设置热点标签可见性
13141
+ */
13142
+ setHotspotLabelVisible(hotspotIndex, visible) {
13143
+ if (hotspotIndex < 0 || hotspotIndex >= this.hotspots.length) return false;
13144
+ const info = this.hotspots[hotspotIndex];
13145
+ if (!info.label) return false;
13146
+ info.label.visible = visible;
13147
+ if (info._labelElement) {
13148
+ info._labelElement.style.display = visible ? "block" : "none";
13149
+ }
13150
+ return true;
13151
+ }
13152
+ /**
13153
+ * 获取热点标签配置
13154
+ */
13155
+ getHotspotLabel(hotspotIndex) {
13156
+ if (hotspotIndex < 0 || hotspotIndex >= this.hotspots.length) return null;
13157
+ return this.hotspots[hotspotIndex].label ?? null;
13158
+ }
13159
+ ensureLabelElement(info) {
13160
+ if (info._labelElement) return;
13161
+ if (!this.labelContainer) return;
13162
+ const el = document.createElement("div");
13163
+ el.style.cssText = "position:absolute;white-space:nowrap;color:#fff;text-shadow:0 1px 4px rgba(0,0,0,0.8);pointer-events:none;transform-origin:center;transition:opacity 0.15s";
13164
+ this.labelContainer.appendChild(el);
13165
+ info._labelElement = el;
13166
+ }
13167
+ applyLabelStyle(info) {
13168
+ const el = info._labelElement;
13169
+ const label = info.label;
13170
+ if (!el || !label) return;
13171
+ el.textContent = label.text;
13172
+ el.style.fontSize = `${label.fontSize}px`;
13173
+ el.style.display = label.visible ? "block" : "none";
13174
+ }
13175
+ /**
13176
+ * 每帧更新所有标签的屏幕位置
13177
+ */
13178
+ updateLabels() {
13179
+ var _a2;
13180
+ if (!this.labelContainer) return;
13181
+ const rect = this.canvas.getBoundingClientRect();
13182
+ const vp = this.camera.viewProjectionMatrix;
13183
+ for (const info of this.hotspots) {
13184
+ if (!((_a2 = info.label) == null ? void 0 : _a2.visible) || !info._labelElement) continue;
13185
+ const [px, py, pz] = info.position;
13186
+ const clipW = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15];
13187
+ if (clipW <= 0) {
13188
+ info._labelElement.style.opacity = "0";
13189
+ continue;
13190
+ }
13191
+ const ndcX = (vp[0] * px + vp[4] * py + vp[8] * pz + vp[12]) / clipW;
13192
+ const ndcY = (vp[1] * px + vp[5] * py + vp[9] * pz + vp[13]) / clipW;
13193
+ const screenX = (ndcX + 1) * 0.5 * rect.width;
13194
+ const screenY = (1 - ndcY) * 0.5 * rect.height;
13195
+ const el = info._labelElement;
13196
+ el.style.opacity = "1";
13197
+ const iconOffset = 20;
13198
+ switch (info.label.position) {
13199
+ case "top":
13200
+ el.style.left = `${screenX}px`;
13201
+ el.style.top = `${screenY - iconOffset}px`;
13202
+ el.style.transform = "translate(-50%, -100%)";
13203
+ break;
13204
+ case "left":
13205
+ el.style.left = `${screenX - iconOffset}px`;
13206
+ el.style.top = `${screenY}px`;
13207
+ el.style.transform = "translate(-100%, -50%)";
13208
+ break;
13209
+ case "right":
13210
+ el.style.left = `${screenX + iconOffset}px`;
13211
+ el.style.top = `${screenY}px`;
13212
+ el.style.transform = "translate(0, -50%)";
13213
+ break;
13214
+ }
13215
+ }
13216
+ }
13217
+ // ============================================
13218
+ // 热点点击检测
13219
+ // ============================================
13220
+ /**
13221
+ * 设置热点点击回调
13222
+ */
13223
+ setOnHotspotClicked(cb) {
13224
+ this.onHotspotClicked = cb;
13225
+ if (cb && !this._clickListenerBound) {
13226
+ this._boundOnCanvasClick = this._onCanvasClick.bind(this);
13227
+ this.canvas.addEventListener("click", this._boundOnCanvasClick);
13228
+ this._clickListenerBound = true;
13229
+ } else if (!cb && this._clickListenerBound && this._boundOnCanvasClick) {
13230
+ this.canvas.removeEventListener("click", this._boundOnCanvasClick);
13231
+ this._clickListenerBound = false;
13232
+ }
13233
+ }
13234
+ _onCanvasClick(e) {
13235
+ if (this.active) return;
13236
+ const idx = this.hitTestHotspot(e.clientX, e.clientY);
13237
+ if (idx >= 0 && this.onHotspotClicked) {
13238
+ this.onHotspotClicked(idx, this.hotspots[idx]);
13239
+ }
13240
+ }
13241
+ /**
13242
+ * 检测给定屏幕坐标命中了哪个热点
13243
+ * @returns 热点索引,-1 表示未命中
13244
+ */
13245
+ hitTestHotspot(clientX, clientY) {
13246
+ if (this.hotspots.length === 0) return -1;
13247
+ const rect = this.canvas.getBoundingClientRect();
13248
+ const ndcX = (clientX - rect.left) / rect.width * 2 - 1;
13249
+ const ndcY = -((clientY - rect.top) / rect.height * 2 - 1);
13250
+ const vp = this.camera.viewProjectionMatrix;
13251
+ const camPos = this.camera.position;
13252
+ let closestDist = Infinity;
13253
+ let closestIdx = -1;
13254
+ for (let i = 0; i < this.hotspots.length; i++) {
13255
+ const h = this.hotspots[i];
13256
+ const [px, py, pz] = h.position;
13257
+ const clipW = vp[3] * px + vp[7] * py + vp[11] * pz + vp[15];
13258
+ if (clipW <= 0) continue;
13259
+ const clipX = (vp[0] * px + vp[4] * py + vp[8] * pz + vp[12]) / clipW;
13260
+ const clipY = (vp[1] * px + vp[5] * py + vp[9] * pz + vp[13]) / clipW;
13261
+ const dx = clipX - ndcX;
13262
+ const dy = clipY - ndcY;
13263
+ const screenDist = Math.sqrt(dx * dx + dy * dy);
13264
+ if (screenDist < this.hotspotHitRadius) {
13265
+ const cx = px - camPos[0];
13266
+ const cy = py - camPos[1];
13267
+ const cz = pz - camPos[2];
13268
+ const dist3D = cx * cx + cy * cy + cz * cz;
13269
+ if (dist3D < closestDist) {
13270
+ closestDist = dist3D;
13271
+ closestIdx = i;
13272
+ }
13273
+ }
13274
+ }
13275
+ return closestIdx;
13276
+ }
13277
+ /**
13278
+ * 设置热点点击命中半径(NDC 空间,默认 0.05)
13279
+ */
13280
+ setHotspotHitRadius(radius) {
13281
+ this.hotspotHitRadius = radius;
13282
+ }
13077
13283
  /**
13078
13284
  * Returns the current pick pixel coordinates in device pixels.
13079
13285
  * Called by the render loop to pass to prepareDepthNormalPass.
@@ -13715,6 +13921,117 @@ class HotspotManager {
13715
13921
  (h) => h.meshStartIndex === overlayMeshStartIndex
13716
13922
  );
13717
13923
  }
13924
+ /**
13925
+ * 在指定位置和法线方向程序化放置一个热点(用于从保存数据恢复)。
13926
+ * 逻辑与交互放置一致:加载 OBJ → 计算朝向矩阵 → addOverlayMesh → 注册到内部列表。
13927
+ * @returns 放置后的 HotspotInfo,失败返回 null
13928
+ */
13929
+ async placeHotspotAt(objUrl, position, normal, visualDiameter, normalOffset, overrideScale) {
13930
+ var _a2;
13931
+ try {
13932
+ const loadedMeshes = await this.objLoader.load(objUrl);
13933
+ if (loadedMeshes.length === 0) return null;
13934
+ if (visualDiameter == null) {
13935
+ const bbox = (_a2 = this.gsRenderer) == null ? void 0 : _a2.getBoundingBox();
13936
+ visualDiameter = bbox ? bbox.radius * 0.03 : 0.3;
13937
+ }
13938
+ if (normalOffset == null) {
13939
+ normalOffset = 0.02;
13940
+ }
13941
+ const meshStartIndex = this.meshRenderer.getOverlayMeshCount();
13942
+ const [nx, ny, nz] = normal;
13943
+ let upX = 0, upY = 1, upZ = 0;
13944
+ if (Math.abs(ny) > 0.99) {
13945
+ upX = 1;
13946
+ upY = 0;
13947
+ upZ = 0;
13948
+ }
13949
+ let rx = upY * nz - upZ * ny;
13950
+ let ry = upZ * nx - upX * nz;
13951
+ let rz = upX * ny - upY * nx;
13952
+ const rLen = Math.sqrt(rx * rx + ry * ry + rz * rz) || 1;
13953
+ rx /= rLen;
13954
+ ry /= rLen;
13955
+ rz /= rLen;
13956
+ let fx = ny * rz - nz * ry;
13957
+ let fy = nz * rx - nx * rz;
13958
+ let fz = nx * ry - ny * rx;
13959
+ const fLen = Math.sqrt(fx * fx + fy * fy + fz * fz) || 1;
13960
+ fx /= fLen;
13961
+ fy /= fLen;
13962
+ fz /= fLen;
13963
+ let placedScale = 1;
13964
+ let placedLocalCenter = [0, 0, 0];
13965
+ const firstBbox = loadedMeshes[0].mesh.getLocalBoundingBox();
13966
+ if (firstBbox) {
13967
+ const modelMaxDim = Math.max(
13968
+ firstBbox.max[0] - firstBbox.min[0],
13969
+ firstBbox.max[1] - firstBbox.min[1],
13970
+ firstBbox.max[2] - firstBbox.min[2],
13971
+ 1e-6
13972
+ );
13973
+ placedScale = overrideScale != null ? overrideScale : visualDiameter / modelMaxDim;
13974
+ placedLocalCenter = [
13975
+ (firstBbox.min[0] + firstBbox.max[0]) / 2,
13976
+ (firstBbox.min[1] + firstBbox.max[1]) / 2,
13977
+ (firstBbox.min[2] + firstBbox.max[2]) / 2
13978
+ ];
13979
+ }
13980
+ for (const { mesh, material } of loadedMeshes) {
13981
+ const bbox = mesh.getLocalBoundingBox();
13982
+ let s = placedScale;
13983
+ let lcx = 0, lcy = 0, lcz = 0;
13984
+ if (bbox) {
13985
+ if (overrideScale != null) {
13986
+ s = overrideScale;
13987
+ } else {
13988
+ const mDim = Math.max(bbox.max[0] - bbox.min[0], bbox.max[1] - bbox.min[1], bbox.max[2] - bbox.min[2], 1e-6);
13989
+ s = visualDiameter / mDim;
13990
+ }
13991
+ lcx = (bbox.min[0] + bbox.max[0]) / 2;
13992
+ lcy = (bbox.min[1] + bbox.max[1]) / 2;
13993
+ lcz = (bbox.min[2] + bbox.max[2]) / 2;
13994
+ }
13995
+ const owx = (rx * lcx + fx * lcy + nx * lcz) * s;
13996
+ const owy = (ry * lcx + fy * lcy + ny * lcz) * s;
13997
+ const owz = (rz * lcx + fz * lcy + nz * lcz) * s;
13998
+ const m = mesh.modelMatrix;
13999
+ m[0] = rx * s;
14000
+ m[1] = ry * s;
14001
+ m[2] = rz * s;
14002
+ m[3] = 0;
14003
+ m[4] = fx * s;
14004
+ m[5] = fy * s;
14005
+ m[6] = fz * s;
14006
+ m[7] = 0;
14007
+ m[8] = nx * s;
14008
+ m[9] = ny * s;
14009
+ m[10] = nz * s;
14010
+ m[11] = 0;
14011
+ m[12] = position[0] + nx * normalOffset - owx;
14012
+ m[13] = position[1] + ny * normalOffset - owy;
14013
+ m[14] = position[2] + nz * normalOffset - owz;
14014
+ m[15] = 1;
14015
+ this.decomposeModelMatrix(mesh);
14016
+ this.meshRenderer.addOverlayMesh(mesh, material);
14017
+ }
14018
+ const info = {
14019
+ position: [...position],
14020
+ normal: [...normal],
14021
+ meshStartIndex,
14022
+ meshCount: loadedMeshes.length,
14023
+ billboard: false,
14024
+ placedScale,
14025
+ placedNormalOffset: normalOffset,
14026
+ placedLocalCenter
14027
+ };
14028
+ this.hotspots.push(info);
14029
+ return info;
14030
+ } catch (error) {
14031
+ console.error("HotspotManager: placeHotspotAt 失败:", error);
14032
+ return null;
14033
+ }
14034
+ }
13718
14035
  /**
13719
14036
  * 每帧调用:更新所有 billboard 热点朝向相机
13720
14037
  */
@@ -13798,6 +14115,7 @@ class HotspotManager {
13798
14115
  this.decomposeModelMatrix(mesh);
13799
14116
  }
13800
14117
  }
14118
+ this.updateLabels();
13801
14119
  }
13802
14120
  /**
13803
14121
  * 关闭 billboard 时恢复到放置时的法线朝向
@@ -13875,7 +14193,18 @@ class HotspotManager {
13875
14193
  // 生命周期
13876
14194
  // ============================================
13877
14195
  destroy() {
14196
+ var _a2, _b2;
13878
14197
  this.exit();
14198
+ if (this._clickListenerBound && this._boundOnCanvasClick) {
14199
+ this.canvas.removeEventListener("click", this._boundOnCanvasClick);
14200
+ this._clickListenerBound = false;
14201
+ }
14202
+ for (const info of this.hotspots) {
14203
+ (_a2 = info._labelElement) == null ? void 0 : _a2.remove();
14204
+ info._labelElement = void 0;
14205
+ }
14206
+ (_b2 = this.labelContainer) == null ? void 0 : _b2.remove();
14207
+ this.labelContainer = null;
13879
14208
  if (this.indicatorMesh && !this.indicatorAdded) {
13880
14209
  this.indicatorMesh.destroy();
13881
14210
  }
@@ -13970,8 +14299,12 @@ class App {
13970
14299
  }
13971
14300
  /**
13972
14301
  * 加载 PLY 文件 (3D Gaussian Splatting)
14302
+ * @param urlOrBuffer PLY 文件 URL 或 ArrayBuffer
14303
+ * @param onProgress 进度回调
14304
+ * @param isLocalFile 是否为本地文件
14305
+ * @param coordinateSystem 源数据坐标系,默认 'blender'(Z-up → Y-up 自动转换)
13973
14306
  */
13974
- async addPLY(urlOrBuffer, onProgress, isLocalFile = false) {
14307
+ async addPLY(urlOrBuffer, onProgress, isLocalFile = false, coordinateSystem = "blender") {
13975
14308
  try {
13976
14309
  const isMobile = isMobileDevice();
13977
14310
  let buffer;
@@ -14000,7 +14333,8 @@ class App {
14000
14333
  const compactData = await this.parsePLYBuffer(buffer, {
14001
14334
  maxSplats: Infinity,
14002
14335
  loadSH: false,
14003
- onProgress: parseProgressCallback
14336
+ onProgress: parseProgressCallback,
14337
+ coordinateSystem
14004
14338
  });
14005
14339
  if (onProgress) onProgress(90, "upload");
14006
14340
  gsRenderer.setCompactData(compactData);
@@ -14014,7 +14348,8 @@ class App {
14014
14348
  const compactData = await this.parsePLYBuffer(buffer, {
14015
14349
  maxSplats: Infinity,
14016
14350
  loadSH: true,
14017
- onProgress: parseProgressCallback
14351
+ onProgress: parseProgressCallback,
14352
+ coordinateSystem
14018
14353
  });
14019
14354
  if (onProgress) onProgress(90, "upload");
14020
14355
  gsRenderer.setCompactData(compactData);
@@ -14029,8 +14364,9 @@ class App {
14029
14364
  }
14030
14365
  /**
14031
14366
  * 加载 Splat 文件
14367
+ * @param coordinateSystem 源数据坐标系,默认 'blender'(Z-up → Y-up 自动转换)
14032
14368
  */
14033
- async addSplat(urlOrBuffer, onProgress, isLocalFile = false) {
14369
+ async addSplat(urlOrBuffer, onProgress, isLocalFile = false, coordinateSystem = "blender") {
14034
14370
  try {
14035
14371
  let buffer;
14036
14372
  if (typeof urlOrBuffer === "string") {
@@ -14047,6 +14383,16 @@ class App {
14047
14383
  }
14048
14384
  if (onProgress) onProgress(50, "parse");
14049
14385
  const splats = deserializeSplat(buffer);
14386
+ if (coordinateSystem === "blender") {
14387
+ for (const s of splats) {
14388
+ const [mx, my, mz] = s.mean;
14389
+ s.mean = [mx, mz, my];
14390
+ const [sx, sy, sz] = s.scale;
14391
+ s.scale = [sx, sz, sy];
14392
+ const [rw, rx, ry, rz] = s.rotation;
14393
+ s.rotation = [-rw, rx, rz, ry];
14394
+ }
14395
+ }
14050
14396
  if (onProgress) onProgress(90, "parse");
14051
14397
  if (onProgress) onProgress(90, "upload");
14052
14398
  const gsRenderer = new GSSplatRenderer(this.renderer, this.camera);
@@ -14062,8 +14408,9 @@ class App {
14062
14408
  }
14063
14409
  /**
14064
14410
  * 加载 SOG 文件 (Spatially Ordered Gaussians)
14411
+ * @param coordinateSystem 源数据坐标系,默认 'blender'(Z-up → Y-up 自动转换)
14065
14412
  */
14066
- async addSOG(urlOrBuffer, onProgress, isLocalFile = false) {
14413
+ async addSOG(urlOrBuffer, onProgress, isLocalFile = false, coordinateSystem = "blender") {
14067
14414
  try {
14068
14415
  const isMobile = isMobileDevice();
14069
14416
  let buffer;
@@ -14085,6 +14432,22 @@ class App {
14085
14432
  onProgress(50 + p / 100 * 40, stage);
14086
14433
  }
14087
14434
  });
14435
+ if (coordinateSystem === "blender") {
14436
+ const { positions, scales, rotations, count } = compactData;
14437
+ for (let i = 0; i < count; i++) {
14438
+ const i3 = i * 3, i4 = i * 4;
14439
+ const py = positions[i3 + 1], pz = positions[i3 + 2];
14440
+ positions[i3 + 1] = pz;
14441
+ positions[i3 + 2] = py;
14442
+ const sy = scales[i3 + 1], sz = scales[i3 + 2];
14443
+ scales[i3 + 1] = sz;
14444
+ scales[i3 + 2] = sy;
14445
+ const rw = rotations[i4], ry = rotations[i4 + 2], rz = rotations[i4 + 3];
14446
+ rotations[i4] = -rw;
14447
+ rotations[i4 + 2] = rz;
14448
+ rotations[i4 + 3] = ry;
14449
+ }
14450
+ }
14088
14451
  if (onProgress) onProgress(90, "upload");
14089
14452
  let gsRenderer;
14090
14453
  if (isMobile) {
@@ -14289,6 +14652,9 @@ class App {
14289
14652
  setGizmoMode(mode) {
14290
14653
  this.gizmoManager.setGizmoMode(mode);
14291
14654
  }
14655
+ setUniformScaleOnly(enabled) {
14656
+ this.gizmoManager.setUniformScaleOnly(enabled);
14657
+ }
14292
14658
  setGizmoTarget(object) {
14293
14659
  this.gizmoManager.setGizmoTarget(object);
14294
14660
  }
@@ -14387,10 +14753,53 @@ class App {
14387
14753
  findHotspotIndexByMeshStart(overlayMeshStartIndex) {
14388
14754
  return this.hotspotManager.findHotspotIndexByMeshStart(overlayMeshStartIndex);
14389
14755
  }
14756
+ async placeHotspotAt(objUrl, position, normal, visualDiameter, normalOffset, overrideScale) {
14757
+ return this.hotspotManager.placeHotspotAt(objUrl, position, normal, visualDiameter, normalOffset, overrideScale);
14758
+ }
14390
14759
  getOverlayMeshByIndex(index) {
14391
14760
  return this.sceneManager.getOverlayMeshByIndex(index);
14392
14761
  }
14393
14762
  // ============================================
14763
+ // 热点点击 & 标签
14764
+ // ============================================
14765
+ /**
14766
+ * 检测给定屏幕坐标命中了哪个热点
14767
+ * @returns 热点索引,-1 表示未命中
14768
+ */
14769
+ hitTestHotspot(clientX, clientY) {
14770
+ return this.hotspotManager.hitTestHotspot(clientX, clientY);
14771
+ }
14772
+ /**
14773
+ * 设置热点点击回调(非放置模式下生效)
14774
+ */
14775
+ setOnHotspotClicked(cb) {
14776
+ this.hotspotManager.setOnHotspotClicked(cb);
14777
+ }
14778
+ /**
14779
+ * 设置热点点击命中半径(NDC 空间,默认 0.05)
14780
+ */
14781
+ setHotspotHitRadius(radius) {
14782
+ this.hotspotManager.setHotspotHitRadius(radius);
14783
+ }
14784
+ /**
14785
+ * 设置热点文字标签
14786
+ */
14787
+ setHotspotLabel(hotspotIndex, text, position, fontSize, visible) {
14788
+ return this.hotspotManager.setHotspotLabel(hotspotIndex, text, position, fontSize, visible);
14789
+ }
14790
+ /**
14791
+ * 设置热点标签可见性
14792
+ */
14793
+ setHotspotLabelVisible(hotspotIndex, visible) {
14794
+ return this.hotspotManager.setHotspotLabelVisible(hotspotIndex, visible);
14795
+ }
14796
+ /**
14797
+ * 获取热点标签配置
14798
+ */
14799
+ getHotspotLabel(hotspotIndex) {
14800
+ return this.hotspotManager.getHotspotLabel(hotspotIndex);
14801
+ }
14802
+ // ============================================
14394
14803
  // 内部方法
14395
14804
  // ============================================
14396
14805
  async fetchWithProgress(url, onProgress) {