@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.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
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
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
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
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
|
-
|
|
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) {
|