s3m-three 0.4.19 → 0.4.21

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.
Files changed (87) hide show
  1. package/dist/browser/assets/{s3mWorker-4iTAmWIu.js → s3mWorker-DvAUA8NJ.js} +1 -1
  2. package/dist/browser/s3m-three.iife.ext-three.js +243 -243
  3. package/dist/browser/s3m-three.iife.js +227 -227
  4. package/dist/core/Draco.d.ts.map +1 -1
  5. package/dist/core/Draco.js +0 -1
  6. package/dist/core/Draco.js.map +1 -1
  7. package/dist/core/S3MBlockParser.d.ts.map +1 -1
  8. package/dist/core/S3MBlockParser.js +27 -14
  9. package/dist/core/S3MBlockParser.js.map +1 -1
  10. package/dist/core/SplatSort.d.ts +14 -1
  11. package/dist/core/SplatSort.d.ts.map +1 -1
  12. package/dist/core/SplatSort.js +51 -4
  13. package/dist/core/SplatSort.js.map +1 -1
  14. package/dist/core/TileGeometry.d.ts +10 -0
  15. package/dist/core/TileGeometry.d.ts.map +1 -1
  16. package/dist/core/TileGeometry.js +17 -14
  17. package/dist/core/TileGeometry.js.map +1 -1
  18. package/dist/render/DatasetManager.d.ts +3 -1
  19. package/dist/render/DatasetManager.d.ts.map +1 -1
  20. package/dist/render/DatasetManager.js +2 -1
  21. package/dist/render/DatasetManager.js.map +1 -1
  22. package/dist/render/FeatureStyleManager.d.ts.map +1 -1
  23. package/dist/render/GeometryBuilder.d.ts +8 -0
  24. package/dist/render/GeometryBuilder.d.ts.map +1 -1
  25. package/dist/render/GeometryBuilder.js +62 -7
  26. package/dist/render/GeometryBuilder.js.map +1 -1
  27. package/dist/render/S3MViewer.d.ts +106 -440
  28. package/dist/render/S3MViewer.d.ts.map +1 -1
  29. package/dist/render/S3MViewer.js +297 -1653
  30. package/dist/render/S3MViewer.js.map +1 -1
  31. package/dist/render/SplatSorter.d.ts.map +1 -1
  32. package/dist/render/SplatSorter.js +8 -1
  33. package/dist/render/SplatSorter.js.map +1 -1
  34. package/dist/render/viewer/EdgeDecorator.d.ts +206 -0
  35. package/dist/render/viewer/EdgeDecorator.d.ts.map +1 -0
  36. package/dist/render/viewer/EdgeDecorator.js +628 -0
  37. package/dist/render/viewer/EdgeDecorator.js.map +1 -0
  38. package/dist/render/viewer/HeightIndex.d.ts +36 -0
  39. package/dist/render/viewer/HeightIndex.d.ts.map +1 -0
  40. package/dist/render/viewer/HeightIndex.js +98 -0
  41. package/dist/render/viewer/HeightIndex.js.map +1 -0
  42. package/dist/render/viewer/LightRig.d.ts +88 -0
  43. package/dist/render/viewer/LightRig.d.ts.map +1 -0
  44. package/dist/render/viewer/LightRig.js +229 -0
  45. package/dist/render/viewer/LightRig.js.map +1 -0
  46. package/dist/render/viewer/PickService.d.ts +109 -0
  47. package/dist/render/viewer/PickService.d.ts.map +1 -0
  48. package/dist/render/viewer/PickService.js +591 -0
  49. package/dist/render/viewer/PickService.js.map +1 -0
  50. package/dist/render/viewer/SelectionDecorator.d.ts +84 -0
  51. package/dist/render/viewer/SelectionDecorator.d.ts.map +1 -0
  52. package/dist/render/viewer/SelectionDecorator.js +339 -0
  53. package/dist/render/viewer/SelectionDecorator.js.map +1 -0
  54. package/dist/render/viewer/SplatLayer.d.ts +26 -0
  55. package/dist/render/viewer/SplatLayer.d.ts.map +1 -0
  56. package/dist/render/viewer/SplatLayer.js +44 -0
  57. package/dist/render/viewer/SplatLayer.js.map +1 -0
  58. package/dist/render/viewer/types.d.ts +256 -0
  59. package/dist/render/viewer/types.d.ts.map +1 -0
  60. package/dist/render/viewer/types.js +3 -0
  61. package/dist/render/viewer/types.js.map +1 -0
  62. package/dist/tileset/S3MTileset.d.ts +30 -3
  63. package/dist/tileset/S3MTileset.d.ts.map +1 -1
  64. package/dist/tileset/S3MTileset.js +99 -5
  65. package/dist/tileset/S3MTileset.js.map +1 -1
  66. package/dist/worker/s3mWorker.js +1 -1
  67. package/dist/worker/s3mWorker.js.map +1 -1
  68. package/docs/API.md +9 -28
  69. package/docs/FORMAT-NOTES.md +38 -14
  70. package/package.json +1 -1
  71. package/src/core/Draco.ts +1 -2
  72. package/src/core/S3MBlockParser.ts +27 -16
  73. package/src/core/SplatSort.ts +67 -5
  74. package/src/core/TileGeometry.ts +24 -11
  75. package/src/render/DatasetManager.ts +5 -1
  76. package/src/render/FeatureStyleManager.ts +1 -1
  77. package/src/render/GeometryBuilder.ts +59 -7
  78. package/src/render/S3MViewer.ts +327 -1916
  79. package/src/render/SplatSorter.ts +8 -1
  80. package/src/render/viewer/EdgeDecorator.ts +722 -0
  81. package/src/render/viewer/HeightIndex.ts +94 -0
  82. package/src/render/viewer/LightRig.ts +267 -0
  83. package/src/render/viewer/PickService.ts +651 -0
  84. package/src/render/viewer/SelectionDecorator.ts +377 -0
  85. package/src/render/viewer/SplatLayer.ts +44 -0
  86. package/src/render/viewer/types.ts +264 -0
  87. package/src/tileset/S3MTileset.ts +106 -6
@@ -22,31 +22,28 @@
22
22
  */
23
23
  import * as THREE from 'three';
24
24
  import { usableGeoBounds } from '../tileset/S3MTileset.js';
25
- import { authFetch } from '../core/AuthFetch.js';
26
- import { sortSplatMesh } from './SplatSorter.js';
27
- import { splatCenterBounds } from './SplatMaterial.js';
28
25
  import { setSplatViewport } from './GeometryBuilder.js';
29
- import { setMaxAnisotropy, setAnisotropyCap, setCompressedTexturesSupported, setTextureOffThreadDecode, setMaxTextureSize as setTextureSizeCap, setSrgbTextureDecode } from './TextureFactory.js';
30
- import { getMaterialQuality, setMaterialQuality, setColorPipeline } from './StyleMaterial.js';
31
- import { resolveLightingPreset } from './lightingPreset.js';
26
+ import { setMaxAnisotropy, setCompressedTexturesSupported, setTextureOffThreadDecode, setMaxTextureSize as setTextureSizeCap } from './TextureFactory.js';
27
+ import { getMaterialQuality, setMaterialQuality } from './StyleMaterial.js';
28
+ import { LightRig } from './viewer/LightRig.js';
29
+ import { HeightIndex } from './viewer/HeightIndex.js';
30
+ import { SplatLayer } from './viewer/SplatLayer.js';
31
+ import { EdgeDecorator } from './viewer/EdgeDecorator.js';
32
+ import { PickService } from './viewer/PickService.js';
33
+ import { SelectionDecorator } from './viewer/SelectionDecorator.js';
32
34
  import { decodeTextureWithWorker } from '../worker/S3MWorkerClient.js';
33
- import { STYLE, STYLE_LABELS, SELECT_MODE, DEFAULT_STYLE, createSharedUniforms, createStyledMaterial, createOutlineMaterial, resolveEdgeModes, sameEdgeModes, restoreBaseBlendState, } from './StyleMaterial.js';
35
+ import { STYLE, STYLE_LABELS, SELECT_MODE, DEFAULT_STYLE, createSharedUniforms, createStyledMaterial, createOutlineMaterial, restoreBaseBlendState, } from './StyleMaterial.js';
34
36
  import { applyPointCloudStyle, isOwnedPointCloudMaterial } from './PointCloudMaterial.js';
35
37
  import { Picker } from './Picker.js';
36
- import { GpuEdgePass } from './GpuEdgePass.js';
37
38
  import { Animator } from '../anim/Animator.js';
38
39
  import { Path } from '../anim/Path.js';
39
40
  import { GPUPathManager } from './GPUPathManager.js';
40
- import { buildFeatureEdgeSegments, buildPositionBatchMap, buildThickLineGeometry, createLineSharedUniforms, createThickLineMaterial, positionQuantKey, } from './EdgeLines.js';
41
- import { mergeVerticesByPosition } from './MergeVertices.js';
42
- import { resolveFeature, measureSegments, buildAttributes, buildInstanceLookup, pickEntryId, } from '../core/FeatureInfo.js';
43
41
  import { AttributeQuery } from '../core/AttributeQuery.js';
44
42
  import { lngLatFromWorld } from './GeoAdapter.js';
45
43
  import { DatasetManager, } from './DatasetManager.js';
46
- import { FeatureStyleManager, resolveRotationMatrix, extractEulerAngles, } from './FeatureStyleManager.js';
44
+ import { FeatureStyleManager, extractEulerAngles, } from './FeatureStyleManager.js';
47
45
  import { GroundManager, } from './GroundManager.js';
48
46
  export { GroundManager, DatasetManager, FeatureStyleManager };
