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