s3m-three 0.3.1 → 0.3.3

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.
@@ -109,8 +109,24 @@ export class S3MViewer {
109
109
  _needsRepaint = true;
110
110
  _frame = 0;
111
111
  _fps = { value: 0, last: 0 };
112
- _keyLight = null;
113
- _fillLight = null;
112
+ _ambientLight;
113
+ _hemiLight;
114
+ _keyLight;
115
+ _fillLight;
116
+ _lightOptions = {
117
+ ambientIntensity: 0.4,
118
+ ambientColor: 0xffffff,
119
+ hemisphereIntensity: 0.8,
120
+ hemisphereColor: 0xffffff,
121
+ hemisphereGroundColor: 0xb6c0cb,
122
+ keyLightIntensity: 1.0,
123
+ keyLightColor: 0xffffff,
124
+ fillLightIntensity: 0.35,
125
+ fillLightColor: 0xffffff,
126
+ mode: 'headlamp',
127
+ azimuth: 150,
128
+ altitude: 50,
129
+ };
114
130
  _datasetQueue;
115
131
  /** 宿主重画信号与地图朝向,构造时注入 */
116
132
  _onRequestRepaint = () => { };
@@ -120,10 +136,19 @@ export class S3MViewer {
120
136
  _requestRepaint() {
121
137
  this._onRequestRepaint();
122
138
  }
139
+ triggerRepaint() {
140
+ this._requestRepaint();
141
+ }
123
142
  constructor(options = {}) {
124
143
  this.options = options;
125
144
  this.pickable = options.pickable !== false;
126
145
  this._lodRangeScale = options.lodRangeScale ?? 1.0;
146
+ if (options.light) {
147
+ Object.assign(this._lightOptions, options.light);
148
+ }
149
+ if (options.totalMaxTiles) {
150
+ this._datasetManager.totalMaxTiles = options.totalMaxTiles;
151
+ }
127
152
  this._onRequestRepaint = options.onRequestRepaint ?? (() => { });
128
153
  this._getBearing = options.getBearing;
129
154
  this.onStats = options.onStats ?? (() => { });
@@ -131,6 +156,11 @@ export class S3MViewer {
131
156
  this.onPick = options.onPick ?? (() => { });
132
157
  this.onError = options.onError ?? ((e) => console.warn('[s3m]', e));
133
158
  this.groundManager = new GroundManager(options.ground);
159
+ if (!this.scene) {
160
+ this.scene = new THREE.Scene();
161
+ this.scene.add(this.groundManager.group);
162
+ this._addLights();
163
+ }
134
164
  this._featureStyleManager = new FeatureStyleManager(() => this.style.floorHeight, () => this._requestRepaint(), () => {
135
165
  if (this._selection)
136
166
  this._syncSelectionStyle();
@@ -159,18 +189,23 @@ export class S3MViewer {
159
189
  * 绑定宿主画布与 WebGL 上下文,建场景并启动数据集队列。
160
190
  * MapLibre 宿主由 S3MLayer 在 onAdd 里调用;纯 three 宿主直接传自己的 canvas / gl。
161
191
  */
162
- mount(canvas, gl) {
163
- this.canvas = canvas;
164
- this.renderer = new THREE.WebGLRenderer({
192
+ /** 创建并配置一个绑定到给定 gl 上下文的 three renderer(mount 与上下文恢复共用) */
193
+ _createRenderer(canvas, gl) {
194
+ const r = new THREE.WebGLRenderer({
165
195
  canvas,
166
196
  context: gl,
167
197
  antialias: true,
168
198
  });
169
- this.renderer.autoClear = false;
170
- this.renderer.setPixelRatio(1);
199
+ r.autoClear = false;
200
+ r.setPixelRatio(1);
171
201
  // 白模 albedo 接近 1,不做色调映射会过曝成一片纯白、丢失明暗关系
172
- this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
173
- this.renderer.toneMappingExposure = 1.0;
202
+ r.toneMapping = THREE.ACESFilmicToneMapping;
203
+ r.toneMappingExposure = 1.0;
204
+ return r;
205
+ }
206
+ mount(canvas, gl) {
207
+ this.canvas = canvas;
208
+ this.renderer = this._createRenderer(canvas, gl);
174
209
  setMaxAnisotropy(this.renderer.capabilities.getMaxAnisotropy());
175
210
  this.scene = new THREE.Scene();
176
211
  this.scene.add(this.groundManager.group);
@@ -183,6 +218,38 @@ export class S3MViewer {
183
218
  void this.addDataset(ds).catch((e) => this.onError(e));
184
219
  this._recomputeAnchors();
185
220
  }
221
+ /**
222
+ * WebGL 上下文恢复后原地换新 renderer,保留已经加载好的场景与瓦片。
223
+ *
224
+ * MapLibre 在 `webglcontextlost` 时会销毁 style 并丢弃自定义图层(自定义图层
225
+ * 无法序列化进 style,所以恢复后不会自动加回)。但自定义图层的 `onRemove` 不会被
226
+ * 调用,因此我们的 scene / 几何体 / 材质 / 纹理 / 已加载瓦片都还在内存里。
227
+ *
228
+ * three 的 GPU 状态是按 renderer 实例缓存的,所以只需把 renderer / picker 换成
229
+ * 绑在新 gl 上的新实例,下一帧渲染时 three 会自动把现存的几何体、材质、纹理
230
+ * 重新上传到新上下文 —— 不需要重建场景,也不需要重新拉瓦片。
231
+ */
232
+ restoreContext(gl) {
233
+ if (!this.canvas) {
234
+ // 理论不会走到:没 mount 过就没有上下文可恢复
235
+ console.warn('[s3m] restoreContext 调用过早(尚未 mount),按首次挂载处理');
236
+ return;
237
+ }
238
+ try {
239
+ // 旧上下文已失效,dispose 可能抛错,忽略即可
240
+ this.renderer?.dispose();
241
+ }
242
+ catch {
243
+ /* noop */
244
+ }
245
+ this.renderer = this._createRenderer(this.canvas, gl);
246
+ setMaxAnisotropy(this.renderer.capabilities.getMaxAnisotropy());
247
+ this.picker?.dispose();
248
+ this.picker = new Picker(this.renderer);
249
+ // 共享 uniform / 材质引用不变,three 重新编译着色器时会自然接管;
250
+ // 这里只确保下一帧尽快驱动一次渲染。
251
+ this._requestRepaint();
252
+ }
186
253
  /** 解绑场景并释放全部 GPU 资源。对应 MapLibre 图层的 onRemove()。 */
187
254
  unmount() {
188
255
  for (const id of [...this.datasets.keys()])
@@ -207,24 +274,64 @@ export class S3MViewer {
207
274
  this.renderer = null;
208
275
  this.canvas = null;
209
276
  }
277
+ _createLights() {
278
+ this._ambientLight = new THREE.AmbientLight(this._lightOptions.ambientColor, this._lightOptions.ambientIntensity);
279
+ this._hemiLight = new THREE.HemisphereLight(this._lightOptions.hemisphereColor, this._lightOptions.hemisphereGroundColor, this._lightOptions.hemisphereIntensity);
280
+ this._keyLight = new THREE.DirectionalLight(this._lightOptions.keyLightColor, this._lightOptions.keyLightIntensity);
281
+ this._fillLight = new THREE.DirectionalLight(this._lightOptions.fillLightColor, this._lightOptions.fillLightIntensity);
282
+ this._updateLights();
283
+ }
210
284
  _addLights() {
211
285
  if (!this.scene)
212
286
  return;
213
- // 环境光与天光打底,保证背光面和折叠处不死黑
214
- this.scene.add(new THREE.AmbientLight(0xffffff, 0.4));
215
- this.scene.add(new THREE.HemisphereLight(0xffffff, 0xb6c0cb, 0.8));
216
- // 主光 / 补光的方向每帧按地图朝向重算(见 _updateLights),
217
- // 这样旋转视角时白模始终是亮的,不会转到背光面就一片死灰。
218
- this._keyLight = new THREE.DirectionalLight(0xffffff, 1.0);
219
- this._fillLight = new THREE.DirectionalLight(0xffffff, 0.35);
220
- this.scene.add(this._keyLight, this._fillLight);
287
+ if (!this._ambientLight)
288
+ this._createLights();
289
+ this.scene.add(this._ambientLight, this._hemiLight, this._keyLight, this._fillLight);
290
+ }
291
+ _applyLightOptions() {
292
+ if (this._ambientLight) {
293
+ this._ambientLight.intensity = this._lightOptions.ambientIntensity;
294
+ this._ambientLight.color.set(this._lightOptions.ambientColor);
295
+ }
296
+ if (this._hemiLight) {
297
+ this._hemiLight.intensity = this._lightOptions.hemisphereIntensity;
298
+ this._hemiLight.color.set(this._lightOptions.hemisphereColor);
299
+ this._hemiLight.groundColor.set(this._lightOptions.hemisphereGroundColor);
300
+ }
301
+ if (this._keyLight) {
302
+ this._keyLight.intensity = this._lightOptions.keyLightIntensity;
303
+ this._keyLight.color.set(this._lightOptions.keyLightColor);
304
+ }
305
+ if (this._fillLight) {
306
+ this._fillLight.intensity = this._lightOptions.fillLightIntensity;
307
+ this._fillLight.color.set(this._lightOptions.fillLightColor);
308
+ }
309
+ }
310
+ /**
311
+ * 动态设置光照配置
312
+ */
313
+ setLight(options) {
314
+ Object.assign(this._lightOptions, options);
315
+ this._applyLightOptions();
316
+ this._updateLights();
317
+ this._needsRepaint = true;
318
+ this.triggerRepaint();
319
+ }
320
+ /**
321
+ * 获取当前光照配置快照
322
+ */
323
+ getLight() {
324
+ return { ...this._lightOptions };
221
325
  }
222
326
  /**
223
- * 让光照跟着地图朝向走:主光在镜头后上方偏左,补光在后上方偏右。
224
- * 相当于一盏头灯,看到的立面永远是受光面。
327
+ * 更新光照位置与方向:
328
+ * - 'headlamp': 随地图方位角 bearing 旋转,主光始终在镜头上方,受光面面向镜头;
329
+ * - 'fixed': 固定世界方位角(如模拟恒定日光)。
225
330
  */
226
331
  _updateLights() {
227
- const bearing = ((this._getBearing?.() ?? 0) * Math.PI) / 180;
332
+ if (!this._keyLight || !this._fillLight)
333
+ return;
334
+ const bearing = this._lightOptions.mode === 'fixed' ? 0 : (((this._getBearing?.() ?? 0) * Math.PI) / 180);
228
335
  const place = (light, azOffsetDeg, elevDeg) => {
229
336
  if (!light)
230
337
  return;
@@ -233,8 +340,8 @@ export class S3MViewer {
233
340
  const c = Math.cos(el);
234
341
  light.position.set(Math.sin(az) * c, Math.cos(az) * c, Math.sin(el)).multiplyScalar(1000);
235
342
  };
236
- place(this._keyLight, 150, 50);
237
- place(this._fillLight, 215, 30);
343
+ place(this._keyLight, this._lightOptions.azimuth, this._lightOptions.altitude);
344
+ place(this._fillLight, this._lightOptions.azimuth + 65, Math.max(10, this._lightOptions.altitude - 20));
238
345
  }
239
346
  /* ---------------------------------------------------------------- 数据集 */
240
347
  _normalizeDatasets(options) {
@@ -629,6 +736,28 @@ export class S3MViewer {
629
736
  * 而 LOD 细化、瓦片重载、淘汰回补都会换掉 mesh —— 不重放的话,用户点中的那栋楼
630
737
  * 一细化就失去高亮(状态还在 _selection.smid 里,只是没人往新 mesh 上刷)。
631
738
  */
739
+ /**
740
+ * 判断一块无 SMID 单体化信息的网格(如倾斜摄影或白模整块)是否空间覆盖当前选中命中点
741
+ */
742
+ _isMeshCoveringSelection(mesh, sel) {
743
+ if (!sel.point)
744
+ return false;
745
+ const geom = mesh.geometry;
746
+ if (!geom)
747
+ return false;
748
+ if (!geom.boundingBox)
749
+ geom.computeBoundingBox();
750
+ if (!geom.boundingBox)
751
+ return false;
752
+ // 将选中的三维场景点转入网格局部坐标系进行包围盒检测
753
+ const pt = new THREE.Vector3(sel.point[0], sel.point[1], sel.point[2]);
754
+ mesh.updateWorldMatrix(true, false);
755
+ const inv = mesh.matrixWorld.clone().invert();
756
+ const localPt = pt.applyMatrix4(inv);
757
+ // 留出 0.5 米容差,避免刚好处于三角网格边缘缝隙时漏判
758
+ const box = geom.boundingBox.clone().expandByScalar(0.5);
759
+ return box.containsPoint(localPt);
760
+ }
632
761
  _replaySelectionOnto(mesh) {
633
762
  const sel = this._selection;
634
763
  if (!sel)
@@ -647,8 +776,14 @@ export class S3MViewer {
647
776
  if (mesh === sel.mesh)
648
777
  batchId = sel.batchId;
649
778
  // 这块几何里根本没有这个单体 → 不动它(否则会把无关瓦片一起点亮)
650
- if (batchId < 0 && mesh !== sel.mesh)
651
- return;
779
+ if (batchId < 0 && mesh !== sel.mesh) {
780
+ if (sel.smid == null && ud.datasetId === sel.datasetId && this._isMeshCoveringSelection(mesh, sel)) {
781
+ batchId = -1;
782
+ }
783
+ else {
784
+ return;
785
+ }
786
+ }
652
787
  mat.userData.uSelBatch.value = batchId;
653
788
  mat.userData.uSelWhole.value = batchId < 0 ? 1 : 0;
654
789
  if (!this._selectedMeshes.includes(mesh))
@@ -658,46 +793,57 @@ export class S3MViewer {
658
793
  this.uniforms.uSelMix.value = 1;
659
794
  this.uniforms.uDim.value = this.style.dimOthers ? 1 : 0;
660
795
  }
661
- /** 瓦片被淘汰:从拾取表和场景里摘掉,并释放它自己的 GPU 资源 */
796
+ /** 瓦片被淘汰:从拾取表和场景里摘掉,并深度彻底释放所有几何体与材质显存 */
662
797
  _unregisterTile(node, entry) {
663
- for (const child of [...node.group.children]) {
664
- const mesh = child;
665
- if (!mesh.isMesh)
666
- continue;
667
- const ud = mesh.userData;
668
- // 「这块 mesh 没了」≠「用户不再选中」:LOD 细化 / 淘汰回补都会换掉 mesh。
669
- // 以前这里一律 select(null),于是点中的那栋楼一拉近,选中状态就整个被清掉。
670
- const wasSelected = this._selectedMeshes.includes(mesh);
671
- if (wasSelected) {
672
- this._selectedMeshes = this._selectedMeshes.filter((m) => m !== mesh);
798
+ node.group.traverse((child) => {
799
+ const obj = child;
800
+ if (obj.userData?.pickId != null) {
801
+ this._pickables.delete(obj.userData.pickId);
802
+ this._releasePickId(obj.userData.pickId);
803
+ obj.userData.pickId = undefined;
673
804
  }
674
- if (this._selection?.mesh === mesh) {
675
- const smid = this._selection.smid;
676
- const carrier = smid != null ? [...(this._smidMeshes.get(smid) ?? [])].find((m) => m !== mesh && m.parent) : undefined;
677
- if (carrier) {
678
- // 选中身份交给承载同一 smid 的另一块 mesh(通常是接替显示的子瓦)
679
- this._selection = { ...this._selection, mesh: carrier };
680
- this._replaySelectionOnto(carrier);
805
+ if (obj.isMesh) {
806
+ const wasSelected = this._selectedMeshes.includes(obj);
807
+ if (wasSelected) {
808
+ this._selectedMeshes = this._selectedMeshes.filter((m) => m !== obj);
681
809
  }
682
- else {
683
- this.select(null);
810
+ const currentSel = this._selection;
811
+ if (currentSel && currentSel.mesh === obj) {
812
+ const smid = currentSel.smid;
813
+ const carrier = (smid != null
814
+ ? [...(this._smidMeshes.get(smid) ?? [])].find((m) => m !== obj && m.parent)
815
+ : this._selectedMeshes.find((m) => m !== obj && m.parent && m.parent.visible) ??
816
+ this._selectedMeshes.find((m) => m !== obj && m.parent));
817
+ if (carrier) {
818
+ // 选中身份交给承载同一 smid 的另一块 mesh(通常是接替显示的子瓦)
819
+ this._selection = { ...currentSel, mesh: carrier };
820
+ this._replaySelectionOnto(carrier);
821
+ }
822
+ else {
823
+ this.select(null);
824
+ }
684
825
  }
826
+ this._unindexMeshSmids(obj);
827
+ this._removeMeshHeight(obj);
828
+ this._featureStyleManager.disposeMesh(obj);
829
+ const styled = styledMaterialOf(obj);
830
+ styled?.dispose?.();
685
831
  }
686
- if (ud.pickId != null) {
687
- this._pickables.delete(ud.pickId);
688
- this._releasePickId(ud.pickId);
689
- ud.pickId = undefined;
832
+ // 深度释放材质(点云、普通材质、材质数组)
833
+ if (isOwnedPointCloudMaterial(obj.material)) {
834
+ obj.material.dispose();
690
835
  }
691
- this._unindexMeshSmids(mesh);
692
- this._removeMeshHeight(mesh);
693
- this._featureStyleManager.disposeMesh(mesh);
694
- const styled = styledMaterialOf(mesh);
695
- styled?.dispose?.();
696
- // 点云材质是本模块按模式建的,不走 styled 那条链
697
- if (isOwnedPointCloudMaterial(mesh.material))
698
- mesh.material.dispose();
699
- mesh.geometry?.dispose?.();
700
- }
836
+ else if (obj.material) {
837
+ if (Array.isArray(obj.material)) {
838
+ obj.material.forEach((m) => m?.dispose?.());
839
+ }
840
+ else {
841
+ obj.material.dispose?.();
842
+ }
843
+ }
844
+ // 释放几何体显存缓冲
845
+ obj.geometry?.dispose?.();
846
+ });
701
847
  entry.root.remove(node.group);
702
848
  }
703
849
  /**
@@ -1738,6 +1884,10 @@ export class S3MViewer {
1738
1884
  renderFrame(opts) {
1739
1885
  if (!this.renderer || !this.scene || !this.camera || !this.sceneAnchor || !this.canvas)
1740
1886
  return false;
1887
+ // 上下文丢失期间(丢失→恢复之间)不要尝试渲染,否则会刷一堆 GL 错误
1888
+ const gl = this.renderer.getContext();
1889
+ if (gl && typeof gl.isContextLost === 'function' && gl.isContextLost())
1890
+ return false;
1741
1891
  // 脏标记正常由微任务刷掉;这里再兜一次底,保证「本帧画出来的」一定是最新样式
1742
1892
  this.flushOverrides();
1743
1893
  const canvas = this.canvas;