@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 +430 -21
- package/dist/3dgs-lib.cjs.map +1 -1
- package/dist/3dgs-lib.js +430 -21
- package/dist/3dgs-lib.js.map +1 -1
- package/dist/App.d.ts +38 -4
- package/dist/core/gizmo/TransformGizmo.d.ts +2 -0
- package/dist/gs/PLYLoader.d.ts +7 -1
- package/dist/gs/PLYLoaderMobile.d.ts +8 -0
- package/dist/index.d.ts +3 -3
- package/dist/interaction/GizmoManager.d.ts +1 -0
- package/dist/interaction/HotspotManager.d.ts +57 -0
- package/package.json +1 -1
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
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
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
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
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
|
-
|
|
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) {
|