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
@@ -23,13 +23,25 @@
23
23
  import * as THREE from 'three';
24
24
  import { S3MTileset, usableGeoBounds } from '../tileset/S3MTileset.js';
25
25
  import type { BuildTileFn, TileNode } from '../tileset/S3MTileset.js';
26
- import { authFetch, type AuthenticateFn } from '../core/AuthFetch.js';
27
- import { sortSplatMesh } from './SplatSorter.js';
28
- import { splatCenterBounds } from './SplatMaterial.js';
26
+ import type { AuthenticateFn } from '../core/AuthFetch.js';
29
27
  import { setSplatViewport } from './GeometryBuilder.js';
30
- import { setMaxAnisotropy, setAnisotropyCap, setCompressedTexturesSupported, setTextureOffThreadDecode, setMaxTextureSize as setTextureSizeCap, setSrgbTextureDecode } from './TextureFactory.js';
31
- import { getMaterialQuality, setMaterialQuality, setColorPipeline } from './StyleMaterial.js';
32
- import { resolveLightingPreset, type LightingPreset } from './lightingPreset.js';
28
+ import { setMaxAnisotropy, setCompressedTexturesSupported, setTextureOffThreadDecode, setMaxTextureSize as setTextureSizeCap } from './TextureFactory.js';
29
+ import { getMaterialQuality, setMaterialQuality } from './StyleMaterial.js';
30
+ import type { LightingPreset } from './lightingPreset.js';
31
+ import { LightRig } from './viewer/LightRig.js';
32
+ import { HeightIndex } from './viewer/HeightIndex.js';
33
+ import { SplatLayer } from './viewer/SplatLayer.js';
34
+ import { EdgeDecorator } from './viewer/EdgeDecorator.js';
35
+ import { PickService } from './viewer/PickService.js';
36
+ import { SelectionDecorator } from './viewer/SelectionDecorator.js';
37
+ import type {
38
+ LightOptions,
39
+ S3MViewerOptions,
40
+ PickResult,
41
+ PickableRef,
42
+ PathFollowOptions,
43
+ MeshUserData,
44
+ } from './viewer/types.js';
33
45
  import { decodeTextureWithWorker } from '../worker/S3MWorkerClient.js';