49
- const MAX_PICK_ID = 65534;
50
47
  function styledMaterialOf(mesh) {
51
48
  const m = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
52
49
  const styled = m;
@@ -98,7 +95,7 @@ const STYLE_SCALAR_KEYS = [
98
95
  const STYLE_COLOR_KEYS = ['colorA', 'colorB', 'colorC', 'selectColor', 'edgeColor'];
99
96
  /** 可动画的单体属性 */
100
97
  const FEATURE_PROPS = ['translation', 'elevation', 'opacity', 'color', 'rotation'];
101
- /* ------------------------------------------------------------------ 图层 */
98
+ /** 单体沿折线运动的参数 */
102
99
  export class S3MViewer {
103
100
  options;
104
101
  pickable;
@@ -126,12 +123,8 @@ export class S3MViewer {
126
123
  edgeMaterial;
127
124
  /** 选中棱线材质(同上,选中棱线的逐网格材质共享它的对象) */
128
125
  selectionEdgeMaterial;
129
- /** 全局棱线的外观 uniform:逐网格棱线材质直接复用这些对象 */
130
- _edgeStyleUniforms;
131
- /** 选中棱线的外观 uniform */
132
- _selEdgeStyleUniforms;
133
- /** 全局外扩轮廓的外观 uniform:逐网格轮廓壳共享这些对象 */
134
- _hullStyleUniforms;
126
+ /** 棱线装饰(见 viewer/EdgeDecorator.ts) */
127
+ _edges;
135
128
  /** 3D 物理地表面 / 基坑开挖管理器 */
136
129
  groundManager;
137
130
  /** 多数据集生命周期与锚点管理器 */
@@ -152,29 +145,15 @@ export class S3MViewer {
152
145
  set sceneAnchor(val) {
153
146
  this._datasetManager.sceneAnchor = val;
154
147
  }
155
- _pickables = new Map();
156
- _pickSeq = 0;
157
- _freePickIds = [];
148
+ /** 拾取表 / pickId 池 / 射线求交(见 viewer/PickService.ts) */
149
+ _picks;
158
150
  /** SMID → 包含它的网格。改单个单体样式时只重算这几块,不用全量扫 */
159
151
  _smidMeshes = new Map();
160
152
  /** 每块网格的世界高度范围,用来增量维护 uHeightMin / uHeightMax */
161
- _meshHeight = new Map();
162
- _heightMin = Infinity;
163
- _heightMax = -Infinity;
164
- _boxScratch = new THREE.Box3();
165
- _selection = null;
166
- _selectedMeshes = [];
167
- _outlineMesh = null;
168
- _outlineMeshes = [];
169
- _selectionEdges = null;
170
- _selectionEdgeMeshes = [];
171
- _edgesEnabled = false;
172
- /** 当前生效的棱线画法(line / offset / hull / gpu),决定要不要建 / 拆哪种装饰 */
173
- _edgeModes = resolveEdgeModes(false);
174
- /** GPU 描边后处理(深度 + 法线),只在 edges 含 'gpu' 时按帧执行 */
175
- _gpuEdge = null;
176
- /** 给 GPU 描边传色用的临时 Color,避免每帧 new */
177
- _gpuEdgeColor = new THREE.Color();
153
+ /** 世界高度范围增量维护(见 viewer/HeightIndex.ts) */
154
+ _heights;
155
+ /** 选中态与装饰(见 viewer/SelectionDecorator.ts) */
156
+ _sel;
178
157
  /** 瓦片淡入通道的自增序号(不用 pickId:pickId 会被回收复用) */
179
158
  _tileFadeSeq = 0;
180
159
  /** setMode 编排的代次,用来作废被新调用顶掉的旧编排 */
@@ -191,15 +170,10 @@ export class S3MViewer {
191
170
  _pathScratch = new Float32Array(3);
192
171
  /** 求切线用的第二个采样点(包围盒换算 GPU 路径朝向时用) */
193
172
  _pathScratch2 = new Float32Array(3);
194
- /** 预制棱线缓存(Crease Cache):从外部 source-creases 载入的全局统一 LineSegments,性能最高、视觉最干净 */
195
- _creaseLines = null;
196
- /** 数据自带的棱线对象(LineSegments),改棱线颜色时只刷这些,不再全场景 traverse */
197
- _nativeEdgeLines = new Set();
198
173
  /** 点云对象集合:透明度动画时只刷它们,避免每帧遍历全部 pickables */
199
174
  _pointClouds = new Set();
200
- /** 泼溅排序状态:相机不动且没有新瓦片时,排序结果可以直接复用 */
201
- _lastSplatSortView = new THREE.Matrix4();
202
- _splatsSorted = false;
175
+ /** 泼溅排序调度(见 viewer/SplatLayer.ts) */
176
+ _splats = new SplatLayer();
203
177
  /** 当前材质是否处于 transparent 状态,用来避免逐帧重复切换 program */
204
178
  _opacityTransparent = false;
205
179
  /**
@@ -216,29 +190,10 @@ export class S3MViewer {
216
190
  _frameMvp = new THREE.Matrix4();
217
191
  /** 颜色换算用的临时对象,避免每帧 new */
218
192
  _colorScratch = new THREE.Color();
219
- _needsRepaint = true;
220
193
  _frame = 0;
221
194
  _fps = { value: 0, last: 0 };
222
- _ambientLight;
223
- _hemiLight;
224
- _keyLight;
225
- _fillLight;
226
- /** 环境贴图的 PMREM 渲染目标(setEnvironment 建的,unmount 时释放) */
227
- _envRT = null;
228
- _lightOptions = {
229
- ambientIntensity: 0.4,
230
- ambientColor: 0xffffff,
231
- hemisphereIntensity: 0.8,
232
- hemisphereColor: 0xffffff,
233
- hemisphereGroundColor: 0xb6c0cb,
234
- keyLightIntensity: 1.0,
235
- keyLightColor: 0xffffff,
236
- fillLightIntensity: 0.35,
237
- fillLightColor: 0xffffff,
238
- mode: 'headlamp',
239
- azimuth: 150,
240
- altitude: 50,
241
- };
195
+ /** 光源 / 色调映射 / 环境贴图(见 viewer/LightRig.ts) */
196
+ _lights;
242
197
  _datasetQueue;
243
198
  /** 宿主重画信号与地图朝向,构造时注入 */
244
199
  _onRequestRepaint = () => { };
@@ -265,13 +220,6 @@ export class S3MViewer {
265
220
  this._lodPixelRatioCap = options.lodPixelRatioCap ?? Infinity;
266
221
  setTextureSizeCap(options.maxTextureSize ?? Infinity);
267
222
  setMaterialQuality(options.materialQuality ?? 'pbr');
268
- if (options.lightingPreset && options.lightingPreset !== 'default') {
269
- Object.assign(this._lightOptions, resolveLightingPreset(options.lightingPreset));
270
- this._applyColorPipeline(options.lightingPreset);
271
- }
272
- if (options.light) {
273
- Object.assign(this._lightOptions, options.light);
274
- }
275
223
  if (options.totalMaxTiles) {
276
224
  this._datasetManager.totalMaxTiles = options.totalMaxTiles;
277
225
  }
@@ -287,15 +235,61 @@ export class S3MViewer {
287
235
  if (!this.scene) {
288
236
  this.scene = new THREE.Scene();
289
237
  this.scene.add(this.groundManager.group);
290
- this._addLights();
291
238
  }
292
239
  this._featureStyleManager = new FeatureStyleManager(() => this.style.floorHeight, () => this._requestRepaint(), () => {
293
- if (this._selection)
294
- this._syncSelectionStyle();
240
+ if (this._sel.selection)
241
+ this._sel.syncStyle();
295
242
  }, () => this.getAllMeshes(), (mesh) => this._applyOverridesToMesh(mesh),
296
243
  // 图层自身是否半透明:全局透明度改走 uOpacity 后,材质 opacity 不再代表它
297
244
  () => this._opacityTransparent);
298
245
  this.uniforms = createSharedUniforms();
246
+ // 光源状态在这里就位:LightRig 构造时会按 lightingPreset 切模块级颜色管线,
247
+ // 挂进场景要等 scene 建好(构造函数上面已经建了)。
248
+ this._lights = new LightRig({
249
+ scene: () => this.scene,
250
+ renderer: () => this.renderer,
251
+ uniforms: this.uniforms,
252
+ options: this.options,
253
+ getBearing: () => this._getBearing?.(),
254
+ // 走公共 triggerRepaint 而不是 _requestRepaint:宿主 / 单测会覆盖前者来观察重绘,
255
+ // 绕过它会让「有没有触发重绘」这件事失去观测点
256
+ repaint: () => this.triggerRepaint(),
257
+ });
258
+ this._lights.addToScene();
259
+ this._heights = new HeightIndex(this.uniforms);
260
+ const self = this;
261
+ this._picks = new PickService({
262
+ get picker() {
263
+ return self.picker;
264
+ },
265
+ get camera() {
266
+ return self.camera;
267
+ },
268
+ get scene() {
269
+ return self.scene;
270
+ },
271
+ get canvas() {
272
+ return self.canvas;
273
+ },
274
+ get style() {
275
+ return self.style;
276
+ },
277
+ get sceneAnchor() {
278
+ return self.sceneAnchor;
279
+ },
280
+ get datasets() {
281
+ return self.datasets;
282
+ },
283
+ get featureStyleManager() {
284
+ return self._featureStyleManager;
285
+ },
286
+ get smidMeshes() {
287
+ return self._smidMeshes;
288
+ },
289
+ lngLatFromWorld: (x, y) => self._lngLatFromWorld(x, y),
290
+ gpuPathPoseAt: (smid) => self._gpuPathPoseAt(smid),
291
+ isFeatureHidden: (smid, batchId) => self._isFeatureHidden(smid, batchId),
292
+ });
299
293
  // 纹理解码优先派给共享解析 Worker(崩溃自动回主线程,见 decodeTextureWithWorker)
300
294
  setTextureOffThreadDecode(decodeTextureWithWorker);
301
295
  this.style = { ...DEFAULT_STYLE, ...(options.style ?? {}) };
@@ -307,29 +301,35 @@ export class S3MViewer {
307
301
  // 不踢的话动画「卡住不动」,直到别的什么碰巧触发了重绘
308
302
  this.animator.onActive = () => this._requestRepaint();
309
303
  this.outlineMaterial = createOutlineMaterial(this.uniforms);
310
- this.lineShared = createLineSharedUniforms();
311
- // 棱线外观 uniform:全局棱线材质与逐网格棱线材质**共用同一批对象**,
312
- // _syncEdgeStyle 改一次,所有棱线(含淡入乘子)同时生效,保持 O(1)。
313
- this._edgeStyleUniforms = {
314
- uColor: { value: new THREE.Color(this.style.edgeColor) },
315
- uWidth: { value: Math.max(0.1, this.style.edgeWidth ?? 1.5) },
316
- uOpacity: { value: 0.6 },
317
- uOffset: { value: this.style.edgeOffset ?? 0.15 },
318
- };
319
- this.edgeMaterial = createThickLineMaterial(this.lineShared, {}, this._edgeStyleUniforms);
320
- this._selEdgeStyleUniforms = {
321
- uColor: { value: new THREE.Color(this.style.outlineColor ?? this.style.selectColor) },
322
- uWidth: { value: Math.max(0.1, this.style.outlineWidthPx ?? 3) },
323
- uOpacity: { value: 1 },
324
- uElevation: { value: this.style.selectElevation ?? 0 },
325
- };
326
- this.selectionEdgeMaterial = createThickLineMaterial(this.lineShared, {}, this._selEdgeStyleUniforms);
327
- // 全局外扩轮廓的外观 uniform:逐网格轮廓壳复用这几个对象,改一次全场景生效
328
- this._hullStyleUniforms = {
329
- uColor: { value: new THREE.Color(this.style.edgeColor) },
330
- uWidth: { value: Math.max(0, this.style.edgeHullWidth ?? 0.9) },
331
- uOpacity: { value: 0.6 },
332
- };
304
+ // 棱线装饰:内部持有共享 uniform 与全局材质,公共字段从它取出来保持 API 不变
305
+ this._edges = new EdgeDecorator({
306
+ uniforms: this.uniforms,
307
+ style: this.style,
308
+ styleTarget: this._styleTarget,
309
+ animator: this.animator,
310
+ pickables: this._picks.pickables,
311
+ scene: this.scene,
312
+ renderer: this.renderer,
313
+ camera: this.camera,
314
+ authenticate: this._authenticate,
315
+ requestRepaint: () => this.triggerRepaint(),
316
+ });
317
+ this.lineShared = this._edges.lineShared;
318
+ this.edgeMaterial = this._edges.edgeMaterial;
319
+ this.selectionEdgeMaterial = this._edges.selectionEdgeMaterial;
320
+ this._sel = new SelectionDecorator({
321
+ uniforms: this.uniforms,
322
+ style: this.style,
323
+ outlineMaterial: this.outlineMaterial,
324
+ selectionEdgeMaterial: this.selectionEdgeMaterial,
325
+ featureStyleManager: this._featureStyleManager,
326
+ smidMeshes: this._smidMeshes,
327
+ pointClouds: this._pointClouds,
328
+ applyPointCloudStyle: (pc, selected) => this._applyPointCloudStyle(pc, selected),
329
+ onPick: (r) => this.onPick(r),
330
+ requestRepaint: () => this.triggerRepaint(),
331
+ createSelectionEdgeMaterial: (mesh) => this._edges.createSelectionEdgeMaterial(mesh),
332
+ });
333
333
  // 关键:style.edges 是个开关,但构造函数从没据此启用过棱线,导致
334
334
  // `style: { edges: true }` 形同虚设、_edgesEnabled 永远是 false。
335
335
  // 这里按 style 实际值调一次 setEdges,让开关真正生效(与 setEdges 后来的手工调用一致)。
@@ -415,8 +415,7 @@ export class S3MViewer {
415
415
  this.picker?.dispose();
416
416
  this.picker = new Picker(this.renderer);
417
417
  // GPU 描边的 RT / 材质绑定在旧上下文上,整体丢弃,下一帧按需重建
418
- this._gpuEdge?.dispose();
419
- this._gpuEdge = null;
418
+ this._edges.disposeGpuEdge();
420
419
  // 共享 uniform / 材质引用不变,three 重新编译着色器时会自然接管;
421
420
  // 这里只确保下一帧尽快驱动一次渲染。
422
421
  this._requestRepaint();
@@ -429,159 +428,72 @@ export class S3MViewer {
429
428
  this.groundManager.dispose();
430
429
  this.picker?.dispose();
431
430
  this.picker = null;
432
- this._envRT?.dispose();
433
- this._envRT = null;
431
+ this._lights.dispose();
434
432
  this.outlineMaterial.dispose();
435
- this._gpuEdge?.dispose();
436
- this._gpuEdge = null;
433
+ this._edges.disposeGpuEdge();
437
434
  this.edgeMaterial.dispose();
438
435
  this.selectionEdgeMaterial.dispose();
439
436
  this.scene?.clear();
440
- this._selectedMeshes = [];
441
- this._outlineMeshes = [];
442
- this._selectionEdgeMeshes = [];
443
- this._outlineMesh = null;
444
- this._selectionEdges = null;
437
+ this._sel.reset();
445
438
  this._smidMeshes.clear();
446
- this._meshHeight.clear();
439
+ this._heights.clear();
447
440
  // 场景已经清空,还挂着的动画通道(瓦片淡入 / 单体位移)不能再对着死对象写
448
441
  this.animator.clear();
449
442
  this.animator.unregisterPrefix('tile.');
450
443
  this.animator.unregisterPrefix('feature.');
451
444
  this._pathSpecs.clear();
452
- this._heightMin = Infinity;
453
- this._heightMax = -Infinity;
454
445
  this.renderer?.dispose?.();
455
446
  this.renderer = null;
456
447
  this.canvas = null;
457
448
  }
458
- _createLights() {
459
- this._ambientLight = new THREE.AmbientLight(this._lightOptions.ambientColor, this._lightOptions.ambientIntensity);
460
- this._hemiLight = new THREE.HemisphereLight(this._lightOptions.hemisphereColor, this._lightOptions.hemisphereGroundColor, this._lightOptions.hemisphereIntensity);
461
- this._keyLight = new THREE.DirectionalLight(this._lightOptions.keyLightColor, this._lightOptions.keyLightIntensity);
462
- this._fillLight = new THREE.DirectionalLight(this._lightOptions.fillLightColor, this._lightOptions.fillLightIntensity);
463
- this._updateLights();
449
+ /* ---------------------------------------------------------------- 光照 */
450
+ /**
451
+ * @internal 光源实例的转发 —— `lighting.test.ts` 直接读这几个字段。
452
+ * 实现全在 viewer/LightRig.ts。
453
+ */
454
+ get _ambientLight() {
455
+ return this._lights.ambient;
464
456
  }
465
- _addLights() {
466
- if (!this.scene)
467
- return;
468
- if (!this._ambientLight)
469
- this._createLights();
470
- this.scene.add(this._ambientLight, this._hemiLight, this._keyLight, this._fillLight);
457
+ get _hemiLight() {
458
+ return this._lights.hemi;
471
459
  }
472
- _applyLightOptions() {
473
- if (this._ambientLight) {
474
- this._ambientLight.intensity = this._lightOptions.ambientIntensity;
475
- this._ambientLight.color.set(this._lightOptions.ambientColor);
476
- }
477
- if (this._hemiLight) {
478
- this._hemiLight.intensity = this._lightOptions.hemisphereIntensity;
479
- this._hemiLight.color.set(this._lightOptions.hemisphereColor);
480
- this._hemiLight.groundColor.set(this._lightOptions.hemisphereGroundColor);
481
- }
482
- if (this._keyLight) {
483
- this._keyLight.intensity = this._lightOptions.keyLightIntensity;
484
- this._keyLight.color.set(this._lightOptions.keyLightColor);
485
- }
486
- if (this._fillLight) {
487
- this._fillLight.intensity = this._lightOptions.fillLightIntensity;
488
- this._fillLight.color.set(this._lightOptions.fillLightColor);
489
- }
460
+ get _keyLight() {
461
+ return this._lights.key;
490
462
  }
491
- /**
492
- * 运行时切换光照预设:覆盖四个强度,颜色/朝向保持不变。
493
- * 之后再调 `setLight({...})` 仍然可以逐项微调。
494
- */
495
- /**
496
- * supermap 完整版 = 光照强度 + 颜色管线一起切:
497
- * direct 管线下纹理不做 sRGB 解码、片元按官方常数 Lambert 直乘(见 lightingPreset.ts)。
498
- * 两个都是模块级全局态,只影响之后创建的材质 / 纹理 —— 已加载瓦片要重载才换。
499
- */
500
- _applyColorPipeline(preset) {
501
- const direct = preset === 'supermap';
502
- setColorPipeline(direct ? 'direct' : 'linear');
503
- setSrgbTextureDecode(!direct);
504
- // 官方不开各向异性:远处直接采高 mip 平均成纯色("灰色滤镜"的一部分)。
505
- // 我们默认 Infinity(倾斜摄影立面清晰度优先),supermap 模式必须收回。
506
- setAnisotropyCap(direct ? 1 : Infinity);
463
+ get _fillLight() {
464
+ return this._lights.fill;
465
+ }
466
+ /** @internal 光照位置按当前 bearing 重算,见 viewer/LightRig.ts */
467
+ _updateLights() {
468
+ this._lights.update();
469
+ }
470
+ _addLights() {
471
+ this._lights.addToScene();
507
472
  }
508
473
  setLightingPreset(preset) {
509
- Object.assign(this._lightOptions, resolveLightingPreset(preset));
510
- this._applyColorPipeline(preset);
511
- this.options.lightingPreset = preset;
512
- this._applyLightOptions();
513
- this._updateLights();
514
- this._needsRepaint = true;
515
- this.triggerRepaint();
474
+ this._lights.setLightingPreset(preset);
516
475
  }
517
- /**
518
- * 动态设置光照配置
519
- */
476
+ /** 动态设置光照配置 */
520
477
  setLight(options) {
521
- const normalized = { ...options };
522
- if (normalized.mainColor !== undefined && normalized.keyLightColor === undefined) {
523
- normalized.keyLightColor = normalized.mainColor;
524
- }
525
- if (normalized.mainIntensity !== undefined && normalized.keyLightIntensity === undefined) {
526
- normalized.keyLightIntensity = normalized.mainIntensity;
527
- }
528
- Object.assign(this._lightOptions, normalized);
529
- this._applyLightOptions();
530
- this._updateLights();
531
- this._needsRepaint = true;
532
- this.triggerRepaint();
478
+ this._lights.setLight(options);
533
479
  }
534
- /**
535
- * 获取当前光照配置快照
536
- */
480
+ /** 获取当前光照配置快照 */
537
481
  getLight() {
538
- return { ...this._lightOptions };
482
+ return this._lights.getLight();
539
483
  }
540
- /**
541
- * 动态设置色调映射模式与曝光度
542
- */
484
+ /** 动态设置色调映射模式与曝光度 */
543
485
  setToneMapping(toneMapping, exposure = 1.0) {
544
- this.options.toneMapping = toneMapping;
545
- this.options.toneMappingExposure = exposure;
546
- if (this.renderer) {
547
- this.renderer.toneMapping = toneMapping;
548
- this.renderer.toneMappingExposure = exposure;
549
- this._needsRepaint = true;
550
- this.triggerRepaint();
551
- }
486
+ this._lights.setToneMapping(toneMapping, exposure);
552
487
  }
553
- /**
554
- * 获取当前色调映射模式与曝光度
555
- */
488
+ /** 获取当前色调映射模式与曝光度 */
556
489
  getToneMapping() {
557
- return {
558
- toneMapping: this.renderer ? this.renderer.toneMapping : (this.options.toneMapping ?? THREE.NoToneMapping),
559
- toneMappingExposure: this.renderer ? this.renderer.toneMappingExposure : (this.options.toneMappingExposure ?? 1.0),
560
- };
490
+ return this._lights.getToneMapping();
561
491
  }
562
492
  /**
563
- * 更新光照位置与方向:
564
- * - 'headlamp': 随地图方位角 bearing 旋转,主光始终在镜头上方,受光面面向镜头;
565
- * - 'fixed': 固定世界方位角(如模拟恒定日光)。
493
+ * 挂 / 摘环境贴图。见 viewer/LightRig.ts(PMREM 预滤波 + 旧 RT 释放)。
566
494
  */
567
- _updateLights() {
568
- if (!this._keyLight || !this._fillLight)
569
- return;
570
- const bearing = this._lightOptions.mode === 'fixed' ? 0 : (((this._getBearing?.() ?? 0) * Math.PI) / 180);
571
- const place = (light, azOffsetDeg, elevDeg) => {
572
- if (!light)
573
- return;
574
- const az = bearing + (azOffsetDeg * Math.PI) / 180;
575
- const el = (elevDeg * Math.PI) / 180;
576
- const c = Math.cos(el);
577
- light.position.set(Math.sin(az) * c, Math.cos(az) * c, Math.sin(el)).multiplyScalar(1000);
578
- };
579
- place(this._keyLight, this._lightOptions.azimuth, this._lightOptions.altitude);
580
- place(this._fillLight, this._lightOptions.azimuth + 65, Math.max(10, this._lightOptions.altitude - 20));
581
- // direct 管线的官方 Lambert 用主光方向(共享 uniform,所有材质同步生效)
582
- if (this.uniforms?.uSunDirection && this._keyLight) {
583
- this.uniforms.uSunDirection.value.copy(this._keyLight.position).normalize();
584
- }
495
+ setEnvironment(source, intensity = 1) {
496
+ return this._lights.setEnvironment(source, intensity);
585
497
  }
586
498
  /* ---------------------------------------------------------------- 数据集 */
587
499
  _normalizeDatasets(options) {
@@ -603,13 +515,11 @@ export class S3MViewer {
603
515
  onTile: (node, e) => {
604
516
  this._registerTile(node, e);
605
517
  this.onStats(this.stats);
606
- this._needsRepaint = true;
607
518
  this._requestRepaint();
608
519
  },
609
520
  onTileRemoved: (node, e) => {
610
521
  this._unregisterTile(node, e);
611
522
  this.onStats(this.stats);
612
- this._needsRepaint = true;
613
523
  this._requestRepaint();
614
524
  },
615
525
  onStats: () => {
@@ -622,7 +532,7 @@ export class S3MViewer {
622
532
  },
623
533
  onReady: (e) => {
624
534
  this.onReady(e);
625
- this._refreshAllHeights();
535
+ this._heights.refreshAll();
626
536
  if (!this.groundManager.excavationBounds) {
627
537
  this.groundManager.autoFitExcavation(this.datasets.values());
628
538
  }
@@ -630,7 +540,7 @@ export class S3MViewer {
630
540
  triggerRepaint: () => {
631
541
  this._requestRepaint();
632
542
  },
633
- }, this.options.maxTriangles ?? Infinity, this.options.maxImageCache ?? 128, this._authenticate);
543
+ }, this.options.maxTriangles ?? Infinity, this.options.maxImageCache ?? 128, this._authenticate, this.options.maxMemory ?? Infinity);
634
544
  return entry;
635
545
  }
636
546
  /**
@@ -642,7 +552,7 @@ export class S3MViewer {
642
552
  /** 当地图 CRS 投影切换或宿主 map 挂载后,重新计算场景锚点与各数据集 offset */
643
553
  recomputeAnchors() {
644
554
  this._datasetManager.recomputeAnchors(this.geo);
645
- this._refreshAllHeights();
555
+ this._heights.refreshAll();
646
556
  this._requestRepaint();
647
557
  }
648
558
  _recomputeAnchors() {
@@ -674,7 +584,7 @@ export class S3MViewer {
674
584
  }
675
585
  this._datasetManager.setDatasetHeight(id, h, this.geo);
676
586
  this._syncGroundHeight();
677
- this._refreshAllHeights();
587
+ this._heights.refreshAll();
678
588
  this._requestRepaint();
679
589
  }
680
590
  /**
@@ -707,7 +617,7 @@ export class S3MViewer {
707
617
  a = align ?? 'origin';
708
618
  }
709
619
  this._datasetManager.setDatasetAlign(id, a, this.geo);
710
- this._refreshAllHeights();
620
+ this._heights.refreshAll();
711
621
  this._requestRepaint();
712
622
  }
713
623
  /** @internal 语义高度对齐计算代理,供内部与测试使用 */
@@ -1216,7 +1126,7 @@ export class S3MViewer {
1216
1126
  return [...this._smidMeshes.keys()];
1217
1127
  }
1218
1128
  auditBatches() {
1219
- const res = this._featureStyleManager.auditBatches(this._pickables.values());
1129
+ const res = this._featureStyleManager.auditBatches(this._picks.pickables.values());
1220
1130
  res.smids = this._smidMeshes.size;
1221
1131
  return res;
1222
1132
  }
@@ -1311,7 +1221,7 @@ export class S3MViewer {
1311
1221
  }
1312
1222
  getAllMeshes() {
1313
1223
  const list = [];
1314
- for (const p of this._pickables.values())
1224
+ for (const p of this._picks.pickables.values())
1315
1225
  list.push(p.mesh);
1316
1226
  return list;
1317
1227
  }
@@ -1325,14 +1235,6 @@ export class S3MViewer {
1325
1235
  }
1326
1236
  return out;
1327
1237
  }
1328
- _markOverridesDirty(scope = 'all') {
1329
- if (scope === 'all') {
1330
- this._featureStyleManager.markOverridesDirty(undefined, this.getAllMeshes());
1331
- }
1332
- else {
1333
- this._featureStyleManager.markOverridesDirty(scope);
1334
- }
1335
- }
1336
1238
  flushOverrides() {
1337
1239
  this._featureStyleManager.flushOverrides();
1338
1240
  }
@@ -1344,19 +1246,19 @@ export class S3MViewer {
1344
1246
  const entry = this.datasets.get(id);
1345
1247
  if (!entry)
1346
1248
  return;
1347
- if (this._selection?.datasetId === id)
1249
+ if (this._sel.selection?.datasetId === id)
1348
1250
  this.select(null);
1349
1251
  this._datasetManager.removeDataset(id, this.scene, this.geo, (e) => {
1350
1252
  e.root.traverse((o) => {
1351
1253
  const mesh = o;
1352
1254
  const ud = mesh.userData;
1353
1255
  if (ud.pickId != null) {
1354
- this._pickables.delete(ud.pickId);
1256
+ this._picks.pickables.delete(ud.pickId);
1355
1257
  this._releasePickId(ud.pickId);
1356
1258
  ud.pickId = undefined;
1357
1259
  }
1358
1260
  this._unindexMeshSmids(mesh);
1359
- this._removeMeshHeight(mesh);
1261
+ this._heights.remove(mesh);
1360
1262
  mesh.geometry?.dispose?.();
1361
1263
  const styled = styledMaterialOf(mesh);
1362
1264
  if (styled)
@@ -1366,9 +1268,9 @@ export class S3MViewer {
1366
1268
  this._featureStyleManager.disposeMesh(mesh);
1367
1269
  });
1368
1270
  });
1369
- for (const [pickId, p] of this._pickables)
1271
+ for (const [pickId, p] of this._picks.pickables)
1370
1272
  if (p.datasetId === id)
1371
- this._pickables.delete(pickId);
1273
+ this._picks.pickables.delete(pickId);
1372
1274
  this.onStats(this.stats);
1373
1275
  this._requestRepaint();
1374
1276
  }
@@ -1379,7 +1281,7 @@ export class S3MViewer {
1379
1281
  }
1380
1282
  setDatasetVisible(id, visible) {
1381
1283
  this._datasetManager.setDatasetVisible(id, visible);
1382
- if (!visible && this._selection?.datasetId === id)
1284
+ if (!visible && this._sel.selection?.datasetId === id)
1383
1285
  this.select(null);
1384
1286
  this._requestRepaint();
1385
1287
  }
@@ -1391,14 +1293,12 @@ export class S3MViewer {
1391
1293
  // forceCpuEdges:无视数据自带的棱线包,改由 CPU 现算。native 是 1px 的
1392
1294
  // LineBasicMaterial,edgeWidth / 外扩 / 单体跟随全都吃不上。
1393
1295
  // gpu 画法由 shader 自己描边,native 线段包同样不能留
1394
- const useNativeEdges = hasNativeEdges && this.style.forceCpuEdges !== true && !this._edgeModes.gpu;
1296
+ const useNativeEdges = this._edges.useNativeEdges(hasNativeEdges);
1395
1297
  for (const child of node.group.children) {
1396
1298
  // 数据自带的棱线包:只跟着 edges 开关显示,不参与拾取、不参与着色
1397
1299
  if (child.isLineSegments && child.userData.isEdgeLines) {
1398
- // 一律登记进 _nativeEdgeLines:forceCpuEdges 之后又关掉时还要能把它们放出来
1399
- this._nativeEdgeLines.add(child);
1400
- child.visible = useNativeEdges && this._edgesEnabled;
1401
- this._styleEdgeLine(child);
1300
+ // 一律登记:forceCpuEdges 之后又关掉时还要能把它们放出来
1301
+ this._edges.registerNativeLine(child, useNativeEdges && this._edges.enabled);
1402
1302
  continue;
1403
1303
  }
1404
1304
  // 点云是 THREE.Points 不是 Mesh,但同样要进拾取表、吃不透明度、跟着着色模式走
@@ -1410,31 +1310,24 @@ export class S3MViewer {
1410
1310
  this._pointClouds.add(child);
1411
1311
  // 新瓦片里的泼溅实例还没按当前视角排过,下一帧要重排
1412
1312
  if (isPoints)
1413
- this._splatsSorted = false;
1313
+ this._splats.markDirty();
1414
1314
  const ud = mesh.userData;
1415
1315
  ud.pickId = this._allocPickId();
1416
1316
  ud.datasetId = entry.id;
1417
1317
  ud.nativeEdges = useNativeEdges;
1418
1318
  ud.nativeEdgesRaw = hasNativeEdges;
1419
- this._pickables.set(ud.pickId, {
1319
+ this._picks.pickables.set(ud.pickId, {
1420
1320
  datasetId: entry.id,
1421
1321
  mesh,
1422
1322
  tile: node.tile,
1423
1323
  skeletonName: ud.skeletonName ?? '',
1424
1324
  });
1425
1325
  this._indexMeshSmids(mesh);
1426
- this._addMeshHeight(mesh);
1326
+ this._heights.add(mesh);
1427
1327
  // 点云没有「棱」:给一堆孤立点建 EdgesGeometry 又慢又没意义
1428
1328
  // 点云没有「棱」:给一堆孤立点建 EdgesGeometry 又慢又没意义。
1429
1329
  // 自动描边受三道开关控制:全局 style.edges / 数据集 enableEdges / 地形绿地等关键字跳过。
1430
- if (!useNativeEdges && !isPoints && this._datasetEdgesEnabled(entry) && !this._shouldSkipAutoEdges(ud, entry.name)) {
1431
- // 数据集强制开启(enableEdges: true)时全局画法可能还是关的,退回默认折边线;
1432
- // 但 gpu 画法本来就不建几何,别在这里多此一举
1433
- if (this._edgeModes.line || this._edgeModes.hull)
1434
- this._ensureEdgeDecoration(mesh);
1435
- else if (!this._edgeModes.gpu)
1436
- this._ensureEdges(mesh);
1437
- }
1330
+ this._edges.autoDecorate(mesh, entry, useNativeEdges);
1438
1331
  // 新瓦片要继承当前的不透明度与着色方式
1439
1332
  if (isPoints) {
1440
1333
  this._applyPointCloudStyle(child);
@@ -1460,7 +1353,7 @@ export class S3MViewer {
1460
1353
  }
1461
1354
  // 选中状态也要跟着继承:LOD 细化 / 瓦片重载会换掉 mesh,
1462
1355
  // 不重放就出现「点中的那栋楼一拉近就没高亮了」
1463
- this._replaySelectionOnto(mesh);
1356
+ this._sel.replayOnto(mesh);
1464
1357
  }
1465
1358
  }
1466
1359
  /**
@@ -1510,76 +1403,16 @@ export class S3MViewer {
1510
1403
  },
1511
1404
  });
1512
1405
  }
1513
- /**
1514
- * 把「当前选中」重放到一块新登记的 mesh 上。
1515
- *
1516
- * 为什么需要:选中高亮是直接写在各 mesh 材质的 uSelBatch / uSelWhole 上的,
1517
- * 而 LOD 细化、瓦片重载、淘汰回补都会换掉 mesh —— 不重放的话,用户点中的那栋楼
1518
- * 一细化就失去高亮(状态还在 _selection.smid 里,只是没人往新 mesh 上刷)。
1519
- */
1520
- /**
1521
- * 判断一块无 SMID 单体化信息的网格(如倾斜摄影或白模整块)是否空间覆盖当前选中命中点
1522
- */
1523
- _isMeshCoveringSelection(mesh, sel) {
1524
- if (!sel.point)
1525
- return false;
1526
- const geom = mesh.geometry;
1527
- if (!geom)
1528
- return false;
1529
- if (!geom.boundingBox)
1530
- geom.computeBoundingBox();
1531
- if (!geom.boundingBox)
1532
- return false;
1533
- // 将选中的三维场景点转入网格局部坐标系进行包围盒检测
1534
- const pt = new THREE.Vector3(sel.point[0], sel.point[1], sel.point[2]);
1535
- mesh.updateWorldMatrix(true, false);
1536
- const inv = mesh.matrixWorld.clone().invert();
1537
- const localPt = pt.applyMatrix4(inv);
1538
- // 留出 0.5 米容差,避免刚好处于三角网格边缘缝隙时漏判
1539
- const box = geom.boundingBox.clone().expandByScalar(0.5);
1540
- return box.containsPoint(localPt);
1541
- }
1406
+ /** @internal 把当前选中重放到新登记的 mesh 上,见 viewer/SelectionDecorator.ts */
1542
1407
  _replaySelectionOnto(mesh) {
1543
- const sel = this._selection;
1544
- if (!sel)
1545
- return;
1546
- const mat = styledMaterialOf(mesh);
1547
- if (!mat)
1548
- return;
1549
- const ud = mesh.userData;
1550
- let batchId = -1;
1551
- if (sel.smid != null) {
1552
- const pickInfo = ud.geoRef?.pickInfo;
1553
- const entry = pickInfo?.[sel.smid] ?? pickInfo?.[String(sel.smid)];
1554
- batchId = entry ? pickEntryId(entry) : -1;
1555
- }
1556
- // 拾取命中的那块 mesh 用原始 batchId;同一 smid 散在别的几何里时按各自的区间算
1557
- if (mesh === sel.mesh)
1558
- batchId = sel.batchId;
1559
- // 这块几何里根本没有这个单体 → 不动它(否则会把无关瓦片一起点亮)
1560
- if (batchId < 0 && mesh !== sel.mesh) {
1561
- if (sel.smid == null && ud.datasetId === sel.datasetId && this._isMeshCoveringSelection(mesh, sel)) {
1562
- batchId = -1;
1563
- }
1564
- else {
1565
- return;
1566
- }
1567
- }
1568
- mat.userData.uSelBatch.value = batchId;
1569
- mat.userData.uSelWhole.value = batchId < 0 ? 1 : 0;
1570
- if (!this._selectedMeshes.includes(mesh))
1571
- this._selectedMeshes.push(mesh);
1572
- if (isPointCloudObject(mesh))
1573
- this._applyPointCloudStyle(mesh, true);
1574
- this.uniforms.uSelMix.value = 1;
1575
- this.uniforms.uDim.value = this.style.dimOthers ? 1 : 0;
1408
+ this._sel.replayOnto(mesh);
1576
1409
  }
1577
1410
  /** 瓦片被淘汰:从拾取表和场景里摘掉,并深度彻底释放所有几何体与材质显存 */
1578
1411
  _unregisterTile(node, entry) {
1579
1412
  node.group.traverse((child) => {
1580
1413
  const obj = child;
1581
1414
  if (child.isLineSegments && obj.userData?.isEdgeLines) {
1582
- this._nativeEdgeLines.delete(child);
1415
+ this._edges.unregisterNativeLine(child);
1583
1416
  }
1584
1417
  if (isPointCloudObject(child))
1585
1418
  this._pointClouds.delete(child);
@@ -1589,33 +1422,33 @@ export class S3MViewer {
1589
1422
  obj.userData.tileFadeKey = undefined;
1590
1423
  }
1591
1424
  if (obj.userData?.pickId != null) {
1592
- this._pickables.delete(obj.userData.pickId);
1425
+ this._picks.pickables.delete(obj.userData.pickId);
1593
1426
  this._releasePickId(obj.userData.pickId);
1594
1427
  obj.userData.pickId = undefined;
1595
1428
  }
1596
1429
  if (obj.isMesh) {
1597
- const wasSelected = this._selectedMeshes.includes(obj);
1430
+ const wasSelected = this._sel.selectedMeshes.includes(obj);
1598
1431
  if (wasSelected) {
1599
- this._selectedMeshes = this._selectedMeshes.filter((m) => m !== obj);
1432
+ this._sel.selectedMeshes = this._sel.selectedMeshes.filter((m) => m !== obj);
1600
1433
  }
1601
- const currentSel = this._selection;
1434
+ const currentSel = this._sel.selection;
1602
1435
  if (currentSel && currentSel.mesh === obj) {
1603
1436
  const smid = currentSel.smid;
1604
1437
  const carrier = (smid != null
1605
1438
  ? [...(this._smidMeshes.get(smid) ?? [])].find((m) => m !== obj && m.parent)
1606
- : this._selectedMeshes.find((m) => m !== obj && m.parent && m.parent.visible) ??
1607
- this._selectedMeshes.find((m) => m !== obj && m.parent));
1439
+ : this._sel.selectedMeshes.find((m) => m !== obj && m.parent && m.parent.visible) ??
1440
+ this._sel.selectedMeshes.find((m) => m !== obj && m.parent));
1608
1441
  if (carrier) {
1609
1442
  // 选中身份交给承载同一 smid 的另一块 mesh(通常是接替显示的子瓦)
1610
- this._selection = { ...currentSel, mesh: carrier };
1611
- this._replaySelectionOnto(carrier);
1443
+ this._sel.selection = { ...currentSel, mesh: carrier };
1444
+ this._sel.replayOnto(carrier);
1612
1445
  }
1613
1446
  else {
1614
1447
  this.select(null);
1615
1448
  }
1616
1449
  }
1617
1450
  this._unindexMeshSmids(obj);
1618
- this._removeMeshHeight(obj);
1451
+ this._heights.remove(obj);
1619
1452
  this._featureStyleManager.disposeMesh(obj);
1620
1453
  const styled = styledMaterialOf(obj);
1621
1454
  styled?.dispose?.();
@@ -1641,21 +1474,6 @@ export class S3MViewer {
1641
1474
  * 拾取 ID 只有 2 个字节(RG 两个通道),65535 个网格就到头了,
1642
1475
  * 所以必须回收:瓦片淘汰 / 数据集移除时把 id 还回池子。
1643
1476
  */
1644
- _allocPickId() {
1645
- const reused = this._freePickIds.pop();
1646
- if (reused !== undefined)
1647
- return reused;
1648
- if (this._pickSeq > MAX_PICK_ID) {
1649
- for (let id = 0; id <= MAX_PICK_ID; id++)
1650
- if (!this._pickables.has(id))
1651
- return id;
1652
- throw new Error(`[s3m] 可拾取网格超过 ${MAX_PICK_ID + 1} 个,2 字节 ID 编码不够用`);
1653
- }
1654
- return this._pickSeq++;
1655
- }
1656
- _releasePickId(id) {
1657
- this._freePickIds.push(id);
1658
- }
1659
1477
  /* ---------------------------------------------------------------- 样式 */
1660
1478
  /** 动态设置图层整体透明度 (0.0 ~ 1.0) */
1661
1479
  setOpacity(opacity, transition) {
@@ -1706,46 +1524,6 @@ export class S3MViewer {
1706
1524
  getStyle() {
1707
1525
  return { ...this._styleTarget, modeLabel: STYLE_LABELS[this._styleTarget.mode] };
1708
1526
  }
1709
- /**
1710
- * 挂 / 摘环境贴图。
1711
- *
1712
- * 金属度(`setStyle({ metalness })`)**必须**有环境图才看得出质感:
1713
- * PBR 的金属几乎没有漫反射,画面全靠 `scene.environment` 里的环境反射,
1714
- * 没有它时 metalness 一高就整片发黑,只剩两盏平行灯打成的小高光点。
1715
- *
1716
- * @param source 等距柱状图(.hdr / .jpg / .png,2:1)地址,或已加载的 `THREE.Texture`;
1717
- * `null` = 关掉环境图
1718
- * @param intensity 环境光强度(`scene.environmentIntensity`,默认 1)
1719
- *
1720
- * 传入 URL 时纹理由内部加载并转成 PMREM(预滤波 mipmap);传 Texture 时不会接管它的生命周期。
1721
- */
1722
- async setEnvironment(source, intensity = 1) {
1723
- if (!this.renderer || !this.scene)
1724
- return;
1725
- this.scene.environmentIntensity = Math.max(0, intensity);
1726
- if (source == null) {
1727
- this.scene.environment = null;
1728
- this._envRT?.dispose();
1729
- this._envRT = null;
1730
- this._requestRepaint();
1731
- return;
1732
- }
1733
- const fromUrl = typeof source === 'string';
1734
- const tex = fromUrl ? await new THREE.TextureLoader().loadAsync(source) : source;
1735
- // PMREM 只认等距柱状图;普通贴图也按 2:1 全景处理
1736
- tex.mapping = THREE.EquirectangularReflectionMapping;
1737
- const pmrem = new THREE.PMREMGenerator(this.renderer);
1738
- pmrem.compileEquirectangularShader();
1739
- const rt = pmrem.fromEquirectangular(tex);
1740
- pmrem.dispose();
1741
- if (fromUrl)
1742
- tex.dispose();
1743
- this.scene.environment = rt.texture;
1744
- // 上一张(同一个 viewer 反复换)要释放,否则显存里会一直堆
1745
- this._envRT?.dispose();
1746
- this._envRT = rt;
1747
- this._requestRepaint();
1748
- }
1749
1527
  /** 把某个样式键的取值转成动画通道能吃的形状(标量 or 线性 RGB 三元组) */
1750
1528
  _animTargetOf(key, patch) {
1751
1529
  const value = patch[key];
@@ -1805,7 +1583,7 @@ export class S3MViewer {
1805
1583
  write: (v) => {
1806
1584
  if (this.uniforms.uEdgeFade)
1807
1585
  this.uniforms.uEdgeFade.value = v[0];
1808
- this._syncEdgeStyle();
1586
+ this._edges.syncStyle();
1809
1587
  },
1810
1588
  });
1811
1589
  // 整体压暗乘子:setMode 的 fade-through 用它,也不是 S3MStyle 字段
@@ -1833,7 +1611,7 @@ export class S3MViewer {
1833
1611
  key === 'edgeOpacity' ||
1834
1612
  key === 'edgeOffset' ||
1835
1613
  key === 'edgeHullWidth') {
1836
- this._refreshEdgeLines();
1614
+ this._edges.refreshLines();
1837
1615
  }
1838
1616
  }
1839
1617
  /** 取某个颜色键对应的共享 uniform Color 对象 */
@@ -1867,8 +1645,8 @@ export class S3MViewer {
1867
1645
  // 临界角变了要重建 CPU 棱线(EdgesGeometry 的参数),开 / 关走 setEdges 整体刷新。
1868
1646
  // 必须先 _disposeEdges():setEdges 只挑「还没有棱线」的网格来建,
1869
1647
  // 不先拆掉的话已经建好的会被全部跳过,临界角改了等于没改。
1870
- if (('edgeThreshold' in patch || 'forceCpuEdges' in patch) && this._edgesEnabled) {
1871
- this._disposeEdges();
1648
+ if (('edgeThreshold' in patch || 'forceCpuEdges' in patch) && this._edges.enabled) {
1649
+ this._edges.disposeEdgeGeometry();
1872
1650
  this.setEdges(this.style.edges);
1873
1651
  }
1874
1652
  else if ('edgeColor' in patch ||
@@ -1878,14 +1656,14 @@ export class S3MViewer {
1878
1656
  'edgeOffset' in patch ||
1879
1657
  'edgeHullWidth' in patch) {
1880
1658
  // 颜色 / 粗细 / 外推距离 / 外扩厚度都只是 uniform,拖着滑块逐帧改也不重建几何
1881
- this._refreshEdgeLines();
1659
+ this._edges.refreshLines();
1882
1660
  }
1883
- if (this._selection) {
1661
+ if (this._sel.selection) {
1884
1662
  // 换描边画法要重建几何;颜色 / 粗细 / 拔高只是 uniform,拖着滑块也不卡
1885
1663
  if ('outline' in patch || 'outlineStyle' in patch)
1886
- this.select(this._selection, { silent: true });
1664
+ this.select(this._sel.selection, { silent: true });
1887
1665
  else
1888
- this._syncSelectionStyle();
1666
+ this._sel.syncStyle();
1889
1667
  }
1890
1668
  }
1891
1669
  _applyStyleUniforms() {
@@ -1965,7 +1743,7 @@ export class S3MViewer {
1965
1743
  this._opacityTransparent = transparent;
1966
1744
  // 关键:若 depthWrite 开启(默认开启),即使半透明也保持深度写入,完美遮挡背面棱线,防止背面线条透出
1967
1745
  const keepDepth = this.style.depthWrite !== false;
1968
- for (const p of this._pickables.values()) {
1746
+ for (const p of this._picks.pickables.values()) {
1969
1747
  if (isPointCloudObject(p.mesh))
1970
1748
  continue;
1971
1749
  const m = styledMaterialOf(p.mesh);
@@ -1984,11 +1762,11 @@ export class S3MViewer {
1984
1762
  // 点云不走 StyleMaterial,自己有一套 uOpacity,逐帧刷的只有这几个对象
1985
1763
  for (const pc of this._pointClouds)
1986
1764
  this._applyPointCloudStyle(pc);
1987
- this._syncEdgeStyle();
1765
+ this._edges.syncStyle();
1988
1766
  this._requestRepaint();
1989
1767
  }
1990
1768
  /** 单个点云对象同步当前样式(着色模式 + 不透明度 + 选中高亮) */
1991
- _applyPointCloudStyle(points, selected = this._selectedMeshes.includes(points)) {
1769
+ _applyPointCloudStyle(points, selected = this._sel.selectedMeshes.includes(points)) {
1992
1770
  applyPointCloudStyle(points, this.style, this.uniforms, selected);
1993
1771
  }
1994
1772
  /** 全部点云刷新:着色模式切换时用(uniform 是共享引用,改色不用走这里) */
@@ -2002,7 +1780,7 @@ export class S3MViewer {
2002
1780
  // shading 现在是共享 uniform(uFlatShading)驱动的运行期分支,不再是
2003
1781
  // flatShading define:翻一个 float 就完成切换,零重编译、零卡顿。
2004
1782
  const target = this.style.shading !== 'smooth' ? 1 : 0;
2005
- for (const p of this._pickables.values()) {
1783
+ for (const p of this._picks.pickables.values()) {
2006
1784
  const m = styledMaterialOf(p.mesh);
2007
1785
  const u = m?.userData.uFlatShading;
2008
1786
  if (!u || u.value === target)
@@ -2040,7 +1818,7 @@ export class S3MViewer {
2040
1818
  'uBatchTexActive',
2041
1819
  'uTileFade',
2042
1820
  ];
2043
- for (const p of this._pickables.values()) {
1821
+ for (const p of this._picks.pickables.values()) {
2044
1822
  const mesh = p.mesh;
2045
1823
  const raw = mesh.material;
2046
1824
  if (Array.isArray(raw))
@@ -2078,7 +1856,7 @@ export class S3MViewer {
2078
1856
  return;
2079
1857
  // 场上还没有材质就没必要预热:program 会在首个材质出现时编译
2080
1858
  let hasMaterial = false;
2081
- for (const p of this._pickables.values()) {
1859
+ for (const p of this._picks.pickables.values()) {
2082
1860
  if (styledMaterialOf(p.mesh)) {
2083
1861
  hasMaterial = true;
2084
1862
  break;
@@ -2106,67 +1884,13 @@ export class S3MViewer {
2106
1884
  * 加载 n 块瓦片就是 O(n²) 次矩阵乘 + n² 个垃圾对象。现在只在网格进出的记一次,
2107
1885
  * 全局值从缓存里滚:只有被移除的那块正好卡在边界上时,才回退到一次 O(n) 重算。
2108
1886
  */
1887
+ /** @internal 见 viewer/HeightIndex.ts(regressions.test.ts 直接调) */
2109
1888
  _addMeshHeight(mesh) {
2110
- const bb = mesh.geometry?.boundingBox;
2111
- if (!bb)
2112
- return;
2113
- this._boxScratch.copy(bb).applyMatrix4(mesh.matrixWorld);
2114
- const min = this._boxScratch.min.z;
2115
- const max = this._boxScratch.max.z;
2116
- if (!Number.isFinite(min) || !Number.isFinite(max))
2117
- return;
2118
- this._meshHeight.set(mesh, { min, max });
2119
- if (min < this._heightMin)
2120
- this._heightMin = min;
2121
- if (max > this._heightMax)
2122
- this._heightMax = max;
2123
- this._pushHeightRange();
1889
+ this._heights.add(mesh);
2124
1890
  }
1891
+ /** @internal 见 viewer/HeightIndex.ts */
2125
1892
  _removeMeshHeight(mesh) {
2126
- const rec = this._meshHeight.get(mesh);
2127
- if (!rec)
2128
- return;
2129
- this._meshHeight.delete(mesh);
2130
- // 只有正好卡在边界上才需要重算,否则滚动的 min/max 依然成立
2131
- if (rec.min === this._heightMin || rec.max === this._heightMax)
2132
- this._recomputeHeightRange();
2133
- else
2134
- this._pushHeightRange();
2135
- }
2136
- /** 数据集整体位移(换高程偏移 / 重新布局)后,缓存的世界高度全部失效 */
2137
- _refreshAllHeights() {
2138
- if (this._meshHeight.size === 0)
2139
- return;
2140
- const meshes = [...this._meshHeight.keys()];
2141
- this._meshHeight.clear();
2142
- this._heightMin = Infinity;
2143
- this._heightMax = -Infinity;
2144
- for (const mesh of meshes)
2145
- this._addMeshHeight(mesh);
2146
- this._pushHeightRange();
2147
- }
2148
- _recomputeHeightRange() {
2149
- let min = Infinity;
2150
- let max = -Infinity;
2151
- for (const h of this._meshHeight.values()) {
2152
- if (h.min < min)
2153
- min = h.min;
2154
- if (h.max > max)
2155
- max = h.max;
2156
- }
2157
- this._heightMin = min;
2158
- this._heightMax = max;
2159
- this._pushHeightRange();
2160
- }
2161
- _pushHeightRange() {
2162
- let min = this._heightMin;
2163
- let max = this._heightMax;
2164
- if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
2165
- min = 0;
2166
- max = 100;
2167
- }
2168
- this.uniforms.uHeightMin.value = min;
2169
- this.uniforms.uHeightMax.value = max;
1893
+ this._heights.remove(mesh);
2170
1894
  }
2171
1895
  /** 建 SMID → 网格 反查表,逐单体改样式时才知道该重算哪几块 */
2172
1896
  _indexMeshSmids(mesh) {
@@ -2197,1187 +1921,118 @@ export class S3MViewer {
2197
1921
  this._smidMeshes.delete(smid);
2198
1922
  }
2199
1923
  }
2200
- /**
2201
- * 全局棱线开关。
2202
- *
2203
- * 取值是 `boolean | EdgeStyle | EdgeStyle[]`(见 resolveEdgeModes):
2204
- * - `true` / `'line'` 折边线(旧口径)
2205
- * - `'offset'` 折边线 + 沿边法线外推 `edgeOffset` 米
2206
- * - `'hull'` 背面外扩轮廓(选中侧 outlineStyle:'hull' 的整场景版)
2207
- * - `'gpu'` 纯片元描边:零几何、零额外 pass,线宽约 1 像素
2208
- * - 数组 叠加,例如 `['line', 'hull']`
2209
- *
2210
- * 数据自带棱线包(primitiveType 13)时直接用,成本为零;`style.forceCpuEdges`
2211
- * 可以无视它,改由 CPU 现算 —— 那是 CPU 端的活,几十万面一次性建会卡住主线程
2212
- * (实测 20 个网格 1.9s),所以按 8ms 一帧切片,边建边出。
2213
- *
2214
- * 棱线本身用「屏幕空间等宽线」画(见 EdgeLines.ts):WebGL 的 lineWidth 基本无效,
2215
- * 只有摊成面向屏幕的四边形,`edgeWidth` 才能真正调粗。
2216
- *
2217
- * 带 `transition` 时做「淡入 / 淡出」,**不对几何做插值**(几何没法插值):
2218
- * 开:立刻置位 + 开始分片建线,同时 `uEdgeFade` 0→1 —— 建好一块亮一块,视觉上是渐显;
2219
- * 关:`uEdgeFade` 1→0,淡完才 dispose,避免棱线「啪」一下消失。
2220
- */
2221
- setEdges(edges, transition) {
2222
- const wantAnim = !!transition && ((transition.duration ?? 0) > 0 || !!transition.spring);
2223
- const modes = resolveEdgeModes(edges);
2224
- // 换画法(line ↔ hull ↔ offset)是结构变更:旧装饰必须拆掉再建,
2225
- // 否则会出现「切到 hull 了折边线还留着」这种叠影。
2226
- // 同一画法重复调用(滑块逐帧 setEdges(true))不重建,几何是复用的。
2227
- const modesChanged = !sameEdgeModes(this._edgeModes, modes);
2228
- this._edgeModes = modes;
2229
- this._edgesEnabled = modes.enabled;
2230
- this.style.edges = edges;
2231
- this._styleTarget.edges = edges;
2232
- // 数据自带的棱线:直接切可见性(forceCpuEdges 时永远收起,改由 CPU 现算)。
2233
- // 只遍历注册时收集好的那批 LineSegments —— 全数据集 traverse 在几千节点下太贵。
2234
- // gpu 模式下 shader 自己描边,native 线段包必须收起,否则叠成双线
2235
- const nativeVisible = modes.enabled && !modes.gpu && this.style.forceCpuEdges !== true;
2236
- for (const lines of this._nativeEdgeLines) {
2237
- lines.visible = nativeVisible;
2238
- this._styleEdgeLine(lines);
2239
- }
2240
- this._syncEdgeStyle();
2241
- // 关掉 gpu 画法就把后处理资源还回去(整屏 RT 白占显存没意义)。
2242
- // 必须放在「整体关闭」的早退之前,否则关棱线时永远轮不到它。
2243
- if (!modes.gpu && this._gpuEdge) {
2244
- this._gpuEdge.dispose();
2245
- this._gpuEdge = null;
2246
- }
2247
- if (!modes.enabled) {
2248
- if (wantAnim) {
2249
- this.animator.to('style.edgeFade', 0, {
2250
- ...transition,
2251
- onComplete: () => {
2252
- this._disposeEdges();
2253
- this._requestRepaint();
2254
- transition?.onComplete?.();
2255
- },
2256
- });
2257
- }
2258
- else {
2259
- this._setEdgeFade(1);
2260
- this._disposeEdges();
2261
- }
2262
- this._requestRepaint();
2263
- return;
2264
- }
2265
- if (modesChanged)
2266
- this._disposeEdges();
2267
- const pending = [...this._pickables.values()]
2268
- .map((p) => p.mesh)
2269
- .filter((m) => {
2270
- const ud = m.userData;
2271
- // 点云没有棱可描
2272
- if (isPointCloudObject(m))
2273
- return false;
2274
- // native 棱线还算不算数,取决于**当前**的 forceCpuEdges(它可以在运行中切换),
2275
- // 所以这里按注册时记下的原始标记重算,而不是沿用注册那一刻的结论。
2276
- ud.nativeEdges = ud.nativeEdgesRaw === true && this.style.forceCpuEdges !== true;
2277
- return !ud.nativeEdges && this._needsEdgeDecoration(ud);
2278
- });
2279
- let i = 0;
2280
- const step = () => {
2281
- const t0 = performance.now();
2282
- while (i < pending.length && performance.now() - t0 < 8)
2283
- this._ensureEdgeDecoration(pending[i++]);
2284
- if (i < pending.length)
2285
- setTimeout(step, 0);
2286
- else
2287
- this._requestRepaint();
2288
- };
2289
- step();
2290
- if (wantAnim) {
2291
- // 从 0 起淡入:新建出来的棱线自动继承当前进度,不会半路冒出一条满亮度的线
2292
- this._setEdgeFade(0);
2293
- this.animator.to('style.edgeFade', 1, transition);
2294
- }
2295
- else {
2296
- this._setEdgeFade(1);
2297
- }
2298
- }
2299
- /** 直接写棱线淡入乘子(不走动画通道) */
2300
- _setEdgeFade(v) {
2301
- if (this.uniforms.uEdgeFade)
2302
- this.uniforms.uEdgeFade.value = v;
2303
- this._syncEdgeStyle();
2304
- }
2305
- /** 释放所有现算棱线几何 */
2306
- _disposeEdges() {
2307
- for (const p of this._pickables.values())
2308
- this._removeEdges(p.mesh);
2309
- }
2310
- /**
2311
- * 挂载预制棱线缓存(Crease Cache)。
2312
- *
2313
- * 直接把 `source-creases.f32` 这类 Float32Array 缓冲载入为一组全局统一的
2314
- * LineSegments —— 相比逐 Mesh 现算 EdgesGeometry,性能高一个量级,视觉也最干净。
2315
- * 坐标按「场景本地坐标」(相对场景锚点的米)处理,与瓦片几何同一坐标系。
2316
- *
2317
- * @param url 棱线缓存地址(二进制 Float32 序列:x,y,z,x,y,z,… 成对成段)
2318
- */
2319
- async loadCreases(url) {
2320
- const resp = await authFetch(url, this._authenticate, 'creases');
2321
- if (!resp.ok)
2322
- throw new Error(`[s3m] 棱线缓存加载失败: ${resp.status} ${url}`);
2323
- const buf = await resp.arrayBuffer();
2324
- const arr = new Float32Array(buf);
2325
- if (arr.length < 6)
2326
- return;
2327
- const geo = new THREE.BufferGeometry();
2328
- geo.setAttribute('position', new THREE.BufferAttribute(arr, 3));
2329
- const s = this.style;
2330
- const mat = new THREE.LineBasicMaterial({
2331
- color: new THREE.Color(s.edgeColor),
2332
- transparent: true,
2333
- opacity: Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) * clampOpacity(s.opacity),
2334
- depthWrite: false,
2335
- });
2336
- const lines = new THREE.LineSegments(geo, mat);
2337
- lines.renderOrder = 2;
2338
- lines.frustumCulled = false;
2339
- lines.raycast = () => { };
2340
- this.clearCreases();
2341
- this._creaseLines = lines;
2342
- this.scene?.add(lines);
2343
- this._requestRepaint();
2344
- }
2345
- /** 移除并释放预制棱线缓存 */
2346
- clearCreases() {
2347
- if (!this._creaseLines)
2348
- return;
2349
- this.scene?.remove(this._creaseLines);
2350
- this._creaseLines.geometry.dispose();
2351
- this._creaseLines.material?.dispose?.();
2352
- this._creaseLines = null;
2353
- this._requestRepaint();
2354
- }
2355
- /**
2356
- * 让全局棱线材质跟上 style.edgeColor / edgeWidth / edgeOpacity / edgeOffset。
2357
- *
2358
- * 全程 O(1):只有一份共享 edgeMaterial(所有逐 mesh 棱线都用它)+ 一份预制缓存
2359
- * + 一批共享外观对象的轮廓壳,所以棱线淡入淡出只要改一个 uniform,
2360
- * 新建瓦片自动继承当前进度。
2361
- */
2362
- _syncEdgeStyle() {
2363
- const s = this.style;
2364
- const fade = this.uniforms.uEdgeFade?.value ?? 1;
2365
- const opacity = Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) *
2366
- clampOpacity(s.opacity) *
2367
- fade;
2368
- const u = this.edgeMaterial.uniforms;
2369
- u.uColor.value.set(s.edgeColor);
2370
- u.uWidth.value = Math.max(0.1, s.edgeWidth ?? 1.5);
2371
- u.uOpacity.value = opacity;
2372
- // 外推距离只在 offset 画法下非 0;其它画法把线钉在面上(与改动前一致)
2373
- u.uOffset.value = this._edgeModes.offset ? Math.max(0, s.edgeOffset ?? 0.15) : 0;
2374
- // 外扩轮廓壳与折边线共用颜色 / 透明度,厚度是米制的单独一项
2375
- const h = this._hullStyleUniforms;
2376
- h.uColor.value.set(s.edgeColor);
2377
- h.uWidth.value = Math.max(0, s.edgeHullWidth ?? 0.9);
2378
- h.uOpacity.value = opacity;
2379
- // GPU 描边(edges: 'gpu')是后处理,参数每帧从 style 现取,这里不用同步 uniform
2380
- // 预制棱线缓存(LineBasicMaterial)同步颜色 / 不透明度
2381
- if (this._creaseLines) {
2382
- const m = this._creaseLines.material;
2383
- m.color.set(s.edgeColor);
2384
- m.opacity = opacity;
2385
- }
2386
- }
2387
- /** 让数据自带的棱线跟着 style.edgeColor / opacity 走(这些是 LineBasicMaterial,改不了粗细) */
2388
- _styleEdgeLine(lines) {
2389
- const mat = lines.material;
2390
- if (!mat || !mat.color)
2391
- return;
2392
- mat.color.set(this.style.edgeColor);
2393
- const baseOpacity = this.style.edgeOpacity ?? 0.6;
2394
- mat.opacity =
2395
- baseOpacity *
2396
- clampOpacity(this.style.opacity) *
2397
- (this.uniforms.uEdgeFade?.value ?? 1);
2398
- mat.transparent = true;
2399
- mat.polygonOffset = true;
2400
- mat.polygonOffsetFactor = -1;
2401
- mat.polygonOffsetUnits = -1;
2402
- mat.needsUpdate = true;
2403
- }
2404
- /**
2405
- * 刷新所有棱线外观。
2406
- *
2407
- * 数据自带的棱线是逐对象的 LineBasicMaterial,改颜色只能一个个写;但原来每次都要
2408
- * 遍历整个数据集树(几千个节点),颜色动画逐帧跑就是灾难。改成只刷注册时收集好的
2409
- * 那批 LineSegments,复杂度降到 O(棱线条数)。
2410
- */
2411
- _refreshEdgeLines() {
2412
- this._syncEdgeStyle();
2413
- for (const lines of this._nativeEdgeLines) {
2414
- if (lines.parent)
2415
- this._styleEdgeLine(lines);
2416
- }
2417
- }
2418
- /** 地形 / 绿地 / 道路 / 地面铺装等不参与自动描边(数据自带棱线包另说) */
2419
- static _EDGE_SKIP_KEYWORDS = [
2420
- 'terrain', 'ground', 'vegetation', 'green', 'road', 'dem',
2421
- '绿地', '道路', '地面', '地形', '植被', '铺装',
2422
- ];
2423
- /** 数据集级别的棱线开关:enableEdges 优先,否则跟随全局 style.edges */
2424
- _datasetEdgesEnabled(entry) {
2425
- const opt = entry.options?.enableEdges;
2426
- if (opt === true)
2427
- return true;
2428
- if (opt === false)
2429
- return false;
2430
- return this._edgesEnabled;
1924
+ /* ---------------------------------------------------------------- 棱线 */
1925
+ /** @internal 测试用:棱线是否开启(实现在 EdgeDecorator) */
1926
+ get _edgesEnabled() {
1927
+ return this._edges.enabled;
2431
1928
  }
2432
- /** 该 mesh 是否命中「跳过自动描边」的关键字(skeletonName / 数据集名) */
2433
- _shouldSkipAutoEdges(ud, datasetName) {
2434
- const hay = `${ud.skeletonName ?? ''} ${datasetName}`.toLowerCase();
2435
- return S3MViewer._EDGE_SKIP_KEYWORDS.some((k) => hay.includes(k));
1929
+ /** @internal 测试用:当前画法 */
1930
+ get _edgeModes() {
1931
+ return this._edges.modes;
2436
1932
  }
2437
- /**
2438
- * 这块网格还缺哪种棱线装饰(缺就建,齐了就不动)。
2439
- *
2440
- * setEdges 的分片建线靠它筛「还没建完的」,注册新瓦片也靠它决定要不要动手。
2441
- */
2442
- _needsEdgeDecoration(ud) {
2443
- if (this._edgeModes.line && !ud.edges)
2444
- return true;
2445
- if (this._edgeModes.hull && !ud.edgeHull)
2446
- return true;
2447
- return false;
1933
+ /** @internal 测试用:GPU 描边实例(可替换) */
1934
+ get _gpuEdge() {
1935
+ return this._edges.gpuEdge;
2448
1936
  }
2449
- /** 按当前画法补齐这块网格的棱线装饰 */
2450
- _ensureEdgeDecoration(mesh) {
2451
- if (this._edgeModes.line)
2452
- this._ensureEdges(mesh);
2453
- if (this._edgeModes.hull)
2454
- this._ensureHull(mesh);
1937
+ set _gpuEdge(v) {
1938
+ this._edges.gpuEdge = v;
2455
1939
  }
1940
+ /** @internal 测试用:给一块网格补折边线 */
2456
1941
  _ensureEdges(mesh) {
2457
- const ud = mesh.userData;
2458
- if (ud.edges || ud.nativeEdges || !mesh.geometry)
2459
- return;
2460
- // 地形 / 绿地 / 道路等不参与自动描边(即使全局开棱线也要跳过)
2461
- if (this._shouldSkipAutoEdges(ud, ''))
2462
- return;
2463
- const posAttr = mesh.geometry.getAttribute('position');
2464
- const indexCount = mesh.geometry.index?.count ?? (posAttr ? posAttr.count : 0);
2465
- // 棱线是 CPU 端建的,超大几何直接跳过,避免拖慢加载
2466
- if (indexCount > 120000)
2467
- return;
2468
- // 1. 先做顶点容差焊合,让相邻三角形正确共享边索引。
2469
- // S3M 几何常为「每面独立顶点」,不焊合的话 EdgesGeometry 会把每条三角形边都当成边界,棱线爆炸。
2470
- // 注意用本地实现而不是 three/examples/jsm —— jsm 子路径导入会在宿主里
2471
- // 制造第二份 three 实例("Multiple instances of Three.js" 警告的元凶)。
2472
- // tolerance 取米级模型下的 5mm,足以合并重合顶点又不会误并真实相邻面。
2473
- const welded = mergeVerticesByPosition(mesh.geometry, 0.005);
2474
- // 2. 调大临界角(默认 55°,可在 style.edgeThreshold 调),过滤浅坡与曲面细分,只保留真正的棱。
2475
- const thresholdAngle = this.style.edgeThreshold ?? 55;
2476
- // 'offset' 画法要额外带一条「边法线」才知道往哪推,所以走本地实现(它本来就算好了
2477
- // 相邻两面法线);其余画法保持 EdgesGeometry 这条老路,几何输出与改动前逐字一致。
2478
- let segments;
2479
- let normals;
2480
- if (this._edgeModes.offset) {
2481
- const raw = [];
2482
- segments = buildFeatureEdgeSegments(welded, null, thresholdAngle, raw);
2483
- if (raw.length > 0)
2484
- normals = new Float32Array(raw);
2485
- }
2486
- else {
2487
- const source = new THREE.EdgesGeometry(welded, thresholdAngle);
2488
- const attr = source.getAttribute('position');
2489
- segments = attr ? attr.array : null;
2490
- source.dispose();
2491
- }
2492
- welded.dispose();
2493
- if (!segments || segments.length < 6)
2494
- return;
2495
- // 3. 回填每段棱线所属的单体 batchId。
2496
- // EdgesGeometry 只输出坐标不输出顶点号,只能拿端点坐标去查「位置 → batchId」表。
2497
- // 没有这张表,单体一动棱线就留在原地——这正是「棱线跟随」的关键一环。
2498
- const TOL = 0.005;
2499
- const batchMap = buildPositionBatchMap(mesh.geometry, TOL);
2500
- let batchIds;
2501
- if (batchMap) {
2502
- const segCount = segments.length / 6;
2503
- batchIds = new Float32Array(segCount);
2504
- for (let i = 0; i < segCount; i++) {
2505
- const s = i * 6;
2506
- batchIds[i] =
2507
- batchMap.get(positionQuantKey(segments[s], segments[s + 1], segments[s + 2], 1 / TOL)) ?? -1;
2508
- }
2509
- }
2510
- const lines = new THREE.Mesh(buildThickLineGeometry(segments, batchIds, normals), this._createEdgeMaterial(mesh));
2511
- lines.renderOrder = 2;
2512
- // 屏幕空间外扩后可能超出原包围球,关掉剔除更稳
2513
- lines.frustumCulled = false;
2514
- lines.raycast = () => { };
2515
- lines.userData.isThickEdge = true;
2516
- ud.edges = lines;
2517
- mesh.add(lines); // 作为子节点,自动继承 mesh 的世界变换
1942
+ this._edges.ensureEdges(mesh);
2518
1943
  }
2519
- /**
2520
- * 给一块网格挂「背面外扩轮廓」(整场景版)。
2521
- *
2522
- * 复用选中侧那套 `createOutlineMaterial`:顶点沿法线鼓 `edgeHullWidth` 米、只画背面,
2523
- * 于是被本体挡住的部分不显示,露出来的那圈就是描边。与折边线的差别在于——
2524
- * 这里**不建任何新几何**,成本只是把同一块网格多画一遍,折边判定那套 CPU 活全省了。
2525
- *
2526
- * 实例化几何拿不到逐实例变换(轮廓壳是 mesh 的子节点,不吃 instanceMatrix),
2527
- * 直接跳过:宁可这栋没描边,也不要所有实例的壳叠在原点。
2528
- */
2529
- _ensureHull(mesh) {
2530
- const ud = mesh.userData;
2531
- if (ud.edgeHull || !mesh.geometry)
2532
- return;
2533
- if (mesh.isInstancedMesh)
2534
- return;
2535
- // 地形 / 绿地 / 道路等不参与自动描边(即使全局开棱线也要跳过)
2536
- if (this._shouldSkipAutoEdges(ud, ''))
2537
- return;
2538
- // 逐网格一份材质(各绑各的单体状态纹理),外观 uniform 复用共享对象
2539
- const mat = createOutlineMaterial(this.uniforms, this.style.edgeHullWidth ?? 0.9, this._hullStyleUniforms, true);
2540
- // 整块几何都要外扩:选中侧靠 uSelBatch 只扩一栋,全局必须置 whole
2541
- mat.uniforms.uSelWhole.value = 1;
2542
- mat.uniforms.uSelBatch.value = -1;
2543
- const styled = styledMaterialOf(mesh);
2544
- if (styled?.userData?.uBatchTex) {
2545
- mat.uniforms.uBatchTex.value = styled.userData.uBatchTex.value;
2546
- mat.uniforms.uBatchTexWidth.value = styled.userData.uBatchTexWidth.value;
2547
- mat.uniforms.uBatchTexActive.value = styled.userData.uBatchTexActive.value;
2548
- }
2549
- const hull = new THREE.Mesh(mesh.geometry, mat);
2550
- hull.renderOrder = 2;
2551
- hull.frustumCulled = false;
2552
- hull.raycast = () => { };
2553
- hull.userData.isEdgeHull = true;
2554
- ud.edgeHull = hull;
2555
- mesh.add(hull); // 作为子节点,自动继承 mesh 的世界变换
1944
+ /** @internal 测试用:拆掉一块网格的棱线装饰 */
1945
+ _removeEdges(mesh) {
1946
+ this._edges.removeEdges(mesh);
2556
1947
  }
2557
- /**
2558
- * 给一块网格造专属的棱线材质。
2559
- *
2560
- * 棱线材质必须逐网格一份,因为它要绑定**这个网格**的单体状态纹理;但外观
2561
- * (颜色 / 粗细 / 透明度)必须全局统一——所以外观 uniform 直接复用
2562
- * `_edgeStyleUniforms` 里的对象,`_syncEdgeStyle()` 改一次全部生效,
2563
- * program 也因为着色器相同而只编译一次。
2564
- */
2565
- _createEdgeMaterial(mesh) {
2566
- const styled = styledMaterialOf(mesh);
2567
- const batch = styled?.userData?.uBatchTex
2568
- ? {
2569
- uBatchTex: styled.userData.uBatchTex,
2570
- uBatchTex2: styled.userData.uBatchTex2,
2571
- uBatchMix: this.uniforms.uBatchMix,
2572
- uBatchTexWidth: styled.userData.uBatchTexWidth,
2573
- uBatchTexActive: styled.userData.uBatchTexActive,
2574
- uPathTex: this.uniforms.uPathTex,
2575
- uPathCount: this.uniforms.uPathCount,
2576
- uPathTexActive: this.uniforms.uPathTexActive,
2577
- uPathTime: this.uniforms.uPathTime,
2578
- }
2579
- : undefined;
2580
- return createThickLineMaterial(this.lineShared, {}, this._edgeStyleUniforms, batch);
1948
+ /** 全局棱线开关,实现在 viewer/EdgeDecorator.ts */
1949
+ setEdges(edges, transition) {
1950
+ this._edges.setEdges(edges, transition);
2581
1951
  }
2582
- /** 选中棱线的逐网格材质,道理同上 */
2583
- _createSelectionEdgeMaterial(mesh) {
2584
- const styled = styledMaterialOf(mesh);
2585
- const batch = styled?.userData?.uBatchTex
2586
- ? {
2587
- uBatchTex: styled.userData.uBatchTex,
2588
- uBatchTex2: styled.userData.uBatchTex2,
2589
- uBatchMix: this.uniforms.uBatchMix,
2590
- uBatchTexWidth: styled.userData.uBatchTexWidth,
2591
- uBatchTexActive: styled.userData.uBatchTexActive,
2592
- uPathTex: this.uniforms.uPathTex,
2593
- uPathCount: this.uniforms.uPathCount,
2594
- uPathTexActive: this.uniforms.uPathTexActive,
2595
- uPathTime: this.uniforms.uPathTime,
2596
- }
2597
- : undefined;
2598
- return createThickLineMaterial(this.lineShared, {}, this._selEdgeStyleUniforms, batch);
1952
+ /** 挂载预制棱线缓存(Crease Cache),见 viewer/EdgeDecorator.ts */
1953
+ loadCreases(url) {
1954
+ return this._edges.loadCreases(url);
2599
1955
  }
2600
- _removeEdges(mesh) {
2601
- const ud = mesh.userData;
2602
- const lines = ud.edges;
2603
- if (lines) {
2604
- mesh.remove(lines);
2605
- lines.geometry?.dispose();
2606
- // 棱线材质现在是逐网格一份(要绑各自的 batch 纹理),得跟着释放
2607
- const mat = lines.material;
2608
- if (Array.isArray(mat)) {
2609
- mat.forEach((m) => m?.dispose?.());
2610
- }
2611
- else {
2612
- mat?.dispose?.();
2613
- }
2614
- ud.edges = null;
2615
- }
2616
- // 外扩壳的 geometry 是**复用主网格的**,只能释放材质,不能 dispose 几何
2617
- const hull = ud.edgeHull;
2618
- if (hull) {
2619
- mesh.remove(hull);
2620
- const hmat = hull.material;
2621
- if (Array.isArray(hmat))
2622
- hmat.forEach((m) => m?.dispose?.());
2623
- else
2624
- hmat?.dispose?.();
2625
- ud.edgeHull = null;
2626
- }
1956
+ /** 移除并释放预制棱线缓存 */
1957
+ clearCreases() {
1958
+ this._edges.clearCreases();
2627
1959
  }
2628
1960
  /* ---------------------------------------------------------------- 拾取 */
2629
- /** 获取指定屏幕像素对应的场景射线(用于高精度求交与穿透拾取) */
1961
+ /** 屏幕像素 → 场景射线,见 viewer/PickService.ts */
2630
1962
  getRayAt(x, y) {
2631
- if (!this.camera || !this.canvas)
2632
- return null;
2633
- const canvas = this.canvas;
2634
- const w = canvas.clientWidth || canvas.width;
2635
- const h = canvas.clientHeight || canvas.height;
2636
- if (!w || !h)
2637
- return null;
2638
- const ndcX = (x / w) * 2 - 1;
2639
- const ndcY = -(y / h) * 2 + 1;
2640
- const near = new THREE.Vector3(ndcX, ndcY, -1).unproject(this.camera);
2641
- const far = new THREE.Vector3(ndcX, ndcY, 1).unproject(this.camera);
2642
- const dir = far.clone().sub(near).normalize();
2643
- return new THREE.Ray(near, dir);
2644
- }
2645
- _intersectMeshAtRay(mesh, ray, instanceIndex) {
2646
- const raycaster = new THREE.Raycaster();
2647
- raycaster.ray.copy(ray);
2648
- const hits = raycaster.intersectObject(mesh, false);
2649
- if (!hits.length)
2650
- return null;
2651
- if (instanceIndex != null) {
2652
- const match = hits.find((h) => h.instanceId === instanceIndex);
2653
- return match ? match.point : hits[0].point;
2654
- }
2655
- return hits[0].point;
2656
- }
2657
- _buildPickResult(hit, ref, exactPoint, ray) {
2658
- const ud = ref.mesh.userData;
2659
- // 泼溅走独立分支:它既没有 instanceMatrix,也没有 geoRef.pickInfo 里的单体表,
2660
- // 走下面那段会在 `_instanceFeature()` 拿到 null 直接返回 —— 表现是「点得中但拿不到结果」。
2661
- if (ud.isGaussianSplat)
2662
- return this._buildSplatPickResult(hit, ref, exactPoint, ray);
2663
- let smid = null;
2664
- let batchId = hit.batchId;
2665
- let instanceIndex = hit.instanceIndex;
2666
- let geoRef = ud.geoRef;
2667
- let segments = [];
2668
- if (instanceIndex != null) {
2669
- const feature = this._instanceFeature(ref.mesh, geoRef, instanceIndex);
2670
- if (!feature)
2671
- return null;
2672
- smid = feature.smid;
2673
- batchId = feature.batchId;
2674
- }
2675
- else {
2676
- geoRef = ud.geoByBatchId?.get(hit.batchId) ?? ud.geoRef;
2677
- const resolved = resolveFeature(geoRef, hit.batchId);
2678
- smid = resolved.smid;
2679
- segments = resolved.segments;
2680
- }
2681
- if (this._isFeatureHidden(smid, batchId))
2682
- return null;
2683
- let box = null;
2684
- let totalTriangles = 0;
2685
- if (instanceIndex != null) {
2686
- box = this._instanceBox(ref.mesh, instanceIndex);
2687
- totalTriangles = ref.mesh.geometry.index ? Math.round(ref.mesh.geometry.index.count / 3) : 0;
2688
- }
2689
- else if (smid != null && this._smidMeshes.has(smid)) {
2690
- const meshes = this._smidMeshes.get(smid);
2691
- let minX = Infinity;
2692
- let minY = Infinity;
2693
- let minZ = Infinity;
2694
- let maxX = -Infinity;
2695
- let maxY = -Infinity;
2696
- let maxZ = -Infinity;
2697
- let count = 0;
2698
- for (const m of meshes) {
2699
- const mUd = m.userData;
2700
- const mPick = mUd.geoRef?.pickInfo;
2701
- const entry = mPick?.[smid] ??
2702
- mPick?.[String(smid)];
2703
- const bId = entry ? pickEntryId(entry) : -1;
2704
- const mSegs = entry?.segments ?? (bId >= 0 ? resolveFeature(mUd.geoRef, bId).segments : []);
2705
- const mBox = measureSegments(m.geometry, mSegs);
2706
- if (mBox) {
2707
- if (mBox.minX < minX)
2708
- minX = mBox.minX;
2709
- if (mBox.minY < minY)
2710
- minY = mBox.minY;
2711
- if (mBox.minZ < minZ)
2712
- minZ = mBox.minZ;
2713
- if (mBox.maxX > maxX)
2714
- maxX = mBox.maxX;
2715
- if (mBox.maxY > maxY)
2716
- maxY = mBox.maxY;
2717
- if (mBox.maxZ > maxZ)
2718
- maxZ = mBox.maxZ;
2719
- count += mBox.count;
2720
- totalTriangles += mBox.triangles;
2721
- }
2722
- }
2723
- if (count > 0) {
2724
- box = { minX, minY, minZ, maxX, maxY, maxZ, count, triangles: totalTriangles };
2725
- }
2726
- }
2727
- if (!box) {
2728
- box = measureSegments(ref.mesh.geometry, segments);
2729
- if (!box && ref.mesh.geometry) {
2730
- if (!ref.mesh.geometry.boundingBox)
2731
- ref.mesh.geometry.computeBoundingBox();
2732
- const bb = ref.mesh.geometry.boundingBox;
2733
- if (bb) {
2734
- box = {
2735
- minX: bb.min.x,
2736
- maxX: bb.max.x,
2737
- minY: bb.min.y,
2738
- maxY: bb.max.y,
2739
- minZ: bb.min.z,
2740
- maxZ: bb.max.z,
2741
- count: 0,
2742
- triangles: 0,
2743
- };
2744
- }
2745
- }
2746
- }
2747
- const geometry = ref.mesh.geometry;
2748
- const triangles = totalTriangles > 0 ? totalTriangles : (geometry.index ? Math.round(geometry.index.count / 3) : 0);
2749
- const attributes = buildAttributes({ smid, box, triangles, floorHeight: this.style.floorHeight });
2750
- let centerLngLat = null;
2751
- if (box && this.sceneAnchor) {
2752
- const v = new THREE.Vector3((box.minX + box.maxX) / 2, (box.minY + box.maxY) / 2, (box.minZ + box.maxZ) / 2);
2753
- v.applyMatrix4(ref.mesh.matrixWorld);
2754
- const s = this.sceneAnchor.meterInMercator;
2755
- centerLngLat = this._lngLatFromWorld(v.x * s + this.sceneAnchor.x, -v.y * s + this.sceneAnchor.y);
2756
- }
2757
- // 精确命中点求交计算
2758
- let hitPoint = exactPoint ?? null;
2759
- if (!hitPoint && ray) {
2760
- hitPoint = this._intersectMeshAtRay(ref.mesh, ray, instanceIndex);
2761
- }
2762
- let point = null;
2763
- let lngLat = null;
2764
- let altitude = null;
2765
- if (hitPoint && this.sceneAnchor) {
2766
- const off = smid != null ? this._featureStyleManager.getFeatureEffectiveOffset(smid) : {};
2767
- const trans = off.translation;
2768
- const elev = off.elevation;
2769
- const finalPos = hitPoint.clone();
2770
- if (off.rotation) {
2771
- const rotMat = resolveRotationMatrix(off.rotation);
2772
- if (rotMat) {
2773
- const m3 = new THREE.Matrix3().set(rotMat[0], rotMat[1], rotMat[2], rotMat[3], rotMat[4], rotMat[5], rotMat[6], rotMat[7], rotMat[8]);
2774
- const piv = off.pivot ? new THREE.Vector3(off.pivot[0], off.pivot[1], off.pivot[2]) : new THREE.Vector3(0, 0, 0);
2775
- finalPos.sub(piv).applyMatrix3(m3).add(piv);
2776
- }
2777
- }
2778
- if (trans)
2779
- finalPos.add(new THREE.Vector3(trans[0], trans[1], trans[2]));
2780
- else if (elev)
2781
- finalPos.z += elev;
2782
- point = [finalPos.x, finalPos.y, finalPos.z];
2783
- const s = this.sceneAnchor.meterInMercator;
2784
- lngLat = this._lngLatFromWorld(finalPos.x * s + this.sceneAnchor.x, -finalPos.y * s + this.sceneAnchor.y);
2785
- altitude = finalPos.z + (this.sceneAnchor.altitude ?? 0);
2786
- }
2787
- else if (centerLngLat && box && this.sceneAnchor) {
2788
- lngLat = centerLngLat;
2789
- altitude = (box.minZ + box.maxZ) / 2 + (this.sceneAnchor.altitude ?? 0);
2790
- }
2791
- return {
2792
- datasetId: ref.datasetId,
2793
- datasetName: this.datasets.get(ref.datasetId)?.name ?? ref.datasetId,
2794
- objectId: hit.objectId,
2795
- batchId,
2796
- instanceIndex,
2797
- skeletonName: ref.skeletonName,
2798
- centerLngLat,
2799
- point,
2800
- lngLat,
2801
- altitude,
2802
- ...attributes,
2803
- mesh: ref.mesh,
2804
- };
2805
- }
2806
- /**
2807
- * 泼溅的拾取结果。
2808
- *
2809
- * 整块泼溅瓦片当一个单体(没有单体级 smid 可报),命中点由 `splatRaycast` 给出 ——
2810
- * 它按每个高斯的 3σ 球求交,比拾取那一趟为了命中率摊开的 4px 方块准得多。
2811
- * 包围盒必须由 `iCenter` 算:geometry 的 position 只是 [-1,1]² 的四边形角点。
2812
- */
2813
- _buildSplatPickResult(hit, ref, exactPoint, ray) {
2814
- const anchor = this.sceneAnchor;
2815
- if (!anchor)
2816
- return null;
2817
- const mesh = ref.mesh;
2818
- let hitPoint = exactPoint ?? null;
2819
- if (!hitPoint && ray)
2820
- hitPoint = this._intersectMeshAtRay(mesh, ray);
2821
- const local = splatCenterBounds(mesh.geometry);
2822
- const box = local
2823
- ? {
2824
- minX: local.minX,
2825
- minY: local.minY,
2826
- minZ: local.minZ,
2827
- maxX: local.maxX,
2828
- maxY: local.maxY,
2829
- maxZ: local.maxZ,
2830
- count: local.count,
2831
- triangles: 0,
2832
- }
2833
- : null;
2834
- const attributes = buildAttributes({
2835
- smid: null,
2836
- box,
2837
- triangles: 0,
2838
- floorHeight: this.style.floorHeight,
2839
- });
2840
- const s = anchor.meterInMercator;
2841
- let point = null;
2842
- let lngLat = null;
2843
- let altitude = null;
2844
- let centerLngLat = null;
2845
- if (hitPoint) {
2846
- point = [hitPoint.x, hitPoint.y, hitPoint.z];
2847
- lngLat = this._lngLatFromWorld(hitPoint.x * s + anchor.x, -hitPoint.y * s + anchor.y);
2848
- altitude = hitPoint.z + (anchor.altitude ?? 0);
2849
- // 泼溅命中的就是一个高斯,没有「单体中心」可言,用命中点当中心
2850
- centerLngLat = lngLat;
2851
- }
2852
- else if (box) {
2853
- const v = new THREE.Vector3((box.minX + box.maxX) / 2, (box.minY + box.maxY) / 2, (box.minZ + box.maxZ) / 2).applyMatrix4(mesh.matrixWorld);
2854
- centerLngLat = this._lngLatFromWorld(v.x * s + anchor.x, -v.y * s + anchor.y);
2855
- altitude = v.z + (anchor.altitude ?? 0);
2856
- }
2857
- return {
2858
- datasetId: ref.datasetId,
2859
- datasetName: this.datasets.get(ref.datasetId)?.name ?? ref.datasetId,
2860
- objectId: hit.objectId,
2861
- batchId: -1,
2862
- instanceIndex: undefined,
2863
- skeletonName: ref.skeletonName,
2864
- centerLngLat,
2865
- point,
2866
- lngLat,
2867
- altitude,
2868
- ...attributes,
2869
- mesh,
2870
- };
1963
+ return this._picks.getRayAt(x, y);
2871
1964
  }
2872
- /** 按 canvas 左上角的 CSS 像素坐标拾取。 */
1965
+ /** 按 canvas 左上角的 CSS 像素坐标拾取 */
2873
1966
  pick(x, y) {
2874
- if (!this.picker || !this.scene || !this.camera || !this.sceneAnchor)
2875
- return null;
2876
- const hit = this.picker.pick(this.scene, this.camera, x, y);
2877
- if (!hit)
2878
- return null;
2879
- const ref = this._pickables.get(hit.objectId);
2880
- if (!ref)
2881
- return null;
2882
- const ray = this.getRayAt(x, y);
2883
- return this._buildPickResult(hit, ref, undefined, ray ?? undefined);
1967
+ return this._picks.pick(x, y);
2884
1968
  }
2885
- /**
2886
- * 拾取指定屏幕像素对应的世界坐标与场景位置。
2887
- * 优先与命中的建筑网格精确求交;未命中建筑时若视线与地表相交则返回地面交点。
2888
- */
1969
+ /** 拾取并返回经纬度 + 高程 */
2889
1970
  pickPosition(x, y) {
2890
- if (!this.camera || !this.sceneAnchor)
2891
- return null;
2892
- const hit = this.pick(x, y);
2893
- if (hit?.point && hit?.lngLat && hit.altitude != null) {
2894
- return {
2895
- lngLat: hit.lngLat,
2896
- altitude: hit.altitude,
2897
- point: hit.point,
2898
- };
2899
- }
2900
- const ray = this.getRayAt(x, y);
2901
- if (!ray)
2902
- return null;
2903
- const groundPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
2904
- const target = new THREE.Vector3();
2905
- const intersect = ray.intersectPlane(groundPlane, target);
2906
- if (intersect) {
2907
- const s = this.sceneAnchor.meterInMercator;
2908
- const lngLat = this._lngLatFromWorld(intersect.x * s + this.sceneAnchor.x, -intersect.y * s + this.sceneAnchor.y);
2909
- const altitude = intersect.z + (this.sceneAnchor.altitude ?? 0);
2910
- return {
2911
- lngLat,
2912
- altitude,
2913
- point: [intersect.x, intersect.y, intersect.z],
2914
- };
2915
- }
2916
- return null;
1971
+ return this._picks.pickPosition(x, y);
2917
1972
  }
2918
- /**
2919
- * 沿视线方向穿透拾取多个单体,按由近至远排序返回。
2920
- */
1973
+ /** 穿透拾取:沿射线返回前 `limit` 个命中 */
2921
1974
  drillPick(x, y, limit = 10) {
2922
- if (!this.sceneAnchor || !this.camera)
2923
- return [];
2924
- const ray = this.getRayAt(x, y);
2925
- if (!ray)
2926
- return [];
2927
- const raycaster = new THREE.Raycaster();
2928
- raycaster.ray.copy(ray);
2929
- const meshes = [...this._pickables.values()]
2930
- .map((p) => p.mesh)
2931
- .filter((m) => {
2932
- let curr = m;
2933
- while (curr) {
2934
- if (!curr.visible)
2935
- return false;
2936
- curr = curr.parent;
2937
- }
2938
- return true;
2939
- });
2940
- const hits = raycaster.intersectObjects(meshes, false);
2941
- if (!hits.length)
2942
- return [];
2943
- const results = [];
2944
- const seenSmids = new Set();
2945
- const seenBatches = new Set();
2946
- for (const hit of hits) {
2947
- if (results.length >= limit)
2948
- break;
2949
- const mesh = hit.object;
2950
- const ud = mesh.userData;
2951
- if (ud.pickId == null)
2952
- continue;
2953
- const ref = this._pickables.get(ud.pickId);
2954
- if (!ref)
2955
- continue;
2956
- let smid = null;
2957
- let batchId = -1;
2958
- let instanceIndex = hit.instanceId;
2959
- if (instanceIndex != null) {
2960
- const feature = this._instanceFeature(mesh, ud.geoRef, instanceIndex);
2961
- if (!feature)
2962
- continue;
2963
- smid = feature.smid;
2964
- batchId = feature.batchId;
2965
- }
2966
- else if (hit.faceIndex != null) {
2967
- const batchAttr = mesh.geometry.attributes.aBatchId;
2968
- if (batchAttr) {
2969
- const idx = mesh.geometry.index ? mesh.geometry.index.getX(hit.faceIndex * 3) : hit.faceIndex * 3;
2970
- batchId = Math.round(batchAttr.getX(idx));
2971
- const geoRef = ud.geoByBatchId?.get(batchId) ?? ud.geoRef;
2972
- const resolved = resolveFeature(geoRef, batchId);
2973
- smid = resolved.smid;
2974
- }
2975
- }
2976
- if (smid != null) {
2977
- if (seenSmids.has(smid))
2978
- continue;
2979
- seenSmids.add(smid);
2980
- }
2981
- else {
2982
- const key = `${ud.pickId}:${batchId}:${instanceIndex ?? -1}`;
2983
- if (seenBatches.has(key))
2984
- continue;
2985
- seenBatches.add(key);
2986
- }
2987
- if (this._isFeatureHidden(smid, batchId))
2988
- continue;
2989
- const pickHit = {
2990
- objectId: ud.pickId,
2991
- batchId,
2992
- instanceIndex,
2993
- };
2994
- const res = this._buildPickResult(pickHit, ref, hit.point);
2995
- if (res)
2996
- results.push(res);
2997
- }
2998
- return results;
1975
+ return this._picks.drillPick(x, y, limit);
2999
1976
  }
3000
- /**
3001
- * 获取指定 SMID 单体的经纬度包围盒 [[minLng, minLat], [maxLng, maxLat]]。
3002
- * 若模型尚未加载到场景中或无此单体则返回 null。
3003
- */
1977
+ /** 单体的经纬度包围盒 */
3004
1978
  getFeatureBounds(smid) {
3005
- if (!this.sceneAnchor)
3006
- return null;
3007
- let minLng = Infinity;
3008
- let maxLng = -Infinity;
3009
- let minLat = Infinity;
3010
- let maxLat = -Infinity;
3011
- let found = false;
3012
- for (const ref of this._pickables.values()) {
3013
- const mesh = ref.mesh;
3014
- const ud = mesh.userData;
3015
- const geoRef = ud.geoRef;
3016
- const pickInfo = geoRef?.pickInfo;
3017
- if (!pickInfo)
3018
- continue;
3019
- let box = null;
3020
- if (mesh.isInstancedMesh) {
3021
- let lookup = ud.instanceLookup;
3022
- if (!lookup) {
3023
- lookup = buildInstanceLookup(pickInfo);
3024
- ud.instanceLookup = lookup;
3025
- }
3026
- for (const [instIdx, feat] of lookup) {
3027
- if (feat.smid === smid) {
3028
- box = this._instanceBox(mesh, instIdx);
3029
- break;
3030
- }
3031
- }
3032
- }
3033
- else {
3034
- const infoMap = pickInfo;
3035
- const entry = infoMap[smid] ?? infoMap[String(smid)];
3036
- if (entry) {
3037
- const bId = pickEntryId(entry);
3038
- const resolved = resolveFeature(geoRef, bId);
3039
- box = measureSegments(mesh.geometry, resolved.segments);
3040
- if (!box && mesh.geometry) {
3041
- if (!mesh.geometry.boundingBox)
3042
- mesh.geometry.computeBoundingBox();
3043
- const bb = mesh.geometry.boundingBox;
3044
- if (bb) {
3045
- box = {
3046
- minX: bb.min.x,
3047
- maxX: bb.max.x,
3048
- minY: bb.min.y,
3049
- maxY: bb.max.y,
3050
- minZ: bb.min.z,
3051
- maxZ: bb.max.z,
3052
- count: 0,
3053
- triangles: 0,
3054
- };
3055
- }
3056
- }
3057
- }
3058
- }
3059
- if (box) {
3060
- found = true;
3061
- const corners = [
3062
- new THREE.Vector3(box.minX, box.minY, box.minZ),
3063
- new THREE.Vector3(box.maxX, box.minY, box.minZ),
3064
- new THREE.Vector3(box.minX, box.maxY, box.minZ),
3065
- new THREE.Vector3(box.maxX, box.maxY, box.minZ),
3066
- new THREE.Vector3(box.minX, box.minY, box.maxZ),
3067
- new THREE.Vector3(box.maxX, box.minY, box.maxZ),
3068
- new THREE.Vector3(box.minX, box.maxY, box.maxZ),
3069
- new THREE.Vector3(box.maxX, box.maxY, box.maxZ),
3070
- ];
3071
- const off = smid != null ? this._featureStyleManager.getFeatureEffectiveOffset(smid) : {};
3072
- const trans = off.translation;
3073
- const elev = off.elevation;
3074
- const rotMat = off.rotation ? resolveRotationMatrix(off.rotation) : null;
3075
- let rotM3 = null;
3076
- let rotPiv = null;
3077
- if (rotMat) {
3078
- rotM3 = new THREE.Matrix3().set(rotMat[0], rotMat[1], rotMat[2], rotMat[3], rotMat[4], rotMat[5], rotMat[6], rotMat[7], rotMat[8]);
3079
- rotPiv = off.pivot ? new THREE.Vector3(off.pivot[0], off.pivot[1], off.pivot[2]) : new THREE.Vector3(0, 0, 0);
3080
- }
3081
- // GPU 路径:位移发生在顶点着色器里,包围盒要按同一套公式在 CPU 复算,
3082
- // 否则「运动中的车」的包围盒会停在路径原点(相机 fitBounds / 属性过滤都会错)。
3083
- const gpuPose = smid != null ? this._gpuPathPoseAt(smid) : null;
3084
- let headM3 = null;
3085
- if (gpuPose && gpuPose.yaw !== 0) {
3086
- const c = Math.cos(gpuPose.yaw);
3087
- const s = Math.sin(gpuPose.yaw);
3088
- // 与着色器的 mat3( c, s, 0, -s, c, 0, 0, 0, 1 ) 等价:GLSL 是列优先,
3089
- // three 的 Matrix3.set 是行优先,所以这里写成 (c, -s; s, c) 才是同一个矩阵。
3090
- headM3 = new THREE.Matrix3().set(c, -s, 0, s, c, 0, 0, 0, 1);
3091
- }
3092
- for (const c of corners) {
3093
- if (rotM3 && rotPiv)
3094
- c.sub(rotPiv).applyMatrix3(rotM3).add(rotPiv);
3095
- if (headM3) {
3096
- const piv = rotPiv ?? new THREE.Vector3(0, 0, 0);
3097
- c.sub(piv).applyMatrix3(headM3).add(piv);
3098
- }
3099
- if (trans)
3100
- c.add(new THREE.Vector3(trans[0], trans[1], trans[2]));
3101
- else if (elev)
3102
- c.z += elev;
3103
- if (gpuPose)
3104
- c.add(new THREE.Vector3(gpuPose.pos[0], gpuPose.pos[1], gpuPose.pos[2]));
3105
- c.applyMatrix4(mesh.matrixWorld);
3106
- const s = this.sceneAnchor.meterInMercator;
3107
- const [lng, lat] = this._lngLatFromWorld(c.x * s + this.sceneAnchor.x, -c.y * s + this.sceneAnchor.y);
3108
- if (lng < minLng)
3109
- minLng = lng;
3110
- if (lng > maxLng)
3111
- maxLng = lng;
3112
- if (lat < minLat)
3113
- minLat = lat;
3114
- if (lat > maxLat)
3115
- maxLat = lat;
3116
- }
3117
- }
3118
- }
3119
- if (!found)
3120
- return null;
3121
- return [
3122
- [minLng, minLat],
3123
- [maxLng, maxLat],
3124
- ];
1979
+ return this._picks.getFeatureBounds(smid);
3125
1980
  }
3126
- /** 实例序号 → 所属单体(按网格缓存反查表,实例多的数据不用每次重建) */
1981
+ /** @internal 见 viewer/PickService.ts */
1982
+ _intersectMeshAtRay(mesh, ray, instanceIndex) {
1983
+ return this._picks.intersectMeshAtRay(mesh, ray, instanceIndex);
1984
+ }
1985
+ /** @internal 见 viewer/PickService.ts */
1986
+ _buildPickResult(hit, ref, exactPoint, ray) {
1987
+ return this._picks.buildPickResult(hit, ref, exactPoint, ray);
1988
+ }
1989
+ /** @internal 见 viewer/PickService.ts */
1990
+ _buildSplatPickResult(hit, ref, exactPoint, ray) {
1991
+ return this._picks.buildSplatPickResult(hit, ref, exactPoint, ray);
1992
+ }
1993
+ /** @internal 见 viewer/PickService.ts */
3127
1994
  _instanceFeature(mesh, geoRef, instanceIndex) {
3128
- const ud = mesh.userData;
3129
- if (!ud.instanceLookup)
3130
- ud.instanceLookup = buildInstanceLookup(geoRef?.pickInfo);
3131
- return ud.instanceLookup.get(instanceIndex) ?? null;
1995
+ return this._picks.instanceFeature(mesh, geoRef, instanceIndex);
3132
1996
  }
3133
- /** 实例化几何的单体包围盒:base mesh 的包围盒套上该实例的矩阵 */
1997
+ /** @internal 见 viewer/PickService.ts */
3134
1998
  _instanceBox(mesh, instanceIndex) {
3135
- const im = mesh;
3136
- if (!im.isInstancedMesh)
3137
- return null;
3138
- const geometry = mesh.geometry;
3139
- if (!geometry.boundingBox)
3140
- geometry.computeBoundingBox();
3141
- const bb = geometry.boundingBox;
3142
- if (!bb)
3143
- return null;
3144
- const m = new THREE.Matrix4();
3145
- im.getMatrixAt(instanceIndex, m);
3146
- const box = bb.clone().applyMatrix4(m);
3147
- return {
3148
- minX: box.min.x,
3149
- minY: box.min.y,
3150
- minZ: box.min.z,
3151
- maxX: box.max.x,
3152
- maxY: box.max.y,
3153
- maxZ: box.max.z,
3154
- count: geometry.getAttribute('position')?.count ?? 0,
3155
- triangles: geometry.index ? Math.round(geometry.index.count / 3) : 0,
3156
- };
1999
+ return this._picks.instanceBox(mesh, instanceIndex);
3157
2000
  }
3158
- /** 选中/取消选中单体。`silent` 为 true 时不回调 onPick。 */
3159
- select(result, { silent = false } = {}) {
3160
- // 清掉上一次
3161
- if (this._selection) {
3162
- for (const obj of this._selectedMeshes) {
3163
- // 点云:恢复成当前着色模式(去掉选中混色)
3164
- if (isPointCloudObject(obj)) {
3165
- this._applyPointCloudStyle(obj, false);
3166
- continue;
3167
- }
3168
- const mat = styledMaterialOf(obj);
3169
- if (mat) {
3170
- mat.userData.uSelBatch.value = -1;
3171
- mat.userData.uSelWhole.value = 0;
3172
- }
3173
- }
3174
- this._selectedMeshes = [];
3175
- this._clearSelectionDecoration();
3176
- this._selection = null;
3177
- }
3178
- if (!result) {
3179
- this.uniforms.uSelMix.value = 0;
3180
- this.uniforms.uDim.value = 0;
3181
- this.outlineMaterial.uniforms.uSelBatch.value = -1;
3182
- this.outlineMaterial.uniforms.uSelWhole.value = 0;
3183
- this.outlineMaterial.uniforms.uElevation.value = 0;
3184
- this._requestRepaint();
3185
- if (!silent)
3186
- this.onPick(null);
3187
- return;
3188
- }
3189
- this._selection = result;
3190
- const targetMeshes = new Set();
3191
- if (result.smid != null) {
3192
- const smidSet = this._smidMeshes.get(result.smid);
3193
- if (smidSet) {
3194
- for (const m of smidSet)
3195
- targetMeshes.add(m);
3196
- }
3197
- }
3198
- targetMeshes.add(result.mesh);
3199
- for (const mesh of targetMeshes) {
3200
- const mat = styledMaterialOf(mesh);
3201
- if (!mat)
3202
- continue;
3203
- let batchId = result.batchId;
3204
- if (result.instanceIndex != null && mesh === result.mesh) {
3205
- batchId = result.batchId;
3206
- }
3207
- else if (result.smid != null) {
3208
- const ud = mesh.userData;
3209
- const pickInfo = ud.geoRef?.pickInfo;
3210
- const entry = pickInfo?.[result.smid] ??
3211
- pickInfo?.[String(result.smid)];
3212
- const bId = entry ? pickEntryId(entry) : -1;
3213
- batchId = bId >= 0 ? bId : (mesh === result.mesh ? result.batchId : -1);
3214
- }
3215
- mat.userData.uSelBatch.value = batchId;
3216
- // 没有单体化信息时整块高亮,否则会出现「点中了但什么都没亮、其他全暗了」
3217
- mat.userData.uSelWhole.value = batchId < 0 ? 1 : 0;
3218
- this._selectedMeshes.push(mesh);
3219
- }
3220
- // 点云没有单体粒度:整块混成 selectColor,让选中至少看得见
3221
- for (const mesh of this._selectedMeshes) {
3222
- if (isPointCloudObject(mesh))
3223
- this._applyPointCloudStyle(mesh, true);
3224
- }
3225
- this.uniforms.uSelMix.value = 1;
3226
- this.uniforms.uDim.value = this.style.dimOthers ? 1 : 0;
3227
- if (this.style.outline) {
3228
- // 棱线 / 外扩轮廓都是按「面」设计的,点云画不出来也不该画
3229
- const decoMeshes = [...targetMeshes].filter((m) => !isPointCloudObject(m));
3230
- if (decoMeshes.length) {
3231
- if ((this.style.outlineStyle ?? 'edges') === 'edges') {
3232
- this._addSelectionEdges(result, decoMeshes);
3233
- }
3234
- else {
3235
- this._addSelectionHull(result, decoMeshes);
3236
- }
3237
- }
3238
- }
3239
- this._requestRepaint();
2001
+ /** @internal 拾取 id 池,见 viewer/PickService.ts */
2002
+ _allocPickId() {
2003
+ return this._picks.allocPickId();
2004
+ }
2005
+ /** @internal */
2006
+ _releasePickId(id) {
2007
+ this._picks.releasePickId(id);
3240
2008
  }
2009
+ /** @internal 拾取表(测试直接读写),见 viewer/PickService.ts */
2010
+ get _pickables() {
2011
+ return this._picks.pickables;
2012
+ }
2013
+ /* ---------------------------------------------------------------- 选中 */
3241
2014
  /**
3242
- * 选中单体的棱线:只挑这个单体自己的三角面算折边,再画成屏幕空间等宽线。
3243
- * 抽出来的线段数很少(一栋楼通常几百到几千条),所以选中开销可以忽略。
2015
+ * 选中一个单体(或整块网格),高亮与轮廓装饰见 viewer/SelectionDecorator.ts。
2016
+ * 传 `null` 清空选中;`silent` 为真时不触发 `onPick`。
3244
2017
  */
3245
- _addSelectionEdges(result, meshes) {
3246
- this._syncSelectionStyle();
3247
- for (const mesh of meshes) {
3248
- const ud = mesh.userData;
3249
- const geoRef = ud.geoRef;
3250
- let segments = [];
3251
- // 这块网格上的棱线全部属于选中的那个单体,整批填同一个 batchId,
3252
- // 顶点着色器才能从状态纹理里取到平移 / 拔高——选中跟随移动靠它。
3253
- let batchId = -1;
3254
- if (mesh === result.mesh && result.instanceIndex != null) {
3255
- // 实例化几何没有逐单体的顶点区间,整个 base mesh 就是这一栋的外形
3256
- segments = [{ vertexOffset: 0, vertexCount: mesh.geometry.getAttribute('position')?.count ?? 0 }];
3257
- const pickInfo = geoRef?.pickInfo;
3258
- const entry = pickInfo?.[result.smid ?? ''] ??
3259
- pickInfo?.[String(result.smid)];
3260
- batchId = entry ? pickEntryId(entry) : -1;
3261
- }
3262
- else if (result.smid != null) {
3263
- const pickInfo = geoRef?.pickInfo;
3264
- const entry = pickInfo?.[result.smid] ??
3265
- pickInfo?.[String(result.smid)];
3266
- const bId = entry ? pickEntryId(entry) : -1;
3267
- batchId = bId;
3268
- segments = entry?.segments ?? (bId >= 0 ? resolveFeature(geoRef, bId).segments : []);
3269
- }
3270
- else if (mesh === result.mesh) {
3271
- const localGeo = ud.geoByBatchId?.get(result.batchId) ?? geoRef;
3272
- segments = resolveFeature(localGeo, result.batchId).segments;
3273
- batchId = result.batchId;
3274
- }
3275
- const pairs = buildFeatureEdgeSegments(mesh.geometry, segments);
3276
- if (pairs && pairs.length > 0) {
3277
- const batchIds = batchId >= 0 ? new Float32Array(pairs.length / 6).fill(batchId) : undefined;
3278
- const lines = new THREE.Mesh(buildThickLineGeometry(pairs, batchIds), this._createSelectionEdgeMaterial(mesh));
3279
- lines.renderOrder = 4;
3280
- // 屏幕空间外扩后可能超出原包围球,关掉剔除更稳
3281
- lines.frustumCulled = false;
3282
- lines.raycast = () => { };
3283
- lines.userData.isSelectionEdge = true;
3284
- if (mesh === result.mesh && result.instanceIndex != null) {
3285
- this._applyInstanceTransform(lines, result);
3286
- }
3287
- mesh.add(lines); // 作为子节点,直接继承世界变换
3288
- this._selectionEdgeMeshes.push(lines);
3289
- }
3290
- }
3291
- if (this._selectionEdgeMeshes.length === 0) {
3292
- // 抽不出折边(退化几何 / 没有任何夹角)时退回外扩轮廓,至少保证选中可见
3293
- this._addSelectionHull(result, meshes);
3294
- return;
3295
- }
3296
- this._selectionEdges = this._selectionEdgeMeshes[0] ?? null;
3297
- }
3298
- /** 实例化几何:把某个实例的矩阵套到一个普通 Mesh/子节点上(子节点不参与父级的 instanceMatrix) */
3299
- _applyInstanceTransform(obj, result) {
3300
- const im = result.mesh;
3301
- if (result.instanceIndex == null || !im.isInstancedMesh)
3302
- return;
3303
- const m = new THREE.Matrix4();
3304
- im.getMatrixAt(result.instanceIndex, m);
3305
- m.decompose(obj.position, obj.quaternion, obj.scale);
3306
- }
3307
- /** 旧的描边画法:背面外扩法,整块几何一起上,靠 uSelBatch 只显示选中那栋 */
3308
- _addSelectionHull(result, meshes) {
3309
- const instanced = result.instanceIndex != null;
3310
- // 实例化:轮廓是这个实例的单独一份副本(普通 Mesh),所以整份都画;
3311
- // 顶点区间型:整块几何一起画,靠 uSelBatch 只显示选中那栋。
3312
- this.outlineMaterial.uniforms.uSelBatch.value = instanced ? -1 : result.batchId;
3313
- this.outlineMaterial.uniforms.uSelWhole.value = instanced || result.batchId < 0 ? 1 : 0;
3314
- this.outlineMaterial.uniforms.uElevation.value = instanced
3315
- ? this._selectionElevation(result)
3316
- : this.style.selectElevation ?? 0;
3317
- for (const mesh of meshes) {
3318
- const mat = styledMaterialOf(mesh);
3319
- if (mat?.userData?.uBatchTex) {
3320
- this.outlineMaterial.uniforms.uBatchTex.value = mat.userData.uBatchTex.value;
3321
- this.outlineMaterial.uniforms.uBatchTexWidth.value = mat.userData.uBatchTexWidth.value;
3322
- this.outlineMaterial.uniforms.uBatchTexActive.value = mat.userData.uBatchTexActive.value;
3323
- }
3324
- const outline = new THREE.Mesh(mesh.geometry, this.outlineMaterial);
3325
- outline.renderOrder = 3;
3326
- outline.frustumCulled = false;
3327
- outline.raycast = () => { };
3328
- if (mesh === result.mesh && result.instanceIndex != null) {
3329
- this._applyInstanceTransform(outline, result);
3330
- }
3331
- mesh.add(outline); // 作为子节点,直接继承世界变换
3332
- this._outlineMeshes.push(outline);
3333
- }
3334
- this._outlineMesh = this._outlineMeshes[0] ?? null;
2018
+ select(result, opts = {}) {
2019
+ this._sel.select(result, opts);
3335
2020
  }
3336
- /** 只改 uniform:颜色 / 粗细 / 拔高,拖着滑块实时生效,不重建几何 */
2021
+ /** @internal 选中外观跟随样式变化,见 viewer/SelectionDecorator.ts */
3337
2022
  _syncSelectionStyle() {
3338
- const s = this.style;
3339
- const u = this.selectionEdgeMaterial.uniforms;
3340
- u.uColor.value.set(s.outlineColor ?? s.selectColor);
3341
- u.uWidth.value = Math.max(0.1, s.outlineWidthPx ?? 3);
3342
- u.uOpacity.value = clampOpacity(s.opacity);
3343
- // 只加全局 selectElevation:单体自己的拔高已经写在状态纹理第 2 行 z 里,
3344
- // 顶点着色器采样时带出来了,这里再加一次就会双重拔高
3345
- if (u.uElevation)
3346
- u.uElevation.value = s.selectElevation ?? 0;
3347
- // 外扩描边是另一个材质实例,单体状态纹理的开关是建的时候抄过去的。
3348
- // 先选中、再抬高(或反过来)时模板会停留在旧值,这里补一次同步。
3349
- for (const outline of this._outlineMeshes) {
3350
- const parent = outline.parent;
3351
- const ud = parent ? styledMaterialOf(parent)?.userData : null;
3352
- if (ud?.uBatchTex) {
3353
- this.outlineMaterial.uniforms.uBatchTex.value = ud.uBatchTex.value;
3354
- this.outlineMaterial.uniforms.uBatchTexWidth.value = ud.uBatchTexWidth.value;
3355
- this.outlineMaterial.uniforms.uBatchTexActive.value = ud.uBatchTexActive.value;
3356
- }
3357
- }
2023
+ this._sel.syncStyle();
3358
2024
  }
3359
- /** 选中单体的实际拔高 = 全局选中拔高 + 该单体的独立拔高(与着色器口径一致) */
3360
- _selectionElevation(result) {
3361
- let elev = this.style.selectElevation ?? 0;
3362
- if (result.smid != null) {
3363
- const off = this._featureStyleManager.getFeatureEffectiveOffset(result.smid);
3364
- if (off.elevation)
3365
- elev += off.elevation;
3366
- }
3367
- return elev;
2025
+ /** @internal 测试用:当前选中 */
2026
+ get _selection() {
2027
+ return this._sel.selection;
3368
2028
  }
3369
- _clearSelectionDecoration() {
3370
- for (const outline of this._outlineMeshes) {
3371
- outline.parent?.remove(outline);
3372
- }
3373
- this._outlineMeshes = [];
3374
- this._outlineMesh = null;
3375
- for (const edges of this._selectionEdgeMeshes) {
3376
- edges.parent?.remove(edges);
3377
- edges.geometry.dispose();
3378
- }
3379
- this._selectionEdgeMeshes = [];
3380
- this._selectionEdges = null;
2029
+ /** @internal 测试用:被高亮的网格 */
2030
+ get _selectedMeshes() {
2031
+ return this._sel.selectedMeshes;
2032
+ }
2033
+ /** @internal 测试用:选中棱线的网格列表 */
2034
+ get _selectionEdgeMeshes() {
2035
+ return this._sel.selectionEdgeMeshes;
3381
2036
  }
3382
2037
  /* ---------------------------------------------------------------- 渲染 */
3383
2038
  /**
@@ -3386,30 +2041,6 @@ export class S3MViewer {
3386
2041
  * 参数全部来自 style,不缓存:`edgeWidth` 在这里是「采样偏移」,线宽 ≈ 2×它;
3387
2042
  * `edgeThreshold` 是折边敏感度(夹角小于它不描),和 CPU 折边线同一口径。
3388
2043
  */
3389
- _renderGpuEdges(canvas) {
3390
- if (!this.renderer || !this.scene || !this.camera)
3391
- return;
3392
- const s = this.style;
3393
- const fade = this.uniforms.uEdgeFade?.value ?? 1;
3394
- const opacity = Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) * clampOpacity(s.opacity) * fade;
3395
- if (opacity <= 0.004)
3396
- return;
3397
- if (!this._gpuEdge)
3398
- this._gpuEdge = new GpuEdgePass();
3399
- if (!this._gpuEdge.supported(this.renderer))
3400
- return;
3401
- // 度数 → 归一化法线差(|Δn| = 2·sin(θ/2)),乘 0.6 让阈值落在「刚好能描出」的位置
3402
- const deg = Math.min(179, Math.max(1, s.edgeThreshold ?? 55));
3403
- const normalThreshold = 2 * Math.sin((deg * Math.PI) / 360) * 0.6;
3404
- this._gpuEdge.render(this.renderer, this.scene, this.camera, canvas.width, canvas.height, {
3405
- color: this._gpuEdgeColor.set(s.edgeColor),
3406
- opacity,
3407
- radius: Math.max(1, s.edgeWidth ?? 1.5),
3408
- normalThreshold,
3409
- depthThreshold: 0.08,
3410
- });
3411
- }
3412
- /** 等宽线要按设备像素换算线宽:uResolution 用绘图缓冲尺寸,uDpr 是缓冲 / CSS 的比值 */
3413
2044
  _updateLineResolution(canvas) {
3414
2045
  const w = canvas.width || 1;
3415
2046
  const h = canvas.height || 1;
@@ -3502,6 +2133,30 @@ export class S3MViewer {
3502
2133
  }
3503
2134
  }
3504
2135
  }
2136
+ /** 当前每个数据集的驻留显存/内存字节预算。Infinity = 不限制 */
2137
+ get maxMemory() {
2138
+ return this.options.maxMemory ?? Infinity;
2139
+ }
2140
+ /**
2141
+ * 调整每个数据集驻留显存字节数的软上限(默认 Infinity = 不限制)。
2142
+ * 不传 `datasetId` 时对所有已存在数据集立即生效,并作为之后新增数据集的默认值。
2143
+ * 实际裁剪发生在每帧调度末尾(make-before-break),不会当场开洞。
2144
+ */
2145
+ setMaxMemory(bytes, datasetId) {
2146
+ const next = Number.isFinite(bytes) && bytes > 0 ? bytes : Infinity;
2147
+ if (datasetId) {
2148
+ const entry = this.datasets.get(datasetId);
2149
+ if (entry?.tileset)
2150
+ entry.tileset.setMaxMemory(next);
2151
+ }
2152
+ else {
2153
+ this.options.maxMemory = next;
2154
+ for (const entry of this.datasets.values()) {
2155
+ if (entry.tileset)
2156
+ entry.tileset.setMaxMemory(next);
2157
+ }
2158
+ }
2159
+ }
3505
2160
  /** 当前各向异性过滤上限。Infinity = 不封顶 */
3506
2161
  get anisotropyCap() {
3507
2162
  return this.options.anisotropyCap ?? Infinity;
@@ -3559,19 +2214,9 @@ export class S3MViewer {
3559
2214
  * 乱序混合就是满屏彩色噪点。相机没动且没有新瓦片时整个跳过;
3560
2215
  * 单个瓦片顺序本来就对时 sortSplatMesh 内部也会跳过重写。
3561
2216
  */
2217
+ /** @internal 泼溅按视深重排,见 viewer/SplatLayer.ts */
3562
2218
  _sortSplats() {
3563
- const cam = this.camera;
3564
- if (!cam)
3565
- return;
3566
- if (this._splatsSorted && this._lastSplatSortView.equals(cam.matrixWorldInverse))
3567
- return;
3568
- for (const pc of this._pointClouds) {
3569
- if (!pc.userData.isGaussianSplat)
3570
- continue;
3571
- sortSplatMesh(pc, cam.matrixWorldInverse);
3572
- }
3573
- this._lastSplatSortView.copy(cam.matrixWorldInverse);
3574
- this._splatsSorted = true;
2219
+ this._splats.sort(this.camera, this._pointClouds);
3575
2220
  }
3576
2221
  /**
3577
2222
  * 渲染一帧。所有宿主相关量都由调用方喂进来,viewer 不碰任何地图 API:
@@ -3655,8 +2300,7 @@ export class S3MViewer {
3655
2300
  this.renderer.render(this.scene, this.camera);
3656
2301
  // GPU 描边必须画在主场景之后:它的全屏 quad 参与宿主深度测试,
3657
2302
  // 依赖本帧已经写好的深度(被地形挡住的地方不该描边)
3658
- if (this._edgeModes.gpu)
3659
- this._renderGpuEdges(canvas);
2303
+ this._edges.renderGpuEdges(canvas);
3660
2304
  }
3661
2305
  this._frame++;
3662
2306
  const now = performance.now();
@@ -3673,7 +2317,7 @@ export class S3MViewer {
3673
2317
  this.stats.loading > 0 ||
3674
2318
  tilesetChanged;
3675
2319
  // 首帧画完、场上有材质了,再把另一种 shading 变体编出来(异步,不挡当前帧)
3676
- if (this._prewarmShadingEnabled && !this._shadingPrewarmed && this._pickables.size > 0) {
2320
+ if (this._prewarmShadingEnabled && !this._shadingPrewarmed && this._picks.pickables.size > 0) {
3677
2321
  void this.prewarmShading();
3678
2322
  }
3679
2323
  return needsNextFrame;