34
46
  import {
35
47
  STYLE,
@@ -39,17 +51,13 @@ import {
39
51
  createSharedUniforms,
40
52
  createStyledMaterial,
41
53
  createOutlineMaterial,
42
- resolveEdgeModes,
43
- sameEdgeModes,
44
54
  restoreBaseBlendState,
45
55
  } from './StyleMaterial.js';
46
56
  import type {
47
57
  OutlineMaterial,
48
- OutlineStyleUniforms,
49
58
  StyledMaterial,
50
59
  StyledMaterialOptions,
51
60
  StyleMode,
52
- SelectMode,
53
61
  S3MStyle,
54
62
  EdgeModes,
55
63
  EdgesOption,
@@ -63,35 +71,16 @@ import { Animator } from '../anim/Animator.js';
63
71
  import type { Channel, TransitionOptions } from '../anim/Animator.js';
64
72
  import { Path } from '../anim/Path.js';
65
73
  import type { EasingFn, EasingName } from '../anim/easing.js';
66
- import type { LineSharedUniforms, ThickLineMaterial, ThickLineStyleUniforms } from './EdgeLines.js';
74
+ import type { LineSharedUniforms, ThickLineMaterial } from './EdgeLines.js';
67
75
  import { GPUPathManager } from './GPUPathManager.js';
68
76
  import type { GPUPathOptions } from './FeatureStyleManager.js';
69
- import {
70
- buildFeatureEdgeSegments,
71
- buildPositionBatchMap,
72
- buildThickLineGeometry,
73
- createLineSharedUniforms,
74
- createThickLineMaterial,
75
- positionQuantKey,
76
- } from './EdgeLines.js';
77
- import { mergeVerticesByPosition } from './MergeVertices.js';
78
- import {
79
- resolveFeature,
80
- measureSegments,
81
- buildAttributes,
82
- buildInstanceLookup,
83
- pickEntryId,
84
- DEFAULT_FLOOR_HEIGHT,
85
- } from '../core/FeatureInfo.js';
86
77
  import type { InstanceFeature } from '../core/FeatureInfo.js';
87
78
  import { AttributeQuery } from '../core/AttributeQuery.js';
88
- import type { AttributeQueryOptions, AttributeRow } from '../core/AttributeQuery.js';
79
+ import type { AttributeQueryOptions } from '../core/AttributeQuery.js';
89
80
  import type {
90
- Anchor,
91
81
  FeatureAttributes,
92
82
  GeoPackageEntry,
93
83
  ParsedTile,
94
- PickSegment,
95
84
  SegmentBox,
96
85
  Stats,
97
86
  StyleUniforms,
@@ -110,16 +99,13 @@ import {
110
99
  FeatureStyleManager,
111
100
  type FeatureStyle,
112
101
  type ColorByFieldOptions,
113
- type ThematicLegendEntry,
114
102
  type ThematicResult,
115
- type BatchConflict,
116
103
  type BatchAudit,
117
104
  type FieldFilterOptions,
118
105
  type FieldFilterResult,
119
106
  type FieldElevationOptions,
120
107
  type FieldElevationResult,
121
108
  type S3MFilter,
122
- resolveRotationMatrix,
123
109
  extractEulerAngles,
124
110
  } from './FeatureStyleManager.js';
125
111
 
@@ -156,224 +142,14 @@ export type {
156
142
 
157
143
  export { GroundManager, DatasetManager, FeatureStyleManager };
158
144
 
159
- const MAX_PICK_ID = 65534;
160
-
161
- export interface PickResult extends FeatureAttributes {
162
- datasetId: string;
163
- datasetName: string;
164
- /** 网格编号 */
165
- objectId: number;
166
- /** 单体控制位(顶点区间型 = batchId,实例化型 = 状态纹理下标),-1 表示没有单体化信息 */
167
- batchId: number;
168
- /** 实例化几何命中的实例序号;非实例化为 undefined */
169
- instanceIndex?: number;
170
- skeletonName: string;
171
- /** 单体中心经纬度,拿不到时为 null */
172
- centerLngLat: [number, number] | null;
173
- /** 命中点三维场景坐标 [x, y, z](米,场景 ENU) */
174
- point?: [number, number, number] | null;
175
- /** 命中点经纬度 [lng, lat] */
176
- lngLat?: [number, number] | null;
177
- /** 命中点高程(米) */
178
- altitude?: number | null;
179
- mesh: THREE.Mesh;
180
- }
181
-
182
- export interface LightOptions {
183
- /** 环境光强度,默认 0.4 */
184
- ambientIntensity?: number;
185
- /** 环境光颜色,十六进制或 CSS 颜色字符串,默认 0xffffff */
186
- ambientColor?: number | string;
187
- /** 半球光天空强度,默认 0.8 */
188
- hemisphereIntensity?: number;
189
- /** 半球光天空颜色,默认 0xffffff */
190
- hemisphereColor?: number | string;
191
- /** 半球光地面颜色,默认 0xb6c0cb */
192
- hemisphereGroundColor?: number | string;
193
- /** 主平行光强度,默认 1.0 */
194
- keyLightIntensity?: number;
195
- /** 主平行光强度别名(兼容写法) */
196
- mainIntensity?: number;
197
- /** 主平行光颜色,默认 0xffffff */
198
- keyLightColor?: number | string;
199
- /** 主平行光颜色别名(兼容写法) */
200
- mainColor?: number | string;
201
- /** 补光强度,默认 0.35 */
202
- fillLightIntensity?: number;
203
- /** 补光颜色,默认 0xffffff */
204
- fillLightColor?: number | string;
205
- /**
206
- * 光照朝向模式:
207
- * - 'headlamp': 默认头灯模式(随相机 bearing 旋转,受光面始终面向镜头)
208
- * - 'fixed': 固定世界坐标方位角(如太阳光模拟)
209
- */
210
- mode?: 'headlamp' | 'fixed';
211
- /** 方位角(度,0~360),固定模式下以正北为 0 顺时针,头灯模式下为相机相对偏角,默认 150 */
212
- azimuth?: number;
213
- /** 太阳/光源仰角(度,0~90),默认 50 */
214
- altitude?: number;
215
- }
216
-
217
- export interface S3MViewerOptions {
218
- /**
219
- * 光照预设:
220
- * - `'default'`(默认):现有 PBR 打光,亮部线性总量 ≈ 2.55;
221
- * - `'supermap'`:对齐 iClient3D 观感(暗部 0.46 / 亮部 2.44),远处不发灰、饱和度更高。
222
- * 只改光源,不动材质与色调映射;`light` 里的显式字段仍然优先生效。
223
- */
224
- lightingPreset?: LightingPreset;
225
- /** 光照参数配置(强度、颜色、朝向模式、太阳方位角/仰角等) */
226
- light?: LightOptions;
227
- /**
228
- * 三角面材质深度偏移(共面摩尔纹 / 重合面归属的人工定序)。
229
- * 例:factor -1 / units -2 把配置材质的片元深度略微拉近。
230
- * 这是 mesh 粒度开关,见 BuildTileOptions.polygonOffset 的说明。
231
- */
232
- polygonOffset?: { factor: number; units: number };
233
- /**
234
- * 色调映射模式(默认 THREE.NoToneMapping,避免 ACESFilmic 压暗中暗部及导致底图与建筑色彩失真;
235
- * 可传入 THREE.ACESFilmicToneMapping、THREE.LinearToneMapping、THREE.ReinhardToneMapping 等)
236
- */
237
- toneMapping?: THREE.ToneMapping;
238
- /** 色调映射曝光度,默认 1.0 */
239
- toneMappingExposure?: number;
240
- /** 全局总驻留瓦片上限(多数据集多图层共享预算),在所有可见数据集之间动态均衡分配,彻底防止爆显存 */
241
- totalMaxTiles?: number;
242
- /** 单数据集简写 */
243
- configUrl?: string;
244
- /** 多数据集 */
245
- datasets?: S3MDatasetOptions[];
246
- style?: Partial<S3MStyle>;
247
- /** 3D 物理地表面 / 基坑开挖配置 */
248
- ground?: GroundOptions;
249
- pickable?: boolean;
250
- /** 每个数据集最多驻留瓦片 */
251
- maxTiles?: number;
252
- /** 每个数据集并发请求数 */
253
- maxConcurrent?: number;
254
- /**
255
- * 图层级默认的降级处置策略(默认 `'warn'`),数据集可用 `unsupported` 单独覆盖。
256
- * 见 `S3MTilesetOptions.unsupported`。
257
- */
258
- unsupported?: UnsupportedPolicy;
259
- /** 是否用 Web Worker 解析经典 .s3mb 瓦片(默认开,崩溃自动降级主线程),见 S3MTilesetOptions.useWorker */
260
- useWorker?: boolean;
261
- /** LOD 切换范围缩放系数(默认 1.0),> 1 更倾向保留粗模,< 1 提前换细模 */
262
- lodRangeScale?: number;
263
- /**
264
- * LOD 判定用屏幕高度时的「设备像素比上限」(默认不限制,行为与旧版一致)。
265
- *
266
- * LOD 的屏幕空间误差按 `canvasHeight` 物理像素算(与官方 `calcPixFromCam` 同口径),
267
- * 于是 3x DPR 的手机上判定高度是 2556 而不是 852,细化层数与瓦片数都会远多于桌面。
268
- * 传 2 表示「按 CSS 高度 × 2 截断」,砍掉 3x 屏的超采样余量;传 `Infinity` = 不截断。
269
- *
270
- * 代价:截断会把每块瓦片的屏幕投影放大同样倍数,几何误差与纹理密度同步变差,
271
- * 所以默认不启用,由接入方按设备档位自行决定。运行时可用 `setLodPixelRatioCap()` 调整。
272
- */
273
- lodPixelRatioCap?: number;
274
- /**
275
- * 每个数据集驻留三角形总数的软上限(默认不限制)。
276
- * 见 `S3MTilesetOptions.maxTriangles`:瓦片数管不住的场景(精模、大面片)用这条刹车。
277
- */
278
- maxTriangles?: number;
279
- /**
280
- * 纹理缓存条数上限(默认 128)。一条对应一张已解码的瓦片纹理,
281
- * 1024² 的 RGBA 一张约 4MB,128 条就是 512MB 显存 —— 低端设备建议 32~48。
282
- * 与 `maxTextureSize` 配合使用:后者降单张尺寸,这条降张数。
283
- */
284
- maxImageCache?: number;
285
- /**
286
- * 各向异性过滤上限(默认不封顶,取 GPU 报的最大值,通常 16)。
287
- * 移动端封到 4 或 8 肉眼通常无差,采样成本明显下降。只影响**之后创建**的纹理。
288
- */
289
- anisotropyCap?: number;
290
- /**
291
- * 纹理长边像素上限(默认不限制)。
292
- * 非图集的 RGBA / WebP 纹理会按 2× 盒式滤波逐次减半到上限内;DXT 压缩直传与
293
- * 图集(UV 已重映射)不受影响。手机上 1024 与 2048 肉眼几乎无差,显存差 4 倍。
294
- */
295
- maxTextureSize?: number;
296
- /**
297
- * 光照模型(默认 `'pbr'`)。
298
- *
299
- * - `'pbr'`:`MeshStandardMaterial`,GGX + 环境球谐,质感最好也最贵
300
- * - `'lambert'`:`MeshLambertMaterial`,只保留漫反射 + 平行光,片元开销约省一半;
301
- * 白模 / 纯色 / 按高度着色这类不需要高光的模式肉眼差别很小
302
- *
303
- * 模块级全局状态:同页多个图层会互相覆盖,后创建的那个决定全局。
304
- * 只影响**之后创建**的材质 —— 已加载的瓦片要重新加载才会换。
305
- */
306
- materialQuality?: 'pbr' | 'lambert';
307
- /** 自定义瓦片构建器(测试 / 服务端渲染时注入) */
308
- buildTile?: BuildTileFn | null;
309
- /**
310
- * 可选:业务属性查询配置(iServer 数据服务)。
311
- * 配上之后才能用 `colorByField()` 按业务字段着色 —— 属性值不在 S3M 瓦片里,只能去数据服务查。
312
- */
313
- attributes?: AttributeQueryOptions;
314
- /** 宿主请求重画的信号。MapLibre 传 `() => map.triggerRepaint()`;纯 three 宿主传自己的渲染循环 */
315
- onRequestRepaint?: () => void;
316
- /** 地图朝向(弧度),用于让灯光跟着视角走。不传则灯光方向固定 */
317
- getBearing?: () => number;
318
- /**
319
- * 鉴权适配函数(全局默认,数据集可用自己的 `authenticate` 覆盖)。
320
- * 内核主线程发出的全部请求(配置 scp / 瓦片 / 子图集 / 棱线缓存 / 属性查询)
321
- * 发出前都过一遍,可改写 URL(加 `?token=`)或附加请求头。
322
- * 不传 = 行为与旧版完全一致。见 `AuthenticateFn`。
323
- */
324
- authenticate?: AuthenticateFn;
325
- onStats?: (stats: Stats) => void;
326
- onReady?: (entry: S3MDatasetEntry) => void;
327
- onPick?: (result: PickResult | null) => void;
328
- onError?: (error: unknown) => void;
329
- /**
330
- * 首次出现瓦片后自动预热另一种 `shading` 变体(默认开)。
331
- *
332
- * `flat` / `smooth` 是编译期变体,不预热的话第一次 `setStyle({ shading })` 会同步
333
- * 编译着色器、卡一帧。预热在首帧之后异步做,期间跳一两帧渲染,肉眼不可见。
334
- */
335
- prewarmShading?: boolean;
336
- }
337
-
338
- /* ------------------------------------------------------------------ 内部类型 */
339
-
340
- interface PickableRef {
341
- datasetId: string;
342
- mesh: THREE.Mesh;
343
- tile: ParsedTile;
344
- skeletonName: string;
345
- }
346
-
347
- interface MeshUserData {
348
- pickId?: number;
349
- datasetId?: string;
350
- skeletonName?: string;
351
- geoRef?: GeoPackageEntry;
352
- tileRef?: ParsedTile;
353
- primitiveType?: number;
354
- /** 点云对象(THREE.Points):不吃面着色,也没有棱可描 */
355
- isPointCloud?: boolean;
356
- /** CPU 端现算的棱线对象(等宽线 Mesh) */
357
- edges?: THREE.Object3D | null;
358
- /** 背面外扩轮廓壳(edges 含 'hull' 时挂上) */
359
- edgeHull?: THREE.Mesh | null;
360
- /** 数据自带棱线包(此时不再用 EdgesGeometry 现算) */
361
- nativeEdges?: boolean;
362
- /** 数据本身有没有棱线包(不受 forceCpuEdges 影响,用来运行中切换时重算 nativeEdges) */
363
- nativeEdgesRaw?: boolean;
364
- /** 数据自带的棱线对象本身 */
365
- isEdgeLines?: boolean;
366
- /** CPU 现算的等宽棱线(与 isEdgeLines 区分,便于刷新样式) */
367
- isThickEdge?: boolean;
368
- /** 背面外扩轮廓壳(edges 含 'hull' 时挂上) */
369
- isEdgeHull?: boolean;
370
- /** 选中单体的棱线对象 */
371
- isSelectionEdge?: boolean;
372
- /** 该网格正在淡入时占用的动画通道 key,瓦片淘汰时要回收 */
373
- tileFadeKey?: string;
374
- pickMaterial?: THREE.Material;
375
- [key: string]: unknown;
376
- }
145
+ /* 公共类型定义在 viewer/types.ts;这里 re-export 保持既有 import 路径不变 */
146
+ export type {
147
+ PickResult,
148
+ LightOptions,
149
+ S3MViewerOptions,
150
+ PickableRef,
151
+ PathFollowOptions,
152
+ } from './viewer/types.js';
377
153
 
378
154
  function styledMaterialOf(mesh: THREE.Mesh): StyledMaterial | null {
379
155
  const m = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
@@ -436,25 +212,6 @@ const FEATURE_PROPS = ['translation', 'elevation', 'opacity', 'color', 'rotation
436
212
  type FeatureProp = (typeof FEATURE_PROPS)[number];
437
213
 
438
214
  /** 单体沿折线运动的参数 */
439
- export interface PathFollowOptions {
440
- /** 走完全程的时长(毫秒)。不传则按 speed 或默认 2000ms 推算 */
441
- duration?: number;
442
- /** 线速度(米/秒)。给了 duration 就以 duration 为准 */
443
- speed?: number;
444
- /** 是否循环 */
445
- loop?: boolean;
446
- /** 是否把折线首尾相连 */
447
- closed?: boolean;
448
- /** 缓动曲线:作用在**已走距离**上,默认 linear(恒速) */
449
- easing?: EasingName | EasingFn;
450
- /** 路径整体偏移(米),相当于路径原点 */
451
- offset?: [number, number, number];
452
- /** 单次走完(非循环)时回调 */
453
- onComplete?: () => void;
454
- }
455
-
456
- /* ------------------------------------------------------------------ 图层 */
457
-
458
215
  export class S3MViewer {
459
216
  readonly options: S3MViewerOptions;
460
217
  readonly pickable: boolean;
@@ -485,12 +242,8 @@ export class S3MViewer {
485
242
  readonly edgeMaterial: ThickLineMaterial;
486
243
  /** 选中棱线材质(同上,选中棱线的逐网格材质共享它的对象) */
487
244
  readonly selectionEdgeMaterial: ThickLineMaterial;
488
- /** 全局棱线的外观 uniform:逐网格棱线材质直接复用这些对象 */
489
- private readonly _edgeStyleUniforms: ThickLineStyleUniforms;
490
- /** 选中棱线的外观 uniform */
491
- private readonly _selEdgeStyleUniforms: ThickLineStyleUniforms;
492
- /** 全局外扩轮廓的外观 uniform:逐网格轮廓壳共享这些对象 */
493
- private readonly _hullStyleUniforms: OutlineStyleUniforms;
245
+ /** 棱线装饰(见 viewer/EdgeDecorator.ts) */
246
+ private readonly _edges: EdgeDecorator;
494
247
 
495
248
  /** 3D 物理地表面 / 基坑开挖管理器 */
496
249
  readonly groundManager: GroundManager;
@@ -515,29 +268,16 @@ export class S3MViewer {
515
268
  this._datasetManager.sceneAnchor = val;
516
269
  }
517
270
 
518
- private readonly _pickables = new Map<number, PickableRef>();
519
- private _pickSeq = 0;
520
- private readonly _freePickIds: number[] = [];
271
+ /** 拾取表 / pickId 池 / 射线求交(见 viewer/PickService.ts) */
272
+ private readonly _picks: PickService;
521
273
  /** SMID → 包含它的网格。改单个单体样式时只重算这几块,不用全量扫 */
522
274
  private readonly _smidMeshes = new Map<number, Set<THREE.Mesh>>();
523
275
  /** 每块网格的世界高度范围,用来增量维护 uHeightMin / uHeightMax */
524
- private readonly _meshHeight = new Map<THREE.Mesh, { min: number; max: number }>();
525
- private _heightMin = Infinity;
526
- private _heightMax = -Infinity;
527
- private readonly _boxScratch = new THREE.Box3();
528
- private _selection: PickResult | null = null;
529
- private _selectedMeshes: THREE.Object3D[] = [];
530
- private _outlineMesh: THREE.Mesh | null = null;
531
- private _outlineMeshes: THREE.Mesh[] = [];
532
- private _selectionEdges: THREE.Mesh | null = null;
533
- private _selectionEdgeMeshes: THREE.Mesh[] = [];
534
- private _edgesEnabled = false;
535
- /** 当前生效的棱线画法(line / offset / hull / gpu),决定要不要建 / 拆哪种装饰 */
536
- private _edgeModes: EdgeModes = resolveEdgeModes(false);
537
- /** GPU 描边后处理(深度 + 法线),只在 edges 含 'gpu' 时按帧执行 */
538
- private _gpuEdge: GpuEdgePass | null = null;
539
- /** 给 GPU 描边传色用的临时 Color,避免每帧 new */
540
- private readonly _gpuEdgeColor = new THREE.Color();
276
+ /** 世界高度范围增量维护(见 viewer/HeightIndex.ts) */
277
+ private readonly _heights: HeightIndex;
278
+ /** 选中态与装饰(见 viewer/SelectionDecorator.ts) */
279
+ private readonly _sel: SelectionDecorator;
280
+
541
281
  /** 瓦片淡入通道的自增序号(不用 pickId:pickId 会被回收复用) */
542
282
  private _tileFadeSeq = 0;
543
283
  /** setMode 编排的代次,用来作废被新调用顶掉的旧编排 */
@@ -554,15 +294,11 @@ export class S3MViewer {
554
294
  private readonly _pathScratch = new Float32Array(3);
555
295
  /** 求切线用的第二个采样点(包围盒换算 GPU 路径朝向时用) */
556
296
  private readonly _pathScratch2 = new Float32Array(3);
557
- /** 预制棱线缓存(Crease Cache):从外部 source-creases 载入的全局统一 LineSegments,性能最高、视觉最干净 */
558
- private _creaseLines: THREE.LineSegments | null = null;
559
- /** 数据自带的棱线对象(LineSegments),改棱线颜色时只刷这些,不再全场景 traverse */
560
- private readonly _nativeEdgeLines = new Set<THREE.LineSegments>();
297
+
561
298
  /** 点云对象集合:透明度动画时只刷它们,避免每帧遍历全部 pickables */
562
299
  private readonly _pointClouds = new Set<THREE.Points>();
563
- /** 泼溅排序状态:相机不动且没有新瓦片时,排序结果可以直接复用 */
564
- private readonly _lastSplatSortView = new THREE.Matrix4();
565
- private _splatsSorted = false;
300
+ /** 泼溅排序调度(见 viewer/SplatLayer.ts) */
301
+ private readonly _splats = new SplatLayer();
566
302
  /** 当前材质是否处于 transparent 状态,用来避免逐帧重复切换 program */
567
303
  private _opacityTransparent = false;
568
304
 
@@ -582,29 +318,10 @@ export class S3MViewer {
582
318
  private readonly _frameMvp = new THREE.Matrix4();
583
319
  /** 颜色换算用的临时对象,避免每帧 new */
584
320
  private readonly _colorScratch = new THREE.Color();
585
- private _needsRepaint = true;
586
321
  private _frame = 0;
587
322
  private readonly _fps = { value: 0, last: 0 };
588
- private _ambientLight!: THREE.AmbientLight;
589
- private _hemiLight!: THREE.HemisphereLight;
590
- private _keyLight!: THREE.DirectionalLight;
591
- private _fillLight!: THREE.DirectionalLight;
592
- /** 环境贴图的 PMREM 渲染目标(setEnvironment 建的,unmount 时释放) */
593
- private _envRT: THREE.WebGLRenderTarget | null = null;
594
- private readonly _lightOptions: Required<Omit<LightOptions, 'mainIntensity' | 'mainColor'>> = {
595
- ambientIntensity: 0.4,
596
- ambientColor: 0xffffff,
597
- hemisphereIntensity: 0.8,
598
- hemisphereColor: 0xffffff,
599
- hemisphereGroundColor: 0xb6c0cb,
600
- keyLightIntensity: 1.0,
601
- keyLightColor: 0xffffff,
602
- fillLightIntensity: 0.35,
603
- fillLightColor: 0xffffff,
604
- mode: 'headlamp',
605
- azimuth: 150,
606
- altitude: 50,
607
- };
323
+ /** 光源 / 色调映射 / 环境贴图(见 viewer/LightRig.ts) */
324
+ private readonly _lights: LightRig;
608
325
 
609
326
  private readonly _datasetQueue: S3MDatasetOptions[];
610
327
 
@@ -636,13 +353,6 @@ export class S3MViewer {
636
353
  this._lodPixelRatioCap = options.lodPixelRatioCap ?? Infinity;
637
354
  setTextureSizeCap(options.maxTextureSize ?? Infinity);
638
355
  setMaterialQuality(options.materialQuality ?? 'pbr');
639
- if (options.lightingPreset && options.lightingPreset !== 'default') {
640
- Object.assign(this._lightOptions, resolveLightingPreset(options.lightingPreset));
641
- this._applyColorPipeline(options.lightingPreset);
642
- }
643
- if (options.light) {
644
- Object.assign(this._lightOptions, options.light);
645
- }
646
356
  if (options.totalMaxTiles) {
647
357
  this._datasetManager.totalMaxTiles = options.totalMaxTiles;
648
358
  }
@@ -659,13 +369,12 @@ export class S3MViewer {
659
369
  if (!this.scene) {
660
370
  this.scene = new THREE.Scene();
661
371
  this.scene.add(this.groundManager.group);
662
- this._addLights();
663
372
  }
664
373
  this._featureStyleManager = new FeatureStyleManager(
665
374
  () => this.style.floorHeight,
666
375
  () => this._requestRepaint(),
667
376
  () => {
668
- if (this._selection) this._syncSelectionStyle();
377
+ if (this._sel.selection) this._sel.syncStyle();
669
378
  },
670
379
  () => this.getAllMeshes(),
671
380
  (mesh) => this._applyOverridesToMesh(mesh),
@@ -674,8 +383,56 @@ export class S3MViewer {
674
383
  );
675
384
 
676
385
  this.uniforms = createSharedUniforms();
386
+ // 光源状态在这里就位:LightRig 构造时会按 lightingPreset 切模块级颜色管线,
387
+ // 挂进场景要等 scene 建好(构造函数上面已经建了)。
388
+ this._lights = new LightRig({
389
+ scene: () => this.scene,
390
+ renderer: () => this.renderer,
391
+ uniforms: this.uniforms,
392
+ options: this.options,
393
+ getBearing: () => this._getBearing?.(),
394
+ // 走公共 triggerRepaint 而不是 _requestRepaint:宿主 / 单测会覆盖前者来观察重绘,
395
+ // 绕过它会让「有没有触发重绘」这件事失去观测点
396
+ repaint: () => this.triggerRepaint(),
397
+ });
398
+ this._lights.addToScene();
399
+ this._heights = new HeightIndex(this.uniforms);
400
+ const self = this;
401
+ this._picks = new PickService({
402
+ get picker() {
403
+ return self.picker;
404
+ },
405
+ get camera() {
406
+ return self.camera;
407
+ },
408
+ get scene() {
409
+ return self.scene;
410
+ },
411
+ get canvas() {
412
+ return self.canvas;
413
+ },
414
+ get style() {
415
+ return self.style;
416
+ },
417
+ get sceneAnchor() {
418
+ return self.sceneAnchor;
419
+ },
420
+ get datasets() {
421
+ return self.datasets;
422
+ },
423
+ get featureStyleManager() {
424
+ return self._featureStyleManager;
425
+ },
426
+ get smidMeshes() {
427
+ return self._smidMeshes;
428
+ },
429
+ lngLatFromWorld: (x, y) => self._lngLatFromWorld(x, y),
430
+ gpuPathPoseAt: (smid) => self._gpuPathPoseAt(smid),
431
+ isFeatureHidden: (smid, batchId) => self._isFeatureHidden(smid, batchId),
432
+ });
677
433
  // 纹理解码优先派给共享解析 Worker(崩溃自动回主线程,见 decodeTextureWithWorker)
678
- setTextureOffThreadDecode(decodeTextureWithWorker); this.style = { ...DEFAULT_STYLE, ...(options.style ?? {}) };
434
+ setTextureOffThreadDecode(decodeTextureWithWorker);
435
+ this.style = { ...DEFAULT_STYLE, ...(options.style ?? {}) };
679
436
  this._styleTarget = { ...this.style };
680
437
  this._opacityTransparent = (this.style.opacity ?? 1) < 0.999;
681
438
  this._applyStyleUniforms();
@@ -685,29 +442,35 @@ export class S3MViewer {
685
442
  this.animator.onActive = () => this._requestRepaint();
686
443
  this.outlineMaterial = createOutlineMaterial(this.uniforms);
687
444
 
688
- this.lineShared = createLineSharedUniforms();
689
- // 棱线外观 uniform:全局棱线材质与逐网格棱线材质**共用同一批对象**,
690
- // _syncEdgeStyle 改一次,所有棱线(含淡入乘子)同时生效,保持 O(1)。
691
- this._edgeStyleUniforms = {
692
- uColor: { value: new THREE.Color(this.style.edgeColor) },
693
- uWidth: { value: Math.max(0.1, this.style.edgeWidth ?? 1.5) },
694
- uOpacity: { value: 0.6 },
695
- uOffset: { value: this.style.edgeOffset ?? 0.15 },
696
- };
697
- this.edgeMaterial = createThickLineMaterial(this.lineShared, {}, this._edgeStyleUniforms);
698
- this._selEdgeStyleUniforms = {
699
- uColor: { value: new THREE.Color(this.style.outlineColor ?? this.style.selectColor) },
700
- uWidth: { value: Math.max(0.1, this.style.outlineWidthPx ?? 3) },
701
- uOpacity: { value: 1 },
702
- uElevation: { value: this.style.selectElevation ?? 0 },
703
- };
704
- this.selectionEdgeMaterial = createThickLineMaterial(this.lineShared, {}, this._selEdgeStyleUniforms);
705
- // 全局外扩轮廓的外观 uniform:逐网格轮廓壳复用这几个对象,改一次全场景生效
706
- this._hullStyleUniforms = {
707
- uColor: { value: new THREE.Color(this.style.edgeColor) },
708
- uWidth: { value: Math.max(0, this.style.edgeHullWidth ?? 0.9) },
709
- uOpacity: { value: 0.6 },
710
- };
445
+ // 棱线装饰:内部持有共享 uniform 与全局材质,公共字段从它取出来保持 API 不变
446
+ this._edges = new EdgeDecorator({
447
+ uniforms: this.uniforms,
448
+ style: this.style,
449
+ styleTarget: this._styleTarget,
450
+ animator: this.animator,
451
+ pickables: this._picks.pickables,
452
+ scene: this.scene,
453
+ renderer: this.renderer,
454
+ camera: this.camera,
455
+ authenticate: this._authenticate,
456
+ requestRepaint: () => this.triggerRepaint(),
457
+ });
458
+ this.lineShared = this._edges.lineShared;
459
+ this.edgeMaterial = this._edges.edgeMaterial;
460
+ this.selectionEdgeMaterial = this._edges.selectionEdgeMaterial;
461
+ this._sel = new SelectionDecorator({
462
+ uniforms: this.uniforms,
463
+ style: this.style,
464
+ outlineMaterial: this.outlineMaterial,
465
+ selectionEdgeMaterial: this.selectionEdgeMaterial,
466
+ featureStyleManager: this._featureStyleManager,
467
+ smidMeshes: this._smidMeshes,
468
+ pointClouds: this._pointClouds,
469
+ applyPointCloudStyle: (pc, selected) => this._applyPointCloudStyle(pc, selected),
470
+ onPick: (r) => this.onPick(r),
471
+ requestRepaint: () => this.triggerRepaint(),
472
+ createSelectionEdgeMaterial: (mesh) => this._edges.createSelectionEdgeMaterial(mesh),
473
+ });
711
474
 
712
475
  // 关键:style.edges 是个开关,但构造函数从没据此启用过棱线,导致
713
476
  // `style: { edges: true }` 形同虚设、_edgesEnabled 永远是 false。
@@ -804,8 +567,7 @@ export class S3MViewer {
804
567
  this.picker?.dispose();
805
568
  this.picker = new Picker(this.renderer);
806
569
  // GPU 描边的 RT / 材质绑定在旧上下文上,整体丢弃,下一帧按需重建
807
- this._gpuEdge?.dispose();
808
- this._gpuEdge = null;
570
+ this._edges.disposeGpuEdge();
809
571
  // 共享 uniform / 材质引用不变,three 重新编译着色器时会自然接管;
810
572
  // 这里只确保下一帧尽快驱动一次渲染。
811
573
  this._requestRepaint();
@@ -818,169 +580,83 @@ export class S3MViewer {
818
580
  this.groundManager.dispose();
819
581
  this.picker?.dispose();
820
582
  this.picker = null;
821
- this._envRT?.dispose();
822
- this._envRT = null;
583
+ this._lights.dispose();
823
584
  this.outlineMaterial.dispose();
824
- this._gpuEdge?.dispose();
825
- this._gpuEdge = null;
585
+ this._edges.disposeGpuEdge();
826
586
  this.edgeMaterial.dispose();
827
587
  this.selectionEdgeMaterial.dispose();
828
588
  this.scene?.clear();
829
- this._selectedMeshes = [];
830
- this._outlineMeshes = [];
831
- this._selectionEdgeMeshes = [];
832
- this._outlineMesh = null;
833
- this._selectionEdges = null;
589
+ this._sel.reset();
834
590
  this._smidMeshes.clear();
835
- this._meshHeight.clear();
591
+ this._heights.clear();
836
592
  // 场景已经清空,还挂着的动画通道(瓦片淡入 / 单体位移)不能再对着死对象写
837
593
  this.animator.clear();
838
594
  this.animator.unregisterPrefix('tile.');
839
595
  this.animator.unregisterPrefix('feature.');
840
596
  this._pathSpecs.clear();
841
- this._heightMin = Infinity;
842
- this._heightMax = -Infinity;
597
+
843
598
  this.renderer?.dispose?.();
844
599
  this.renderer = null;
845
600
  this.canvas = null;
846
601
  }
847
602
 
848
- private _createLights(): void {
849
- this._ambientLight = new THREE.AmbientLight(this._lightOptions.ambientColor as any, this._lightOptions.ambientIntensity);
850
- this._hemiLight = new THREE.HemisphereLight(
851
- this._lightOptions.hemisphereColor as any,
852
- this._lightOptions.hemisphereGroundColor as any,
853
- this._lightOptions.hemisphereIntensity
854
- );
855
- this._keyLight = new THREE.DirectionalLight(this._lightOptions.keyLightColor as any, this._lightOptions.keyLightIntensity);
856
- this._fillLight = new THREE.DirectionalLight(this._lightOptions.fillLightColor as any, this._lightOptions.fillLightIntensity);
857
- this._updateLights();
858
- }
603
+ /* ---------------------------------------------------------------- 光照 */
859
604
 
860
- private _addLights(): void {
861
- if (!this.scene) return;
862
- if (!this._ambientLight) this._createLights();
863
- this.scene.add(this._ambientLight, this._hemiLight, this._keyLight, this._fillLight);
605
+ /**
606
+ * @internal 光源实例的转发 —— `lighting.test.ts` 直接读这几个字段。
607
+ * 实现全在 viewer/LightRig.ts。
608
+ */
609
+ get _ambientLight(): THREE.AmbientLight | null {
610
+ return this._lights.ambient;
611
+ }
612
+ get _hemiLight(): THREE.HemisphereLight | null {
613
+ return this._lights.hemi;
614
+ }
615
+ get _keyLight(): THREE.DirectionalLight | null {
616
+ return this._lights.key;
617
+ }
618
+ get _fillLight(): THREE.DirectionalLight | null {
619
+ return this._lights.fill;
864
620
  }
865
621
 
866
- private _applyLightOptions(): void {
867
- if (this._ambientLight) {
868
- this._ambientLight.intensity = this._lightOptions.ambientIntensity;
869
- this._ambientLight.color.set(this._lightOptions.ambientColor as any);
870
- }
871
- if (this._hemiLight) {
872
- this._hemiLight.intensity = this._lightOptions.hemisphereIntensity;
873
- this._hemiLight.color.set(this._lightOptions.hemisphereColor as any);
874
- this._hemiLight.groundColor.set(this._lightOptions.hemisphereGroundColor as any);
875
- }
876
- if (this._keyLight) {
877
- this._keyLight.intensity = this._lightOptions.keyLightIntensity;
878
- this._keyLight.color.set(this._lightOptions.keyLightColor as any);
879
- }
880
- if (this._fillLight) {
881
- this._fillLight.intensity = this._lightOptions.fillLightIntensity;
882
- this._fillLight.color.set(this._lightOptions.fillLightColor as any);
883
- }
622
+ /** @internal 光照位置按当前 bearing 重算,见 viewer/LightRig.ts */
623
+ _updateLights(): void {
624
+ this._lights.update();
884
625
  }
885
626
 
886
- /**
887
- * 运行时切换光照预设:覆盖四个强度,颜色/朝向保持不变。
888
- * 之后再调 `setLight({...})` 仍然可以逐项微调。
889
- */
890
- /**
891
- * supermap 完整版 = 光照强度 + 颜色管线一起切:
892
- * direct 管线下纹理不做 sRGB 解码、片元按官方常数 Lambert 直乘(见 lightingPreset.ts)。
893
- * 两个都是模块级全局态,只影响之后创建的材质 / 纹理 —— 已加载瓦片要重载才换。
894
- */
895
- private _applyColorPipeline(preset: LightingPreset): void {
896
- const direct = preset === 'supermap';
897
- setColorPipeline(direct ? 'direct' : 'linear');
898
- setSrgbTextureDecode(!direct);
899
- // 官方不开各向异性:远处直接采高 mip 平均成纯色("灰色滤镜"的一部分)。
900
- // 我们默认 Infinity(倾斜摄影立面清晰度优先),supermap 模式必须收回。
901
- setAnisotropyCap(direct ? 1 : Infinity);
627
+ private _addLights(): void {
628
+ this._lights.addToScene();
902
629
  }
903
630
 
904
631
  setLightingPreset(preset: LightingPreset): void {
905
- Object.assign(this._lightOptions, resolveLightingPreset(preset));
906
- this._applyColorPipeline(preset);
907
- this.options.lightingPreset = preset;
908
- this._applyLightOptions();
909
- this._updateLights();
910
- this._needsRepaint = true;
911
- this.triggerRepaint();
632
+ this._lights.setLightingPreset(preset);
912
633
  }
913
634
 
914
- /**
915
- * 动态设置光照配置
916
- */
635
+ /** 动态设置光照配置 */
917
636
  setLight(options: Partial<LightOptions>): void {
918
- const normalized: Partial<LightOptions> = { ...options };
919
- if (normalized.mainColor !== undefined && normalized.keyLightColor === undefined) {
920
- normalized.keyLightColor = normalized.mainColor;
921
- }
922
- if (normalized.mainIntensity !== undefined && normalized.keyLightIntensity === undefined) {
923
- normalized.keyLightIntensity = normalized.mainIntensity;
924
- }
925
- Object.assign(this._lightOptions, normalized);
926
- this._applyLightOptions();
927
- this._updateLights();
928
- this._needsRepaint = true;
929
- this.triggerRepaint();
637
+ this._lights.setLight(options);
930
638
  }
931
639
 
932
- /**
933
- * 获取当前光照配置快照
934
- */
640
+ /** 获取当前光照配置快照 */
935
641
  getLight(): LightOptions {
936
- return { ...this._lightOptions };
642
+ return this._lights.getLight();
937
643
  }
938
644
 
939
- /**
940
- * 动态设置色调映射模式与曝光度
941
- */
645
+ /** 动态设置色调映射模式与曝光度 */
942
646
  setToneMapping(toneMapping: THREE.ToneMapping, exposure = 1.0): void {
943
- this.options.toneMapping = toneMapping;
944
- this.options.toneMappingExposure = exposure;
945
- if (this.renderer) {
946
- this.renderer.toneMapping = toneMapping;
947
- this.renderer.toneMappingExposure = exposure;
948
- this._needsRepaint = true;
949
- this.triggerRepaint();
950
- }
647
+ this._lights.setToneMapping(toneMapping, exposure);
951
648
  }
952
649
 
953
- /**
954
- * 获取当前色调映射模式与曝光度
955
- */
650
+ /** 获取当前色调映射模式与曝光度 */
956
651
  getToneMapping(): { toneMapping: THREE.ToneMapping; toneMappingExposure: number } {
957
- return {
958
- toneMapping: this.renderer ? this.renderer.toneMapping : (this.options.toneMapping ?? THREE.NoToneMapping),
959
- toneMappingExposure: this.renderer ? this.renderer.toneMappingExposure : (this.options.toneMappingExposure ?? 1.0),
960
- };
652
+ return this._lights.getToneMapping();
961
653
  }
962
654
 
963
655
  /**
964
- * 更新光照位置与方向:
965
- * - 'headlamp': 随地图方位角 bearing 旋转,主光始终在镜头上方,受光面面向镜头;
966
- * - 'fixed': 固定世界方位角(如模拟恒定日光)。
656
+ * 挂 / 摘环境贴图。见 viewer/LightRig.ts(PMREM 预滤波 + 旧 RT 释放)。
967
657
  */
968
- private _updateLights(): void {
969
- if (!this._keyLight || !this._fillLight) return;
970
- const bearing = this._lightOptions.mode === 'fixed' ? 0 : (((this._getBearing?.() ?? 0) * Math.PI) / 180);
971
- const place = (light: THREE.DirectionalLight | null, azOffsetDeg: number, elevDeg: number): void => {
972
- if (!light) return;
973
- const az = bearing + (azOffsetDeg * Math.PI) / 180;
974
- const el = (elevDeg * Math.PI) / 180;
975
- const c = Math.cos(el);
976
- light.position.set(Math.sin(az) * c, Math.cos(az) * c, Math.sin(el)).multiplyScalar(1000);
977
- };
978
- place(this._keyLight, this._lightOptions.azimuth, this._lightOptions.altitude);
979
- place(this._fillLight, this._lightOptions.azimuth + 65, Math.max(10, this._lightOptions.altitude - 20));
980
- // direct 管线的官方 Lambert 用主光方向(共享 uniform,所有材质同步生效)
981
- if (this.uniforms?.uSunDirection && this._keyLight) {
982
- (this.uniforms.uSunDirection.value as THREE.Vector3).copy(this._keyLight.position).normalize();
983
- }
658
+ setEnvironment(source: string | THREE.Texture | null, intensity = 1): Promise<void> {
659
+ return this._lights.setEnvironment(source, intensity);
984
660
  }
985
661
 
986
662
  /* ---------------------------------------------------------------- 数据集 */
@@ -1013,13 +689,11 @@ export class S3MViewer {
1013
689
  onTile: (node, e) => {
1014
690
  this._registerTile(node, e);
1015
691
  this.onStats(this.stats);
1016
- this._needsRepaint = true;
1017
692
  this._requestRepaint();
1018
693
  },
1019
694
  onTileRemoved: (node, e) => {
1020
695
  this._unregisterTile(node, e);
1021
696
  this.onStats(this.stats);
1022
- this._needsRepaint = true;
1023
697
  this._requestRepaint();
1024
698
  },
1025
699
  onStats: () => {
@@ -1032,7 +706,7 @@ export class S3MViewer {
1032
706
  },
1033
707
  onReady: (e) => {
1034
708
  this.onReady(e);
1035
- this._refreshAllHeights();
709
+ this._heights.refreshAll();
1036
710
  if (!this.groundManager.excavationBounds) {
1037
711
  this.groundManager.autoFitExcavation(this.datasets.values());
1038
712
  }
@@ -1043,7 +717,8 @@ export class S3MViewer {
1043
717
  },
1044
718
  this.options.maxTriangles ?? Infinity,
1045
719
  this.options.maxImageCache ?? 128,
1046
- this._authenticate
720
+ this._authenticate,
721
+ this.options.maxMemory ?? Infinity
1047
722
  );
1048
723
  return entry;
1049
724
  }
@@ -1058,7 +733,7 @@ export class S3MViewer {
1058
733
  /** 当地图 CRS 投影切换或宿主 map 挂载后,重新计算场景锚点与各数据集 offset */
1059
734
  recomputeAnchors(): void {
1060
735
  this._datasetManager.recomputeAnchors(this.geo);
1061
- this._refreshAllHeights();
736
+ this._heights.refreshAll();
1062
737
  this._requestRepaint();
1063
738
  }
1064
739
 
@@ -1092,7 +767,7 @@ export class S3MViewer {
1092
767
  }
1093
768
  this._datasetManager.setDatasetHeight(id, h, this.geo);
1094
769
  this._syncGroundHeight();
1095
- this._refreshAllHeights();
770
+ this._heights.refreshAll();
1096
771
  this._requestRepaint();
1097
772
  }
1098
773
 
@@ -1124,7 +799,7 @@ export class S3MViewer {
1124
799
  a = align ?? 'origin';
1125
800
  }
1126
801
  this._datasetManager.setDatasetAlign(id, a, this.geo);
1127
- this._refreshAllHeights();
802
+ this._heights.refreshAll();
1128
803
  this._requestRepaint();
1129
804
  }
1130
805
 
@@ -1719,7 +1394,7 @@ export class S3MViewer {
1719
1394
  }
1720
1395
 
1721
1396
  auditBatches(): BatchAudit {
1722
- const res = this._featureStyleManager.auditBatches(this._pickables.values());
1397
+ const res = this._featureStyleManager.auditBatches(this._picks.pickables.values());
1723
1398
  res.smids = this._smidMeshes.size;
1724
1399
  return res;
1725
1400
  }
@@ -1832,7 +1507,7 @@ export class S3MViewer {
1832
1507
 
1833
1508
  getAllMeshes(): THREE.Mesh[] {
1834
1509
  const list: THREE.Mesh[] = [];
1835
- for (const p of this._pickables.values()) list.push(p.mesh);
1510
+ for (const p of this._picks.pickables.values()) list.push(p.mesh);
1836
1511
  return list;
1837
1512
  }
1838
1513
 
@@ -1845,14 +1520,6 @@ export class S3MViewer {
1845
1520
  return out;
1846
1521
  }
1847
1522
 
1848
- private _markOverridesDirty(scope: Iterable<THREE.Mesh> | 'all' = 'all'): void {
1849
- if (scope === 'all') {
1850
- this._featureStyleManager.markOverridesDirty(undefined, this.getAllMeshes());
1851
- } else {
1852
- this._featureStyleManager.markOverridesDirty(scope);
1853
- }
1854
- }
1855
-
1856
1523
  flushOverrides(): void {
1857
1524
  this._featureStyleManager.flushOverrides();
1858
1525
  }
@@ -1865,18 +1532,18 @@ export class S3MViewer {
1865
1532
  removeDataset(id: string): void {
1866
1533
  const entry = this.datasets.get(id);
1867
1534
  if (!entry) return;
1868
- if (this._selection?.datasetId === id) this.select(null);
1535
+ if (this._sel.selection?.datasetId === id) this.select(null);
1869
1536
  this._datasetManager.removeDataset(id, this.scene!, this.geo, (e) => {
1870
1537
  e.root.traverse((o) => {
1871
1538
  const mesh = o as THREE.Mesh;
1872
1539
  const ud = mesh.userData as MeshUserData;
1873
1540
  if (ud.pickId != null) {
1874
- this._pickables.delete(ud.pickId);
1541
+ this._picks.pickables.delete(ud.pickId);
1875
1542
  this._releasePickId(ud.pickId);
1876
1543
  ud.pickId = undefined;
1877
1544
  }
1878
1545
  this._unindexMeshSmids(mesh);
1879
- this._removeMeshHeight(mesh);
1546
+ this._heights.remove(mesh);
1880
1547
  mesh.geometry?.dispose?.();
1881
1548
  const styled = styledMaterialOf(mesh);
1882
1549
  if (styled) styled.dispose();
@@ -1884,7 +1551,7 @@ export class S3MViewer {
1884
1551
  this._featureStyleManager.disposeMesh(mesh);
1885
1552
  });
1886
1553
  });
1887
- for (const [pickId, p] of this._pickables) if (p.datasetId === id) this._pickables.delete(pickId);
1554
+ for (const [pickId, p] of this._picks.pickables) if (p.datasetId === id) this._picks.pickables.delete(pickId);
1888
1555
  this.onStats(this.stats);
1889
1556
  this._requestRepaint();
1890
1557
  }
@@ -1896,7 +1563,7 @@ export class S3MViewer {
1896
1563
 
1897
1564
  setDatasetVisible(id: string, visible: boolean): void {
1898
1565
  this._datasetManager.setDatasetVisible(id, visible);
1899
- if (!visible && this._selection?.datasetId === id) this.select(null);
1566
+ if (!visible && this._sel.selection?.datasetId === id) this.select(null);
1900
1567
  this._requestRepaint();
1901
1568
  }
1902
1569
 
@@ -1910,16 +1577,13 @@ export class S3MViewer {
1910
1577
  // forceCpuEdges:无视数据自带的棱线包,改由 CPU 现算。native 是 1px 的
1911
1578
  // LineBasicMaterial,edgeWidth / 外扩 / 单体跟随全都吃不上。
1912
1579
  // gpu 画法由 shader 自己描边,native 线段包同样不能留
1913
- const useNativeEdges =
1914
- hasNativeEdges && this.style.forceCpuEdges !== true && !this._edgeModes.gpu;
1580
+ const useNativeEdges = this._edges.useNativeEdges(hasNativeEdges);
1915
1581
 
1916
1582
  for (const child of node.group.children) {
1917
1583
  // 数据自带的棱线包:只跟着 edges 开关显示,不参与拾取、不参与着色
1918
1584
  if ((child as THREE.LineSegments).isLineSegments && (child.userData as MeshUserData).isEdgeLines) {
1919
- // 一律登记进 _nativeEdgeLines:forceCpuEdges 之后又关掉时还要能把它们放出来
1920
- this._nativeEdgeLines.add(child as THREE.LineSegments);
1921
- child.visible = useNativeEdges && this._edgesEnabled;
1922
- this._styleEdgeLine(child as THREE.LineSegments);
1585
+ // 一律登记:forceCpuEdges 之后又关掉时还要能把它们放出来
1586
+ this._edges.registerNativeLine(child as THREE.LineSegments, useNativeEdges && this._edges.enabled);
1923
1587
  continue;
1924
1588
  }
1925
1589
 
@@ -1929,29 +1593,24 @@ export class S3MViewer {
1929
1593
  if (!mesh.isMesh && !isPoints) continue;
1930
1594
  if (isPoints) this._pointClouds.add(child as THREE.Points);
1931
1595
  // 新瓦片里的泼溅实例还没按当前视角排过,下一帧要重排
1932
- if (isPoints) this._splatsSorted = false;
1596
+ if (isPoints) this._splats.markDirty();
1933
1597
  const ud = mesh.userData as MeshUserData;
1934
1598
  ud.pickId = this._allocPickId();
1935
1599
  ud.datasetId = entry.id;
1936
1600
  ud.nativeEdges = useNativeEdges;
1937
1601
  ud.nativeEdgesRaw = hasNativeEdges;
1938
- this._pickables.set(ud.pickId, {
1602
+ this._picks.pickables.set(ud.pickId, {
1939
1603
  datasetId: entry.id,
1940
1604
  mesh,
1941
1605
  tile: node.tile,
1942
1606
  skeletonName: ud.skeletonName ?? '',
1943
1607
  });
1944
1608
  this._indexMeshSmids(mesh);
1945
- this._addMeshHeight(mesh);
1609
+ this._heights.add(mesh);
1946
1610
  // 点云没有「棱」:给一堆孤立点建 EdgesGeometry 又慢又没意义
1947
1611
  // 点云没有「棱」:给一堆孤立点建 EdgesGeometry 又慢又没意义。
1948
1612
  // 自动描边受三道开关控制:全局 style.edges / 数据集 enableEdges / 地形绿地等关键字跳过。
1949
- if (!useNativeEdges && !isPoints && this._datasetEdgesEnabled(entry) && !this._shouldSkipAutoEdges(ud, entry.name)) {
1950
- // 数据集强制开启(enableEdges: true)时全局画法可能还是关的,退回默认折边线;
1951
- // 但 gpu 画法本来就不建几何,别在这里多此一举
1952
- if (this._edgeModes.line || this._edgeModes.hull) this._ensureEdgeDecoration(mesh);
1953
- else if (!this._edgeModes.gpu) this._ensureEdges(mesh);
1954
- }
1613
+ this._edges.autoDecorate(mesh, entry, useNativeEdges);
1955
1614
 
1956
1615
  // 新瓦片要继承当前的不透明度与着色方式
1957
1616
  if (isPoints) {
@@ -1978,7 +1637,7 @@ export class S3MViewer {
1978
1637
 
1979
1638
  // 选中状态也要跟着继承:LOD 细化 / 瓦片重载会换掉 mesh,
1980
1639
  // 不重放就出现「点中的那栋楼一拉近就没高亮了」
1981
- this._replaySelectionOnto(mesh);
1640
+ this._sel.replayOnto(mesh);
1982
1641
  }
1983
1642
  }
1984
1643
 
@@ -2030,64 +1689,9 @@ export class S3MViewer {
2030
1689
  });
2031
1690
  }
2032
1691
 
2033
- /**
2034
- * 把「当前选中」重放到一块新登记的 mesh 上。
2035
- *
2036
- * 为什么需要:选中高亮是直接写在各 mesh 材质的 uSelBatch / uSelWhole 上的,
2037
- * 而 LOD 细化、瓦片重载、淘汰回补都会换掉 mesh —— 不重放的话,用户点中的那栋楼
2038
- * 一细化就失去高亮(状态还在 _selection.smid 里,只是没人往新 mesh 上刷)。
2039
- */
2040
- /**
2041
- * 判断一块无 SMID 单体化信息的网格(如倾斜摄影或白模整块)是否空间覆盖当前选中命中点
2042
- */
2043
- private _isMeshCoveringSelection(mesh: THREE.Mesh, sel: PickResult): boolean {
2044
- if (!sel.point) return false;
2045
- const geom = mesh.geometry;
2046
- if (!geom) return false;
2047
- if (!geom.boundingBox) geom.computeBoundingBox();
2048
- if (!geom.boundingBox) return false;
2049
-
2050
- // 将选中的三维场景点转入网格局部坐标系进行包围盒检测
2051
- const pt = new THREE.Vector3(sel.point[0], sel.point[1], sel.point[2]);
2052
- mesh.updateWorldMatrix(true, false);
2053
- const inv = mesh.matrixWorld.clone().invert();
2054
- const localPt = pt.applyMatrix4(inv);
2055
-
2056
- // 留出 0.5 米容差,避免刚好处于三角网格边缘缝隙时漏判
2057
- const box = geom.boundingBox.clone().expandByScalar(0.5);
2058
- return box.containsPoint(localPt);
2059
- }
2060
-
2061
- private _replaySelectionOnto(mesh: THREE.Mesh): void {
2062
- const sel = this._selection;
2063
- if (!sel) return;
2064
- const mat = styledMaterialOf(mesh);
2065
- if (!mat) return;
2066
-
2067
- const ud = mesh.userData as MeshUserData;
2068
- let batchId = -1;
2069
- if (sel.smid != null) {
2070
- const pickInfo = ud.geoRef?.pickInfo as Record<string | number, unknown> | undefined;
2071
- const entry = pickInfo?.[sel.smid] ?? pickInfo?.[String(sel.smid)];
2072
- batchId = entry ? pickEntryId(entry as any) : -1;
2073
- }
2074
- // 拾取命中的那块 mesh 用原始 batchId;同一 smid 散在别的几何里时按各自的区间算
2075
- if (mesh === sel.mesh) batchId = sel.batchId;
2076
- // 这块几何里根本没有这个单体 → 不动它(否则会把无关瓦片一起点亮)
2077
- if (batchId < 0 && mesh !== sel.mesh) {
2078
- if (sel.smid == null && ud.datasetId === sel.datasetId && this._isMeshCoveringSelection(mesh, sel)) {
2079
- batchId = -1;
2080
- } else {
2081
- return;
2082
- }
2083
- }
2084
-
2085
- mat.userData.uSelBatch.value = batchId;
2086
- mat.userData.uSelWhole.value = batchId < 0 ? 1 : 0;
2087
- if (!this._selectedMeshes.includes(mesh)) this._selectedMeshes.push(mesh);
2088
- if (isPointCloudObject(mesh)) this._applyPointCloudStyle(mesh, true);
2089
- this.uniforms.uSelMix.value = 1;
2090
- this.uniforms.uDim.value = this.style.dimOthers ? 1 : 0;
1692
+ /** @internal 把当前选中重放到新登记的 mesh 上,见 viewer/SelectionDecorator.ts */
1693
+ _replaySelectionOnto(mesh: THREE.Mesh): void {
1694
+ this._sel.replayOnto(mesh);
2091
1695
  }
2092
1696
 
2093
1697
  /** 瓦片被淘汰:从拾取表和场景里摘掉,并深度彻底释放所有几何体与材质显存 */
@@ -2095,7 +1699,7 @@ export class S3MViewer {
2095
1699
  node.group.traverse((child) => {
2096
1700
  const obj = child as any;
2097
1701
  if ((child as THREE.LineSegments).isLineSegments && obj.userData?.isEdgeLines) {
2098
- this._nativeEdgeLines.delete(child as THREE.LineSegments);
1702
+ this._edges.unregisterNativeLine(child as THREE.LineSegments);
2099
1703
  }
2100
1704
  if (isPointCloudObject(child)) this._pointClouds.delete(child);
2101
1705
  // 淡入还没结束就被淘汰:把通道收掉,否则会对着已释放的材质继续写
@@ -2104,34 +1708,34 @@ export class S3MViewer {
2104
1708
  obj.userData.tileFadeKey = undefined;
2105
1709
  }
2106
1710
  if (obj.userData?.pickId != null) {
2107
- this._pickables.delete(obj.userData.pickId);
1711
+ this._picks.pickables.delete(obj.userData.pickId);
2108
1712
  this._releasePickId(obj.userData.pickId);
2109
1713
  obj.userData.pickId = undefined;
2110
1714
  }
2111
1715
  if (obj.isMesh) {
2112
- const wasSelected = this._selectedMeshes.includes(obj);
1716
+ const wasSelected = this._sel.selectedMeshes.includes(obj);
2113
1717
  if (wasSelected) {
2114
- this._selectedMeshes = this._selectedMeshes.filter((m) => m !== obj);
1718
+ this._sel.selectedMeshes = this._sel.selectedMeshes.filter((m) => m !== obj);
2115
1719
  }
2116
- const currentSel = this._selection;
1720
+ const currentSel = this._sel.selection;
2117
1721
  if (currentSel && currentSel.mesh === obj) {
2118
1722
  const smid = currentSel.smid;
2119
1723
  const carrier = (
2120
1724
  smid != null
2121
1725
  ? [...(this._smidMeshes.get(smid) ?? [])].find((m) => m !== obj && m.parent)
2122
- : this._selectedMeshes.find((m) => m !== obj && m.parent && (m.parent as any).visible) ??
2123
- this._selectedMeshes.find((m) => m !== obj && m.parent)
1726
+ : this._sel.selectedMeshes.find((m) => m !== obj && m.parent && (m.parent as any).visible) ??
1727
+ this._sel.selectedMeshes.find((m) => m !== obj && m.parent)
2124
1728
  ) as THREE.Mesh | undefined;
2125
1729
  if (carrier) {
2126
1730
  // 选中身份交给承载同一 smid 的另一块 mesh(通常是接替显示的子瓦)
2127
- this._selection = { ...currentSel, mesh: carrier };
2128
- this._replaySelectionOnto(carrier);
1731
+ this._sel.selection = { ...currentSel, mesh: carrier };
1732
+ this._sel.replayOnto(carrier);
2129
1733
  } else {
2130
1734
  this.select(null);
2131
1735
  }
2132
1736
  }
2133
1737
  this._unindexMeshSmids(obj);
2134
- this._removeMeshHeight(obj);
1738
+ this._heights.remove(obj);
2135
1739
  this._featureStyleManager.disposeMesh(obj);
2136
1740
  const styled = styledMaterialOf(obj);
2137
1741
  styled?.dispose?.();
@@ -2156,20 +1760,6 @@ export class S3MViewer {
2156
1760
  * 拾取 ID 只有 2 个字节(RG 两个通道),65535 个网格就到头了,
2157
1761
  * 所以必须回收:瓦片淘汰 / 数据集移除时把 id 还回池子。
2158
1762
  */
2159
- private _allocPickId(): number {
2160
- const reused = this._freePickIds.pop();
2161
- if (reused !== undefined) return reused;
2162
- if (this._pickSeq > MAX_PICK_ID) {
2163
- for (let id = 0; id <= MAX_PICK_ID; id++) if (!this._pickables.has(id)) return id;
2164
- throw new Error(`[s3m] 可拾取网格超过 ${MAX_PICK_ID + 1} 个,2 字节 ID 编码不够用`);
2165
- }
2166
- return this._pickSeq++;
2167
- }
2168
-
2169
- private _releasePickId(id: number): void {
2170
- this._freePickIds.push(id);
2171
- }
2172
-
2173
1763
  /* ---------------------------------------------------------------- 样式 */
2174
1764
 
2175
1765
  /** 动态设置图层整体透明度 (0.0 ~ 1.0) */
@@ -2228,52 +1818,6 @@ export class S3MViewer {
2228
1818
  return { ...this._styleTarget, modeLabel: STYLE_LABELS[this._styleTarget.mode] };
2229
1819
  }
2230
1820
 
2231
- /**
2232
- * 挂 / 摘环境贴图。
2233
- *
2234
- * 金属度(`setStyle({ metalness })`)**必须**有环境图才看得出质感:
2235
- * PBR 的金属几乎没有漫反射,画面全靠 `scene.environment` 里的环境反射,
2236
- * 没有它时 metalness 一高就整片发黑,只剩两盏平行灯打成的小高光点。
2237
- *
2238
- * @param source 等距柱状图(.hdr / .jpg / .png,2:1)地址,或已加载的 `THREE.Texture`;
2239
- * `null` = 关掉环境图
2240
- * @param intensity 环境光强度(`scene.environmentIntensity`,默认 1)
2241
- *
2242
- * 传入 URL 时纹理由内部加载并转成 PMREM(预滤波 mipmap);传 Texture 时不会接管它的生命周期。
2243
- */
2244
- async setEnvironment(
2245
- source: string | THREE.Texture | null,
2246
- intensity = 1
2247
- ): Promise<void> {
2248
- if (!this.renderer || !this.scene) return;
2249
- this.scene.environmentIntensity = Math.max(0, intensity);
2250
-
2251
- if (source == null) {
2252
- this.scene.environment = null;
2253
- this._envRT?.dispose();
2254
- this._envRT = null;
2255
- this._requestRepaint();
2256
- return;
2257
- }
2258
-
2259
- const fromUrl = typeof source === 'string';
2260
- const tex = fromUrl ? await new THREE.TextureLoader().loadAsync(source) : source;
2261
- // PMREM 只认等距柱状图;普通贴图也按 2:1 全景处理
2262
- tex.mapping = THREE.EquirectangularReflectionMapping;
2263
-
2264
- const pmrem = new THREE.PMREMGenerator(this.renderer);
2265
- pmrem.compileEquirectangularShader();
2266
- const rt = pmrem.fromEquirectangular(tex);
2267
- pmrem.dispose();
2268
- if (fromUrl) tex.dispose();
2269
-
2270
- this.scene.environment = rt.texture;
2271
- // 上一张(同一个 viewer 反复换)要释放,否则显存里会一直堆
2272
- this._envRT?.dispose();
2273
- this._envRT = rt;
2274
- this._requestRepaint();
2275
- }
2276
-
2277
1821
  /** 把某个样式键的取值转成动画通道能吃的形状(标量 or 线性 RGB 三元组) */
2278
1822
  private _animTargetOf(key: string, patch: Partial<S3MStyle>): number | number[] {
2279
1823
  const value = (patch as Record<string, unknown>)[key];
@@ -2335,7 +1879,7 @@ export class S3MViewer {
2335
1879
  },
2336
1880
  write: (v) => {
2337
1881
  if (this.uniforms.uEdgeFade) this.uniforms.uEdgeFade.value = v[0];
2338
- this._syncEdgeStyle();
1882
+ this._edges.syncStyle();
2339
1883
  },
2340
1884
  });
2341
1885
 
@@ -2366,7 +1910,7 @@ export class S3MViewer {
2366
1910
  key === 'edgeOffset' ||
2367
1911
  key === 'edgeHullWidth'
2368
1912
  ) {
2369
- this._refreshEdgeLines();
1913
+ this._edges.refreshLines();
2370
1914
  }
2371
1915
  }
2372
1916
 
@@ -2399,8 +1943,8 @@ export class S3MViewer {
2399
1943
  // 临界角变了要重建 CPU 棱线(EdgesGeometry 的参数),开 / 关走 setEdges 整体刷新。
2400
1944
  // 必须先 _disposeEdges():setEdges 只挑「还没有棱线」的网格来建,
2401
1945
  // 不先拆掉的话已经建好的会被全部跳过,临界角改了等于没改。
2402
- if (('edgeThreshold' in patch || 'forceCpuEdges' in patch) && this._edgesEnabled) {
2403
- this._disposeEdges();
1946
+ if (('edgeThreshold' in patch || 'forceCpuEdges' in patch) && this._edges.enabled) {
1947
+ this._edges.disposeEdgeGeometry();
2404
1948
  this.setEdges(this.style.edges);
2405
1949
  } else if (
2406
1950
  'edgeColor' in patch ||
@@ -2411,12 +1955,12 @@ export class S3MViewer {
2411
1955
  'edgeHullWidth' in patch
2412
1956
  ) {
2413
1957
  // 颜色 / 粗细 / 外推距离 / 外扩厚度都只是 uniform,拖着滑块逐帧改也不重建几何
2414
- this._refreshEdgeLines();
1958
+ this._edges.refreshLines();
2415
1959
  }
2416
- if (this._selection) {
1960
+ if (this._sel.selection) {
2417
1961
  // 换描边画法要重建几何;颜色 / 粗细 / 拔高只是 uniform,拖着滑块也不卡
2418
- if ('outline' in patch || 'outlineStyle' in patch) this.select(this._selection, { silent: true });
2419
- else this._syncSelectionStyle();
1962
+ if ('outline' in patch || 'outlineStyle' in patch) this.select(this._sel.selection, { silent: true });
1963
+ else this._sel.syncStyle();
2420
1964
  }
2421
1965
  }
2422
1966
 
@@ -2492,7 +2036,7 @@ export class S3MViewer {
2492
2036
  this._opacityTransparent = transparent;
2493
2037
  // 关键:若 depthWrite 开启(默认开启),即使半透明也保持深度写入,完美遮挡背面棱线,防止背面线条透出
2494
2038
  const keepDepth = this.style.depthWrite !== false;
2495
- for (const p of this._pickables.values()) {
2039
+ for (const p of this._picks.pickables.values()) {
2496
2040
  if (isPointCloudObject(p.mesh)) continue;
2497
2041
  const m = styledMaterialOf(p.mesh);
2498
2042
  if (!m) continue;
@@ -2508,12 +2052,12 @@ export class S3MViewer {
2508
2052
  // 点云不走 StyleMaterial,自己有一套 uOpacity,逐帧刷的只有这几个对象
2509
2053
  for (const pc of this._pointClouds) this._applyPointCloudStyle(pc);
2510
2054
 
2511
- this._syncEdgeStyle();
2055
+ this._edges.syncStyle();
2512
2056
  this._requestRepaint();
2513
2057
  }
2514
2058
 
2515
2059
  /** 单个点云对象同步当前样式(着色模式 + 不透明度 + 选中高亮) */
2516
- private _applyPointCloudStyle(points: THREE.Points, selected = this._selectedMeshes.includes(points)): void {
2060
+ private _applyPointCloudStyle(points: THREE.Points, selected = this._sel.selectedMeshes.includes(points)): void {
2517
2061
  applyPointCloudStyle(points, this.style, this.uniforms, selected);
2518
2062
  }
2519
2063
 
@@ -2528,7 +2072,7 @@ export class S3MViewer {
2528
2072
  // shading 现在是共享 uniform(uFlatShading)驱动的运行期分支,不再是
2529
2073
  // flatShading define:翻一个 float 就完成切换,零重编译、零卡顿。
2530
2074
  const target = this.style.shading !== 'smooth' ? 1 : 0;
2531
- for (const p of this._pickables.values()) {
2075
+ for (const p of this._picks.pickables.values()) {
2532
2076
  const m = styledMaterialOf(p.mesh);
2533
2077
  const u = m?.userData.uFlatShading;
2534
2078
  if (!u || u.value === target) continue;
@@ -2568,7 +2112,7 @@ export class S3MViewer {
2568
2112
  'uBatchTexActive',
2569
2113
  'uTileFade',
2570
2114
  ] as const;
2571
- for (const p of this._pickables.values()) {
2115
+ for (const p of this._picks.pickables.values()) {
2572
2116
  const mesh = p.mesh;
2573
2117
  const raw = mesh.material;
2574
2118
  if (Array.isArray(raw)) continue; // styled 材质恒为单材质;数组材质(点云等)不归这里管
@@ -2603,7 +2147,7 @@ export class S3MViewer {
2603
2147
 
2604
2148
  // 场上还没有材质就没必要预热:program 会在首个材质出现时编译
2605
2149
  let hasMaterial = false;
2606
- for (const p of this._pickables.values()) {
2150
+ for (const p of this._picks.pickables.values()) {
2607
2151
  if (styledMaterialOf(p.mesh)) {
2608
2152
  hasMaterial = true;
2609
2153
  break;
@@ -2631,60 +2175,14 @@ export class S3MViewer {
2631
2175
  * 加载 n 块瓦片就是 O(n²) 次矩阵乘 + n² 个垃圾对象。现在只在网格进出的记一次,
2632
2176
  * 全局值从缓存里滚:只有被移除的那块正好卡在边界上时,才回退到一次 O(n) 重算。
2633
2177
  */
2634
- private _addMeshHeight(mesh: THREE.Mesh): void {
2635
- const bb = mesh.geometry?.boundingBox;
2636
- if (!bb) return;
2637
- this._boxScratch.copy(bb).applyMatrix4(mesh.matrixWorld);
2638
- const min = this._boxScratch.min.z;
2639
- const max = this._boxScratch.max.z;
2640
- if (!Number.isFinite(min) || !Number.isFinite(max)) return;
2641
- this._meshHeight.set(mesh, { min, max });
2642
- if (min < this._heightMin) this._heightMin = min;
2643
- if (max > this._heightMax) this._heightMax = max;
2644
- this._pushHeightRange();
2645
- }
2646
-
2647
- private _removeMeshHeight(mesh: THREE.Mesh): void {
2648
- const rec = this._meshHeight.get(mesh);
2649
- if (!rec) return;
2650
- this._meshHeight.delete(mesh);
2651
- // 只有正好卡在边界上才需要重算,否则滚动的 min/max 依然成立
2652
- if (rec.min === this._heightMin || rec.max === this._heightMax) this._recomputeHeightRange();
2653
- else this._pushHeightRange();
2654
- }
2655
-
2656
- /** 数据集整体位移(换高程偏移 / 重新布局)后,缓存的世界高度全部失效 */
2657
- private _refreshAllHeights(): void {
2658
- if (this._meshHeight.size === 0) return;
2659
- const meshes = [...this._meshHeight.keys()];
2660
- this._meshHeight.clear();
2661
- this._heightMin = Infinity;
2662
- this._heightMax = -Infinity;
2663
- for (const mesh of meshes) this._addMeshHeight(mesh);
2664
- this._pushHeightRange();
2665
- }
2666
-
2667
- private _recomputeHeightRange(): void {
2668
- let min = Infinity;
2669
- let max = -Infinity;
2670
- for (const h of this._meshHeight.values()) {
2671
- if (h.min < min) min = h.min;
2672
- if (h.max > max) max = h.max;
2673
- }
2674
- this._heightMin = min;
2675
- this._heightMax = max;
2676
- this._pushHeightRange();
2178
+ /** @internal 见 viewer/HeightIndex.ts(regressions.test.ts 直接调) */
2179
+ _addMeshHeight(mesh: THREE.Mesh): void {
2180
+ this._heights.add(mesh);
2677
2181
  }
2678
2182
 
2679
- private _pushHeightRange(): void {
2680
- let min = this._heightMin;
2681
- let max = this._heightMax;
2682
- if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
2683
- min = 0;
2684
- max = 100;
2685
- }
2686
- this.uniforms.uHeightMin.value = min;
2687
- this.uniforms.uHeightMax.value = max;
2183
+ /** @internal 见 viewer/HeightIndex.ts */
2184
+ _removeMeshHeight(mesh: THREE.Mesh): void {
2185
+ this._heights.remove(mesh);
2688
2186
  }
2689
2187
 
2690
2188
  /** 建 SMID → 网格 反查表,逐单体改样式时才知道该重算哪几块 */
@@ -2712,1232 +2210,154 @@ export class S3MViewer {
2712
2210
  }
2713
2211
  }
2714
2212
 
2715
- /**
2716
- * 全局棱线开关。
2717
- *
2718
- * 取值是 `boolean | EdgeStyle | EdgeStyle[]`(见 resolveEdgeModes):
2719
- * - `true` / `'line'` 折边线(旧口径)
2720
- * - `'offset'` 折边线 + 沿边法线外推 `edgeOffset` 米
2721
- * - `'hull'` 背面外扩轮廓(选中侧 outlineStyle:'hull' 的整场景版)
2722
- * - `'gpu'` 纯片元描边:零几何、零额外 pass,线宽约 1 像素
2723
- * - 数组 叠加,例如 `['line', 'hull']`
2724
- *
2725
- * 数据自带棱线包(primitiveType 13)时直接用,成本为零;`style.forceCpuEdges`
2726
- * 可以无视它,改由 CPU 现算 —— 那是 CPU 端的活,几十万面一次性建会卡住主线程
2727
- * (实测 20 个网格 1.9s),所以按 8ms 一帧切片,边建边出。
2728
- *
2729
- * 棱线本身用「屏幕空间等宽线」画(见 EdgeLines.ts):WebGL 的 lineWidth 基本无效,
2730
- * 只有摊成面向屏幕的四边形,`edgeWidth` 才能真正调粗。
2731
- *
2732
- * 带 `transition` 时做「淡入 / 淡出」,**不对几何做插值**(几何没法插值):
2733
- * 开:立刻置位 + 开始分片建线,同时 `uEdgeFade` 0→1 —— 建好一块亮一块,视觉上是渐显;
2734
- * 关:`uEdgeFade` 1→0,淡完才 dispose,避免棱线「啪」一下消失。
2735
- */
2736
- setEdges(edges: EdgesOption, transition?: TransitionOptions): void {
2737
- const wantAnim = !!transition && ((transition.duration ?? 0) > 0 || !!transition.spring);
2738
- const modes = resolveEdgeModes(edges);
2739
- // 换画法(line ↔ hull ↔ offset)是结构变更:旧装饰必须拆掉再建,
2740
- // 否则会出现「切到 hull 了折边线还留着」这种叠影。
2741
- // 同一画法重复调用(滑块逐帧 setEdges(true))不重建,几何是复用的。
2742
- const modesChanged = !sameEdgeModes(this._edgeModes, modes);
2743
-
2744
- this._edgeModes = modes;
2745
- this._edgesEnabled = modes.enabled;
2746
- this.style.edges = edges;
2747
- this._styleTarget.edges = edges;
2748
-
2749
- // 数据自带的棱线:直接切可见性(forceCpuEdges 时永远收起,改由 CPU 现算)。
2750
- // 只遍历注册时收集好的那批 LineSegments —— 全数据集 traverse 在几千节点下太贵。
2751
- // gpu 模式下 shader 自己描边,native 线段包必须收起,否则叠成双线
2752
- const nativeVisible = modes.enabled && !modes.gpu && this.style.forceCpuEdges !== true;
2753
- for (const lines of this._nativeEdgeLines) {
2754
- lines.visible = nativeVisible;
2755
- this._styleEdgeLine(lines);
2756
- }
2757
- this._syncEdgeStyle();
2758
-
2759
- // 关掉 gpu 画法就把后处理资源还回去(整屏 RT 白占显存没意义)。
2760
- // 必须放在「整体关闭」的早退之前,否则关棱线时永远轮不到它。
2761
- if (!modes.gpu && this._gpuEdge) {
2762
- this._gpuEdge.dispose();
2763
- this._gpuEdge = null;
2764
- }
2765
-
2766
- if (!modes.enabled) {
2767
- if (wantAnim) {
2768
- this.animator.to('style.edgeFade', 0, {
2769
- ...transition,
2770
- onComplete: () => {
2771
- this._disposeEdges();
2772
- this._requestRepaint();
2773
- transition?.onComplete?.();
2774
- },
2775
- });
2776
- } else {
2777
- this._setEdgeFade(1);
2778
- this._disposeEdges();
2779
- }
2780
- this._requestRepaint();
2781
- return;
2782
- }
2783
-
2784
- if (modesChanged) this._disposeEdges();
2785
-
2786
- const pending = [...this._pickables.values()]
2787
- .map((p) => p.mesh)
2788
- .filter((m) => {
2789
- const ud = m.userData as MeshUserData;
2790
- // 点云没有棱可描
2791
- if (isPointCloudObject(m)) return false;
2792
- // native 棱线还算不算数,取决于**当前**的 forceCpuEdges(它可以在运行中切换),
2793
- // 所以这里按注册时记下的原始标记重算,而不是沿用注册那一刻的结论。
2794
- ud.nativeEdges = ud.nativeEdgesRaw === true && this.style.forceCpuEdges !== true;
2795
- return !ud.nativeEdges && this._needsEdgeDecoration(ud);
2796
- });
2797
- let i = 0;
2798
- const step = (): void => {
2799
- const t0 = performance.now();
2800
- while (i < pending.length && performance.now() - t0 < 8) this._ensureEdgeDecoration(pending[i++]);
2801
- if (i < pending.length) setTimeout(step, 0);
2802
- else this._requestRepaint();
2803
- };
2804
- step();
2805
-
2806
- if (wantAnim) {
2807
- // 从 0 起淡入:新建出来的棱线自动继承当前进度,不会半路冒出一条满亮度的线
2808
- this._setEdgeFade(0);
2809
- this.animator.to('style.edgeFade', 1, transition);
2810
- } else {
2811
- this._setEdgeFade(1);
2812
- }
2813
- }
2814
-
2815
- /** 直接写棱线淡入乘子(不走动画通道) */
2816
- private _setEdgeFade(v: number): void {
2817
- if (this.uniforms.uEdgeFade) this.uniforms.uEdgeFade.value = v;
2818
- this._syncEdgeStyle();
2819
- }
2213
+ /* ---------------------------------------------------------------- 棱线 */
2820
2214
 
2821
- /** 释放所有现算棱线几何 */
2822
- private _disposeEdges(): void {
2823
- for (const p of this._pickables.values()) this._removeEdges(p.mesh);
2215
+ /** @internal 测试用:棱线是否开启(实现在 EdgeDecorator) */
2216
+ get _edgesEnabled(): boolean {
2217
+ return this._edges.enabled;
2824
2218
  }
2825
-
2826
- /**
2827
- * 挂载预制棱线缓存(Crease Cache)。
2828
- *
2829
- * 直接把 `source-creases.f32` 这类 Float32Array 缓冲载入为一组全局统一的
2830
- * LineSegments —— 相比逐 Mesh 现算 EdgesGeometry,性能高一个量级,视觉也最干净。
2831
- * 坐标按「场景本地坐标」(相对场景锚点的米)处理,与瓦片几何同一坐标系。
2832
- *
2833
- * @param url 棱线缓存地址(二进制 Float32 序列:x,y,z,x,y,z,… 成对成段)
2834
- */
2835
- async loadCreases(url: string): Promise<void> {
2836
- const resp = await authFetch(url, this._authenticate, 'creases');
2837
- if (!resp.ok) throw new Error(`[s3m] 棱线缓存加载失败: ${resp.status} ${url}`);
2838
- const buf = await resp.arrayBuffer();
2839
- const arr = new Float32Array(buf);
2840
- if (arr.length < 6) return;
2841
-
2842
- const geo = new THREE.BufferGeometry();
2843
- geo.setAttribute('position', new THREE.BufferAttribute(arr, 3));
2844
- const s = this.style;
2845
- const mat = new THREE.LineBasicMaterial({
2846
- color: new THREE.Color(s.edgeColor),
2847
- transparent: true,
2848
- opacity: Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) * clampOpacity(s.opacity),
2849
- depthWrite: false,
2850
- });
2851
- const lines = new THREE.LineSegments(geo, mat);
2852
- lines.renderOrder = 2;
2853
- lines.frustumCulled = false;
2854
- lines.raycast = () => {};
2855
-
2856
- this.clearCreases();
2857
- this._creaseLines = lines;
2858
- this.scene?.add(lines);
2859
- this._requestRepaint();
2219
+ /** @internal 测试用:当前画法 */
2220
+ get _edgeModes(): EdgeModes {
2221
+ return this._edges.modes;
2860
2222
  }
2861
-
2862
- /** 移除并释放预制棱线缓存 */
2863
- clearCreases(): void {
2864
- if (!this._creaseLines) return;
2865
- this.scene?.remove(this._creaseLines);
2866
- this._creaseLines.geometry.dispose();
2867
- (this._creaseLines.material as THREE.Material)?.dispose?.();
2868
- this._creaseLines = null;
2869
- this._requestRepaint();
2223
+ /** @internal 测试用:GPU 描边实例(可替换) */
2224
+ get _gpuEdge(): GpuEdgePass | null {
2225
+ return this._edges.gpuEdge;
2870
2226
  }
2871
-
2872
- /**
2873
- * 让全局棱线材质跟上 style.edgeColor / edgeWidth / edgeOpacity / edgeOffset。
2874
- *
2875
- * 全程 O(1):只有一份共享 edgeMaterial(所有逐 mesh 棱线都用它)+ 一份预制缓存
2876
- * + 一批共享外观对象的轮廓壳,所以棱线淡入淡出只要改一个 uniform,
2877
- * 新建瓦片自动继承当前进度。
2878
- */
2879
- private _syncEdgeStyle(): void {
2880
- const s = this.style;
2881
- const fade = this.uniforms.uEdgeFade?.value ?? 1;
2882
- const opacity =
2883
- Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) *
2884
- clampOpacity(s.opacity) *
2885
- fade;
2886
- const u = this.edgeMaterial.uniforms;
2887
- u.uColor.value.set(s.edgeColor);
2888
- u.uWidth.value = Math.max(0.1, s.edgeWidth ?? 1.5);
2889
- u.uOpacity.value = opacity;
2890
- // 外推距离只在 offset 画法下非 0;其它画法把线钉在面上(与改动前一致)
2891
- u.uOffset.value = this._edgeModes.offset ? Math.max(0, s.edgeOffset ?? 0.15) : 0;
2892
- // 外扩轮廓壳与折边线共用颜色 / 透明度,厚度是米制的单独一项
2893
- const h = this._hullStyleUniforms;
2894
- h.uColor.value.set(s.edgeColor);
2895
- h.uWidth.value = Math.max(0, s.edgeHullWidth ?? 0.9);
2896
- h.uOpacity.value = opacity;
2897
- // GPU 描边(edges: 'gpu')是后处理,参数每帧从 style 现取,这里不用同步 uniform
2898
- // 预制棱线缓存(LineBasicMaterial)同步颜色 / 不透明度
2899
- if (this._creaseLines) {
2900
- const m = this._creaseLines.material as THREE.LineBasicMaterial;
2901
- m.color.set(s.edgeColor);
2902
- m.opacity = opacity;
2903
- }
2227
+ set _gpuEdge(v: GpuEdgePass | null) {
2228
+ this._edges.gpuEdge = v;
2904
2229
  }
2905
2230
 
2906
- /** 让数据自带的棱线跟着 style.edgeColor / opacity 走(这些是 LineBasicMaterial,改不了粗细) */
2907
- private _styleEdgeLine(lines: THREE.LineSegments): void {
2908
- const mat = lines.material as THREE.LineBasicMaterial;
2909
- if (!mat || !mat.color) return;
2910
- mat.color.set(this.style.edgeColor);
2911
- const baseOpacity = this.style.edgeOpacity ?? 0.6;
2912
- mat.opacity =
2913
- baseOpacity *
2914
- clampOpacity(this.style.opacity) *
2915
- (this.uniforms.uEdgeFade?.value ?? 1);
2916
- mat.transparent = true;
2917
- mat.polygonOffset = true;
2918
- mat.polygonOffsetFactor = -1;
2919
- mat.polygonOffsetUnits = -1;
2920
- mat.needsUpdate = true;
2231
+ /** @internal 测试用:给一块网格补折边线 */
2232
+ _ensureEdges(mesh: THREE.Mesh): void {
2233
+ this._edges.ensureEdges(mesh);
2921
2234
  }
2922
2235
 
2923
- /**
2924
- * 刷新所有棱线外观。
2925
- *
2926
- * 数据自带的棱线是逐对象的 LineBasicMaterial,改颜色只能一个个写;但原来每次都要
2927
- * 遍历整个数据集树(几千个节点),颜色动画逐帧跑就是灾难。改成只刷注册时收集好的
2928
- * 那批 LineSegments,复杂度降到 O(棱线条数)。
2929
- */
2930
- private _refreshEdgeLines(): void {
2931
- this._syncEdgeStyle();
2932
- for (const lines of this._nativeEdgeLines) {
2933
- if (lines.parent) this._styleEdgeLine(lines);
2934
- }
2236
+ /** @internal 测试用:拆掉一块网格的棱线装饰 */
2237
+ _removeEdges(mesh: THREE.Mesh): void {
2238
+ this._edges.removeEdges(mesh);
2935
2239
  }
2936
2240
 
2937
- /** 地形 / 绿地 / 道路 / 地面铺装等不参与自动描边(数据自带棱线包另说) */
2938
- private static readonly _EDGE_SKIP_KEYWORDS = [
2939
- 'terrain', 'ground', 'vegetation', 'green', 'road', 'dem',
2940
- '绿地', '道路', '地面', '地形', '植被', '铺装',
2941
- ];
2942
-
2943
- /** 数据集级别的棱线开关:enableEdges 优先,否则跟随全局 style.edges */
2944
- private _datasetEdgesEnabled(entry: S3MDatasetEntry): boolean {
2945
- const opt = entry.options?.enableEdges;
2946
- if (opt === true) return true;
2947
- if (opt === false) return false;
2948
- return this._edgesEnabled;
2241
+ /** 全局棱线开关,实现在 viewer/EdgeDecorator.ts */
2242
+ setEdges(edges: EdgesOption, transition?: TransitionOptions): void {
2243
+ this._edges.setEdges(edges, transition);
2949
2244
  }
2950
2245
 
2951
- /** 该 mesh 是否命中「跳过自动描边」的关键字(skeletonName / 数据集名) */
2952
- private _shouldSkipAutoEdges(ud: MeshUserData, datasetName: string): boolean {
2953
- const hay = `${ud.skeletonName ?? ''} ${datasetName}`.toLowerCase();
2954
- return S3MViewer._EDGE_SKIP_KEYWORDS.some((k) => hay.includes(k));
2246
+ /** 挂载预制棱线缓存(Crease Cache),见 viewer/EdgeDecorator.ts */
2247
+ loadCreases(url: string): Promise<void> {
2248
+ return this._edges.loadCreases(url);
2955
2249
  }
2956
2250
 
2957
- /**
2958
- * 这块网格还缺哪种棱线装饰(缺就建,齐了就不动)。
2959
- *
2960
- * setEdges 的分片建线靠它筛「还没建完的」,注册新瓦片也靠它决定要不要动手。
2961
- */
2962
- private _needsEdgeDecoration(ud: MeshUserData): boolean {
2963
- if (this._edgeModes.line && !ud.edges) return true;
2964
- if (this._edgeModes.hull && !ud.edgeHull) return true;
2965
- return false;
2251
+ /** 移除并释放预制棱线缓存 */
2252
+ clearCreases(): void {
2253
+ this._edges.clearCreases();
2966
2254
  }
2967
2255
 
2968
- /** 按当前画法补齐这块网格的棱线装饰 */
2969
- private _ensureEdgeDecoration(mesh: THREE.Mesh): void {
2970
- if (this._edgeModes.line) this._ensureEdges(mesh);
2971
- if (this._edgeModes.hull) this._ensureHull(mesh);
2972
- }
2973
-
2974
- private _ensureEdges(mesh: THREE.Mesh): void {
2975
- const ud = mesh.userData as MeshUserData;
2976
- if (ud.edges || ud.nativeEdges || !mesh.geometry) return;
2977
- // 地形 / 绿地 / 道路等不参与自动描边(即使全局开棱线也要跳过)
2978
- if (this._shouldSkipAutoEdges(ud, '')) return;
2979
-
2980
- const posAttr = mesh.geometry.getAttribute('position');
2981
- const indexCount = mesh.geometry.index?.count ?? (posAttr ? posAttr.count : 0);
2982
- // 棱线是 CPU 端建的,超大几何直接跳过,避免拖慢加载
2983
- if (indexCount > 120000) return;
2984
-
2985
- // 1. 先做顶点容差焊合,让相邻三角形正确共享边索引。
2986
- // S3M 几何常为「每面独立顶点」,不焊合的话 EdgesGeometry 会把每条三角形边都当成边界,棱线爆炸。
2987
- // 注意用本地实现而不是 three/examples/jsm —— jsm 子路径导入会在宿主里
2988
- // 制造第二份 three 实例("Multiple instances of Three.js" 警告的元凶)。
2989
- // tolerance 取米级模型下的 5mm,足以合并重合顶点又不会误并真实相邻面。
2990
- const welded = mergeVerticesByPosition(mesh.geometry, 0.005);
2991
- // 2. 调大临界角(默认 55°,可在 style.edgeThreshold 调),过滤浅坡与曲面细分,只保留真正的棱。
2992
- const thresholdAngle = this.style.edgeThreshold ?? 55;
2993
-
2994
- // 'offset' 画法要额外带一条「边法线」才知道往哪推,所以走本地实现(它本来就算好了
2995
- // 相邻两面法线);其余画法保持 EdgesGeometry 这条老路,几何输出与改动前逐字一致。
2996
- let segments: Float32Array | null;
2997
- let normals: Float32Array | undefined;
2998
- if (this._edgeModes.offset) {
2999
- const raw: number[] = [];
3000
- segments = buildFeatureEdgeSegments(welded, null, thresholdAngle, raw);
3001
- if (raw.length > 0) normals = new Float32Array(raw);
3002
- } else {
3003
- const source = new THREE.EdgesGeometry(welded, thresholdAngle);
3004
- const attr = source.getAttribute('position') as THREE.BufferAttribute | undefined;
3005
- segments = attr ? (attr.array as Float32Array) : null;
3006
- source.dispose();
3007
- }
3008
- welded.dispose();
3009
- if (!segments || segments.length < 6) return;
3010
-
3011
- // 3. 回填每段棱线所属的单体 batchId。
3012
- // EdgesGeometry 只输出坐标不输出顶点号,只能拿端点坐标去查「位置 → batchId」表。
3013
- // 没有这张表,单体一动棱线就留在原地——这正是「棱线跟随」的关键一环。
3014
- const TOL = 0.005;
3015
- const batchMap = buildPositionBatchMap(mesh.geometry, TOL);
3016
- let batchIds: Float32Array | undefined;
3017
- if (batchMap) {
3018
- const segCount = segments.length / 6;
3019
- batchIds = new Float32Array(segCount);
3020
- for (let i = 0; i < segCount; i++) {
3021
- const s = i * 6;
3022
- batchIds[i] =
3023
- batchMap.get(positionQuantKey(segments[s], segments[s + 1], segments[s + 2], 1 / TOL)) ?? -1;
3024
- }
3025
- }
2256
+ /* ---------------------------------------------------------------- 拾取 */
3026
2257
 
3027
- const lines = new THREE.Mesh(
3028
- buildThickLineGeometry(segments, batchIds, normals),
3029
- this._createEdgeMaterial(mesh)
3030
- );
3031
- lines.renderOrder = 2;
3032
- // 屏幕空间外扩后可能超出原包围球,关掉剔除更稳
3033
- lines.frustumCulled = false;
3034
- lines.raycast = () => {};
3035
- (lines.userData as MeshUserData).isThickEdge = true;
3036
- ud.edges = lines;
3037
- mesh.add(lines); // 作为子节点,自动继承 mesh 的世界变换
2258
+ /** 屏幕像素 → 场景射线,见 viewer/PickService.ts */
2259
+ getRayAt(x: number, y: number): THREE.Ray | null {
2260
+ return this._picks.getRayAt(x, y);
3038
2261
  }
3039
2262
 
3040
- /**
3041
- * 给一块网格挂「背面外扩轮廓」(整场景版)。
3042
- *
3043
- * 复用选中侧那套 `createOutlineMaterial`:顶点沿法线鼓 `edgeHullWidth` 米、只画背面,
3044
- * 于是被本体挡住的部分不显示,露出来的那圈就是描边。与折边线的差别在于——
3045
- * 这里**不建任何新几何**,成本只是把同一块网格多画一遍,折边判定那套 CPU 活全省了。
3046
- *
3047
- * 实例化几何拿不到逐实例变换(轮廓壳是 mesh 的子节点,不吃 instanceMatrix),
3048
- * 直接跳过:宁可这栋没描边,也不要所有实例的壳叠在原点。
3049
- */
3050
- private _ensureHull(mesh: THREE.Mesh): void {
3051
- const ud = mesh.userData as MeshUserData;
3052
- if (ud.edgeHull || !mesh.geometry) return;
3053
- if ((mesh as unknown as { isInstancedMesh?: boolean }).isInstancedMesh) return;
3054
- // 地形 / 绿地 / 道路等不参与自动描边(即使全局开棱线也要跳过)
3055
- if (this._shouldSkipAutoEdges(ud, '')) return;
3056
-
3057
- // 逐网格一份材质(各绑各的单体状态纹理),外观 uniform 复用共享对象
3058
- const mat = createOutlineMaterial(
3059
- this.uniforms,
3060
- this.style.edgeHullWidth ?? 0.9,
3061
- this._hullStyleUniforms,
3062
- true
3063
- );
3064
- // 整块几何都要外扩:选中侧靠 uSelBatch 只扩一栋,全局必须置 whole
3065
- mat.uniforms.uSelWhole.value = 1;
3066
- mat.uniforms.uSelBatch.value = -1;
3067
- const styled = styledMaterialOf(mesh);
3068
- if (styled?.userData?.uBatchTex) {
3069
- mat.uniforms.uBatchTex.value = styled.userData.uBatchTex.value as THREE.Texture;
3070
- mat.uniforms.uBatchTexWidth.value = styled.userData.uBatchTexWidth.value as number;
3071
- mat.uniforms.uBatchTexActive.value = styled.userData.uBatchTexActive.value as number;
3072
- }
3073
-
3074
- const hull = new THREE.Mesh(mesh.geometry, mat);
3075
- hull.renderOrder = 2;
3076
- hull.frustumCulled = false;
3077
- hull.raycast = () => {};
3078
- (hull.userData as MeshUserData).isEdgeHull = true;
3079
- ud.edgeHull = hull;
3080
- mesh.add(hull); // 作为子节点,自动继承 mesh 的世界变换
2263
+ /** 按 canvas 左上角的 CSS 像素坐标拾取 */
2264
+ pick(x: number, y: number): PickResult | null {
2265
+ return this._picks.pick(x, y);
3081
2266
  }
3082
2267
 
3083
- /**
3084
- * 给一块网格造专属的棱线材质。
3085
- *
3086
- * 棱线材质必须逐网格一份,因为它要绑定**这个网格**的单体状态纹理;但外观
3087
- * (颜色 / 粗细 / 透明度)必须全局统一——所以外观 uniform 直接复用
3088
- * `_edgeStyleUniforms` 里的对象,`_syncEdgeStyle()` 改一次全部生效,
3089
- * program 也因为着色器相同而只编译一次。
3090
- */
3091
- private _createEdgeMaterial(mesh: THREE.Mesh): ThickLineMaterial {
3092
- const styled = styledMaterialOf(mesh);
3093
- const batch = styled?.userData?.uBatchTex
3094
- ? {
3095
- uBatchTex: styled.userData.uBatchTex,
3096
- uBatchTex2: styled.userData.uBatchTex2,
3097
- uBatchMix: this.uniforms.uBatchMix,
3098
- uBatchTexWidth: styled.userData.uBatchTexWidth,
3099
- uBatchTexActive: styled.userData.uBatchTexActive,
3100
- uPathTex: this.uniforms.uPathTex,
3101
- uPathCount: this.uniforms.uPathCount,
3102
- uPathTexActive: this.uniforms.uPathTexActive,
3103
- uPathTime: this.uniforms.uPathTime,
3104
- }
3105
- : undefined;
3106
- return createThickLineMaterial(this.lineShared, {}, this._edgeStyleUniforms, batch);
2268
+ /** 拾取并返回经纬度 + 高程 */
2269
+ pickPosition(x: number, y: number): { lngLat: [number, number]; altitude: number; point: [number, number, number] } | null {
2270
+ return this._picks.pickPosition(x, y);
3107
2271
  }
3108
2272
 
3109
- /** 选中棱线的逐网格材质,道理同上 */
3110
- private _createSelectionEdgeMaterial(mesh: THREE.Mesh): ThickLineMaterial {
3111
- const styled = styledMaterialOf(mesh);
3112
- const batch = styled?.userData?.uBatchTex
3113
- ? {
3114
- uBatchTex: styled.userData.uBatchTex,
3115
- uBatchTex2: styled.userData.uBatchTex2,
3116
- uBatchMix: this.uniforms.uBatchMix,
3117
- uBatchTexWidth: styled.userData.uBatchTexWidth,
3118
- uBatchTexActive: styled.userData.uBatchTexActive,
3119
- uPathTex: this.uniforms.uPathTex,
3120
- uPathCount: this.uniforms.uPathCount,
3121
- uPathTexActive: this.uniforms.uPathTexActive,
3122
- uPathTime: this.uniforms.uPathTime,
3123
- }
3124
- : undefined;
3125
- return createThickLineMaterial(this.lineShared, {}, this._selEdgeStyleUniforms, batch);
2273
+ /** 穿透拾取:沿射线返回前 `limit` 个命中 */
2274
+ drillPick(x: number, y: number, limit = 10): PickResult[] {
2275
+ return this._picks.drillPick(x, y, limit);
3126
2276
  }
3127
2277
 
3128
- private _removeEdges(mesh: THREE.Mesh): void {
3129
- const ud = mesh.userData as MeshUserData;
3130
-
3131
- const lines = ud.edges;
3132
- if (lines) {
3133
- mesh.remove(lines);
3134
- (lines as THREE.Mesh).geometry?.dispose();
3135
- // 棱线材质现在是逐网格一份(要绑各自的 batch 纹理),得跟着释放
3136
- const mat = (lines as THREE.Mesh).material;
3137
- if (Array.isArray(mat)) {
3138
- mat.forEach((m) => (m as any)?.dispose?.());
3139
- } else {
3140
- (mat as any)?.dispose?.();
3141
- }
3142
- ud.edges = null;
3143
- }
3144
-
3145
- // 外扩壳的 geometry 是**复用主网格的**,只能释放材质,不能 dispose 几何
3146
- const hull = ud.edgeHull;
3147
- if (hull) {
3148
- mesh.remove(hull);
3149
- const hmat = hull.material;
3150
- if (Array.isArray(hmat)) hmat.forEach((m) => (m as any)?.dispose?.());
3151
- else (hmat as any)?.dispose?.();
3152
- ud.edgeHull = null;
3153
- }
2278
+ /** 单体的经纬度包围盒 */
2279
+ getFeatureBounds(smid: number): [[number, number], [number, number]] | null {
2280
+ return this._picks.getFeatureBounds(smid);
3154
2281
  }
3155
2282
 
3156
- /* ---------------------------------------------------------------- 拾取 */
3157
-
3158
- /** 获取指定屏幕像素对应的场景射线(用于高精度求交与穿透拾取) */
3159
- getRayAt(x: number, y: number): THREE.Ray | null {
3160
- if (!this.camera || !this.canvas) return null;
3161
- const canvas = this.canvas;
3162
- const w = canvas.clientWidth || canvas.width;
3163
- const h = canvas.clientHeight || canvas.height;
3164
- if (!w || !h) return null;
3165
-
3166
- const ndcX = (x / w) * 2 - 1;
3167
- const ndcY = -(y / h) * 2 + 1;
3168
-
3169
- const near = new THREE.Vector3(ndcX, ndcY, -1).unproject(this.camera);
3170
- const far = new THREE.Vector3(ndcX, ndcY, 1).unproject(this.camera);
3171
- const dir = far.clone().sub(near).normalize();
3172
- return new THREE.Ray(near, dir);
3173
- }
3174
-
3175
- private _intersectMeshAtRay(mesh: THREE.Mesh, ray: THREE.Ray, instanceIndex?: number): THREE.Vector3 | null {
3176
- const raycaster = new THREE.Raycaster();
3177
- raycaster.ray.copy(ray);
3178
- const hits = raycaster.intersectObject(mesh, false);
3179
- if (!hits.length) return null;
3180
- if (instanceIndex != null) {
3181
- const match = hits.find((h) => h.instanceId === instanceIndex);
3182
- return match ? match.point : hits[0].point;
3183
- }
3184
- return hits[0].point;
2283
+ /** @internal 见 viewer/PickService.ts */
2284
+ _intersectMeshAtRay(mesh: THREE.Mesh, ray: THREE.Ray, instanceIndex?: number): THREE.Vector3 | null {
2285
+ return this._picks.intersectMeshAtRay(mesh, ray, instanceIndex);
3185
2286
  }
3186
2287
 
3187
- private _buildPickResult(
2288
+ /** @internal 见 viewer/PickService.ts */
2289
+ _buildPickResult(
3188
2290
  hit: { objectId: number; batchId: number; instanceIndex?: number },
3189
2291
  ref: PickableRef,
3190
2292
  exactPoint?: THREE.Vector3,
3191
2293
  ray?: THREE.Ray
3192
2294
  ): PickResult | null {
3193
- const ud = ref.mesh.userData as MeshUserData;
3194
- // 泼溅走独立分支:它既没有 instanceMatrix,也没有 geoRef.pickInfo 里的单体表,
3195
- // 走下面那段会在 `_instanceFeature()` 拿到 null 直接返回 —— 表现是「点得中但拿不到结果」。
3196
- if (ud.isGaussianSplat) return this._buildSplatPickResult(hit, ref, exactPoint, ray);
3197
- let smid: number | null = null;
3198
- let batchId = hit.batchId;
3199
- let instanceIndex = hit.instanceIndex;
3200
- let geoRef = ud.geoRef;
3201
- let segments: Array<{ vertexOffset: number; vertexCount: number }> = [];
3202
-
3203
- if (instanceIndex != null) {
3204
- const feature = this._instanceFeature(ref.mesh, geoRef, instanceIndex);
3205
- if (!feature) return null;
3206
- smid = feature.smid;
3207
- batchId = feature.batchId;
3208
- } else {
3209
- geoRef = (ud.geoByBatchId as Map<number, GeoPackageEntry> | undefined)?.get(hit.batchId) ?? ud.geoRef;
3210
- const resolved = resolveFeature(geoRef, hit.batchId);
3211
- smid = resolved.smid;
3212
- segments = resolved.segments;
3213
- }
3214
-
3215
- if (this._isFeatureHidden(smid, batchId)) return null;
3216
-
3217
- let box: SegmentBox | null = null;
3218
- let totalTriangles = 0;
3219
-
3220
- if (instanceIndex != null) {
3221
- box = this._instanceBox(ref.mesh, instanceIndex);
3222
- totalTriangles = ref.mesh.geometry.index ? Math.round(ref.mesh.geometry.index.count / 3) : 0;
3223
- } else if (smid != null && this._smidMeshes.has(smid)) {
3224
- const meshes = this._smidMeshes.get(smid)!;
3225
- let minX = Infinity;
3226
- let minY = Infinity;
3227
- let minZ = Infinity;
3228
- let maxX = -Infinity;
3229
- let maxY = -Infinity;
3230
- let maxZ = -Infinity;
3231
- let count = 0;
3232
- for (const m of meshes) {
3233
- const mUd = m.userData as MeshUserData;
3234
- const mPick = mUd.geoRef?.pickInfo;
3235
- const entry = (mPick as Record<string | number, unknown> | undefined)?.[smid] ??
3236
- (mPick as Record<string | number, unknown> | undefined)?.[String(smid)];
3237
- const bId = entry ? pickEntryId(entry as any) : -1;
3238
- const mSegs = (entry as any)?.segments ?? (bId >= 0 ? resolveFeature(mUd.geoRef, bId).segments : []);
3239
- const mBox = measureSegments(m.geometry, mSegs);
3240
- if (mBox) {
3241
- if (mBox.minX < minX) minX = mBox.minX;
3242
- if (mBox.minY < minY) minY = mBox.minY;
3243
- if (mBox.minZ < minZ) minZ = mBox.minZ;
3244
- if (mBox.maxX > maxX) maxX = mBox.maxX;
3245
- if (mBox.maxY > maxY) maxY = mBox.maxY;
3246
- if (mBox.maxZ > maxZ) maxZ = mBox.maxZ;
3247
- count += mBox.count;
3248
- totalTriangles += mBox.triangles;
3249
- }
3250
- }
3251
- if (count > 0) {
3252
- box = { minX, minY, minZ, maxX, maxY, maxZ, count, triangles: totalTriangles };
3253
- }
3254
- }
3255
-
3256
- if (!box) {
3257
- box = measureSegments(ref.mesh.geometry, segments);
3258
- if (!box && ref.mesh.geometry) {
3259
- if (!ref.mesh.geometry.boundingBox) ref.mesh.geometry.computeBoundingBox();
3260
- const bb = ref.mesh.geometry.boundingBox;
3261
- if (bb) {
3262
- box = {
3263
- minX: bb.min.x,
3264
- maxX: bb.max.x,
3265
- minY: bb.min.y,
3266
- maxY: bb.max.y,
3267
- minZ: bb.min.z,
3268
- maxZ: bb.max.z,
3269
- count: 0,
3270
- triangles: 0,
3271
- };
3272
- }
3273
- }
3274
- }
3275
- const geometry = ref.mesh.geometry;
3276
- const triangles = totalTriangles > 0 ? totalTriangles : (geometry.index ? Math.round(geometry.index.count / 3) : 0);
3277
- const attributes = buildAttributes({ smid, box, triangles, floorHeight: this.style.floorHeight });
3278
-
3279
- let centerLngLat: [number, number] | null = null;
3280
- if (box && this.sceneAnchor) {
3281
- const v = new THREE.Vector3((box.minX + box.maxX) / 2, (box.minY + box.maxY) / 2, (box.minZ + box.maxZ) / 2);
3282
- v.applyMatrix4(ref.mesh.matrixWorld);
3283
- const s = this.sceneAnchor.meterInMercator;
3284
- centerLngLat = this._lngLatFromWorld(v.x * s + this.sceneAnchor.x, -v.y * s + this.sceneAnchor.y);
3285
- }
3286
-
3287
- // 精确命中点求交计算
3288
- let hitPoint: THREE.Vector3 | null = exactPoint ?? null;
3289
- if (!hitPoint && ray) {
3290
- hitPoint = this._intersectMeshAtRay(ref.mesh, ray, instanceIndex);
3291
- }
3292
-
3293
- let point: [number, number, number] | null = null;
3294
- let lngLat: [number, number] | null = null;
3295
- let altitude: number | null = null;
3296
-
3297
- if (hitPoint && this.sceneAnchor) {
3298
- const off = smid != null ? this._featureStyleManager.getFeatureEffectiveOffset(smid) : {};
3299
- const trans = off.translation;
3300
- const elev = off.elevation;
3301
- const finalPos = hitPoint.clone();
3302
-
3303
- if (off.rotation) {
3304
- const rotMat = resolveRotationMatrix(off.rotation);
3305
- if (rotMat) {
3306
- const m3 = new THREE.Matrix3().set(
3307
- rotMat[0], rotMat[1], rotMat[2],
3308
- rotMat[3], rotMat[4], rotMat[5],
3309
- rotMat[6], rotMat[7], rotMat[8]
3310
- );
3311
- const piv = off.pivot ? new THREE.Vector3(off.pivot[0], off.pivot[1], off.pivot[2]) : new THREE.Vector3(0, 0, 0);
3312
- finalPos.sub(piv).applyMatrix3(m3).add(piv);
3313
- }
3314
- }
3315
-
3316
- if (trans) finalPos.add(new THREE.Vector3(trans[0], trans[1], trans[2]));
3317
- else if (elev) finalPos.z += elev;
3318
-
3319
- point = [finalPos.x, finalPos.y, finalPos.z];
3320
- const s = this.sceneAnchor.meterInMercator;
3321
- lngLat = this._lngLatFromWorld(finalPos.x * s + this.sceneAnchor.x, -finalPos.y * s + this.sceneAnchor.y);
3322
- altitude = finalPos.z + (this.sceneAnchor.altitude ?? 0);
3323
- } else if (centerLngLat && box && this.sceneAnchor) {
3324
- lngLat = centerLngLat;
3325
- altitude = (box.minZ + box.maxZ) / 2 + (this.sceneAnchor.altitude ?? 0);
3326
- }
3327
-
3328
- return {
3329
- datasetId: ref.datasetId,
3330
- datasetName: this.datasets.get(ref.datasetId)?.name ?? ref.datasetId,
3331
- objectId: hit.objectId,
3332
- batchId,
3333
- instanceIndex,
3334
- skeletonName: ref.skeletonName,
3335
- centerLngLat,
3336
- point,
3337
- lngLat,
3338
- altitude,
3339
- ...attributes,
3340
- mesh: ref.mesh,
3341
- };
2295
+ return this._picks.buildPickResult(hit, ref, exactPoint, ray);
3342
2296
  }
3343
2297
 
3344
- /**
3345
- * 泼溅的拾取结果。
3346
- *
3347
- * 整块泼溅瓦片当一个单体(没有单体级 smid 可报),命中点由 `splatRaycast` 给出 ——
3348
- * 它按每个高斯的 3σ 球求交,比拾取那一趟为了命中率摊开的 4px 方块准得多。
3349
- * 包围盒必须由 `iCenter` 算:geometry 的 position 只是 [-1,1]² 的四边形角点。
3350
- */
3351
- private _buildSplatPickResult(
2298
+ /** @internal 见 viewer/PickService.ts */
2299
+ _buildSplatPickResult(
3352
2300
  hit: { objectId: number; batchId: number; instanceIndex?: number },
3353
2301
  ref: PickableRef,
3354
2302
  exactPoint?: THREE.Vector3,
3355
2303
  ray?: THREE.Ray
3356
2304
  ): PickResult | null {
3357
- const anchor = this.sceneAnchor;
3358
- if (!anchor) return null;
3359
- const mesh = ref.mesh;
3360
-
3361
- let hitPoint = exactPoint ?? null;
3362
- if (!hitPoint && ray) hitPoint = this._intersectMeshAtRay(mesh, ray);
3363
-
3364
- const local = splatCenterBounds(mesh.geometry);
3365
- const box: SegmentBox | null = local
3366
- ? {
3367
- minX: local.minX,
3368
- minY: local.minY,
3369
- minZ: local.minZ,
3370
- maxX: local.maxX,
3371
- maxY: local.maxY,
3372
- maxZ: local.maxZ,
3373
- count: local.count,
3374
- triangles: 0,
3375
- }
3376
- : null;
3377
-
3378
- const attributes = buildAttributes({
3379
- smid: null,
3380
- box,
3381
- triangles: 0,
3382
- floorHeight: this.style.floorHeight,
3383
- });
3384
-
3385
- const s = anchor.meterInMercator;
3386
- let point: [number, number, number] | null = null;
3387
- let lngLat: [number, number] | null = null;
3388
- let altitude: number | null = null;
3389
- let centerLngLat: [number, number] | null = null;
3390
-
3391
- if (hitPoint) {
3392
- point = [hitPoint.x, hitPoint.y, hitPoint.z];
3393
- lngLat = this._lngLatFromWorld(hitPoint.x * s + anchor.x, -hitPoint.y * s + anchor.y);
3394
- altitude = hitPoint.z + (anchor.altitude ?? 0);
3395
- // 泼溅命中的就是一个高斯,没有「单体中心」可言,用命中点当中心
3396
- centerLngLat = lngLat;
3397
- } else if (box) {
3398
- const v = new THREE.Vector3(
3399
- (box.minX + box.maxX) / 2,
3400
- (box.minY + box.maxY) / 2,
3401
- (box.minZ + box.maxZ) / 2
3402
- ).applyMatrix4(mesh.matrixWorld);
3403
- centerLngLat = this._lngLatFromWorld(v.x * s + anchor.x, -v.y * s + anchor.y);
3404
- altitude = v.z + (anchor.altitude ?? 0);
3405
- }
3406
-
3407
- return {
3408
- datasetId: ref.datasetId,
3409
- datasetName: this.datasets.get(ref.datasetId)?.name ?? ref.datasetId,
3410
- objectId: hit.objectId,
3411
- batchId: -1,
3412
- instanceIndex: undefined,
3413
- skeletonName: ref.skeletonName,
3414
- centerLngLat,
3415
- point,
3416
- lngLat,
3417
- altitude,
3418
- ...attributes,
3419
- mesh,
3420
- };
2305
+ return this._picks.buildSplatPickResult(hit, ref, exactPoint, ray);
3421
2306
  }
3422
2307
 
3423
- /** 按 canvas 左上角的 CSS 像素坐标拾取。 */
3424
- pick(x: number, y: number): PickResult | null {
3425
- if (!this.picker || !this.scene || !this.camera || !this.sceneAnchor) return null;
3426
- const hit = this.picker.pick(this.scene, this.camera, x, y);
3427
- if (!hit) return null;
3428
-
3429
- const ref = this._pickables.get(hit.objectId);
3430
- if (!ref) return null;
3431
-
3432
- const ray = this.getRayAt(x, y);
3433
- return this._buildPickResult(hit, ref, undefined, ray ?? undefined);
2308
+ /** @internal 见 viewer/PickService.ts */
2309
+ _instanceFeature(mesh: THREE.Mesh, geoRef: GeoPackageEntry | undefined, instanceIndex: number): InstanceFeature | null {
2310
+ return this._picks.instanceFeature(mesh, geoRef, instanceIndex);
3434
2311
  }
3435
2312
 
3436
- /**
3437
- * 拾取指定屏幕像素对应的世界坐标与场景位置。
3438
- * 优先与命中的建筑网格精确求交;未命中建筑时若视线与地表相交则返回地面交点。
3439
- */
3440
- pickPosition(x: number, y: number): { lngLat: [number, number]; altitude: number; point: [number, number, number] } | null {
3441
- if (!this.camera || !this.sceneAnchor) return null;
3442
-
3443
- const hit = this.pick(x, y);
3444
- if (hit?.point && hit?.lngLat && hit.altitude != null) {
3445
- return {
3446
- lngLat: hit.lngLat,
3447
- altitude: hit.altitude,
3448
- point: hit.point,
3449
- };
3450
- }
3451
-
3452
- const ray = this.getRayAt(x, y);
3453
- if (!ray) return null;
3454
-
3455
- const groundPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
3456
- const target = new THREE.Vector3();
3457
- const intersect = ray.intersectPlane(groundPlane, target);
3458
- if (intersect) {
3459
- const s = this.sceneAnchor.meterInMercator;
3460
- const lngLat = this._lngLatFromWorld(intersect.x * s + this.sceneAnchor.x, -intersect.y * s + this.sceneAnchor.y);
3461
- const altitude = intersect.z + (this.sceneAnchor.altitude ?? 0);
3462
- return {
3463
- lngLat,
3464
- altitude,
3465
- point: [intersect.x, intersect.y, intersect.z],
3466
- };
3467
- }
3468
- return null;
2313
+ /** @internal 见 viewer/PickService.ts */
2314
+ _instanceBox(mesh: THREE.Mesh, instanceIndex: number): SegmentBox | null {
2315
+ return this._picks.instanceBox(mesh, instanceIndex);
3469
2316
  }
3470
2317
 
3471
- /**
3472
- * 沿视线方向穿透拾取多个单体,按由近至远排序返回。
3473
- */
3474
- drillPick(x: number, y: number, limit = 10): PickResult[] {
3475
- if (!this.sceneAnchor || !this.camera) return [];
3476
- const ray = this.getRayAt(x, y);
3477
- if (!ray) return [];
3478
-
3479
- const raycaster = new THREE.Raycaster();
3480
- raycaster.ray.copy(ray);
3481
-
3482
- const meshes = [...this._pickables.values()]
3483
- .map((p) => p.mesh)
3484
- .filter((m) => {
3485
- let curr: THREE.Object3D | null = m;
3486
- while (curr) {
3487
- if (!curr.visible) return false;
3488
- curr = curr.parent;
3489
- }
3490
- return true;
3491
- });
3492
- const hits = raycaster.intersectObjects(meshes, false);
3493
- if (!hits.length) return [];
3494
-
3495
- const results: PickResult[] = [];
3496
- const seenSmids = new Set<number>();
3497
- const seenBatches = new Set<string>();
3498
-
3499
- for (const hit of hits) {
3500
- if (results.length >= limit) break;
3501
- const mesh = hit.object as THREE.Mesh;
3502
- const ud = mesh.userData as MeshUserData & { pickId?: number };
3503
- if (ud.pickId == null) continue;
3504
- const ref = this._pickables.get(ud.pickId);
3505
- if (!ref) continue;
3506
-
3507
- let smid: number | null = null;
3508
- let batchId = -1;
3509
- let instanceIndex = hit.instanceId;
3510
-
3511
- if (instanceIndex != null) {
3512
- const feature = this._instanceFeature(mesh, ud.geoRef, instanceIndex);
3513
- if (!feature) continue;
3514
- smid = feature.smid;
3515
- batchId = feature.batchId;
3516
- } else if (hit.faceIndex != null) {
3517
- const batchAttr = mesh.geometry.attributes.aBatchId;
3518
- if (batchAttr) {
3519
- const idx = mesh.geometry.index ? mesh.geometry.index.getX(hit.faceIndex * 3) : hit.faceIndex * 3;
3520
- batchId = Math.round(batchAttr.getX(idx));
3521
- const geoRef = (ud.geoByBatchId as Map<number, GeoPackageEntry> | undefined)?.get(batchId) ?? ud.geoRef;
3522
- const resolved = resolveFeature(geoRef, batchId);
3523
- smid = resolved.smid;
3524
- }
3525
- }
3526
-
3527
- if (smid != null) {
3528
- if (seenSmids.has(smid)) continue;
3529
- seenSmids.add(smid);
3530
- } else {
3531
- const key = `${ud.pickId}:${batchId}:${instanceIndex ?? -1}`;
3532
- if (seenBatches.has(key)) continue;
3533
- seenBatches.add(key);
3534
- }
3535
-
3536
- if (this._isFeatureHidden(smid, batchId)) continue;
3537
-
3538
- const pickHit = {
3539
- objectId: ud.pickId,
3540
- batchId,
3541
- instanceIndex,
3542
- };
3543
- const res = this._buildPickResult(pickHit, ref, hit.point);
3544
- if (res) results.push(res);
3545
- }
3546
-
3547
- return results;
2318
+ /** @internal 拾取 id 池,见 viewer/PickService.ts */
2319
+ _allocPickId(): number {
2320
+ return this._picks.allocPickId();
3548
2321
  }
3549
2322
 
3550
- /**
3551
- * 获取指定 SMID 单体的经纬度包围盒 [[minLng, minLat], [maxLng, maxLat]]。
3552
- * 若模型尚未加载到场景中或无此单体则返回 null。
3553
- */
3554
- getFeatureBounds(smid: number): [[number, number], [number, number]] | null {
3555
- if (!this.sceneAnchor) return null;
3556
- let minLng = Infinity;
3557
- let maxLng = -Infinity;
3558
- let minLat = Infinity;
3559
- let maxLat = -Infinity;
3560
- let found = false;
3561
-
3562
- for (const ref of this._pickables.values()) {
3563
- const mesh = ref.mesh;
3564
- const ud = mesh.userData as MeshUserData;
3565
- const geoRef = ud.geoRef;
3566
- const pickInfo = geoRef?.pickInfo;
3567
- if (!pickInfo) continue;
3568
-
3569
- let box: SegmentBox | null = null;
3570
- if ((mesh as THREE.InstancedMesh).isInstancedMesh) {
3571
- let lookup = ud.instanceLookup as Map<number, InstanceFeature> | undefined;
3572
- if (!lookup) {
3573
- lookup = buildInstanceLookup(pickInfo);
3574
- ud.instanceLookup = lookup;
3575
- }
3576
- for (const [instIdx, feat] of lookup) {
3577
- if (feat.smid === smid) {
3578
- box = this._instanceBox(mesh, instIdx);
3579
- break;
3580
- }
3581
- }
3582
- } else {
3583
- const infoMap = pickInfo as Record<string | number, unknown>;
3584
- const entry = infoMap[smid] ?? infoMap[String(smid)];
3585
- if (entry) {
3586
- const bId = pickEntryId(entry);
3587
- const resolved = resolveFeature(geoRef, bId);
3588
- box = measureSegments(mesh.geometry, resolved.segments);
3589
- if (!box && mesh.geometry) {
3590
- if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
3591
- const bb = mesh.geometry.boundingBox;
3592
- if (bb) {
3593
- box = {
3594
- minX: bb.min.x,
3595
- maxX: bb.max.x,
3596
- minY: bb.min.y,
3597
- maxY: bb.max.y,
3598
- minZ: bb.min.z,
3599
- maxZ: bb.max.z,
3600
- count: 0,
3601
- triangles: 0,
3602
- };
3603
- }
3604
- }
3605
- }
3606
- }
3607
-
3608
- if (box) {
3609
- found = true;
3610
- const corners = [
3611
- new THREE.Vector3(box.minX, box.minY, box.minZ),
3612
- new THREE.Vector3(box.maxX, box.minY, box.minZ),
3613
- new THREE.Vector3(box.minX, box.maxY, box.minZ),
3614
- new THREE.Vector3(box.maxX, box.maxY, box.minZ),
3615
- new THREE.Vector3(box.minX, box.minY, box.maxZ),
3616
- new THREE.Vector3(box.maxX, box.minY, box.maxZ),
3617
- new THREE.Vector3(box.minX, box.maxY, box.maxZ),
3618
- new THREE.Vector3(box.maxX, box.maxY, box.maxZ),
3619
- ];
3620
-
3621
- const off = smid != null ? this._featureStyleManager.getFeatureEffectiveOffset(smid) : {};
3622
- const trans = off.translation;
3623
- const elev = off.elevation;
3624
-
3625
- const rotMat = off.rotation ? resolveRotationMatrix(off.rotation) : null;
3626
- let rotM3: THREE.Matrix3 | null = null;
3627
- let rotPiv: THREE.Vector3 | null = null;
3628
- if (rotMat) {
3629
- rotM3 = new THREE.Matrix3().set(
3630
- rotMat[0], rotMat[1], rotMat[2],
3631
- rotMat[3], rotMat[4], rotMat[5],
3632
- rotMat[6], rotMat[7], rotMat[8]
3633
- );
3634
- rotPiv = off.pivot ? new THREE.Vector3(off.pivot[0], off.pivot[1], off.pivot[2]) : new THREE.Vector3(0, 0, 0);
3635
- }
3636
-
3637
- // GPU 路径:位移发生在顶点着色器里,包围盒要按同一套公式在 CPU 复算,
3638
- // 否则「运动中的车」的包围盒会停在路径原点(相机 fitBounds / 属性过滤都会错)。
3639
- const gpuPose = smid != null ? this._gpuPathPoseAt(smid) : null;
3640
- let headM3: THREE.Matrix3 | null = null;
3641
- if (gpuPose && gpuPose.yaw !== 0) {
3642
- const c = Math.cos(gpuPose.yaw);
3643
- const s = Math.sin(gpuPose.yaw);
3644
- // 与着色器的 mat3( c, s, 0, -s, c, 0, 0, 0, 1 ) 等价:GLSL 是列优先,
3645
- // three 的 Matrix3.set 是行优先,所以这里写成 (c, -s; s, c) 才是同一个矩阵。
3646
- headM3 = new THREE.Matrix3().set(c, -s, 0, s, c, 0, 0, 0, 1);
3647
- }
3648
-
3649
- for (const c of corners) {
3650
- if (rotM3 && rotPiv) c.sub(rotPiv).applyMatrix3(rotM3).add(rotPiv);
3651
- if (headM3) {
3652
- const piv = rotPiv ?? new THREE.Vector3(0, 0, 0);
3653
- c.sub(piv).applyMatrix3(headM3).add(piv);
3654
- }
3655
- if (trans) c.add(new THREE.Vector3(trans[0], trans[1], trans[2]));
3656
- else if (elev) c.z += elev;
3657
- if (gpuPose) c.add(new THREE.Vector3(gpuPose.pos[0], gpuPose.pos[1], gpuPose.pos[2]));
3658
-
3659
- c.applyMatrix4(mesh.matrixWorld);
3660
- const s = this.sceneAnchor.meterInMercator;
3661
- const [lng, lat] = this._lngLatFromWorld(c.x * s + this.sceneAnchor.x, -c.y * s + this.sceneAnchor.y);
3662
- if (lng < minLng) minLng = lng;
3663
- if (lng > maxLng) maxLng = lng;
3664
- if (lat < minLat) minLat = lat;
3665
- if (lat > maxLat) maxLat = lat;
3666
- }
3667
- }
3668
- }
3669
-
3670
- if (!found) return null;
3671
- return [
3672
- [minLng, minLat],
3673
- [maxLng, maxLat],
3674
- ];
2323
+ /** @internal */
2324
+ _releasePickId(id: number): void {
2325
+ this._picks.releasePickId(id);
3675
2326
  }
3676
2327
 
3677
- /** 实例序号 → 所属单体(按网格缓存反查表,实例多的数据不用每次重建) */
3678
- private _instanceFeature(
3679
- mesh: THREE.Mesh,
3680
- geoRef: GeoPackageEntry | undefined,
3681
- instanceIndex: number
3682
- ): InstanceFeature | null {
3683
- const ud = mesh.userData as MeshUserData & { instanceLookup?: Map<number, InstanceFeature> };
3684
- if (!ud.instanceLookup) ud.instanceLookup = buildInstanceLookup(geoRef?.pickInfo);
3685
- return ud.instanceLookup.get(instanceIndex) ?? null;
3686
- }
3687
-
3688
- /** 实例化几何的单体包围盒:base mesh 的包围盒套上该实例的矩阵 */
3689
- private _instanceBox(mesh: THREE.Mesh, instanceIndex: number): SegmentBox | null {
3690
- const im = mesh as THREE.InstancedMesh;
3691
- if (!im.isInstancedMesh) return null;
3692
- const geometry = mesh.geometry;
3693
- if (!geometry.boundingBox) geometry.computeBoundingBox();
3694
- const bb = geometry.boundingBox;
3695
- if (!bb) return null;
3696
- const m = new THREE.Matrix4();
3697
- im.getMatrixAt(instanceIndex, m);
3698
- const box = bb.clone().applyMatrix4(m);
3699
- return {
3700
- minX: box.min.x,
3701
- minY: box.min.y,
3702
- minZ: box.min.z,
3703
- maxX: box.max.x,
3704
- maxY: box.max.y,
3705
- maxZ: box.max.z,
3706
- count: geometry.getAttribute('position')?.count ?? 0,
3707
- triangles: geometry.index ? Math.round(geometry.index.count / 3) : 0,
3708
- };
2328
+ /** @internal 拾取表(测试直接读写),见 viewer/PickService.ts */
2329
+ get _pickables(): Map<number, PickableRef> {
2330
+ return this._picks.pickables;
3709
2331
  }
3710
2332
 
3711
- /** 选中/取消选中单体。`silent` 为 true 时不回调 onPick。 */
3712
- select(result: PickResult | null, { silent = false }: { silent?: boolean } = {}): void {
3713
- // 清掉上一次
3714
- if (this._selection) {
3715
- for (const obj of this._selectedMeshes) {
3716
- // 点云:恢复成当前着色模式(去掉选中混色)
3717
- if (isPointCloudObject(obj)) {
3718
- this._applyPointCloudStyle(obj, false);
3719
- continue;
3720
- }
3721
- const mat = styledMaterialOf(obj as THREE.Mesh);
3722
- if (mat) {
3723
- mat.userData.uSelBatch.value = -1;
3724
- mat.userData.uSelWhole.value = 0;
3725
- }
3726
- }
3727
- this._selectedMeshes = [];
3728
- this._clearSelectionDecoration();
3729
- this._selection = null;
3730
- }
3731
-
3732
- if (!result) {
3733
- this.uniforms.uSelMix.value = 0;
3734
- this.uniforms.uDim.value = 0;
3735
- this.outlineMaterial.uniforms.uSelBatch.value = -1;
3736
- this.outlineMaterial.uniforms.uSelWhole.value = 0;
3737
- this.outlineMaterial.uniforms.uElevation.value = 0;
3738
- this._requestRepaint();
3739
- if (!silent) this.onPick(null);
3740
- return;
3741
- }
3742
-
3743
- this._selection = result;
3744
- const targetMeshes = new Set<THREE.Mesh>();
3745
- if (result.smid != null) {
3746
- const smidSet = this._smidMeshes.get(result.smid);
3747
- if (smidSet) {
3748
- for (const m of smidSet) targetMeshes.add(m);
3749
- }
3750
- }
3751
- targetMeshes.add(result.mesh);
3752
-
3753
- for (const mesh of targetMeshes) {
3754
- const mat = styledMaterialOf(mesh);
3755
- if (!mat) continue;
3756
- let batchId = result.batchId;
3757
- if (result.instanceIndex != null && mesh === result.mesh) {
3758
- batchId = result.batchId;
3759
- } else if (result.smid != null) {
3760
- const ud = mesh.userData as MeshUserData;
3761
- const pickInfo = ud.geoRef?.pickInfo;
3762
- const entry = (pickInfo as Record<string | number, unknown> | undefined)?.[result.smid] ??
3763
- (pickInfo as Record<string | number, unknown> | undefined)?.[String(result.smid)];
3764
- const bId = entry ? pickEntryId(entry as any) : -1;
3765
- batchId = bId >= 0 ? bId : (mesh === result.mesh ? result.batchId : -1);
3766
- }
3767
- mat.userData.uSelBatch.value = batchId;
3768
- // 没有单体化信息时整块高亮,否则会出现「点中了但什么都没亮、其他全暗了」
3769
- mat.userData.uSelWhole.value = batchId < 0 ? 1 : 0;
3770
- this._selectedMeshes.push(mesh);
3771
- }
3772
- // 点云没有单体粒度:整块混成 selectColor,让选中至少看得见
3773
- for (const mesh of this._selectedMeshes) {
3774
- if (isPointCloudObject(mesh)) this._applyPointCloudStyle(mesh, true);
3775
- }
3776
- this.uniforms.uSelMix.value = 1;
3777
- this.uniforms.uDim.value = this.style.dimOthers ? 1 : 0;
3778
-
3779
- if (this.style.outline) {
3780
- // 棱线 / 外扩轮廓都是按「面」设计的,点云画不出来也不该画
3781
- const decoMeshes = [...targetMeshes].filter((m) => !isPointCloudObject(m));
3782
- if (decoMeshes.length) {
3783
- if ((this.style.outlineStyle ?? 'edges') === 'edges') {
3784
- this._addSelectionEdges(result, decoMeshes);
3785
- } else {
3786
- this._addSelectionHull(result, decoMeshes);
3787
- }
3788
- }
3789
- }
3790
- this._requestRepaint();
3791
- }
2333
+ /* ---------------------------------------------------------------- 选中 */
3792
2334
 
3793
2335
  /**
3794
- * 选中单体的棱线:只挑这个单体自己的三角面算折边,再画成屏幕空间等宽线。
3795
- * 抽出来的线段数很少(一栋楼通常几百到几千条),所以选中开销可以忽略。
2336
+ * 选中一个单体(或整块网格),高亮与轮廓装饰见 viewer/SelectionDecorator.ts。
2337
+ * 传 `null` 清空选中;`silent` 为真时不触发 `onPick`。
3796
2338
  */
3797
- private _addSelectionEdges(result: PickResult, meshes: Iterable<THREE.Mesh>): void {
3798
- this._syncSelectionStyle();
3799
- for (const mesh of meshes) {
3800
- const ud = mesh.userData as MeshUserData;
3801
- const geoRef = ud.geoRef;
3802
- let segments: PickSegment[] = [];
3803
- // 这块网格上的棱线全部属于选中的那个单体,整批填同一个 batchId,
3804
- // 顶点着色器才能从状态纹理里取到平移 / 拔高——选中跟随移动靠它。
3805
- let batchId = -1;
3806
- if (mesh === result.mesh && result.instanceIndex != null) {
3807
- // 实例化几何没有逐单体的顶点区间,整个 base mesh 就是这一栋的外形
3808
- segments = [{ vertexOffset: 0, vertexCount: mesh.geometry.getAttribute('position')?.count ?? 0 }];
3809
- const pickInfo = geoRef?.pickInfo;
3810
- const entry = (pickInfo as Record<string | number, unknown> | undefined)?.[result.smid ?? ''] ??
3811
- (pickInfo as Record<string | number, unknown> | undefined)?.[String(result.smid)];
3812
- batchId = entry ? pickEntryId(entry as any) : -1;
3813
- } else if (result.smid != null) {
3814
- const pickInfo = geoRef?.pickInfo;
3815
- const entry = (pickInfo as Record<string | number, unknown> | undefined)?.[result.smid] ??
3816
- (pickInfo as Record<string | number, unknown> | undefined)?.[String(result.smid)];
3817
- const bId = entry ? pickEntryId(entry as any) : -1;
3818
- batchId = bId;
3819
- segments = (entry as any)?.segments ?? (bId >= 0 ? resolveFeature(geoRef, bId).segments : []);
3820
- } else if (mesh === result.mesh) {
3821
- const localGeo = (ud.geoByBatchId as Map<number, GeoPackageEntry> | undefined)?.get(result.batchId) ?? geoRef;
3822
- segments = resolveFeature(localGeo, result.batchId).segments;
3823
- batchId = result.batchId;
3824
- }
3825
-
3826
- const pairs = buildFeatureEdgeSegments(mesh.geometry, segments);
3827
- if (pairs && pairs.length > 0) {
3828
- const batchIds = batchId >= 0 ? new Float32Array(pairs.length / 6).fill(batchId) : undefined;
3829
- const lines = new THREE.Mesh(
3830
- buildThickLineGeometry(pairs, batchIds),
3831
- this._createSelectionEdgeMaterial(mesh)
3832
- );
3833
- lines.renderOrder = 4;
3834
- // 屏幕空间外扩后可能超出原包围球,关掉剔除更稳
3835
- lines.frustumCulled = false;
3836
- lines.raycast = () => {};
3837
- (lines.userData as MeshUserData).isSelectionEdge = true;
3838
- if (mesh === result.mesh && result.instanceIndex != null) {
3839
- this._applyInstanceTransform(lines, result);
3840
- }
3841
- mesh.add(lines); // 作为子节点,直接继承世界变换
3842
- this._selectionEdgeMeshes.push(lines);
3843
- }
3844
- }
3845
-
3846
- if (this._selectionEdgeMeshes.length === 0) {
3847
- // 抽不出折边(退化几何 / 没有任何夹角)时退回外扩轮廓,至少保证选中可见
3848
- this._addSelectionHull(result, meshes);
3849
- return;
3850
- }
3851
- this._selectionEdges = this._selectionEdgeMeshes[0] ?? null;
3852
- }
3853
-
3854
- /** 实例化几何:把某个实例的矩阵套到一个普通 Mesh/子节点上(子节点不参与父级的 instanceMatrix) */
3855
- private _applyInstanceTransform(obj: THREE.Object3D, result: PickResult): void {
3856
- const im = result.mesh as THREE.InstancedMesh;
3857
- if (result.instanceIndex == null || !im.isInstancedMesh) return;
3858
- const m = new THREE.Matrix4();
3859
- im.getMatrixAt(result.instanceIndex, m);
3860
- m.decompose(obj.position, obj.quaternion, obj.scale);
3861
- }
3862
-
3863
- /** 旧的描边画法:背面外扩法,整块几何一起上,靠 uSelBatch 只显示选中那栋 */
3864
- private _addSelectionHull(result: PickResult, meshes: Iterable<THREE.Mesh>): void {
3865
- const instanced = result.instanceIndex != null;
3866
- // 实例化:轮廓是这个实例的单独一份副本(普通 Mesh),所以整份都画;
3867
- // 顶点区间型:整块几何一起画,靠 uSelBatch 只显示选中那栋。
3868
- this.outlineMaterial.uniforms.uSelBatch.value = instanced ? -1 : result.batchId;
3869
- this.outlineMaterial.uniforms.uSelWhole.value = instanced || result.batchId < 0 ? 1 : 0;
3870
- this.outlineMaterial.uniforms.uElevation.value = instanced
3871
- ? this._selectionElevation(result)
3872
- : this.style.selectElevation ?? 0;
3873
-
3874
- for (const mesh of meshes) {
3875
- const mat = styledMaterialOf(mesh);
3876
- if (mat?.userData?.uBatchTex) {
3877
- this.outlineMaterial.uniforms.uBatchTex.value = mat.userData.uBatchTex.value as THREE.Texture;
3878
- this.outlineMaterial.uniforms.uBatchTexWidth.value = mat.userData.uBatchTexWidth.value as number;
3879
- this.outlineMaterial.uniforms.uBatchTexActive.value = mat.userData.uBatchTexActive.value as number;
3880
- }
3881
- const outline = new THREE.Mesh(mesh.geometry, this.outlineMaterial);
3882
- outline.renderOrder = 3;
3883
- outline.frustumCulled = false;
3884
- outline.raycast = () => {};
3885
- if (mesh === result.mesh && result.instanceIndex != null) {
3886
- this._applyInstanceTransform(outline, result);
3887
- }
3888
- mesh.add(outline); // 作为子节点,直接继承世界变换
3889
- this._outlineMeshes.push(outline);
3890
- }
3891
- this._outlineMesh = this._outlineMeshes[0] ?? null;
2339
+ select(result: PickResult | null, opts: { silent?: boolean } = {}): void {
2340
+ this._sel.select(result, opts);
3892
2341
  }
3893
2342
 
3894
- /** 只改 uniform:颜色 / 粗细 / 拔高,拖着滑块实时生效,不重建几何 */
3895
- private _syncSelectionStyle(): void {
3896
- const s = this.style;
3897
- const u = this.selectionEdgeMaterial.uniforms;
3898
- u.uColor.value.set(s.outlineColor ?? s.selectColor);
3899
- u.uWidth.value = Math.max(0.1, s.outlineWidthPx ?? 3);
3900
- u.uOpacity.value = clampOpacity(s.opacity);
3901
- // 只加全局 selectElevation:单体自己的拔高已经写在状态纹理第 2 行 z 里,
3902
- // 顶点着色器采样时带出来了,这里再加一次就会双重拔高
3903
- if (u.uElevation) u.uElevation.value = s.selectElevation ?? 0;
3904
-
3905
- // 外扩描边是另一个材质实例,单体状态纹理的开关是建的时候抄过去的。
3906
- // 先选中、再抬高(或反过来)时模板会停留在旧值,这里补一次同步。
3907
- for (const outline of this._outlineMeshes) {
3908
- const parent = outline.parent as THREE.Mesh | null;
3909
- const ud = parent ? styledMaterialOf(parent)?.userData : null;
3910
- if (ud?.uBatchTex) {
3911
- this.outlineMaterial.uniforms.uBatchTex.value = ud.uBatchTex.value;
3912
- this.outlineMaterial.uniforms.uBatchTexWidth.value = ud.uBatchTexWidth.value;
3913
- this.outlineMaterial.uniforms.uBatchTexActive.value = ud.uBatchTexActive.value;
3914
- }
3915
- }
2343
+ /** @internal 选中外观跟随样式变化,见 viewer/SelectionDecorator.ts */
2344
+ _syncSelectionStyle(): void {
2345
+ this._sel.syncStyle();
3916
2346
  }
3917
2347
 
3918
- /** 选中单体的实际拔高 = 全局选中拔高 + 该单体的独立拔高(与着色器口径一致) */
3919
- private _selectionElevation(result: PickResult): number {
3920
- let elev = this.style.selectElevation ?? 0;
3921
- if (result.smid != null) {
3922
- const off = this._featureStyleManager.getFeatureEffectiveOffset(result.smid);
3923
- if (off.elevation) elev += off.elevation;
3924
- }
3925
- return elev;
2348
+ /** @internal 测试用:当前选中 */
2349
+ get _selection(): PickResult | null {
2350
+ return this._sel.selection;
3926
2351
  }
3927
2352
 
3928
- private _clearSelectionDecoration(): void {
3929
- for (const outline of this._outlineMeshes) {
3930
- outline.parent?.remove(outline);
3931
- }
3932
- this._outlineMeshes = [];
3933
- this._outlineMesh = null;
2353
+ /** @internal 测试用:被高亮的网格 */
2354
+ get _selectedMeshes(): THREE.Object3D[] {
2355
+ return this._sel.selectedMeshes;
2356
+ }
3934
2357
 
3935
- for (const edges of this._selectionEdgeMeshes) {
3936
- edges.parent?.remove(edges);
3937
- edges.geometry.dispose();
3938
- }
3939
- this._selectionEdgeMeshes = [];
3940
- this._selectionEdges = null;
2358
+ /** @internal 测试用:选中棱线的网格列表 */
2359
+ get _selectionEdgeMeshes(): THREE.Mesh[] {
2360
+ return this._sel.selectionEdgeMeshes;
3941
2361
  }
3942
2362
 
3943
2363
  /* ---------------------------------------------------------------- 渲染 */
@@ -3948,31 +2368,6 @@ export class S3MViewer {
3948
2368
  * 参数全部来自 style,不缓存:`edgeWidth` 在这里是「采样偏移」,线宽 ≈ 2×它;
3949
2369
  * `edgeThreshold` 是折边敏感度(夹角小于它不描),和 CPU 折边线同一口径。
3950
2370
  */
3951
- private _renderGpuEdges(canvas: HTMLCanvasElement): void {
3952
- if (!this.renderer || !this.scene || !this.camera) return;
3953
- const s = this.style;
3954
- const fade = this.uniforms.uEdgeFade?.value ?? 1;
3955
- const opacity =
3956
- Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) * clampOpacity(s.opacity) * fade;
3957
- if (opacity <= 0.004) return;
3958
-
3959
- if (!this._gpuEdge) this._gpuEdge = new GpuEdgePass();
3960
- if (!this._gpuEdge.supported(this.renderer)) return;
3961
-
3962
- // 度数 → 归一化法线差(|Δn| = 2·sin(θ/2)),乘 0.6 让阈值落在「刚好能描出」的位置
3963
- const deg = Math.min(179, Math.max(1, s.edgeThreshold ?? 55));
3964
- const normalThreshold = 2 * Math.sin((deg * Math.PI) / 360) * 0.6;
3965
-
3966
- this._gpuEdge.render(this.renderer, this.scene, this.camera, canvas.width, canvas.height, {
3967
- color: this._gpuEdgeColor.set(s.edgeColor),
3968
- opacity,
3969
- radius: Math.max(1, s.edgeWidth ?? 1.5),
3970
- normalThreshold,
3971
- depthThreshold: 0.08,
3972
- });
3973
- }
3974
-
3975
- /** 等宽线要按设备像素换算线宽:uResolution 用绘图缓冲尺寸,uDpr 是缓冲 / CSS 的比值 */
3976
2371
  private _updateLineResolution(canvas: HTMLCanvasElement): void {
3977
2372
  const w = canvas.width || 1;
3978
2373
  const h = canvas.height || 1;
@@ -4067,6 +2462,29 @@ export class S3MViewer {
4067
2462
  }
4068
2463
  }
4069
2464
 
2465
+ /** 当前每个数据集的驻留显存/内存字节预算。Infinity = 不限制 */
2466
+ get maxMemory(): number {
2467
+ return this.options.maxMemory ?? Infinity;
2468
+ }
2469
+
2470
+ /**
2471
+ * 调整每个数据集驻留显存字节数的软上限(默认 Infinity = 不限制)。
2472
+ * 不传 `datasetId` 时对所有已存在数据集立即生效,并作为之后新增数据集的默认值。
2473
+ * 实际裁剪发生在每帧调度末尾(make-before-break),不会当场开洞。
2474
+ */
2475
+ setMaxMemory(bytes: number, datasetId?: string): void {
2476
+ const next = Number.isFinite(bytes) && bytes > 0 ? bytes : Infinity;
2477
+ if (datasetId) {
2478
+ const entry = this.datasets.get(datasetId);
2479
+ if (entry?.tileset) (entry.tileset as any).setMaxMemory(next);
2480
+ } else {
2481
+ this.options.maxMemory = next;
2482
+ for (const entry of this.datasets.values()) {
2483
+ if (entry.tileset) (entry.tileset as any).setMaxMemory(next);
2484
+ }
2485
+ }
2486
+ }
2487
+
4070
2488
  /** 当前各向异性过滤上限。Infinity = 不封顶 */
4071
2489
  get anisotropyCap(): number {
4072
2490
  return this.options.anisotropyCap ?? Infinity;
@@ -4125,16 +2543,9 @@ export class S3MViewer {
4125
2543
  * 乱序混合就是满屏彩色噪点。相机没动且没有新瓦片时整个跳过;
4126
2544
  * 单个瓦片顺序本来就对时 sortSplatMesh 内部也会跳过重写。
4127
2545
  */
4128
- private _sortSplats(): void {
4129
- const cam = this.camera;
4130
- if (!cam) return;
4131
- if (this._splatsSorted && this._lastSplatSortView.equals(cam.matrixWorldInverse)) return;
4132
- for (const pc of this._pointClouds) {
4133
- if (!(pc.userData as { isGaussianSplat?: boolean }).isGaussianSplat) continue;
4134
- sortSplatMesh(pc as unknown as THREE.Mesh, cam.matrixWorldInverse);
4135
- }
4136
- this._lastSplatSortView.copy(cam.matrixWorldInverse);
4137
- this._splatsSorted = true;
2546
+ /** @internal 泼溅按视深重排,见 viewer/SplatLayer.ts */
2547
+ _sortSplats(): void {
2548
+ this._splats.sort(this.camera, this._pointClouds);
4138
2549
  }
4139
2550
 
4140
2551
  /**
@@ -4240,7 +2651,7 @@ export class S3MViewer {
4240
2651
  this.renderer.render(this.scene, this.camera);
4241
2652
  // GPU 描边必须画在主场景之后:它的全屏 quad 参与宿主深度测试,
4242
2653
  // 依赖本帧已经写好的深度(被地形挡住的地方不该描边)
4243
- if (this._edgeModes.gpu) this._renderGpuEdges(canvas);
2654
+ this._edges.renderGpuEdges(canvas);
4244
2655
  }
4245
2656
 
4246
2657
  this._frame++;
@@ -4261,7 +2672,7 @@ export class S3MViewer {
4261
2672
  tilesetChanged;
4262
2673
 
4263
2674
  // 首帧画完、场上有材质了,再把另一种 shading 变体编出来(异步,不挡当前帧)
4264
- if (this._prewarmShadingEnabled && !this._shadingPrewarmed && this._pickables.size > 0) {
2675
+ if (this._prewarmShadingEnabled && !this._shadingPrewarmed && this._picks.pickables.size > 0) {
4265
2676
  void this.prewarmShading();
4266
2677
  }
4267
2678