s3m-three 0.4.18 → 0.4.20

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 (169) hide show
  1. package/dist/browser/assets/s3mWorker-DvAUA8NJ.js +1 -0
  2. package/dist/browser/s3m-three.iife.ext-three.js +619 -304
  3. package/dist/browser/s3m-three.iife.js +647 -332
  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/GaussianSplat.d.ts +64 -0
  8. package/dist/core/GaussianSplat.d.ts.map +1 -0
  9. package/dist/core/GaussianSplat.js +139 -0
  10. package/dist/core/GaussianSplat.js.map +1 -0
  11. package/dist/core/S3M1Parser.d.ts +1 -1
  12. package/dist/core/S3M1Parser.d.ts.map +1 -1
  13. package/dist/core/S3M1Parser.js +320 -25
  14. package/dist/core/S3M1Parser.js.map +1 -1
  15. package/dist/core/S3MBParser.d.ts.map +1 -1
  16. package/dist/core/S3MBParser.js +13 -0
  17. package/dist/core/S3MBParser.js.map +1 -1
  18. package/dist/core/S3MBlockParser.d.ts.map +1 -1
  19. package/dist/core/S3MBlockParser.js +83 -28
  20. package/dist/core/S3MBlockParser.js.map +1 -1
  21. package/dist/core/SplatLod.d.ts +36 -0
  22. package/dist/core/SplatLod.d.ts.map +1 -0
  23. package/dist/core/SplatLod.js +54 -0
  24. package/dist/core/SplatLod.js.map +1 -0
  25. package/dist/core/SplatProjection.d.ts +75 -0
  26. package/dist/core/SplatProjection.d.ts.map +1 -0
  27. package/dist/core/SplatProjection.js +162 -0
  28. package/dist/core/SplatProjection.js.map +1 -0
  29. package/dist/core/SplatSort.d.ts +26 -0
  30. package/dist/core/SplatSort.d.ts.map +1 -0
  31. package/dist/core/SplatSort.js +61 -0
  32. package/dist/core/SplatSort.js.map +1 -0
  33. package/dist/core/TexCoordZ.d.ts +94 -0
  34. package/dist/core/TexCoordZ.d.ts.map +1 -0
  35. package/dist/core/TexCoordZ.js +125 -0
  36. package/dist/core/TexCoordZ.js.map +1 -0
  37. package/dist/core/TextureDecode.d.ts +19 -0
  38. package/dist/core/TextureDecode.d.ts.map +1 -1
  39. package/dist/core/TextureDecode.js +53 -11
  40. package/dist/core/TextureDecode.js.map +1 -1
  41. package/dist/core/TileGeometry.d.ts +39 -0
  42. package/dist/core/TileGeometry.d.ts.map +1 -1
  43. package/dist/core/TileGeometry.js +133 -14
  44. package/dist/core/TileGeometry.js.map +1 -1
  45. package/dist/core/degradations.d.ts +37 -0
  46. package/dist/core/degradations.d.ts.map +1 -0
  47. package/dist/core/degradations.js +32 -0
  48. package/dist/core/degradations.js.map +1 -0
  49. package/dist/core/types.d.ts +36 -0
  50. package/dist/core/types.d.ts.map +1 -1
  51. package/dist/index.d.ts +7 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +4 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/render/DatasetManager.d.ts +11 -0
  56. package/dist/render/DatasetManager.d.ts.map +1 -1
  57. package/dist/render/DatasetManager.js +2 -0
  58. package/dist/render/DatasetManager.js.map +1 -1
  59. package/dist/render/FeatureStyleManager.d.ts.map +1 -1
  60. package/dist/render/FeatureStyleManager.js +7 -5
  61. package/dist/render/FeatureStyleManager.js.map +1 -1
  62. package/dist/render/GeometryBuilder.d.ts +31 -0
  63. package/dist/render/GeometryBuilder.d.ts.map +1 -1
  64. package/dist/render/GeometryBuilder.js +229 -6
  65. package/dist/render/GeometryBuilder.js.map +1 -1
  66. package/dist/render/Picker.d.ts.map +1 -1
  67. package/dist/render/Picker.js +19 -5
  68. package/dist/render/Picker.js.map +1 -1
  69. package/dist/render/S3MViewer.d.ts +106 -379
  70. package/dist/render/S3MViewer.d.ts.map +1 -1
  71. package/dist/render/S3MViewer.js +324 -1494
  72. package/dist/render/S3MViewer.js.map +1 -1
  73. package/dist/render/SplatMaterial.d.ts +103 -0
  74. package/dist/render/SplatMaterial.d.ts.map +1 -0
  75. package/dist/render/SplatMaterial.js +388 -0
  76. package/dist/render/SplatMaterial.js.map +1 -0
  77. package/dist/render/SplatSorter.d.ts +14 -0
  78. package/dist/render/SplatSorter.d.ts.map +1 -0
  79. package/dist/render/SplatSorter.js +66 -0
  80. package/dist/render/SplatSorter.js.map +1 -0
  81. package/dist/render/StyleMaterial.d.ts +48 -0
  82. package/dist/render/StyleMaterial.d.ts.map +1 -1
  83. package/dist/render/StyleMaterial.js +292 -69
  84. package/dist/render/StyleMaterial.js.map +1 -1
  85. package/dist/render/TextureFactory.d.ts +10 -0
  86. package/dist/render/TextureFactory.d.ts.map +1 -1
  87. package/dist/render/TextureFactory.js +35 -5
  88. package/dist/render/TextureFactory.js.map +1 -1
  89. package/dist/render/lightingPreset.d.ts +53 -0
  90. package/dist/render/lightingPreset.d.ts.map +1 -0
  91. package/dist/render/lightingPreset.js +36 -0
  92. package/dist/render/lightingPreset.js.map +1 -0
  93. package/dist/render/materialAlpha.d.ts +51 -0
  94. package/dist/render/materialAlpha.d.ts.map +1 -0
  95. package/dist/render/materialAlpha.js +68 -0
  96. package/dist/render/materialAlpha.js.map +1 -0
  97. package/dist/render/viewer/EdgeDecorator.d.ts +206 -0
  98. package/dist/render/viewer/EdgeDecorator.d.ts.map +1 -0
  99. package/dist/render/viewer/EdgeDecorator.js +628 -0
  100. package/dist/render/viewer/EdgeDecorator.js.map +1 -0
  101. package/dist/render/viewer/HeightIndex.d.ts +36 -0
  102. package/dist/render/viewer/HeightIndex.d.ts.map +1 -0
  103. package/dist/render/viewer/HeightIndex.js +98 -0
  104. package/dist/render/viewer/HeightIndex.js.map +1 -0
  105. package/dist/render/viewer/LightRig.d.ts +88 -0
  106. package/dist/render/viewer/LightRig.d.ts.map +1 -0
  107. package/dist/render/viewer/LightRig.js +229 -0
  108. package/dist/render/viewer/LightRig.js.map +1 -0
  109. package/dist/render/viewer/PickService.d.ts +109 -0
  110. package/dist/render/viewer/PickService.d.ts.map +1 -0
  111. package/dist/render/viewer/PickService.js +591 -0
  112. package/dist/render/viewer/PickService.js.map +1 -0
  113. package/dist/render/viewer/SelectionDecorator.d.ts +84 -0
  114. package/dist/render/viewer/SelectionDecorator.d.ts.map +1 -0
  115. package/dist/render/viewer/SelectionDecorator.js +339 -0
  116. package/dist/render/viewer/SelectionDecorator.js.map +1 -0
  117. package/dist/render/viewer/SplatLayer.d.ts +26 -0
  118. package/dist/render/viewer/SplatLayer.d.ts.map +1 -0
  119. package/dist/render/viewer/SplatLayer.js +44 -0
  120. package/dist/render/viewer/SplatLayer.js.map +1 -0
  121. package/dist/render/viewer/types.d.ts +250 -0
  122. package/dist/render/viewer/types.d.ts.map +1 -0
  123. package/dist/render/viewer/types.js +3 -0
  124. package/dist/render/viewer/types.js.map +1 -0
  125. package/dist/tileset/S3MTileset.d.ts +42 -1
  126. package/dist/tileset/S3MTileset.d.ts.map +1 -1
  127. package/dist/tileset/S3MTileset.js +52 -2
  128. package/dist/tileset/S3MTileset.js.map +1 -1
  129. package/dist/vendor/browser-node-stub.js +10 -0
  130. package/dist/worker/s3mWorker.js +1166 -979
  131. package/dist/worker/s3mWorker.js.map +1 -1
  132. package/docs/API.md +55 -1
  133. package/docs/FORMAT-NOTES.md +362 -0
  134. package/package.json +1 -1
  135. package/src/core/Draco.ts +1 -2
  136. package/src/core/GaussianSplat.ts +175 -0
  137. package/src/core/S3M1Parser.ts +330 -24
  138. package/src/core/S3MBParser.ts +18 -0
  139. package/src/core/S3MBlockParser.ts +83 -27
  140. package/src/core/SplatLod.ts +83 -0
  141. package/src/core/SplatProjection.ts +231 -0
  142. package/src/core/SplatSort.ts +92 -0
  143. package/src/core/TexCoordZ.ts +160 -0
  144. package/src/core/TextureDecode.ts +63 -11
  145. package/src/core/TileGeometry.ts +151 -14
  146. package/src/core/degradations.ts +74 -0
  147. package/src/core/types.ts +32 -0
  148. package/src/index.ts +8 -1
  149. package/src/render/DatasetManager.ts +10 -0
  150. package/src/render/FeatureStyleManager.ts +8 -6
  151. package/src/render/GeometryBuilder.ts +271 -7
  152. package/src/render/Picker.ts +21 -5
  153. package/src/render/S3MViewer.ts +358 -1727
  154. package/src/render/SplatMaterial.ts +433 -0
  155. package/src/render/SplatSorter.ts +66 -0
  156. package/src/render/StyleMaterial.ts +339 -69
  157. package/src/render/TextureFactory.ts +39 -5
  158. package/src/render/lightingPreset.ts +76 -0
  159. package/src/render/materialAlpha.ts +75 -0
  160. package/src/render/viewer/EdgeDecorator.ts +722 -0
  161. package/src/render/viewer/HeightIndex.ts +94 -0
  162. package/src/render/viewer/LightRig.ts +267 -0
  163. package/src/render/viewer/PickService.ts +651 -0
  164. package/src/render/viewer/SelectionDecorator.ts +377 -0
  165. package/src/render/viewer/SplatLayer.ts +44 -0
  166. package/src/render/viewer/types.ts +258 -0
  167. package/src/tileset/S3MTileset.ts +78 -2
  168. package/src/vendor/browser-node-stub.js +10 -0
  169. package/dist/browser/assets/s3mWorker-BjPOUgjg.js +0 -1
@@ -23,9 +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';
26
+ import type { AuthenticateFn } from '../core/AuthFetch.js';
27
+ import { setSplatViewport } from './GeometryBuilder.js';
27
28
  import { setMaxAnisotropy, setCompressedTexturesSupported, setTextureOffThreadDecode, setMaxTextureSize as setTextureSizeCap } from './TextureFactory.js';
28
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';
29
45
  import { decodeTextureWithWorker } from '../worker/S3MWorkerClient.js';
30
46
  import {
31
47
  STYLE,
@@ -35,57 +51,36 @@ import {
35
51
  createSharedUniforms,
36
52
  createStyledMaterial,
37
53
  createOutlineMaterial,
38
- resolveEdgeModes,
39
- sameEdgeModes,
54
+ restoreBaseBlendState,
40
55
  } from './StyleMaterial.js';
41
56
  import type {
42
57
  OutlineMaterial,
43
- OutlineStyleUniforms,
44
58
  StyledMaterial,
45
59
  StyledMaterialOptions,
46
60
  StyleMode,
47
- SelectMode,
48
61
  S3MStyle,
49
62
  EdgeModes,
50
63
  EdgesOption,
51
64
  MaterialQuality,
52
65
  } from './StyleMaterial.js';
53
66
  import { applyPointCloudStyle, isOwnedPointCloudMaterial } from './PointCloudMaterial.js';
67
+ import type { DegradationKind, UnsupportedPolicy } from '../core/degradations.js';
54
68
  import { Picker } from './Picker.js';
55
69
  import { GpuEdgePass } from './GpuEdgePass.js';
56
70
  import { Animator } from '../anim/Animator.js';
57
71
  import type { Channel, TransitionOptions } from '../anim/Animator.js';
58
72
  import { Path } from '../anim/Path.js';
59
73
  import type { EasingFn, EasingName } from '../anim/easing.js';
60
- import type { LineSharedUniforms, ThickLineMaterial, ThickLineStyleUniforms } from './EdgeLines.js';
74
+ import type { LineSharedUniforms, ThickLineMaterial } from './EdgeLines.js';
61
75
  import { GPUPathManager } from './GPUPathManager.js';
62
76
  import type { GPUPathOptions } from './FeatureStyleManager.js';
63
- import {
64
- buildFeatureEdgeSegments,
65
- buildPositionBatchMap,
66
- buildThickLineGeometry,
67
- createLineSharedUniforms,
68
- createThickLineMaterial,
69
- positionQuantKey,
70
- } from './EdgeLines.js';
71
- import { mergeVerticesByPosition } from './MergeVertices.js';
72
- import {
73
- resolveFeature,
74
- measureSegments,
75
- buildAttributes,
76
- buildInstanceLookup,
77
- pickEntryId,
78
- DEFAULT_FLOOR_HEIGHT,
79
- } from '../core/FeatureInfo.js';
80
77
  import type { InstanceFeature } from '../core/FeatureInfo.js';
81
78
  import { AttributeQuery } from '../core/AttributeQuery.js';
82
- import type { AttributeQueryOptions, AttributeRow } from '../core/AttributeQuery.js';
79
+ import type { AttributeQueryOptions } from '../core/AttributeQuery.js';
83
80
  import type {
84
- Anchor,
85
81
  FeatureAttributes,
86
82
  GeoPackageEntry,
87
83
  ParsedTile,
88
- PickSegment,
89
84
  SegmentBox,
90
85
  Stats,
91
86
  StyleUniforms,
@@ -104,16 +99,13 @@ import {
104
99
  FeatureStyleManager,
105
100
  type FeatureStyle,
106
101
  type ColorByFieldOptions,
107
- type ThematicLegendEntry,
108
102
  type ThematicResult,
109
- type BatchConflict,
110
103
  type BatchAudit,
111
104
  type FieldFilterOptions,
112
105
  type FieldFilterResult,
113
106
  type FieldElevationOptions,
114
107
  type FieldElevationResult,
115
108
  type S3MFilter,
116
- resolveRotationMatrix,
117
109
  extractEulerAngles,
118
110
  } from './FeatureStyleManager.js';
119
111
 
@@ -150,206 +142,14 @@ export type {
150
142
 
151
143
  export { GroundManager, DatasetManager, FeatureStyleManager };
152
144
 
153
- const MAX_PICK_ID = 65534;
154
-
155
- export interface PickResult extends FeatureAttributes {
156
- datasetId: string;
157
- datasetName: string;
158
- /** 网格编号 */
159
- objectId: number;
160
- /** 单体控制位(顶点区间型 = batchId,实例化型 = 状态纹理下标),-1 表示没有单体化信息 */
161
- batchId: number;
162
- /** 实例化几何命中的实例序号;非实例化为 undefined */
163
- instanceIndex?: number;
164
- skeletonName: string;
165
- /** 单体中心经纬度,拿不到时为 null */
166
- centerLngLat: [number, number] | null;
167
- /** 命中点三维场景坐标 [x, y, z](米,场景 ENU) */
168
- point?: [number, number, number] | null;
169
- /** 命中点经纬度 [lng, lat] */
170
- lngLat?: [number, number] | null;
171
- /** 命中点高程(米) */
172
- altitude?: number | null;
173
- mesh: THREE.Mesh;
174
- }
175
-
176
- export interface LightOptions {
177
- /** 环境光强度,默认 0.4 */
178
- ambientIntensity?: number;
179
- /** 环境光颜色,十六进制或 CSS 颜色字符串,默认 0xffffff */
180
- ambientColor?: number | string;
181
- /** 半球光天空强度,默认 0.8 */
182
- hemisphereIntensity?: number;
183
- /** 半球光天空颜色,默认 0xffffff */
184
- hemisphereColor?: number | string;
185
- /** 半球光地面颜色,默认 0xb6c0cb */
186
- hemisphereGroundColor?: number | string;
187
- /** 主平行光强度,默认 1.0 */
188
- keyLightIntensity?: number;
189
- /** 主平行光强度别名(兼容写法) */
190
- mainIntensity?: number;
191
- /** 主平行光颜色,默认 0xffffff */
192
- keyLightColor?: number | string;
193
- /** 主平行光颜色别名(兼容写法) */
194
- mainColor?: number | string;
195
- /** 补光强度,默认 0.35 */
196
- fillLightIntensity?: number;
197
- /** 补光颜色,默认 0xffffff */
198
- fillLightColor?: number | string;
199
- /**
200
- * 光照朝向模式:
201
- * - 'headlamp': 默认头灯模式(随相机 bearing 旋转,受光面始终面向镜头)
202
- * - 'fixed': 固定世界坐标方位角(如太阳光模拟)
203
- */
204
- mode?: 'headlamp' | 'fixed';
205
- /** 方位角(度,0~360),固定模式下以正北为 0 顺时针,头灯模式下为相机相对偏角,默认 150 */
206
- azimuth?: number;
207
- /** 太阳/光源仰角(度,0~90),默认 50 */
208
- altitude?: number;
209
- }
210
-
211
- export interface S3MViewerOptions {
212
- /** 光照参数配置(强度、颜色、朝向模式、太阳方位角/仰角等) */
213
- light?: LightOptions;
214
- /**
215
- * 色调映射模式(默认 THREE.NoToneMapping,避免 ACESFilmic 压暗中暗部及导致底图与建筑色彩失真;
216
- * 可传入 THREE.ACESFilmicToneMapping、THREE.LinearToneMapping、THREE.ReinhardToneMapping 等)
217
- */
218
- toneMapping?: THREE.ToneMapping;
219
- /** 色调映射曝光度,默认 1.0 */
220
- toneMappingExposure?: number;
221
- /** 全局总驻留瓦片上限(多数据集多图层共享预算),在所有可见数据集之间动态均衡分配,彻底防止爆显存 */
222
- totalMaxTiles?: number;
223
- /** 单数据集简写 */
224
- configUrl?: string;
225
- /** 多数据集 */
226
- datasets?: S3MDatasetOptions[];
227
- style?: Partial<S3MStyle>;
228
- /** 3D 物理地表面 / 基坑开挖配置 */
229
- ground?: GroundOptions;
230
- pickable?: boolean;
231
- /** 每个数据集最多驻留瓦片 */
232
- maxTiles?: number;
233
- /** 每个数据集并发请求数 */
234
- maxConcurrent?: number;
235
- /** 是否用 Web Worker 解析经典 .s3mb 瓦片(默认开,崩溃自动降级主线程),见 S3MTilesetOptions.useWorker */
236
- useWorker?: boolean;
237
- /** LOD 切换范围缩放系数(默认 1.0),> 1 更倾向保留粗模,< 1 提前换细模 */
238
- lodRangeScale?: number;
239
- /**
240
- * LOD 判定用屏幕高度时的「设备像素比上限」(默认不限制,行为与旧版一致)。
241
- *
242
- * LOD 的屏幕空间误差按 `canvasHeight` 物理像素算(与官方 `calcPixFromCam` 同口径),
243
- * 于是 3x DPR 的手机上判定高度是 2556 而不是 852,细化层数与瓦片数都会远多于桌面。
244
- * 传 2 表示「按 CSS 高度 × 2 截断」,砍掉 3x 屏的超采样余量;传 `Infinity` = 不截断。
245
- *
246
- * 代价:截断会把每块瓦片的屏幕投影放大同样倍数,几何误差与纹理密度同步变差,
247
- * 所以默认不启用,由接入方按设备档位自行决定。运行时可用 `setLodPixelRatioCap()` 调整。
248
- */
249
- lodPixelRatioCap?: number;
250
- /**
251
- * 每个数据集驻留三角形总数的软上限(默认不限制)。
252
- * 见 `S3MTilesetOptions.maxTriangles`:瓦片数管不住的场景(精模、大面片)用这条刹车。
253
- */
254
- maxTriangles?: number;
255
- /**
256
- * 纹理缓存条数上限(默认 128)。一条对应一张已解码的瓦片纹理,
257
- * 1024² 的 RGBA 一张约 4MB,128 条就是 512MB 显存 —— 低端设备建议 32~48。
258
- * 与 `maxTextureSize` 配合使用:后者降单张尺寸,这条降张数。
259
- */
260
- maxImageCache?: number;
261
- /**
262
- * 各向异性过滤上限(默认不封顶,取 GPU 报的最大值,通常 16)。
263
- * 移动端封到 4 或 8 肉眼通常无差,采样成本明显下降。只影响**之后创建**的纹理。
264
- */
265
- anisotropyCap?: number;
266
- /**
267
- * 纹理长边像素上限(默认不限制)。
268
- * 非图集的 RGBA / WebP 纹理会按 2× 盒式滤波逐次减半到上限内;DXT 压缩直传与
269
- * 图集(UV 已重映射)不受影响。手机上 1024 与 2048 肉眼几乎无差,显存差 4 倍。
270
- */
271
- maxTextureSize?: number;
272
- /**
273
- * 光照模型(默认 `'pbr'`)。
274
- *
275
- * - `'pbr'`:`MeshStandardMaterial`,GGX + 环境球谐,质感最好也最贵
276
- * - `'lambert'`:`MeshLambertMaterial`,只保留漫反射 + 平行光,片元开销约省一半;
277
- * 白模 / 纯色 / 按高度着色这类不需要高光的模式肉眼差别很小
278
- *
279
- * 模块级全局状态:同页多个图层会互相覆盖,后创建的那个决定全局。
280
- * 只影响**之后创建**的材质 —— 已加载的瓦片要重新加载才会换。
281
- */
282
- materialQuality?: 'pbr' | 'lambert';
283
- /** 自定义瓦片构建器(测试 / 服务端渲染时注入) */
284
- buildTile?: BuildTileFn | null;
285
- /**
286
- * 可选:业务属性查询配置(iServer 数据服务)。
287
- * 配上之后才能用 `colorByField()` 按业务字段着色 —— 属性值不在 S3M 瓦片里,只能去数据服务查。
288
- */
289
- attributes?: AttributeQueryOptions;
290
- /** 宿主请求重画的信号。MapLibre 传 `() => map.triggerRepaint()`;纯 three 宿主传自己的渲染循环 */
291
- onRequestRepaint?: () => void;
292
- /** 地图朝向(弧度),用于让灯光跟着视角走。不传则灯光方向固定 */
293
- getBearing?: () => number;
294
- /**
295
- * 鉴权适配函数(全局默认,数据集可用自己的 `authenticate` 覆盖)。
296
- * 内核主线程发出的全部请求(配置 scp / 瓦片 / 子图集 / 棱线缓存 / 属性查询)
297
- * 发出前都过一遍,可改写 URL(加 `?token=`)或附加请求头。
298
- * 不传 = 行为与旧版完全一致。见 `AuthenticateFn`。
299
- */
300
- authenticate?: AuthenticateFn;
301
- onStats?: (stats: Stats) => void;
302
- onReady?: (entry: S3MDatasetEntry) => void;
303
- onPick?: (result: PickResult | null) => void;
304
- onError?: (error: unknown) => void;
305
- /**
306
- * 首次出现瓦片后自动预热另一种 `shading` 变体(默认开)。
307
- *
308
- * `flat` / `smooth` 是编译期变体,不预热的话第一次 `setStyle({ shading })` 会同步
309
- * 编译着色器、卡一帧。预热在首帧之后异步做,期间跳一两帧渲染,肉眼不可见。
310
- */
311
- prewarmShading?: boolean;
312
- }
313
-
314
- /* ------------------------------------------------------------------ 内部类型 */
315
-
316
- interface PickableRef {
317
- datasetId: string;
318
- mesh: THREE.Mesh;
319
- tile: ParsedTile;
320
- skeletonName: string;
321
- }
322
-
323
- interface MeshUserData {
324
- pickId?: number;
325
- datasetId?: string;
326
- skeletonName?: string;
327
- geoRef?: GeoPackageEntry;
328
- tileRef?: ParsedTile;
329
- primitiveType?: number;
330
- /** 点云对象(THREE.Points):不吃面着色,也没有棱可描 */
331
- isPointCloud?: boolean;
332
- /** CPU 端现算的棱线对象(等宽线 Mesh) */
333
- edges?: THREE.Object3D | null;
334
- /** 背面外扩轮廓壳(edges 含 'hull' 时挂上) */
335
- edgeHull?: THREE.Mesh | null;
336
- /** 数据自带棱线包(此时不再用 EdgesGeometry 现算) */
337
- nativeEdges?: boolean;
338
- /** 数据本身有没有棱线包(不受 forceCpuEdges 影响,用来运行中切换时重算 nativeEdges) */
339
- nativeEdgesRaw?: boolean;
340
- /** 数据自带的棱线对象本身 */
341
- isEdgeLines?: boolean;
342
- /** CPU 现算的等宽棱线(与 isEdgeLines 区分,便于刷新样式) */
343
- isThickEdge?: boolean;
344
- /** 背面外扩轮廓壳(edges 含 'hull' 时挂上) */
345
- isEdgeHull?: boolean;
346
- /** 选中单体的棱线对象 */
347
- isSelectionEdge?: boolean;
348
- /** 该网格正在淡入时占用的动画通道 key,瓦片淘汰时要回收 */
349
- tileFadeKey?: string;
350
- pickMaterial?: THREE.Material;
351
- [key: string]: unknown;
352
- }
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';
353
153
 
354
154
  function styledMaterialOf(mesh: THREE.Mesh): StyledMaterial | null {
355
155
  const m = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
@@ -395,6 +195,8 @@ const STYLE_SCALAR_KEYS = [
395
195
  'texMix',
396
196
  'roofThreshold',
397
197
  'ambientGain',
198
+ 'metalness',
199
+ 'roughness',
398
200
  'edgeWidth',
399
201
  'edgeOpacity',
400
202
  'edgeOffset',
@@ -410,25 +212,6 @@ const FEATURE_PROPS = ['translation', 'elevation', 'opacity', 'color', 'rotation
410
212
  type FeatureProp = (typeof FEATURE_PROPS)[number];
411
213
 
412
214
  /** 单体沿折线运动的参数 */
413
- export interface PathFollowOptions {
414
- /** 走完全程的时长(毫秒)。不传则按 speed 或默认 2000ms 推算 */
415
- duration?: number;
416
- /** 线速度(米/秒)。给了 duration 就以 duration 为准 */
417
- speed?: number;
418
- /** 是否循环 */
419
- loop?: boolean;
420
- /** 是否把折线首尾相连 */
421
- closed?: boolean;
422
- /** 缓动曲线:作用在**已走距离**上,默认 linear(恒速) */
423
- easing?: EasingName | EasingFn;
424
- /** 路径整体偏移(米),相当于路径原点 */
425
- offset?: [number, number, number];
426
- /** 单次走完(非循环)时回调 */
427
- onComplete?: () => void;
428
- }
429
-
430
- /* ------------------------------------------------------------------ 图层 */
431
-
432
215
  export class S3MViewer {
433
216
  readonly options: S3MViewerOptions;
434
217
  readonly pickable: boolean;
@@ -459,12 +242,8 @@ export class S3MViewer {
459
242
  readonly edgeMaterial: ThickLineMaterial;
460
243
  /** 选中棱线材质(同上,选中棱线的逐网格材质共享它的对象) */
461
244
  readonly selectionEdgeMaterial: ThickLineMaterial;
462
- /** 全局棱线的外观 uniform:逐网格棱线材质直接复用这些对象 */
463
- private readonly _edgeStyleUniforms: ThickLineStyleUniforms;
464
- /** 选中棱线的外观 uniform */
465
- private readonly _selEdgeStyleUniforms: ThickLineStyleUniforms;
466
- /** 全局外扩轮廓的外观 uniform:逐网格轮廓壳共享这些对象 */
467
- private readonly _hullStyleUniforms: OutlineStyleUniforms;
245
+ /** 棱线装饰(见 viewer/EdgeDecorator.ts) */
246
+ private readonly _edges: EdgeDecorator;
468
247
 
469
248
  /** 3D 物理地表面 / 基坑开挖管理器 */
470
249
  readonly groundManager: GroundManager;
@@ -489,29 +268,16 @@ export class S3MViewer {
489
268
  this._datasetManager.sceneAnchor = val;
490
269
  }
491
270
 
492
- private readonly _pickables = new Map<number, PickableRef>();
493
- private _pickSeq = 0;
494
- private readonly _freePickIds: number[] = [];
271
+ /** 拾取表 / pickId 池 / 射线求交(见 viewer/PickService.ts) */
272
+ private readonly _picks: PickService;
495
273
  /** SMID → 包含它的网格。改单个单体样式时只重算这几块,不用全量扫 */
496
274
  private readonly _smidMeshes = new Map<number, Set<THREE.Mesh>>();
497
275
  /** 每块网格的世界高度范围,用来增量维护 uHeightMin / uHeightMax */
498
- private readonly _meshHeight = new Map<THREE.Mesh, { min: number; max: number }>();
499
- private _heightMin = Infinity;
500
- private _heightMax = -Infinity;
501
- private readonly _boxScratch = new THREE.Box3();
502
- private _selection: PickResult | null = null;
503
- private _selectedMeshes: THREE.Object3D[] = [];
504
- private _outlineMesh: THREE.Mesh | null = null;
505
- private _outlineMeshes: THREE.Mesh[] = [];
506
- private _selectionEdges: THREE.Mesh | null = null;
507
- private _selectionEdgeMeshes: THREE.Mesh[] = [];
508
- private _edgesEnabled = false;
509
- /** 当前生效的棱线画法(line / offset / hull / gpu),决定要不要建 / 拆哪种装饰 */
510
- private _edgeModes: EdgeModes = resolveEdgeModes(false);
511
- /** GPU 描边后处理(深度 + 法线),只在 edges 含 'gpu' 时按帧执行 */
512
- private _gpuEdge: GpuEdgePass | null = null;
513
- /** 给 GPU 描边传色用的临时 Color,避免每帧 new */
514
- 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
+
515
281
  /** 瓦片淡入通道的自增序号(不用 pickId:pickId 会被回收复用) */
516
282
  private _tileFadeSeq = 0;
517
283
  /** setMode 编排的代次,用来作废被新调用顶掉的旧编排 */
@@ -528,12 +294,11 @@ export class S3MViewer {
528
294
  private readonly _pathScratch = new Float32Array(3);
529
295
  /** 求切线用的第二个采样点(包围盒换算 GPU 路径朝向时用) */
530
296
  private readonly _pathScratch2 = new Float32Array(3);
531
- /** 预制棱线缓存(Crease Cache):从外部 source-creases 载入的全局统一 LineSegments,性能最高、视觉最干净 */
532
- private _creaseLines: THREE.LineSegments | null = null;
533
- /** 数据自带的棱线对象(LineSegments),改棱线颜色时只刷这些,不再全场景 traverse */
534
- private readonly _nativeEdgeLines = new Set<THREE.LineSegments>();
297
+
535
298
  /** 点云对象集合:透明度动画时只刷它们,避免每帧遍历全部 pickables */
536
299
  private readonly _pointClouds = new Set<THREE.Points>();
300
+ /** 泼溅排序调度(见 viewer/SplatLayer.ts) */
301
+ private readonly _splats = new SplatLayer();
537
302
  /** 当前材质是否处于 transparent 状态,用来避免逐帧重复切换 program */
538
303
  private _opacityTransparent = false;
539
304
 
@@ -553,27 +318,10 @@ export class S3MViewer {
553
318
  private readonly _frameMvp = new THREE.Matrix4();
554
319
  /** 颜色换算用的临时对象,避免每帧 new */
555
320
  private readonly _colorScratch = new THREE.Color();
556
- private _needsRepaint = true;
557
321
  private _frame = 0;
558
322
  private readonly _fps = { value: 0, last: 0 };
559
- private _ambientLight!: THREE.AmbientLight;
560
- private _hemiLight!: THREE.HemisphereLight;
561
- private _keyLight!: THREE.DirectionalLight;
562
- private _fillLight!: THREE.DirectionalLight;
563
- private readonly _lightOptions: Required<Omit<LightOptions, 'mainIntensity' | 'mainColor'>> = {
564
- ambientIntensity: 0.4,
565
- ambientColor: 0xffffff,
566
- hemisphereIntensity: 0.8,
567
- hemisphereColor: 0xffffff,
568
- hemisphereGroundColor: 0xb6c0cb,
569
- keyLightIntensity: 1.0,
570
- keyLightColor: 0xffffff,
571
- fillLightIntensity: 0.35,
572
- fillLightColor: 0xffffff,
573
- mode: 'headlamp',
574
- azimuth: 150,
575
- altitude: 50,
576
- };
323
+ /** 光源 / 色调映射 / 环境贴图(见 viewer/LightRig.ts) */
324
+ private readonly _lights: LightRig;
577
325
 
578
326
  private readonly _datasetQueue: S3MDatasetOptions[];
579
327
 
@@ -583,6 +331,8 @@ export class S3MViewer {
583
331
  /** 鉴权适配(全局默认),构造时注入 */
584
332
  private readonly _authenticate: AuthenticateFn | undefined;
585
333
  private _lodRangeScale = 1.0;
334
+ /** 图层级降级策略,数据集可覆盖,见 `S3MViewerOptions.unsupported` */
335
+ private readonly _unsupported: UnsupportedPolicy;
586
336
  /** LOD 判定屏幕高度的 DPR 上限。Infinity = 不截断(默认,行为同旧版) */
587
337
  private _lodPixelRatioCap = Infinity;
588
338
 
@@ -599,12 +349,10 @@ export class S3MViewer {
599
349
  this.options = options;
600
350
  this.pickable = options.pickable !== false;
601
351
  this._lodRangeScale = options.lodRangeScale ?? 1.0;
352
+ this._unsupported = options.unsupported ?? 'warn';
602
353
  this._lodPixelRatioCap = options.lodPixelRatioCap ?? Infinity;
603
354
  setTextureSizeCap(options.maxTextureSize ?? Infinity);
604
355
  setMaterialQuality(options.materialQuality ?? 'pbr');
605
- if (options.light) {
606
- Object.assign(this._lightOptions, options.light);
607
- }
608
356
  if (options.totalMaxTiles) {
609
357
  this._datasetManager.totalMaxTiles = options.totalMaxTiles;
610
358
  }
@@ -621,13 +369,12 @@ export class S3MViewer {
621
369
  if (!this.scene) {
622
370
  this.scene = new THREE.Scene();
623
371
  this.scene.add(this.groundManager.group);
624
- this._addLights();
625
372
  }
626
373
  this._featureStyleManager = new FeatureStyleManager(
627
374
  () => this.style.floorHeight,
628
375
  () => this._requestRepaint(),
629
376
  () => {
630
- if (this._selection) this._syncSelectionStyle();
377
+ if (this._sel.selection) this._sel.syncStyle();
631
378
  },
632
379
  () => this.getAllMeshes(),
633
380
  (mesh) => this._applyOverridesToMesh(mesh),
@@ -636,8 +383,56 @@ export class S3MViewer {
636
383
  );
637
384
 
638
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
+ });
639
433
  // 纹理解码优先派给共享解析 Worker(崩溃自动回主线程,见 decodeTextureWithWorker)
640
- setTextureOffThreadDecode(decodeTextureWithWorker); this.style = { ...DEFAULT_STYLE, ...(options.style ?? {}) };
434
+ setTextureOffThreadDecode(decodeTextureWithWorker);
435
+ this.style = { ...DEFAULT_STYLE, ...(options.style ?? {}) };
641
436
  this._styleTarget = { ...this.style };
642
437
  this._opacityTransparent = (this.style.opacity ?? 1) < 0.999;
643
438
  this._applyStyleUniforms();
@@ -647,29 +442,35 @@ export class S3MViewer {
647
442
  this.animator.onActive = () => this._requestRepaint();
648
443
  this.outlineMaterial = createOutlineMaterial(this.uniforms);
649
444
 
650
- this.lineShared = createLineSharedUniforms();
651
- // 棱线外观 uniform:全局棱线材质与逐网格棱线材质**共用同一批对象**,
652
- // _syncEdgeStyle 改一次,所有棱线(含淡入乘子)同时生效,保持 O(1)。
653
- this._edgeStyleUniforms = {
654
- uColor: { value: new THREE.Color(this.style.edgeColor) },
655
- uWidth: { value: Math.max(0.1, this.style.edgeWidth ?? 1.5) },
656
- uOpacity: { value: 0.6 },
657
- uOffset: { value: this.style.edgeOffset ?? 0.15 },
658
- };
659
- this.edgeMaterial = createThickLineMaterial(this.lineShared, {}, this._edgeStyleUniforms);
660
- this._selEdgeStyleUniforms = {
661
- uColor: { value: new THREE.Color(this.style.outlineColor ?? this.style.selectColor) },
662
- uWidth: { value: Math.max(0.1, this.style.outlineWidthPx ?? 3) },
663
- uOpacity: { value: 1 },
664
- uElevation: { value: this.style.selectElevation ?? 0 },
665
- };
666
- this.selectionEdgeMaterial = createThickLineMaterial(this.lineShared, {}, this._selEdgeStyleUniforms);
667
- // 全局外扩轮廓的外观 uniform:逐网格轮廓壳复用这几个对象,改一次全场景生效
668
- this._hullStyleUniforms = {
669
- uColor: { value: new THREE.Color(this.style.edgeColor) },
670
- uWidth: { value: Math.max(0, this.style.edgeHullWidth ?? 0.9) },
671
- uOpacity: { value: 0.6 },
672
- };
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
+ });
673
474
 
674
475
  // 关键:style.edges 是个开关,但构造函数从没据此启用过棱线,导致
675
476
  // `style: { edges: true }` 形同虚设、_edgesEnabled 永远是 false。
@@ -766,8 +567,7 @@ export class S3MViewer {
766
567
  this.picker?.dispose();
767
568
  this.picker = new Picker(this.renderer);
768
569
  // GPU 描边的 RT / 材质绑定在旧上下文上,整体丢弃,下一帧按需重建
769
- this._gpuEdge?.dispose();
770
- this._gpuEdge = null;
570
+ this._edges.disposeGpuEdge();
771
571
  // 共享 uniform / 材质引用不变,three 重新编译着色器时会自然接管;
772
572
  // 这里只确保下一帧尽快驱动一次渲染。
773
573
  this._requestRepaint();
@@ -780,151 +580,104 @@ export class S3MViewer {
780
580
  this.groundManager.dispose();
781
581
  this.picker?.dispose();
782
582
  this.picker = null;
583
+ this._lights.dispose();
783
584
  this.outlineMaterial.dispose();
784
- this._gpuEdge?.dispose();
785
- this._gpuEdge = null;
585
+ this._edges.disposeGpuEdge();
786
586
  this.edgeMaterial.dispose();
787
587
  this.selectionEdgeMaterial.dispose();
788
588
  this.scene?.clear();
789
- this._selectedMeshes = [];
790
- this._outlineMeshes = [];
791
- this._selectionEdgeMeshes = [];
792
- this._outlineMesh = null;
793
- this._selectionEdges = null;
589
+ this._sel.reset();
794
590
  this._smidMeshes.clear();
795
- this._meshHeight.clear();
591
+ this._heights.clear();
796
592
  // 场景已经清空,还挂着的动画通道(瓦片淡入 / 单体位移)不能再对着死对象写
797
593
  this.animator.clear();
798
594
  this.animator.unregisterPrefix('tile.');
799
595
  this.animator.unregisterPrefix('feature.');
800
596
  this._pathSpecs.clear();
801
- this._heightMin = Infinity;
802
- this._heightMax = -Infinity;
597
+
803
598
  this.renderer?.dispose?.();
804
599
  this.renderer = null;
805
600
  this.canvas = null;
806
601
  }
807
602
 
808
- private _createLights(): void {
809
- this._ambientLight = new THREE.AmbientLight(this._lightOptions.ambientColor as any, this._lightOptions.ambientIntensity);
810
- this._hemiLight = new THREE.HemisphereLight(
811
- this._lightOptions.hemisphereColor as any,
812
- this._lightOptions.hemisphereGroundColor as any,
813
- this._lightOptions.hemisphereIntensity
814
- );
815
- this._keyLight = new THREE.DirectionalLight(this._lightOptions.keyLightColor as any, this._lightOptions.keyLightIntensity);
816
- this._fillLight = new THREE.DirectionalLight(this._lightOptions.fillLightColor as any, this._lightOptions.fillLightIntensity);
817
- this._updateLights();
603
+ /* ---------------------------------------------------------------- 光照 */
604
+
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;
620
+ }
621
+
622
+ /** @internal 光照位置按当前 bearing 重算,见 viewer/LightRig.ts */
623
+ _updateLights(): void {
624
+ this._lights.update();
818
625
  }
819
626
 
820
627
  private _addLights(): void {
821
- if (!this.scene) return;
822
- if (!this._ambientLight) this._createLights();
823
- this.scene.add(this._ambientLight, this._hemiLight, this._keyLight, this._fillLight);
628
+ this._lights.addToScene();
824
629
  }
825
630
 
826
- private _applyLightOptions(): void {
827
- if (this._ambientLight) {
828
- this._ambientLight.intensity = this._lightOptions.ambientIntensity;
829
- this._ambientLight.color.set(this._lightOptions.ambientColor as any);
830
- }
831
- if (this._hemiLight) {
832
- this._hemiLight.intensity = this._lightOptions.hemisphereIntensity;
833
- this._hemiLight.color.set(this._lightOptions.hemisphereColor as any);
834
- this._hemiLight.groundColor.set(this._lightOptions.hemisphereGroundColor as any);
835
- }
836
- if (this._keyLight) {
837
- this._keyLight.intensity = this._lightOptions.keyLightIntensity;
838
- this._keyLight.color.set(this._lightOptions.keyLightColor as any);
839
- }
840
- if (this._fillLight) {
841
- this._fillLight.intensity = this._lightOptions.fillLightIntensity;
842
- this._fillLight.color.set(this._lightOptions.fillLightColor as any);
843
- }
631
+ setLightingPreset(preset: LightingPreset): void {
632
+ this._lights.setLightingPreset(preset);
844
633
  }
845
634
 
846
- /**
847
- * 动态设置光照配置
848
- */
635
+ /** 动态设置光照配置 */
849
636
  setLight(options: Partial<LightOptions>): void {
850
- const normalized: Partial<LightOptions> = { ...options };
851
- if (normalized.mainColor !== undefined && normalized.keyLightColor === undefined) {
852
- normalized.keyLightColor = normalized.mainColor;
853
- }
854
- if (normalized.mainIntensity !== undefined && normalized.keyLightIntensity === undefined) {
855
- normalized.keyLightIntensity = normalized.mainIntensity;
856
- }
857
- Object.assign(this._lightOptions, normalized);
858
- this._applyLightOptions();
859
- this._updateLights();
860
- this._needsRepaint = true;
861
- this.triggerRepaint();
637
+ this._lights.setLight(options);
862
638
  }
863
639
 
864
- /**
865
- * 获取当前光照配置快照
866
- */
640
+ /** 获取当前光照配置快照 */
867
641
  getLight(): LightOptions {
868
- return { ...this._lightOptions };
642
+ return this._lights.getLight();
869
643
  }
870
644
 
871
- /**
872
- * 动态设置色调映射模式与曝光度
873
- */
645
+ /** 动态设置色调映射模式与曝光度 */
874
646
  setToneMapping(toneMapping: THREE.ToneMapping, exposure = 1.0): void {
875
- this.options.toneMapping = toneMapping;
876
- this.options.toneMappingExposure = exposure;
877
- if (this.renderer) {
878
- this.renderer.toneMapping = toneMapping;
879
- this.renderer.toneMappingExposure = exposure;
880
- this._needsRepaint = true;
881
- this.triggerRepaint();
882
- }
647
+ this._lights.setToneMapping(toneMapping, exposure);
883
648
  }
884
649
 
885
- /**
886
- * 获取当前色调映射模式与曝光度
887
- */
650
+ /** 获取当前色调映射模式与曝光度 */
888
651
  getToneMapping(): { toneMapping: THREE.ToneMapping; toneMappingExposure: number } {
889
- return {
890
- toneMapping: this.renderer ? this.renderer.toneMapping : (this.options.toneMapping ?? THREE.NoToneMapping),
891
- toneMappingExposure: this.renderer ? this.renderer.toneMappingExposure : (this.options.toneMappingExposure ?? 1.0),
892
- };
652
+ return this._lights.getToneMapping();
893
653
  }
894
654
 
895
655
  /**
896
- * 更新光照位置与方向:
897
- * - 'headlamp': 随地图方位角 bearing 旋转,主光始终在镜头上方,受光面面向镜头;
898
- * - 'fixed': 固定世界方位角(如模拟恒定日光)。
656
+ * 挂 / 摘环境贴图。见 viewer/LightRig.ts(PMREM 预滤波 + 旧 RT 释放)。
899
657
  */
900
- private _updateLights(): void {
901
- if (!this._keyLight || !this._fillLight) return;
902
- const bearing = this._lightOptions.mode === 'fixed' ? 0 : (((this._getBearing?.() ?? 0) * Math.PI) / 180);
903
- const place = (light: THREE.DirectionalLight | null, azOffsetDeg: number, elevDeg: number): void => {
904
- if (!light) return;
905
- const az = bearing + (azOffsetDeg * Math.PI) / 180;
906
- const el = (elevDeg * Math.PI) / 180;
907
- const c = Math.cos(el);
908
- light.position.set(Math.sin(az) * c, Math.cos(az) * c, Math.sin(el)).multiplyScalar(1000);
909
- };
910
- place(this._keyLight, this._lightOptions.azimuth, this._lightOptions.altitude);
911
- place(this._fillLight, this._lightOptions.azimuth + 65, Math.max(10, this._lightOptions.altitude - 20));
658
+ setEnvironment(source: string | THREE.Texture | null, intensity = 1): Promise<void> {
659
+ return this._lights.setEnvironment(source, intensity);
912
660
  }
913
661
 
914
662
  /* ---------------------------------------------------------------- 数据集 */
915
663
 
916
664
  private _normalizeDatasets(options: S3MViewerOptions): S3MDatasetOptions[] {
917
- if (Array.isArray(options.datasets)) return options.datasets.filter((d) => !!d?.configUrl);
665
+ if (Array.isArray(options.datasets))
666
+ return options.datasets
667
+ .filter((d) => !!d?.configUrl)
668
+ .map((d) => ({ ...d, polygonOffset: d.polygonOffset ?? options.polygonOffset }));
918
669
  if (options.configUrl)
919
- return [{ id: 'default', name: '默认数据集', configUrl: options.configUrl, useWorker: options.useWorker }];
670
+ return [{ id: 'default', name: '默认数据集', configUrl: options.configUrl, useWorker: options.useWorker, polygonOffset: options.polygonOffset }];
920
671
  return [];
921
672
  }
922
673
 
923
674
  /** 追加一个数据集。图层 addLayer 之前调用会抛错。 */
924
675
  async addDataset(ds: S3MDatasetOptions): Promise<S3MDatasetEntry> {
925
676
  if (!this.scene) throw new Error('[s3m] 图层尚未 addLayer,不能添加数据集');
677
+ // 图层级默认值:数据集自己指定了就用自己的,见 `S3MViewerOptions.unsupported`
678
+ const merged: S3MDatasetOptions = { ...ds, unsupported: ds.unsupported ?? this._unsupported };
926
679
  const entry = await this._datasetManager.addDataset(
927
- ds,
680
+ merged,
928
681
  this.scene,
929
682
  this.geo,
930
683
  this.uniforms,
@@ -936,13 +689,11 @@ export class S3MViewer {
936
689
  onTile: (node, e) => {
937
690
  this._registerTile(node, e);
938
691
  this.onStats(this.stats);
939
- this._needsRepaint = true;
940
692
  this._requestRepaint();
941
693
  },
942
694
  onTileRemoved: (node, e) => {
943
695
  this._unregisterTile(node, e);
944
696
  this.onStats(this.stats);
945
- this._needsRepaint = true;
946
697
  this._requestRepaint();
947
698
  },
948
699
  onStats: () => {
@@ -955,7 +706,7 @@ export class S3MViewer {
955
706
  },
956
707
  onReady: (e) => {
957
708
  this.onReady(e);
958
- this._refreshAllHeights();
709
+ this._heights.refreshAll();
959
710
  if (!this.groundManager.excavationBounds) {
960
711
  this.groundManager.autoFitExcavation(this.datasets.values());
961
712
  }
@@ -981,7 +732,7 @@ export class S3MViewer {
981
732
  /** 当地图 CRS 投影切换或宿主 map 挂载后,重新计算场景锚点与各数据集 offset */
982
733
  recomputeAnchors(): void {
983
734
  this._datasetManager.recomputeAnchors(this.geo);
984
- this._refreshAllHeights();
735
+ this._heights.refreshAll();
985
736
  this._requestRepaint();
986
737
  }
987
738
 
@@ -1015,7 +766,7 @@ export class S3MViewer {
1015
766
  }
1016
767
  this._datasetManager.setDatasetHeight(id, h, this.geo);
1017
768
  this._syncGroundHeight();
1018
- this._refreshAllHeights();
769
+ this._heights.refreshAll();
1019
770
  this._requestRepaint();
1020
771
  }
1021
772
 
@@ -1047,7 +798,7 @@ export class S3MViewer {
1047
798
  a = align ?? 'origin';
1048
799
  }
1049
800
  this._datasetManager.setDatasetAlign(id, a, this.geo);
1050
- this._refreshAllHeights();
801
+ this._heights.refreshAll();
1051
802
  this._requestRepaint();
1052
803
  }
1053
804
 
@@ -1642,7 +1393,7 @@ export class S3MViewer {
1642
1393
  }
1643
1394
 
1644
1395
  auditBatches(): BatchAudit {
1645
- const res = this._featureStyleManager.auditBatches(this._pickables.values());
1396
+ const res = this._featureStyleManager.auditBatches(this._picks.pickables.values());
1646
1397
  res.smids = this._smidMeshes.size;
1647
1398
  return res;
1648
1399
  }
@@ -1755,7 +1506,7 @@ export class S3MViewer {
1755
1506
 
1756
1507
  getAllMeshes(): THREE.Mesh[] {
1757
1508
  const list: THREE.Mesh[] = [];
1758
- for (const p of this._pickables.values()) list.push(p.mesh);
1509
+ for (const p of this._picks.pickables.values()) list.push(p.mesh);
1759
1510
  return list;
1760
1511
  }
1761
1512
 
@@ -1768,14 +1519,6 @@ export class S3MViewer {
1768
1519
  return out;
1769
1520
  }
1770
1521
 
1771
- private _markOverridesDirty(scope: Iterable<THREE.Mesh> | 'all' = 'all'): void {
1772
- if (scope === 'all') {
1773
- this._featureStyleManager.markOverridesDirty(undefined, this.getAllMeshes());
1774
- } else {
1775
- this._featureStyleManager.markOverridesDirty(scope);
1776
- }
1777
- }
1778
-
1779
1522
  flushOverrides(): void {
1780
1523
  this._featureStyleManager.flushOverrides();
1781
1524
  }
@@ -1788,18 +1531,18 @@ export class S3MViewer {
1788
1531
  removeDataset(id: string): void {
1789
1532
  const entry = this.datasets.get(id);
1790
1533
  if (!entry) return;
1791
- if (this._selection?.datasetId === id) this.select(null);
1534
+ if (this._sel.selection?.datasetId === id) this.select(null);
1792
1535
  this._datasetManager.removeDataset(id, this.scene!, this.geo, (e) => {
1793
1536
  e.root.traverse((o) => {
1794
1537
  const mesh = o as THREE.Mesh;
1795
1538
  const ud = mesh.userData as MeshUserData;
1796
1539
  if (ud.pickId != null) {
1797
- this._pickables.delete(ud.pickId);
1540
+ this._picks.pickables.delete(ud.pickId);
1798
1541
  this._releasePickId(ud.pickId);
1799
1542
  ud.pickId = undefined;
1800
1543
  }
1801
1544
  this._unindexMeshSmids(mesh);
1802
- this._removeMeshHeight(mesh);
1545
+ this._heights.remove(mesh);
1803
1546
  mesh.geometry?.dispose?.();
1804
1547
  const styled = styledMaterialOf(mesh);
1805
1548
  if (styled) styled.dispose();
@@ -1807,7 +1550,7 @@ export class S3MViewer {
1807
1550
  this._featureStyleManager.disposeMesh(mesh);
1808
1551
  });
1809
1552
  });
1810
- for (const [pickId, p] of this._pickables) if (p.datasetId === id) this._pickables.delete(pickId);
1553
+ for (const [pickId, p] of this._picks.pickables) if (p.datasetId === id) this._picks.pickables.delete(pickId);
1811
1554
  this.onStats(this.stats);
1812
1555
  this._requestRepaint();
1813
1556
  }
@@ -1819,7 +1562,7 @@ export class S3MViewer {
1819
1562
 
1820
1563
  setDatasetVisible(id: string, visible: boolean): void {
1821
1564
  this._datasetManager.setDatasetVisible(id, visible);
1822
- if (!visible && this._selection?.datasetId === id) this.select(null);
1565
+ if (!visible && this._sel.selection?.datasetId === id) this.select(null);
1823
1566
  this._requestRepaint();
1824
1567
  }
1825
1568
 
@@ -1833,16 +1576,13 @@ export class S3MViewer {
1833
1576
  // forceCpuEdges:无视数据自带的棱线包,改由 CPU 现算。native 是 1px 的
1834
1577
  // LineBasicMaterial,edgeWidth / 外扩 / 单体跟随全都吃不上。
1835
1578
  // gpu 画法由 shader 自己描边,native 线段包同样不能留
1836
- const useNativeEdges =
1837
- hasNativeEdges && this.style.forceCpuEdges !== true && !this._edgeModes.gpu;
1579
+ const useNativeEdges = this._edges.useNativeEdges(hasNativeEdges);
1838
1580
 
1839
1581
  for (const child of node.group.children) {
1840
1582
  // 数据自带的棱线包:只跟着 edges 开关显示,不参与拾取、不参与着色
1841
1583
  if ((child as THREE.LineSegments).isLineSegments && (child.userData as MeshUserData).isEdgeLines) {
1842
- // 一律登记进 _nativeEdgeLines:forceCpuEdges 之后又关掉时还要能把它们放出来
1843
- this._nativeEdgeLines.add(child as THREE.LineSegments);
1844
- child.visible = useNativeEdges && this._edgesEnabled;
1845
- this._styleEdgeLine(child as THREE.LineSegments);
1584
+ // 一律登记:forceCpuEdges 之后又关掉时还要能把它们放出来
1585
+ this._edges.registerNativeLine(child as THREE.LineSegments, useNativeEdges && this._edges.enabled);
1846
1586
  continue;
1847
1587
  }
1848
1588
 
@@ -1851,28 +1591,25 @@ export class S3MViewer {
1851
1591
  const mesh = child as THREE.Mesh;
1852
1592
  if (!mesh.isMesh && !isPoints) continue;
1853
1593
  if (isPoints) this._pointClouds.add(child as THREE.Points);
1594
+ // 新瓦片里的泼溅实例还没按当前视角排过,下一帧要重排
1595
+ if (isPoints) this._splats.markDirty();
1854
1596
  const ud = mesh.userData as MeshUserData;
1855
1597
  ud.pickId = this._allocPickId();
1856
1598
  ud.datasetId = entry.id;
1857
1599
  ud.nativeEdges = useNativeEdges;
1858
1600
  ud.nativeEdgesRaw = hasNativeEdges;
1859
- this._pickables.set(ud.pickId, {
1601
+ this._picks.pickables.set(ud.pickId, {
1860
1602
  datasetId: entry.id,
1861
1603
  mesh,
1862
1604
  tile: node.tile,
1863
1605
  skeletonName: ud.skeletonName ?? '',
1864
1606
  });
1865
1607
  this._indexMeshSmids(mesh);
1866
- this._addMeshHeight(mesh);
1608
+ this._heights.add(mesh);
1867
1609
  // 点云没有「棱」:给一堆孤立点建 EdgesGeometry 又慢又没意义
1868
1610
  // 点云没有「棱」:给一堆孤立点建 EdgesGeometry 又慢又没意义。
1869
1611
  // 自动描边受三道开关控制:全局 style.edges / 数据集 enableEdges / 地形绿地等关键字跳过。
1870
- if (!useNativeEdges && !isPoints && this._datasetEdgesEnabled(entry) && !this._shouldSkipAutoEdges(ud, entry.name)) {
1871
- // 数据集强制开启(enableEdges: true)时全局画法可能还是关的,退回默认折边线;
1872
- // 但 gpu 画法本来就不建几何,别在这里多此一举
1873
- if (this._edgeModes.line || this._edgeModes.hull) this._ensureEdgeDecoration(mesh);
1874
- else if (!this._edgeModes.gpu) this._ensureEdges(mesh);
1875
- }
1612
+ this._edges.autoDecorate(mesh, entry, useNativeEdges);
1876
1613
 
1877
1614
  // 新瓦片要继承当前的不透明度与着色方式
1878
1615
  if (isPoints) {
@@ -1882,9 +1619,11 @@ export class S3MViewer {
1882
1619
  if (mat) {
1883
1620
  const o = clampOpacity(this.style.opacity);
1884
1621
  // 不写 mat.opacity:全局透明度走共享 uOpacity,材质自身 opacity 保留模型自带 alpha
1885
- mat.transparent = o < 0.999;
1622
+ // 混合开关不能写成 `o < 0.999` 一刀切:材质自身可能是 blend / 顶点 alpha
1623
+ // 透明(s3mOpts.transparent,如 SMID 4421),注册时就被这句踩掉,
1624
+ // 表现为「刚进去没透明度,切换材质质感重建后才恢复」。
1886
1625
  const keepDepth = this.style.depthWrite !== false;
1887
- mat.depthWrite = mat.transparent ? keepDepth : (this.style.depthWrite !== false);
1626
+ restoreBaseBlendState(mat, o < 0.999, keepDepth);
1888
1627
  // shading 走共享 uniform(uFlatShading),不写 mat.flatShading / needsUpdate——
1889
1628
  // transparent / depthWrite 也不是 program 决定项,整套样式继承零重编译
1890
1629
  if (mat.userData.uFlatShading) {
@@ -1897,7 +1636,7 @@ export class S3MViewer {
1897
1636
 
1898
1637
  // 选中状态也要跟着继承:LOD 细化 / 瓦片重载会换掉 mesh,
1899
1638
  // 不重放就出现「点中的那栋楼一拉近就没高亮了」
1900
- this._replaySelectionOnto(mesh);
1639
+ this._sel.replayOnto(mesh);
1901
1640
  }
1902
1641
  }
1903
1642
 
@@ -1940,72 +1679,18 @@ export class S3MViewer {
1940
1679
  (mesh.userData as MeshUserData).tileFadeKey = undefined;
1941
1680
  this.animator.unregister(key);
1942
1681
  // 淡入期间强制开了 transparent + depthWrite,结束时不能自己拍板关掉——
1943
- // 图层可能本来就半透明、单体也可能还有淡化。交给淡化流程重新裁决一次。
1682
+ // 先恢复「材质自身语义 || 图层透明度」的基础状态(材质可能是 blend /
1683
+ // 顶点 alpha 透明,如 SMID 4421),再让单体淡化流程有需要时重新打开。
1684
+ restoreBaseBlendState(mat, this._opacityTransparent, this.style.depthWrite !== false);
1944
1685
  this._featureStyleManager.applyOverridesToMesh(mesh);
1945
- mat.depthWrite = this.style.depthWrite !== false;
1946
1686
  this._requestRepaint();
1947
1687
  },
1948
1688
  });
1949
1689
  }
1950
1690
 
1951
- /**
1952
- * 把「当前选中」重放到一块新登记的 mesh 上。
1953
- *
1954
- * 为什么需要:选中高亮是直接写在各 mesh 材质的 uSelBatch / uSelWhole 上的,
1955
- * 而 LOD 细化、瓦片重载、淘汰回补都会换掉 mesh —— 不重放的话,用户点中的那栋楼
1956
- * 一细化就失去高亮(状态还在 _selection.smid 里,只是没人往新 mesh 上刷)。
1957
- */
1958
- /**
1959
- * 判断一块无 SMID 单体化信息的网格(如倾斜摄影或白模整块)是否空间覆盖当前选中命中点
1960
- */
1961
- private _isMeshCoveringSelection(mesh: THREE.Mesh, sel: PickResult): boolean {
1962
- if (!sel.point) return false;
1963
- const geom = mesh.geometry;
1964
- if (!geom) return false;
1965
- if (!geom.boundingBox) geom.computeBoundingBox();
1966
- if (!geom.boundingBox) return false;
1967
-
1968
- // 将选中的三维场景点转入网格局部坐标系进行包围盒检测
1969
- const pt = new THREE.Vector3(sel.point[0], sel.point[1], sel.point[2]);
1970
- mesh.updateWorldMatrix(true, false);
1971
- const inv = mesh.matrixWorld.clone().invert();
1972
- const localPt = pt.applyMatrix4(inv);
1973
-
1974
- // 留出 0.5 米容差,避免刚好处于三角网格边缘缝隙时漏判
1975
- const box = geom.boundingBox.clone().expandByScalar(0.5);
1976
- return box.containsPoint(localPt);
1977
- }
1978
-
1979
- private _replaySelectionOnto(mesh: THREE.Mesh): void {
1980
- const sel = this._selection;
1981
- if (!sel) return;
1982
- const mat = styledMaterialOf(mesh);
1983
- if (!mat) return;
1984
-
1985
- const ud = mesh.userData as MeshUserData;
1986
- let batchId = -1;
1987
- if (sel.smid != null) {
1988
- const pickInfo = ud.geoRef?.pickInfo as Record<string | number, unknown> | undefined;
1989
- const entry = pickInfo?.[sel.smid] ?? pickInfo?.[String(sel.smid)];
1990
- batchId = entry ? pickEntryId(entry as any) : -1;
1991
- }
1992
- // 拾取命中的那块 mesh 用原始 batchId;同一 smid 散在别的几何里时按各自的区间算
1993
- if (mesh === sel.mesh) batchId = sel.batchId;
1994
- // 这块几何里根本没有这个单体 → 不动它(否则会把无关瓦片一起点亮)
1995
- if (batchId < 0 && mesh !== sel.mesh) {
1996
- if (sel.smid == null && ud.datasetId === sel.datasetId && this._isMeshCoveringSelection(mesh, sel)) {
1997
- batchId = -1;
1998
- } else {
1999
- return;
2000
- }
2001
- }
2002
-
2003
- mat.userData.uSelBatch.value = batchId;
2004
- mat.userData.uSelWhole.value = batchId < 0 ? 1 : 0;
2005
- if (!this._selectedMeshes.includes(mesh)) this._selectedMeshes.push(mesh);
2006
- if (isPointCloudObject(mesh)) this._applyPointCloudStyle(mesh, true);
2007
- this.uniforms.uSelMix.value = 1;
2008
- this.uniforms.uDim.value = this.style.dimOthers ? 1 : 0;
1691
+ /** @internal 把当前选中重放到新登记的 mesh 上,见 viewer/SelectionDecorator.ts */
1692
+ _replaySelectionOnto(mesh: THREE.Mesh): void {
1693
+ this._sel.replayOnto(mesh);
2009
1694
  }
2010
1695
 
2011
1696
  /** 瓦片被淘汰:从拾取表和场景里摘掉,并深度彻底释放所有几何体与材质显存 */
@@ -2013,7 +1698,7 @@ export class S3MViewer {
2013
1698
  node.group.traverse((child) => {
2014
1699
  const obj = child as any;
2015
1700
  if ((child as THREE.LineSegments).isLineSegments && obj.userData?.isEdgeLines) {
2016
- this._nativeEdgeLines.delete(child as THREE.LineSegments);
1701
+ this._edges.unregisterNativeLine(child as THREE.LineSegments);
2017
1702
  }
2018
1703
  if (isPointCloudObject(child)) this._pointClouds.delete(child);
2019
1704
  // 淡入还没结束就被淘汰:把通道收掉,否则会对着已释放的材质继续写
@@ -2022,34 +1707,34 @@ export class S3MViewer {
2022
1707
  obj.userData.tileFadeKey = undefined;
2023
1708
  }
2024
1709
  if (obj.userData?.pickId != null) {
2025
- this._pickables.delete(obj.userData.pickId);
1710
+ this._picks.pickables.delete(obj.userData.pickId);
2026
1711
  this._releasePickId(obj.userData.pickId);
2027
1712
  obj.userData.pickId = undefined;
2028
1713
  }
2029
1714
  if (obj.isMesh) {
2030
- const wasSelected = this._selectedMeshes.includes(obj);
1715
+ const wasSelected = this._sel.selectedMeshes.includes(obj);
2031
1716
  if (wasSelected) {
2032
- this._selectedMeshes = this._selectedMeshes.filter((m) => m !== obj);
1717
+ this._sel.selectedMeshes = this._sel.selectedMeshes.filter((m) => m !== obj);
2033
1718
  }
2034
- const currentSel = this._selection;
1719
+ const currentSel = this._sel.selection;
2035
1720
  if (currentSel && currentSel.mesh === obj) {
2036
1721
  const smid = currentSel.smid;
2037
1722
  const carrier = (
2038
1723
  smid != null
2039
1724
  ? [...(this._smidMeshes.get(smid) ?? [])].find((m) => m !== obj && m.parent)
2040
- : this._selectedMeshes.find((m) => m !== obj && m.parent && (m.parent as any).visible) ??
2041
- this._selectedMeshes.find((m) => m !== obj && m.parent)
1725
+ : this._sel.selectedMeshes.find((m) => m !== obj && m.parent && (m.parent as any).visible) ??
1726
+ this._sel.selectedMeshes.find((m) => m !== obj && m.parent)
2042
1727
  ) as THREE.Mesh | undefined;
2043
1728
  if (carrier) {
2044
1729
  // 选中身份交给承载同一 smid 的另一块 mesh(通常是接替显示的子瓦)
2045
- this._selection = { ...currentSel, mesh: carrier };
2046
- this._replaySelectionOnto(carrier);
1730
+ this._sel.selection = { ...currentSel, mesh: carrier };
1731
+ this._sel.replayOnto(carrier);
2047
1732
  } else {
2048
1733
  this.select(null);
2049
1734
  }
2050
1735
  }
2051
1736
  this._unindexMeshSmids(obj);
2052
- this._removeMeshHeight(obj);
1737
+ this._heights.remove(obj);
2053
1738
  this._featureStyleManager.disposeMesh(obj);
2054
1739
  const styled = styledMaterialOf(obj);
2055
1740
  styled?.dispose?.();
@@ -2074,20 +1759,6 @@ export class S3MViewer {
2074
1759
  * 拾取 ID 只有 2 个字节(RG 两个通道),65535 个网格就到头了,
2075
1760
  * 所以必须回收:瓦片淘汰 / 数据集移除时把 id 还回池子。
2076
1761
  */
2077
- private _allocPickId(): number {
2078
- const reused = this._freePickIds.pop();
2079
- if (reused !== undefined) return reused;
2080
- if (this._pickSeq > MAX_PICK_ID) {
2081
- for (let id = 0; id <= MAX_PICK_ID; id++) if (!this._pickables.has(id)) return id;
2082
- throw new Error(`[s3m] 可拾取网格超过 ${MAX_PICK_ID + 1} 个,2 字节 ID 编码不够用`);
2083
- }
2084
- return this._pickSeq++;
2085
- }
2086
-
2087
- private _releasePickId(id: number): void {
2088
- this._freePickIds.push(id);
2089
- }
2090
-
2091
1762
  /* ---------------------------------------------------------------- 样式 */
2092
1763
 
2093
1764
  /** 动态设置图层整体透明度 (0.0 ~ 1.0) */
@@ -2207,7 +1878,7 @@ export class S3MViewer {
2207
1878
  },
2208
1879
  write: (v) => {
2209
1880
  if (this.uniforms.uEdgeFade) this.uniforms.uEdgeFade.value = v[0];
2210
- this._syncEdgeStyle();
1881
+ this._edges.syncStyle();
2211
1882
  },
2212
1883
  });
2213
1884
 
@@ -2238,7 +1909,7 @@ export class S3MViewer {
2238
1909
  key === 'edgeOffset' ||
2239
1910
  key === 'edgeHullWidth'
2240
1911
  ) {
2241
- this._refreshEdgeLines();
1912
+ this._edges.refreshLines();
2242
1913
  }
2243
1914
  }
2244
1915
 
@@ -2271,8 +1942,8 @@ export class S3MViewer {
2271
1942
  // 临界角变了要重建 CPU 棱线(EdgesGeometry 的参数),开 / 关走 setEdges 整体刷新。
2272
1943
  // 必须先 _disposeEdges():setEdges 只挑「还没有棱线」的网格来建,
2273
1944
  // 不先拆掉的话已经建好的会被全部跳过,临界角改了等于没改。
2274
- if (('edgeThreshold' in patch || 'forceCpuEdges' in patch) && this._edgesEnabled) {
2275
- this._disposeEdges();
1945
+ if (('edgeThreshold' in patch || 'forceCpuEdges' in patch) && this._edges.enabled) {
1946
+ this._edges.disposeEdgeGeometry();
2276
1947
  this.setEdges(this.style.edges);
2277
1948
  } else if (
2278
1949
  'edgeColor' in patch ||
@@ -2283,12 +1954,12 @@ export class S3MViewer {
2283
1954
  'edgeHullWidth' in patch
2284
1955
  ) {
2285
1956
  // 颜色 / 粗细 / 外推距离 / 外扩厚度都只是 uniform,拖着滑块逐帧改也不重建几何
2286
- this._refreshEdgeLines();
1957
+ this._edges.refreshLines();
2287
1958
  }
2288
- if (this._selection) {
1959
+ if (this._sel.selection) {
2289
1960
  // 换描边画法要重建几何;颜色 / 粗细 / 拔高只是 uniform,拖着滑块也不卡
2290
- if ('outline' in patch || 'outlineStyle' in patch) this.select(this._selection, { silent: true });
2291
- else this._syncSelectionStyle();
1961
+ if ('outline' in patch || 'outlineStyle' in patch) this.select(this._sel.selection, { silent: true });
1962
+ else this._sel.syncStyle();
2292
1963
  }
2293
1964
  }
2294
1965
 
@@ -2327,6 +1998,9 @@ export class S3MViewer {
2327
1998
  if (u.uAmbientGain && s.ambientGain != null) {
2328
1999
  u.uAmbientGain.value = s.ambientGain;
2329
2000
  }
2001
+ // 金属度 / 粗糙度:负值 = 不覆盖,保持材质(数据)自身的值
2002
+ if (u.uMetalness) u.uMetalness.value = s.metalness ?? -1;
2003
+ if (u.uRoughness) u.uRoughness.value = s.roughness ?? -1;
2330
2004
  if (u.uOpacity && s.opacity != null) {
2331
2005
  u.uOpacity.value = clampOpacity(s.opacity);
2332
2006
  }
@@ -2361,27 +2035,28 @@ export class S3MViewer {
2361
2035
  this._opacityTransparent = transparent;
2362
2036
  // 关键:若 depthWrite 开启(默认开启),即使半透明也保持深度写入,完美遮挡背面棱线,防止背面线条透出
2363
2037
  const keepDepth = this.style.depthWrite !== false;
2364
- for (const p of this._pickables.values()) {
2038
+ for (const p of this._picks.pickables.values()) {
2365
2039
  if (isPointCloudObject(p.mesh)) continue;
2366
2040
  const m = styledMaterialOf(p.mesh);
2367
2041
  if (!m) continue;
2368
2042
  // 有单体淡化 / 正在淡入的材质,transparent 由各自的流程管,这里不要抢
2369
2043
  if (m.userData.uBatchTexActive?.value) continue;
2370
2044
  if ((m.userData.uTileFade?.value ?? 1) < 1) continue;
2371
- m.transparent = transparent;
2372
- m.depthWrite = transparent ? keepDepth : true;
2045
+ // 材质自身可能是 blend / 顶点 alpha 透明(s3mOpts.transparent),
2046
+ // 关全局混合时不能把它一刀切成不透明
2047
+ restoreBaseBlendState(m, transparent, keepDepth);
2373
2048
  }
2374
2049
  }
2375
2050
 
2376
2051
  // 点云不走 StyleMaterial,自己有一套 uOpacity,逐帧刷的只有这几个对象
2377
2052
  for (const pc of this._pointClouds) this._applyPointCloudStyle(pc);
2378
2053
 
2379
- this._syncEdgeStyle();
2054
+ this._edges.syncStyle();
2380
2055
  this._requestRepaint();
2381
2056
  }
2382
2057
 
2383
2058
  /** 单个点云对象同步当前样式(着色模式 + 不透明度 + 选中高亮) */
2384
- private _applyPointCloudStyle(points: THREE.Points, selected = this._selectedMeshes.includes(points)): void {
2059
+ private _applyPointCloudStyle(points: THREE.Points, selected = this._sel.selectedMeshes.includes(points)): void {
2385
2060
  applyPointCloudStyle(points, this.style, this.uniforms, selected);
2386
2061
  }
2387
2062
 
@@ -2396,7 +2071,7 @@ export class S3MViewer {
2396
2071
  // shading 现在是共享 uniform(uFlatShading)驱动的运行期分支,不再是
2397
2072
  // flatShading define:翻一个 float 就完成切换,零重编译、零卡顿。
2398
2073
  const target = this.style.shading !== 'smooth' ? 1 : 0;
2399
- for (const p of this._pickables.values()) {
2074
+ for (const p of this._picks.pickables.values()) {
2400
2075
  const m = styledMaterialOf(p.mesh);
2401
2076
  const u = m?.userData.uFlatShading;
2402
2077
  if (!u || u.value === target) continue;
@@ -2436,7 +2111,7 @@ export class S3MViewer {
2436
2111
  'uBatchTexActive',
2437
2112
  'uTileFade',
2438
2113
  ] as const;
2439
- for (const p of this._pickables.values()) {
2114
+ for (const p of this._picks.pickables.values()) {
2440
2115
  const mesh = p.mesh;
2441
2116
  const raw = mesh.material;
2442
2117
  if (Array.isArray(raw)) continue; // styled 材质恒为单材质;数组材质(点云等)不归这里管
@@ -2471,7 +2146,7 @@ export class S3MViewer {
2471
2146
 
2472
2147
  // 场上还没有材质就没必要预热:program 会在首个材质出现时编译
2473
2148
  let hasMaterial = false;
2474
- for (const p of this._pickables.values()) {
2149
+ for (const p of this._picks.pickables.values()) {
2475
2150
  if (styledMaterialOf(p.mesh)) {
2476
2151
  hasMaterial = true;
2477
2152
  break;
@@ -2499,60 +2174,14 @@ export class S3MViewer {
2499
2174
  * 加载 n 块瓦片就是 O(n²) 次矩阵乘 + n² 个垃圾对象。现在只在网格进出的记一次,
2500
2175
  * 全局值从缓存里滚:只有被移除的那块正好卡在边界上时,才回退到一次 O(n) 重算。
2501
2176
  */
2502
- private _addMeshHeight(mesh: THREE.Mesh): void {
2503
- const bb = mesh.geometry?.boundingBox;
2504
- if (!bb) return;
2505
- this._boxScratch.copy(bb).applyMatrix4(mesh.matrixWorld);
2506
- const min = this._boxScratch.min.z;
2507
- const max = this._boxScratch.max.z;
2508
- if (!Number.isFinite(min) || !Number.isFinite(max)) return;
2509
- this._meshHeight.set(mesh, { min, max });
2510
- if (min < this._heightMin) this._heightMin = min;
2511
- if (max > this._heightMax) this._heightMax = max;
2512
- this._pushHeightRange();
2513
- }
2514
-
2515
- private _removeMeshHeight(mesh: THREE.Mesh): void {
2516
- const rec = this._meshHeight.get(mesh);
2517
- if (!rec) return;
2518
- this._meshHeight.delete(mesh);
2519
- // 只有正好卡在边界上才需要重算,否则滚动的 min/max 依然成立
2520
- if (rec.min === this._heightMin || rec.max === this._heightMax) this._recomputeHeightRange();
2521
- else this._pushHeightRange();
2522
- }
2523
-
2524
- /** 数据集整体位移(换高程偏移 / 重新布局)后,缓存的世界高度全部失效 */
2525
- private _refreshAllHeights(): void {
2526
- if (this._meshHeight.size === 0) return;
2527
- const meshes = [...this._meshHeight.keys()];
2528
- this._meshHeight.clear();
2529
- this._heightMin = Infinity;
2530
- this._heightMax = -Infinity;
2531
- for (const mesh of meshes) this._addMeshHeight(mesh);
2532
- this._pushHeightRange();
2533
- }
2534
-
2535
- private _recomputeHeightRange(): void {
2536
- let min = Infinity;
2537
- let max = -Infinity;
2538
- for (const h of this._meshHeight.values()) {
2539
- if (h.min < min) min = h.min;
2540
- if (h.max > max) max = h.max;
2541
- }
2542
- this._heightMin = min;
2543
- this._heightMax = max;
2544
- this._pushHeightRange();
2177
+ /** @internal 见 viewer/HeightIndex.ts(regressions.test.ts 直接调) */
2178
+ _addMeshHeight(mesh: THREE.Mesh): void {
2179
+ this._heights.add(mesh);
2545
2180
  }
2546
2181
 
2547
- private _pushHeightRange(): void {
2548
- let min = this._heightMin;
2549
- let max = this._heightMax;
2550
- if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
2551
- min = 0;
2552
- max = 100;
2553
- }
2554
- this.uniforms.uHeightMin.value = min;
2555
- this.uniforms.uHeightMax.value = max;
2182
+ /** @internal 见 viewer/HeightIndex.ts */
2183
+ _removeMeshHeight(mesh: THREE.Mesh): void {
2184
+ this._heights.remove(mesh);
2556
2185
  }
2557
2186
 
2558
2187
  /** 建 SMID → 网格 反查表,逐单体改样式时才知道该重算哪几块 */
@@ -2580,1150 +2209,154 @@ export class S3MViewer {
2580
2209
  }
2581
2210
  }
2582
2211
 
2583
- /**
2584
- * 全局棱线开关。
2585
- *
2586
- * 取值是 `boolean | EdgeStyle | EdgeStyle[]`(见 resolveEdgeModes):
2587
- * - `true` / `'line'` 折边线(旧口径)
2588
- * - `'offset'` 折边线 + 沿边法线外推 `edgeOffset` 米
2589
- * - `'hull'` 背面外扩轮廓(选中侧 outlineStyle:'hull' 的整场景版)
2590
- * - `'gpu'` 纯片元描边:零几何、零额外 pass,线宽约 1 像素
2591
- * - 数组 叠加,例如 `['line', 'hull']`
2592
- *
2593
- * 数据自带棱线包(primitiveType 13)时直接用,成本为零;`style.forceCpuEdges`
2594
- * 可以无视它,改由 CPU 现算 —— 那是 CPU 端的活,几十万面一次性建会卡住主线程
2595
- * (实测 20 个网格 1.9s),所以按 8ms 一帧切片,边建边出。
2596
- *
2597
- * 棱线本身用「屏幕空间等宽线」画(见 EdgeLines.ts):WebGL 的 lineWidth 基本无效,
2598
- * 只有摊成面向屏幕的四边形,`edgeWidth` 才能真正调粗。
2599
- *
2600
- * 带 `transition` 时做「淡入 / 淡出」,**不对几何做插值**(几何没法插值):
2601
- * 开:立刻置位 + 开始分片建线,同时 `uEdgeFade` 0→1 —— 建好一块亮一块,视觉上是渐显;
2602
- * 关:`uEdgeFade` 1→0,淡完才 dispose,避免棱线「啪」一下消失。
2603
- */
2604
- setEdges(edges: EdgesOption, transition?: TransitionOptions): void {
2605
- const wantAnim = !!transition && ((transition.duration ?? 0) > 0 || !!transition.spring);
2606
- const modes = resolveEdgeModes(edges);
2607
- // 换画法(line ↔ hull ↔ offset)是结构变更:旧装饰必须拆掉再建,
2608
- // 否则会出现「切到 hull 了折边线还留着」这种叠影。
2609
- // 同一画法重复调用(滑块逐帧 setEdges(true))不重建,几何是复用的。
2610
- const modesChanged = !sameEdgeModes(this._edgeModes, modes);
2611
-
2612
- this._edgeModes = modes;
2613
- this._edgesEnabled = modes.enabled;
2614
- this.style.edges = edges;
2615
- this._styleTarget.edges = edges;
2616
-
2617
- // 数据自带的棱线:直接切可见性(forceCpuEdges 时永远收起,改由 CPU 现算)。
2618
- // 只遍历注册时收集好的那批 LineSegments —— 全数据集 traverse 在几千节点下太贵。
2619
- // gpu 模式下 shader 自己描边,native 线段包必须收起,否则叠成双线
2620
- const nativeVisible = modes.enabled && !modes.gpu && this.style.forceCpuEdges !== true;
2621
- for (const lines of this._nativeEdgeLines) {
2622
- lines.visible = nativeVisible;
2623
- this._styleEdgeLine(lines);
2624
- }
2625
- this._syncEdgeStyle();
2212
+ /* ---------------------------------------------------------------- 棱线 */
2626
2213
 
2627
- // 关掉 gpu 画法就把后处理资源还回去(整屏 RT 白占显存没意义)。
2628
- // 必须放在「整体关闭」的早退之前,否则关棱线时永远轮不到它。
2629
- if (!modes.gpu && this._gpuEdge) {
2630
- this._gpuEdge.dispose();
2631
- this._gpuEdge = null;
2632
- }
2633
-
2634
- if (!modes.enabled) {
2635
- if (wantAnim) {
2636
- this.animator.to('style.edgeFade', 0, {
2637
- ...transition,
2638
- onComplete: () => {
2639
- this._disposeEdges();
2640
- this._requestRepaint();
2641
- transition?.onComplete?.();
2642
- },
2643
- });
2644
- } else {
2645
- this._setEdgeFade(1);
2646
- this._disposeEdges();
2647
- }
2648
- this._requestRepaint();
2649
- return;
2650
- }
2651
-
2652
- if (modesChanged) this._disposeEdges();
2653
-
2654
- const pending = [...this._pickables.values()]
2655
- .map((p) => p.mesh)
2656
- .filter((m) => {
2657
- const ud = m.userData as MeshUserData;
2658
- // 点云没有棱可描
2659
- if (isPointCloudObject(m)) return false;
2660
- // native 棱线还算不算数,取决于**当前**的 forceCpuEdges(它可以在运行中切换),
2661
- // 所以这里按注册时记下的原始标记重算,而不是沿用注册那一刻的结论。
2662
- ud.nativeEdges = ud.nativeEdgesRaw === true && this.style.forceCpuEdges !== true;
2663
- return !ud.nativeEdges && this._needsEdgeDecoration(ud);
2664
- });
2665
- let i = 0;
2666
- const step = (): void => {
2667
- const t0 = performance.now();
2668
- while (i < pending.length && performance.now() - t0 < 8) this._ensureEdgeDecoration(pending[i++]);
2669
- if (i < pending.length) setTimeout(step, 0);
2670
- else this._requestRepaint();
2671
- };
2672
- step();
2673
-
2674
- if (wantAnim) {
2675
- // 从 0 起淡入:新建出来的棱线自动继承当前进度,不会半路冒出一条满亮度的线
2676
- this._setEdgeFade(0);
2677
- this.animator.to('style.edgeFade', 1, transition);
2678
- } else {
2679
- this._setEdgeFade(1);
2680
- }
2214
+ /** @internal 测试用:棱线是否开启(实现在 EdgeDecorator) */
2215
+ get _edgesEnabled(): boolean {
2216
+ return this._edges.enabled;
2681
2217
  }
2682
-
2683
- /** 直接写棱线淡入乘子(不走动画通道) */
2684
- private _setEdgeFade(v: number): void {
2685
- if (this.uniforms.uEdgeFade) this.uniforms.uEdgeFade.value = v;
2686
- this._syncEdgeStyle();
2218
+ /** @internal 测试用:当前画法 */
2219
+ get _edgeModes(): EdgeModes {
2220
+ return this._edges.modes;
2687
2221
  }
2688
-
2689
- /** 释放所有现算棱线几何 */
2690
- private _disposeEdges(): void {
2691
- for (const p of this._pickables.values()) this._removeEdges(p.mesh);
2222
+ /** @internal 测试用:GPU 描边实例(可替换) */
2223
+ get _gpuEdge(): GpuEdgePass | null {
2224
+ return this._edges.gpuEdge;
2692
2225
  }
2693
-
2694
- /**
2695
- * 挂载预制棱线缓存(Crease Cache)。
2696
- *
2697
- * 直接把 `source-creases.f32` 这类 Float32Array 缓冲载入为一组全局统一的
2698
- * LineSegments —— 相比逐 Mesh 现算 EdgesGeometry,性能高一个量级,视觉也最干净。
2699
- * 坐标按「场景本地坐标」(相对场景锚点的米)处理,与瓦片几何同一坐标系。
2700
- *
2701
- * @param url 棱线缓存地址(二进制 Float32 序列:x,y,z,x,y,z,… 成对成段)
2702
- */
2703
- async loadCreases(url: string): Promise<void> {
2704
- const resp = await authFetch(url, this._authenticate, 'creases');
2705
- if (!resp.ok) throw new Error(`[s3m] 棱线缓存加载失败: ${resp.status} ${url}`);
2706
- const buf = await resp.arrayBuffer();
2707
- const arr = new Float32Array(buf);
2708
- if (arr.length < 6) return;
2709
-
2710
- const geo = new THREE.BufferGeometry();
2711
- geo.setAttribute('position', new THREE.BufferAttribute(arr, 3));
2712
- const s = this.style;
2713
- const mat = new THREE.LineBasicMaterial({
2714
- color: new THREE.Color(s.edgeColor),
2715
- transparent: true,
2716
- opacity: Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) * clampOpacity(s.opacity),
2717
- depthWrite: false,
2718
- });
2719
- const lines = new THREE.LineSegments(geo, mat);
2720
- lines.renderOrder = 2;
2721
- lines.frustumCulled = false;
2722
- lines.raycast = () => {};
2723
-
2724
- this.clearCreases();
2725
- this._creaseLines = lines;
2726
- this.scene?.add(lines);
2727
- this._requestRepaint();
2226
+ set _gpuEdge(v: GpuEdgePass | null) {
2227
+ this._edges.gpuEdge = v;
2728
2228
  }
2729
2229
 
2730
- /** 移除并释放预制棱线缓存 */
2731
- clearCreases(): void {
2732
- if (!this._creaseLines) return;
2733
- this.scene?.remove(this._creaseLines);
2734
- this._creaseLines.geometry.dispose();
2735
- (this._creaseLines.material as THREE.Material)?.dispose?.();
2736
- this._creaseLines = null;
2737
- this._requestRepaint();
2230
+ /** @internal 测试用:给一块网格补折边线 */
2231
+ _ensureEdges(mesh: THREE.Mesh): void {
2232
+ this._edges.ensureEdges(mesh);
2738
2233
  }
2739
2234
 
2740
- /**
2741
- * 让全局棱线材质跟上 style.edgeColor / edgeWidth / edgeOpacity / edgeOffset。
2742
- *
2743
- * 全程 O(1):只有一份共享 edgeMaterial(所有逐 mesh 棱线都用它)+ 一份预制缓存
2744
- * + 一批共享外观对象的轮廓壳,所以棱线淡入淡出只要改一个 uniform,
2745
- * 新建瓦片自动继承当前进度。
2746
- */
2747
- private _syncEdgeStyle(): void {
2748
- const s = this.style;
2749
- const fade = this.uniforms.uEdgeFade?.value ?? 1;
2750
- const opacity =
2751
- Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) *
2752
- clampOpacity(s.opacity) *
2753
- fade;
2754
- const u = this.edgeMaterial.uniforms;
2755
- u.uColor.value.set(s.edgeColor);
2756
- u.uWidth.value = Math.max(0.1, s.edgeWidth ?? 1.5);
2757
- u.uOpacity.value = opacity;
2758
- // 外推距离只在 offset 画法下非 0;其它画法把线钉在面上(与改动前一致)
2759
- u.uOffset.value = this._edgeModes.offset ? Math.max(0, s.edgeOffset ?? 0.15) : 0;
2760
- // 外扩轮廓壳与折边线共用颜色 / 透明度,厚度是米制的单独一项
2761
- const h = this._hullStyleUniforms;
2762
- h.uColor.value.set(s.edgeColor);
2763
- h.uWidth.value = Math.max(0, s.edgeHullWidth ?? 0.9);
2764
- h.uOpacity.value = opacity;
2765
- // GPU 描边(edges: 'gpu')是后处理,参数每帧从 style 现取,这里不用同步 uniform
2766
- // 预制棱线缓存(LineBasicMaterial)同步颜色 / 不透明度
2767
- if (this._creaseLines) {
2768
- const m = this._creaseLines.material as THREE.LineBasicMaterial;
2769
- m.color.set(s.edgeColor);
2770
- m.opacity = opacity;
2771
- }
2235
+ /** @internal 测试用:拆掉一块网格的棱线装饰 */
2236
+ _removeEdges(mesh: THREE.Mesh): void {
2237
+ this._edges.removeEdges(mesh);
2772
2238
  }
2773
2239
 
2774
- /** 让数据自带的棱线跟着 style.edgeColor / opacity 走(这些是 LineBasicMaterial,改不了粗细) */
2775
- private _styleEdgeLine(lines: THREE.LineSegments): void {
2776
- const mat = lines.material as THREE.LineBasicMaterial;
2777
- if (!mat || !mat.color) return;
2778
- mat.color.set(this.style.edgeColor);
2779
- const baseOpacity = this.style.edgeOpacity ?? 0.6;
2780
- mat.opacity =
2781
- baseOpacity *
2782
- clampOpacity(this.style.opacity) *
2783
- (this.uniforms.uEdgeFade?.value ?? 1);
2784
- mat.transparent = true;
2785
- mat.polygonOffset = true;
2786
- mat.polygonOffsetFactor = -1;
2787
- mat.polygonOffsetUnits = -1;
2788
- mat.needsUpdate = true;
2240
+ /** 全局棱线开关,实现在 viewer/EdgeDecorator.ts */
2241
+ setEdges(edges: EdgesOption, transition?: TransitionOptions): void {
2242
+ this._edges.setEdges(edges, transition);
2789
2243
  }
2790
2244
 
2791
- /**
2792
- * 刷新所有棱线外观。
2793
- *
2794
- * 数据自带的棱线是逐对象的 LineBasicMaterial,改颜色只能一个个写;但原来每次都要
2795
- * 遍历整个数据集树(几千个节点),颜色动画逐帧跑就是灾难。改成只刷注册时收集好的
2796
- * 那批 LineSegments,复杂度降到 O(棱线条数)。
2797
- */
2798
- private _refreshEdgeLines(): void {
2799
- this._syncEdgeStyle();
2800
- for (const lines of this._nativeEdgeLines) {
2801
- if (lines.parent) this._styleEdgeLine(lines);
2802
- }
2245
+ /** 挂载预制棱线缓存(Crease Cache),见 viewer/EdgeDecorator.ts */
2246
+ loadCreases(url: string): Promise<void> {
2247
+ return this._edges.loadCreases(url);
2803
2248
  }
2804
2249
 
2805
- /** 地形 / 绿地 / 道路 / 地面铺装等不参与自动描边(数据自带棱线包另说) */
2806
- private static readonly _EDGE_SKIP_KEYWORDS = [
2807
- 'terrain', 'ground', 'vegetation', 'green', 'road', 'dem',
2808
- '绿地', '道路', '地面', '地形', '植被', '铺装',
2809
- ];
2810
-
2811
- /** 数据集级别的棱线开关:enableEdges 优先,否则跟随全局 style.edges */
2812
- private _datasetEdgesEnabled(entry: S3MDatasetEntry): boolean {
2813
- const opt = entry.options?.enableEdges;
2814
- if (opt === true) return true;
2815
- if (opt === false) return false;
2816
- return this._edgesEnabled;
2250
+ /** 移除并释放预制棱线缓存 */
2251
+ clearCreases(): void {
2252
+ this._edges.clearCreases();
2817
2253
  }
2818
2254
 
2819
- /** 该 mesh 是否命中「跳过自动描边」的关键字(skeletonName / 数据集名) */
2820
- private _shouldSkipAutoEdges(ud: MeshUserData, datasetName: string): boolean {
2821
- const hay = `${ud.skeletonName ?? ''} ${datasetName}`.toLowerCase();
2822
- return S3MViewer._EDGE_SKIP_KEYWORDS.some((k) => hay.includes(k));
2823
- }
2255
+ /* ---------------------------------------------------------------- 拾取 */
2824
2256
 
2825
- /**
2826
- * 这块网格还缺哪种棱线装饰(缺就建,齐了就不动)。
2827
- *
2828
- * setEdges 的分片建线靠它筛「还没建完的」,注册新瓦片也靠它决定要不要动手。
2829
- */
2830
- private _needsEdgeDecoration(ud: MeshUserData): boolean {
2831
- if (this._edgeModes.line && !ud.edges) return true;
2832
- if (this._edgeModes.hull && !ud.edgeHull) return true;
2833
- return false;
2257
+ /** 屏幕像素 → 场景射线,见 viewer/PickService.ts */
2258
+ getRayAt(x: number, y: number): THREE.Ray | null {
2259
+ return this._picks.getRayAt(x, y);
2834
2260
  }
2835
2261
 
2836
- /** 按当前画法补齐这块网格的棱线装饰 */
2837
- private _ensureEdgeDecoration(mesh: THREE.Mesh): void {
2838
- if (this._edgeModes.line) this._ensureEdges(mesh);
2839
- if (this._edgeModes.hull) this._ensureHull(mesh);
2840
- }
2841
-
2842
- private _ensureEdges(mesh: THREE.Mesh): void {
2843
- const ud = mesh.userData as MeshUserData;
2844
- if (ud.edges || ud.nativeEdges || !mesh.geometry) return;
2845
- // 地形 / 绿地 / 道路等不参与自动描边(即使全局开棱线也要跳过)
2846
- if (this._shouldSkipAutoEdges(ud, '')) return;
2847
-
2848
- const posAttr = mesh.geometry.getAttribute('position');
2849
- const indexCount = mesh.geometry.index?.count ?? (posAttr ? posAttr.count : 0);
2850
- // 棱线是 CPU 端建的,超大几何直接跳过,避免拖慢加载
2851
- if (indexCount > 120000) return;
2852
-
2853
- // 1. 先做顶点容差焊合,让相邻三角形正确共享边索引。
2854
- // S3M 几何常为「每面独立顶点」,不焊合的话 EdgesGeometry 会把每条三角形边都当成边界,棱线爆炸。
2855
- // 注意用本地实现而不是 three/examples/jsm —— jsm 子路径导入会在宿主里
2856
- // 制造第二份 three 实例("Multiple instances of Three.js" 警告的元凶)。
2857
- // tolerance 取米级模型下的 5mm,足以合并重合顶点又不会误并真实相邻面。
2858
- const welded = mergeVerticesByPosition(mesh.geometry, 0.005);
2859
- // 2. 调大临界角(默认 55°,可在 style.edgeThreshold 调),过滤浅坡与曲面细分,只保留真正的棱。
2860
- const thresholdAngle = this.style.edgeThreshold ?? 55;
2861
-
2862
- // 'offset' 画法要额外带一条「边法线」才知道往哪推,所以走本地实现(它本来就算好了
2863
- // 相邻两面法线);其余画法保持 EdgesGeometry 这条老路,几何输出与改动前逐字一致。
2864
- let segments: Float32Array | null;
2865
- let normals: Float32Array | undefined;
2866
- if (this._edgeModes.offset) {
2867
- const raw: number[] = [];
2868
- segments = buildFeatureEdgeSegments(welded, null, thresholdAngle, raw);
2869
- if (raw.length > 0) normals = new Float32Array(raw);
2870
- } else {
2871
- const source = new THREE.EdgesGeometry(welded, thresholdAngle);
2872
- const attr = source.getAttribute('position') as THREE.BufferAttribute | undefined;
2873
- segments = attr ? (attr.array as Float32Array) : null;
2874
- source.dispose();
2875
- }
2876
- welded.dispose();
2877
- if (!segments || segments.length < 6) return;
2878
-
2879
- // 3. 回填每段棱线所属的单体 batchId。
2880
- // EdgesGeometry 只输出坐标不输出顶点号,只能拿端点坐标去查「位置 → batchId」表。
2881
- // 没有这张表,单体一动棱线就留在原地——这正是「棱线跟随」的关键一环。
2882
- const TOL = 0.005;
2883
- const batchMap = buildPositionBatchMap(mesh.geometry, TOL);
2884
- let batchIds: Float32Array | undefined;
2885
- if (batchMap) {
2886
- const segCount = segments.length / 6;
2887
- batchIds = new Float32Array(segCount);
2888
- for (let i = 0; i < segCount; i++) {
2889
- const s = i * 6;
2890
- batchIds[i] =
2891
- batchMap.get(positionQuantKey(segments[s], segments[s + 1], segments[s + 2], 1 / TOL)) ?? -1;
2892
- }
2893
- }
2894
-
2895
- const lines = new THREE.Mesh(
2896
- buildThickLineGeometry(segments, batchIds, normals),
2897
- this._createEdgeMaterial(mesh)
2898
- );
2899
- lines.renderOrder = 2;
2900
- // 屏幕空间外扩后可能超出原包围球,关掉剔除更稳
2901
- lines.frustumCulled = false;
2902
- lines.raycast = () => {};
2903
- (lines.userData as MeshUserData).isThickEdge = true;
2904
- ud.edges = lines;
2905
- mesh.add(lines); // 作为子节点,自动继承 mesh 的世界变换
2262
+ /** 按 canvas 左上角的 CSS 像素坐标拾取 */
2263
+ pick(x: number, y: number): PickResult | null {
2264
+ return this._picks.pick(x, y);
2906
2265
  }
2907
2266
 
2908
- /**
2909
- * 给一块网格挂「背面外扩轮廓」(整场景版)。
2910
- *
2911
- * 复用选中侧那套 `createOutlineMaterial`:顶点沿法线鼓 `edgeHullWidth` 米、只画背面,
2912
- * 于是被本体挡住的部分不显示,露出来的那圈就是描边。与折边线的差别在于——
2913
- * 这里**不建任何新几何**,成本只是把同一块网格多画一遍,折边判定那套 CPU 活全省了。
2914
- *
2915
- * 实例化几何拿不到逐实例变换(轮廓壳是 mesh 的子节点,不吃 instanceMatrix),
2916
- * 直接跳过:宁可这栋没描边,也不要所有实例的壳叠在原点。
2917
- */
2918
- private _ensureHull(mesh: THREE.Mesh): void {
2919
- const ud = mesh.userData as MeshUserData;
2920
- if (ud.edgeHull || !mesh.geometry) return;
2921
- if ((mesh as unknown as { isInstancedMesh?: boolean }).isInstancedMesh) return;
2922
- // 地形 / 绿地 / 道路等不参与自动描边(即使全局开棱线也要跳过)
2923
- if (this._shouldSkipAutoEdges(ud, '')) return;
2924
-
2925
- // 逐网格一份材质(各绑各的单体状态纹理),外观 uniform 复用共享对象
2926
- const mat = createOutlineMaterial(
2927
- this.uniforms,
2928
- this.style.edgeHullWidth ?? 0.9,
2929
- this._hullStyleUniforms,
2930
- true
2931
- );
2932
- // 整块几何都要外扩:选中侧靠 uSelBatch 只扩一栋,全局必须置 whole
2933
- mat.uniforms.uSelWhole.value = 1;
2934
- mat.uniforms.uSelBatch.value = -1;
2935
- const styled = styledMaterialOf(mesh);
2936
- if (styled?.userData?.uBatchTex) {
2937
- mat.uniforms.uBatchTex.value = styled.userData.uBatchTex.value as THREE.Texture;
2938
- mat.uniforms.uBatchTexWidth.value = styled.userData.uBatchTexWidth.value as number;
2939
- mat.uniforms.uBatchTexActive.value = styled.userData.uBatchTexActive.value as number;
2940
- }
2941
-
2942
- const hull = new THREE.Mesh(mesh.geometry, mat);
2943
- hull.renderOrder = 2;
2944
- hull.frustumCulled = false;
2945
- hull.raycast = () => {};
2946
- (hull.userData as MeshUserData).isEdgeHull = true;
2947
- ud.edgeHull = hull;
2948
- mesh.add(hull); // 作为子节点,自动继承 mesh 的世界变换
2267
+ /** 拾取并返回经纬度 + 高程 */
2268
+ pickPosition(x: number, y: number): { lngLat: [number, number]; altitude: number; point: [number, number, number] } | null {
2269
+ return this._picks.pickPosition(x, y);
2949
2270
  }
2950
2271
 
2951
- /**
2952
- * 给一块网格造专属的棱线材质。
2953
- *
2954
- * 棱线材质必须逐网格一份,因为它要绑定**这个网格**的单体状态纹理;但外观
2955
- * (颜色 / 粗细 / 透明度)必须全局统一——所以外观 uniform 直接复用
2956
- * `_edgeStyleUniforms` 里的对象,`_syncEdgeStyle()` 改一次全部生效,
2957
- * program 也因为着色器相同而只编译一次。
2958
- */
2959
- private _createEdgeMaterial(mesh: THREE.Mesh): ThickLineMaterial {
2960
- const styled = styledMaterialOf(mesh);
2961
- const batch = styled?.userData?.uBatchTex
2962
- ? {
2963
- uBatchTex: styled.userData.uBatchTex,
2964
- uBatchTex2: styled.userData.uBatchTex2,
2965
- uBatchMix: this.uniforms.uBatchMix,
2966
- uBatchTexWidth: styled.userData.uBatchTexWidth,
2967
- uBatchTexActive: styled.userData.uBatchTexActive,
2968
- uPathTex: this.uniforms.uPathTex,
2969
- uPathCount: this.uniforms.uPathCount,
2970
- uPathTexActive: this.uniforms.uPathTexActive,
2971
- uPathTime: this.uniforms.uPathTime,
2972
- }
2973
- : undefined;
2974
- return createThickLineMaterial(this.lineShared, {}, this._edgeStyleUniforms, batch);
2272
+ /** 穿透拾取:沿射线返回前 `limit` 个命中 */
2273
+ drillPick(x: number, y: number, limit = 10): PickResult[] {
2274
+ return this._picks.drillPick(x, y, limit);
2975
2275
  }
2976
2276
 
2977
- /** 选中棱线的逐网格材质,道理同上 */
2978
- private _createSelectionEdgeMaterial(mesh: THREE.Mesh): ThickLineMaterial {
2979
- const styled = styledMaterialOf(mesh);
2980
- const batch = styled?.userData?.uBatchTex
2981
- ? {
2982
- uBatchTex: styled.userData.uBatchTex,
2983
- uBatchTex2: styled.userData.uBatchTex2,
2984
- uBatchMix: this.uniforms.uBatchMix,
2985
- uBatchTexWidth: styled.userData.uBatchTexWidth,
2986
- uBatchTexActive: styled.userData.uBatchTexActive,
2987
- uPathTex: this.uniforms.uPathTex,
2988
- uPathCount: this.uniforms.uPathCount,
2989
- uPathTexActive: this.uniforms.uPathTexActive,
2990
- uPathTime: this.uniforms.uPathTime,
2991
- }
2992
- : undefined;
2993
- return createThickLineMaterial(this.lineShared, {}, this._selEdgeStyleUniforms, batch);
2277
+ /** 单体的经纬度包围盒 */
2278
+ getFeatureBounds(smid: number): [[number, number], [number, number]] | null {
2279
+ return this._picks.getFeatureBounds(smid);
2994
2280
  }
2995
2281
 
2996
- private _removeEdges(mesh: THREE.Mesh): void {
2997
- const ud = mesh.userData as MeshUserData;
2998
-
2999
- const lines = ud.edges;
3000
- if (lines) {
3001
- mesh.remove(lines);
3002
- (lines as THREE.Mesh).geometry?.dispose();
3003
- // 棱线材质现在是逐网格一份(要绑各自的 batch 纹理),得跟着释放
3004
- const mat = (lines as THREE.Mesh).material;
3005
- if (Array.isArray(mat)) {
3006
- mat.forEach((m) => (m as any)?.dispose?.());
3007
- } else {
3008
- (mat as any)?.dispose?.();
3009
- }
3010
- ud.edges = null;
3011
- }
3012
-
3013
- // 外扩壳的 geometry 是**复用主网格的**,只能释放材质,不能 dispose 几何
3014
- const hull = ud.edgeHull;
3015
- if (hull) {
3016
- mesh.remove(hull);
3017
- const hmat = hull.material;
3018
- if (Array.isArray(hmat)) hmat.forEach((m) => (m as any)?.dispose?.());
3019
- else (hmat as any)?.dispose?.();
3020
- ud.edgeHull = null;
3021
- }
2282
+ /** @internal 见 viewer/PickService.ts */
2283
+ _intersectMeshAtRay(mesh: THREE.Mesh, ray: THREE.Ray, instanceIndex?: number): THREE.Vector3 | null {
2284
+ return this._picks.intersectMeshAtRay(mesh, ray, instanceIndex);
3022
2285
  }
3023
2286
 
3024
- /* ---------------------------------------------------------------- 拾取 */
3025
-
3026
- /** 获取指定屏幕像素对应的场景射线(用于高精度求交与穿透拾取) */
3027
- getRayAt(x: number, y: number): THREE.Ray | null {
3028
- if (!this.camera || !this.canvas) return null;
3029
- const canvas = this.canvas;
3030
- const w = canvas.clientWidth || canvas.width;
3031
- const h = canvas.clientHeight || canvas.height;
3032
- if (!w || !h) return null;
3033
-
3034
- const ndcX = (x / w) * 2 - 1;
3035
- const ndcY = -(y / h) * 2 + 1;
3036
-
3037
- const near = new THREE.Vector3(ndcX, ndcY, -1).unproject(this.camera);
3038
- const far = new THREE.Vector3(ndcX, ndcY, 1).unproject(this.camera);
3039
- const dir = far.clone().sub(near).normalize();
3040
- return new THREE.Ray(near, dir);
3041
- }
3042
-
3043
- private _intersectMeshAtRay(mesh: THREE.Mesh, ray: THREE.Ray, instanceIndex?: number): THREE.Vector3 | null {
3044
- const raycaster = new THREE.Raycaster();
3045
- raycaster.ray.copy(ray);
3046
- const hits = raycaster.intersectObject(mesh, false);
3047
- if (!hits.length) return null;
3048
- if (instanceIndex != null) {
3049
- const match = hits.find((h) => h.instanceId === instanceIndex);
3050
- return match ? match.point : hits[0].point;
3051
- }
3052
- return hits[0].point;
2287
+ /** @internal 见 viewer/PickService.ts */
2288
+ _buildPickResult(
2289
+ hit: { objectId: number; batchId: number; instanceIndex?: number },
2290
+ ref: PickableRef,
2291
+ exactPoint?: THREE.Vector3,
2292
+ ray?: THREE.Ray
2293
+ ): PickResult | null {
2294
+ return this._picks.buildPickResult(hit, ref, exactPoint, ray);
3053
2295
  }
3054
2296
 
3055
- private _buildPickResult(
2297
+ /** @internal 见 viewer/PickService.ts */
2298
+ _buildSplatPickResult(
3056
2299
  hit: { objectId: number; batchId: number; instanceIndex?: number },
3057
2300
  ref: PickableRef,
3058
2301
  exactPoint?: THREE.Vector3,
3059
2302
  ray?: THREE.Ray
3060
2303
  ): PickResult | null {
3061
- const ud = ref.mesh.userData as MeshUserData;
3062
- let smid: number | null = null;
3063
- let batchId = hit.batchId;
3064
- let instanceIndex = hit.instanceIndex;
3065
- let geoRef = ud.geoRef;
3066
- let segments: Array<{ vertexOffset: number; vertexCount: number }> = [];
3067
-
3068
- if (instanceIndex != null) {
3069
- const feature = this._instanceFeature(ref.mesh, geoRef, instanceIndex);
3070
- if (!feature) return null;
3071
- smid = feature.smid;
3072
- batchId = feature.batchId;
3073
- } else {
3074
- geoRef = (ud.geoByBatchId as Map<number, GeoPackageEntry> | undefined)?.get(hit.batchId) ?? ud.geoRef;
3075
- const resolved = resolveFeature(geoRef, hit.batchId);
3076
- smid = resolved.smid;
3077
- segments = resolved.segments;
3078
- }
3079
-
3080
- if (this._isFeatureHidden(smid, batchId)) return null;
3081
-
3082
- let box: SegmentBox | null = null;
3083
- let totalTriangles = 0;
3084
-
3085
- if (instanceIndex != null) {
3086
- box = this._instanceBox(ref.mesh, instanceIndex);
3087
- totalTriangles = ref.mesh.geometry.index ? Math.round(ref.mesh.geometry.index.count / 3) : 0;
3088
- } else if (smid != null && this._smidMeshes.has(smid)) {
3089
- const meshes = this._smidMeshes.get(smid)!;
3090
- let minX = Infinity;
3091
- let minY = Infinity;
3092
- let minZ = Infinity;
3093
- let maxX = -Infinity;
3094
- let maxY = -Infinity;
3095
- let maxZ = -Infinity;
3096
- let count = 0;
3097
- for (const m of meshes) {
3098
- const mUd = m.userData as MeshUserData;
3099
- const mPick = mUd.geoRef?.pickInfo;
3100
- const entry = (mPick as Record<string | number, unknown> | undefined)?.[smid] ??
3101
- (mPick as Record<string | number, unknown> | undefined)?.[String(smid)];
3102
- const bId = entry ? pickEntryId(entry as any) : -1;
3103
- const mSegs = (entry as any)?.segments ?? (bId >= 0 ? resolveFeature(mUd.geoRef, bId).segments : []);
3104
- const mBox = measureSegments(m.geometry, mSegs);
3105
- if (mBox) {
3106
- if (mBox.minX < minX) minX = mBox.minX;
3107
- if (mBox.minY < minY) minY = mBox.minY;
3108
- if (mBox.minZ < minZ) minZ = mBox.minZ;
3109
- if (mBox.maxX > maxX) maxX = mBox.maxX;
3110
- if (mBox.maxY > maxY) maxY = mBox.maxY;
3111
- if (mBox.maxZ > maxZ) maxZ = mBox.maxZ;
3112
- count += mBox.count;
3113
- totalTriangles += mBox.triangles;
3114
- }
3115
- }
3116
- if (count > 0) {
3117
- box = { minX, minY, minZ, maxX, maxY, maxZ, count, triangles: totalTriangles };
3118
- }
3119
- }
3120
-
3121
- if (!box) {
3122
- box = measureSegments(ref.mesh.geometry, segments);
3123
- if (!box && ref.mesh.geometry) {
3124
- if (!ref.mesh.geometry.boundingBox) ref.mesh.geometry.computeBoundingBox();
3125
- const bb = ref.mesh.geometry.boundingBox;
3126
- if (bb) {
3127
- box = {
3128
- minX: bb.min.x,
3129
- maxX: bb.max.x,
3130
- minY: bb.min.y,
3131
- maxY: bb.max.y,
3132
- minZ: bb.min.z,
3133
- maxZ: bb.max.z,
3134
- count: 0,
3135
- triangles: 0,
3136
- };
3137
- }
3138
- }
3139
- }
3140
- const geometry = ref.mesh.geometry;
3141
- const triangles = totalTriangles > 0 ? totalTriangles : (geometry.index ? Math.round(geometry.index.count / 3) : 0);
3142
- const attributes = buildAttributes({ smid, box, triangles, floorHeight: this.style.floorHeight });
3143
-
3144
- let centerLngLat: [number, number] | null = null;
3145
- if (box && this.sceneAnchor) {
3146
- const v = new THREE.Vector3((box.minX + box.maxX) / 2, (box.minY + box.maxY) / 2, (box.minZ + box.maxZ) / 2);
3147
- v.applyMatrix4(ref.mesh.matrixWorld);
3148
- const s = this.sceneAnchor.meterInMercator;
3149
- centerLngLat = this._lngLatFromWorld(v.x * s + this.sceneAnchor.x, -v.y * s + this.sceneAnchor.y);
3150
- }
3151
-
3152
- // 精确命中点求交计算
3153
- let hitPoint: THREE.Vector3 | null = exactPoint ?? null;
3154
- if (!hitPoint && ray) {
3155
- hitPoint = this._intersectMeshAtRay(ref.mesh, ray, instanceIndex);
3156
- }
3157
-
3158
- let point: [number, number, number] | null = null;
3159
- let lngLat: [number, number] | null = null;
3160
- let altitude: number | null = null;
3161
-
3162
- if (hitPoint && this.sceneAnchor) {
3163
- const off = smid != null ? this._featureStyleManager.getFeatureEffectiveOffset(smid) : {};
3164
- const trans = off.translation;
3165
- const elev = off.elevation;
3166
- const finalPos = hitPoint.clone();
3167
-
3168
- if (off.rotation) {
3169
- const rotMat = resolveRotationMatrix(off.rotation);
3170
- if (rotMat) {
3171
- const m3 = new THREE.Matrix3().set(
3172
- rotMat[0], rotMat[1], rotMat[2],
3173
- rotMat[3], rotMat[4], rotMat[5],
3174
- rotMat[6], rotMat[7], rotMat[8]
3175
- );
3176
- const piv = off.pivot ? new THREE.Vector3(off.pivot[0], off.pivot[1], off.pivot[2]) : new THREE.Vector3(0, 0, 0);
3177
- finalPos.sub(piv).applyMatrix3(m3).add(piv);
3178
- }
3179
- }
3180
-
3181
- if (trans) finalPos.add(new THREE.Vector3(trans[0], trans[1], trans[2]));
3182
- else if (elev) finalPos.z += elev;
3183
-
3184
- point = [finalPos.x, finalPos.y, finalPos.z];
3185
- const s = this.sceneAnchor.meterInMercator;
3186
- lngLat = this._lngLatFromWorld(finalPos.x * s + this.sceneAnchor.x, -finalPos.y * s + this.sceneAnchor.y);
3187
- altitude = finalPos.z + (this.sceneAnchor.altitude ?? 0);
3188
- } else if (centerLngLat && box && this.sceneAnchor) {
3189
- lngLat = centerLngLat;
3190
- altitude = (box.minZ + box.maxZ) / 2 + (this.sceneAnchor.altitude ?? 0);
3191
- }
3192
-
3193
- return {
3194
- datasetId: ref.datasetId,
3195
- datasetName: this.datasets.get(ref.datasetId)?.name ?? ref.datasetId,
3196
- objectId: hit.objectId,
3197
- batchId,
3198
- instanceIndex,
3199
- skeletonName: ref.skeletonName,
3200
- centerLngLat,
3201
- point,
3202
- lngLat,
3203
- altitude,
3204
- ...attributes,
3205
- mesh: ref.mesh,
3206
- };
2304
+ return this._picks.buildSplatPickResult(hit, ref, exactPoint, ray);
3207
2305
  }
3208
2306
 
3209
- /** 按 canvas 左上角的 CSS 像素坐标拾取。 */
3210
- pick(x: number, y: number): PickResult | null {
3211
- if (!this.picker || !this.scene || !this.camera || !this.sceneAnchor) return null;
3212
- const hit = this.picker.pick(this.scene, this.camera, x, y);
3213
- if (!hit) return null;
3214
-
3215
- const ref = this._pickables.get(hit.objectId);
3216
- if (!ref) return null;
3217
-
3218
- const ray = this.getRayAt(x, y);
3219
- return this._buildPickResult(hit, ref, undefined, ray ?? undefined);
2307
+ /** @internal 见 viewer/PickService.ts */
2308
+ _instanceFeature(mesh: THREE.Mesh, geoRef: GeoPackageEntry | undefined, instanceIndex: number): InstanceFeature | null {
2309
+ return this._picks.instanceFeature(mesh, geoRef, instanceIndex);
3220
2310
  }
3221
2311
 
3222
- /**
3223
- * 拾取指定屏幕像素对应的世界坐标与场景位置。
3224
- * 优先与命中的建筑网格精确求交;未命中建筑时若视线与地表相交则返回地面交点。
3225
- */
3226
- pickPosition(x: number, y: number): { lngLat: [number, number]; altitude: number; point: [number, number, number] } | null {
3227
- if (!this.camera || !this.sceneAnchor) return null;
3228
-
3229
- const hit = this.pick(x, y);
3230
- if (hit?.point && hit?.lngLat && hit.altitude != null) {
3231
- return {
3232
- lngLat: hit.lngLat,
3233
- altitude: hit.altitude,
3234
- point: hit.point,
3235
- };
3236
- }
3237
-
3238
- const ray = this.getRayAt(x, y);
3239
- if (!ray) return null;
3240
-
3241
- const groundPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
3242
- const target = new THREE.Vector3();
3243
- const intersect = ray.intersectPlane(groundPlane, target);
3244
- if (intersect) {
3245
- const s = this.sceneAnchor.meterInMercator;
3246
- const lngLat = this._lngLatFromWorld(intersect.x * s + this.sceneAnchor.x, -intersect.y * s + this.sceneAnchor.y);
3247
- const altitude = intersect.z + (this.sceneAnchor.altitude ?? 0);
3248
- return {
3249
- lngLat,
3250
- altitude,
3251
- point: [intersect.x, intersect.y, intersect.z],
3252
- };
3253
- }
3254
- return null;
2312
+ /** @internal 见 viewer/PickService.ts */
2313
+ _instanceBox(mesh: THREE.Mesh, instanceIndex: number): SegmentBox | null {
2314
+ return this._picks.instanceBox(mesh, instanceIndex);
3255
2315
  }
3256
2316
 
3257
- /**
3258
- * 沿视线方向穿透拾取多个单体,按由近至远排序返回。
3259
- */
3260
- drillPick(x: number, y: number, limit = 10): PickResult[] {
3261
- if (!this.sceneAnchor || !this.camera) return [];
3262
- const ray = this.getRayAt(x, y);
3263
- if (!ray) return [];
3264
-
3265
- const raycaster = new THREE.Raycaster();
3266
- raycaster.ray.copy(ray);
3267
-
3268
- const meshes = [...this._pickables.values()]
3269
- .map((p) => p.mesh)
3270
- .filter((m) => {
3271
- let curr: THREE.Object3D | null = m;
3272
- while (curr) {
3273
- if (!curr.visible) return false;
3274
- curr = curr.parent;
3275
- }
3276
- return true;
3277
- });
3278
- const hits = raycaster.intersectObjects(meshes, false);
3279
- if (!hits.length) return [];
3280
-
3281
- const results: PickResult[] = [];
3282
- const seenSmids = new Set<number>();
3283
- const seenBatches = new Set<string>();
3284
-
3285
- for (const hit of hits) {
3286
- if (results.length >= limit) break;
3287
- const mesh = hit.object as THREE.Mesh;
3288
- const ud = mesh.userData as MeshUserData & { pickId?: number };
3289
- if (ud.pickId == null) continue;
3290
- const ref = this._pickables.get(ud.pickId);
3291
- if (!ref) continue;
3292
-
3293
- let smid: number | null = null;
3294
- let batchId = -1;
3295
- let instanceIndex = hit.instanceId;
3296
-
3297
- if (instanceIndex != null) {
3298
- const feature = this._instanceFeature(mesh, ud.geoRef, instanceIndex);
3299
- if (!feature) continue;
3300
- smid = feature.smid;
3301
- batchId = feature.batchId;
3302
- } else if (hit.faceIndex != null) {
3303
- const batchAttr = mesh.geometry.attributes.aBatchId;
3304
- if (batchAttr) {
3305
- const idx = mesh.geometry.index ? mesh.geometry.index.getX(hit.faceIndex * 3) : hit.faceIndex * 3;
3306
- batchId = Math.round(batchAttr.getX(idx));
3307
- const geoRef = (ud.geoByBatchId as Map<number, GeoPackageEntry> | undefined)?.get(batchId) ?? ud.geoRef;
3308
- const resolved = resolveFeature(geoRef, batchId);
3309
- smid = resolved.smid;
3310
- }
3311
- }
3312
-
3313
- if (smid != null) {
3314
- if (seenSmids.has(smid)) continue;
3315
- seenSmids.add(smid);
3316
- } else {
3317
- const key = `${ud.pickId}:${batchId}:${instanceIndex ?? -1}`;
3318
- if (seenBatches.has(key)) continue;
3319
- seenBatches.add(key);
3320
- }
3321
-
3322
- if (this._isFeatureHidden(smid, batchId)) continue;
3323
-
3324
- const pickHit = {
3325
- objectId: ud.pickId,
3326
- batchId,
3327
- instanceIndex,
3328
- };
3329
- const res = this._buildPickResult(pickHit, ref, hit.point);
3330
- if (res) results.push(res);
3331
- }
3332
-
3333
- return results;
2317
+ /** @internal 拾取 id 池,见 viewer/PickService.ts */
2318
+ _allocPickId(): number {
2319
+ return this._picks.allocPickId();
3334
2320
  }
3335
2321
 
3336
- /**
3337
- * 获取指定 SMID 单体的经纬度包围盒 [[minLng, minLat], [maxLng, maxLat]]。
3338
- * 若模型尚未加载到场景中或无此单体则返回 null。
3339
- */
3340
- getFeatureBounds(smid: number): [[number, number], [number, number]] | null {
3341
- if (!this.sceneAnchor) return null;
3342
- let minLng = Infinity;
3343
- let maxLng = -Infinity;
3344
- let minLat = Infinity;
3345
- let maxLat = -Infinity;
3346
- let found = false;
3347
-
3348
- for (const ref of this._pickables.values()) {
3349
- const mesh = ref.mesh;
3350
- const ud = mesh.userData as MeshUserData;
3351
- const geoRef = ud.geoRef;
3352
- const pickInfo = geoRef?.pickInfo;
3353
- if (!pickInfo) continue;
3354
-
3355
- let box: SegmentBox | null = null;
3356
- if ((mesh as THREE.InstancedMesh).isInstancedMesh) {
3357
- let lookup = ud.instanceLookup as Map<number, InstanceFeature> | undefined;
3358
- if (!lookup) {
3359
- lookup = buildInstanceLookup(pickInfo);
3360
- ud.instanceLookup = lookup;
3361
- }
3362
- for (const [instIdx, feat] of lookup) {
3363
- if (feat.smid === smid) {
3364
- box = this._instanceBox(mesh, instIdx);
3365
- break;
3366
- }
3367
- }
3368
- } else {
3369
- const infoMap = pickInfo as Record<string | number, unknown>;
3370
- const entry = infoMap[smid] ?? infoMap[String(smid)];
3371
- if (entry) {
3372
- const bId = pickEntryId(entry);
3373
- const resolved = resolveFeature(geoRef, bId);
3374
- box = measureSegments(mesh.geometry, resolved.segments);
3375
- if (!box && mesh.geometry) {
3376
- if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
3377
- const bb = mesh.geometry.boundingBox;
3378
- if (bb) {
3379
- box = {
3380
- minX: bb.min.x,
3381
- maxX: bb.max.x,
3382
- minY: bb.min.y,
3383
- maxY: bb.max.y,
3384
- minZ: bb.min.z,
3385
- maxZ: bb.max.z,
3386
- count: 0,
3387
- triangles: 0,
3388
- };
3389
- }
3390
- }
3391
- }
3392
- }
3393
-
3394
- if (box) {
3395
- found = true;
3396
- const corners = [
3397
- new THREE.Vector3(box.minX, box.minY, box.minZ),
3398
- new THREE.Vector3(box.maxX, box.minY, box.minZ),
3399
- new THREE.Vector3(box.minX, box.maxY, box.minZ),
3400
- new THREE.Vector3(box.maxX, box.maxY, box.minZ),
3401
- new THREE.Vector3(box.minX, box.minY, box.maxZ),
3402
- new THREE.Vector3(box.maxX, box.minY, box.maxZ),
3403
- new THREE.Vector3(box.minX, box.maxY, box.maxZ),
3404
- new THREE.Vector3(box.maxX, box.maxY, box.maxZ),
3405
- ];
3406
-
3407
- const off = smid != null ? this._featureStyleManager.getFeatureEffectiveOffset(smid) : {};
3408
- const trans = off.translation;
3409
- const elev = off.elevation;
3410
-
3411
- const rotMat = off.rotation ? resolveRotationMatrix(off.rotation) : null;
3412
- let rotM3: THREE.Matrix3 | null = null;
3413
- let rotPiv: THREE.Vector3 | null = null;
3414
- if (rotMat) {
3415
- rotM3 = new THREE.Matrix3().set(
3416
- rotMat[0], rotMat[1], rotMat[2],
3417
- rotMat[3], rotMat[4], rotMat[5],
3418
- rotMat[6], rotMat[7], rotMat[8]
3419
- );
3420
- rotPiv = off.pivot ? new THREE.Vector3(off.pivot[0], off.pivot[1], off.pivot[2]) : new THREE.Vector3(0, 0, 0);
3421
- }
3422
-
3423
- // GPU 路径:位移发生在顶点着色器里,包围盒要按同一套公式在 CPU 复算,
3424
- // 否则「运动中的车」的包围盒会停在路径原点(相机 fitBounds / 属性过滤都会错)。
3425
- const gpuPose = smid != null ? this._gpuPathPoseAt(smid) : null;
3426
- let headM3: THREE.Matrix3 | null = null;
3427
- if (gpuPose && gpuPose.yaw !== 0) {
3428
- const c = Math.cos(gpuPose.yaw);
3429
- const s = Math.sin(gpuPose.yaw);
3430
- // 与着色器的 mat3( c, s, 0, -s, c, 0, 0, 0, 1 ) 等价:GLSL 是列优先,
3431
- // three 的 Matrix3.set 是行优先,所以这里写成 (c, -s; s, c) 才是同一个矩阵。
3432
- headM3 = new THREE.Matrix3().set(c, -s, 0, s, c, 0, 0, 0, 1);
3433
- }
3434
-
3435
- for (const c of corners) {
3436
- if (rotM3 && rotPiv) c.sub(rotPiv).applyMatrix3(rotM3).add(rotPiv);
3437
- if (headM3) {
3438
- const piv = rotPiv ?? new THREE.Vector3(0, 0, 0);
3439
- c.sub(piv).applyMatrix3(headM3).add(piv);
3440
- }
3441
- if (trans) c.add(new THREE.Vector3(trans[0], trans[1], trans[2]));
3442
- else if (elev) c.z += elev;
3443
- if (gpuPose) c.add(new THREE.Vector3(gpuPose.pos[0], gpuPose.pos[1], gpuPose.pos[2]));
3444
-
3445
- c.applyMatrix4(mesh.matrixWorld);
3446
- const s = this.sceneAnchor.meterInMercator;
3447
- const [lng, lat] = this._lngLatFromWorld(c.x * s + this.sceneAnchor.x, -c.y * s + this.sceneAnchor.y);
3448
- if (lng < minLng) minLng = lng;
3449
- if (lng > maxLng) maxLng = lng;
3450
- if (lat < minLat) minLat = lat;
3451
- if (lat > maxLat) maxLat = lat;
3452
- }
3453
- }
3454
- }
3455
-
3456
- if (!found) return null;
3457
- return [
3458
- [minLng, minLat],
3459
- [maxLng, maxLat],
3460
- ];
2322
+ /** @internal */
2323
+ _releasePickId(id: number): void {
2324
+ this._picks.releasePickId(id);
3461
2325
  }
3462
2326
 
3463
- /** 实例序号 → 所属单体(按网格缓存反查表,实例多的数据不用每次重建) */
3464
- private _instanceFeature(
3465
- mesh: THREE.Mesh,
3466
- geoRef: GeoPackageEntry | undefined,
3467
- instanceIndex: number
3468
- ): InstanceFeature | null {
3469
- const ud = mesh.userData as MeshUserData & { instanceLookup?: Map<number, InstanceFeature> };
3470
- if (!ud.instanceLookup) ud.instanceLookup = buildInstanceLookup(geoRef?.pickInfo);
3471
- return ud.instanceLookup.get(instanceIndex) ?? null;
3472
- }
3473
-
3474
- /** 实例化几何的单体包围盒:base mesh 的包围盒套上该实例的矩阵 */
3475
- private _instanceBox(mesh: THREE.Mesh, instanceIndex: number): SegmentBox | null {
3476
- const im = mesh as THREE.InstancedMesh;
3477
- if (!im.isInstancedMesh) return null;
3478
- const geometry = mesh.geometry;
3479
- if (!geometry.boundingBox) geometry.computeBoundingBox();
3480
- const bb = geometry.boundingBox;
3481
- if (!bb) return null;
3482
- const m = new THREE.Matrix4();
3483
- im.getMatrixAt(instanceIndex, m);
3484
- const box = bb.clone().applyMatrix4(m);
3485
- return {
3486
- minX: box.min.x,
3487
- minY: box.min.y,
3488
- minZ: box.min.z,
3489
- maxX: box.max.x,
3490
- maxY: box.max.y,
3491
- maxZ: box.max.z,
3492
- count: geometry.getAttribute('position')?.count ?? 0,
3493
- triangles: geometry.index ? Math.round(geometry.index.count / 3) : 0,
3494
- };
2327
+ /** @internal 拾取表(测试直接读写),见 viewer/PickService.ts */
2328
+ get _pickables(): Map<number, PickableRef> {
2329
+ return this._picks.pickables;
3495
2330
  }
3496
2331
 
3497
- /** 选中/取消选中单体。`silent` 为 true 时不回调 onPick。 */
3498
- select(result: PickResult | null, { silent = false }: { silent?: boolean } = {}): void {
3499
- // 清掉上一次
3500
- if (this._selection) {
3501
- for (const obj of this._selectedMeshes) {
3502
- // 点云:恢复成当前着色模式(去掉选中混色)
3503
- if (isPointCloudObject(obj)) {
3504
- this._applyPointCloudStyle(obj, false);
3505
- continue;
3506
- }
3507
- const mat = styledMaterialOf(obj as THREE.Mesh);
3508
- if (mat) {
3509
- mat.userData.uSelBatch.value = -1;
3510
- mat.userData.uSelWhole.value = 0;
3511
- }
3512
- }
3513
- this._selectedMeshes = [];
3514
- this._clearSelectionDecoration();
3515
- this._selection = null;
3516
- }
3517
-
3518
- if (!result) {
3519
- this.uniforms.uSelMix.value = 0;
3520
- this.uniforms.uDim.value = 0;
3521
- this.outlineMaterial.uniforms.uSelBatch.value = -1;
3522
- this.outlineMaterial.uniforms.uSelWhole.value = 0;
3523
- this.outlineMaterial.uniforms.uElevation.value = 0;
3524
- this._requestRepaint();
3525
- if (!silent) this.onPick(null);
3526
- return;
3527
- }
3528
-
3529
- this._selection = result;
3530
- const targetMeshes = new Set<THREE.Mesh>();
3531
- if (result.smid != null) {
3532
- const smidSet = this._smidMeshes.get(result.smid);
3533
- if (smidSet) {
3534
- for (const m of smidSet) targetMeshes.add(m);
3535
- }
3536
- }
3537
- targetMeshes.add(result.mesh);
3538
-
3539
- for (const mesh of targetMeshes) {
3540
- const mat = styledMaterialOf(mesh);
3541
- if (!mat) continue;
3542
- let batchId = result.batchId;
3543
- if (result.instanceIndex != null && mesh === result.mesh) {
3544
- batchId = result.batchId;
3545
- } else if (result.smid != null) {
3546
- const ud = mesh.userData as MeshUserData;
3547
- const pickInfo = ud.geoRef?.pickInfo;
3548
- const entry = (pickInfo as Record<string | number, unknown> | undefined)?.[result.smid] ??
3549
- (pickInfo as Record<string | number, unknown> | undefined)?.[String(result.smid)];
3550
- const bId = entry ? pickEntryId(entry as any) : -1;
3551
- batchId = bId >= 0 ? bId : (mesh === result.mesh ? result.batchId : -1);
3552
- }
3553
- mat.userData.uSelBatch.value = batchId;
3554
- // 没有单体化信息时整块高亮,否则会出现「点中了但什么都没亮、其他全暗了」
3555
- mat.userData.uSelWhole.value = batchId < 0 ? 1 : 0;
3556
- this._selectedMeshes.push(mesh);
3557
- }
3558
- // 点云没有单体粒度:整块混成 selectColor,让选中至少看得见
3559
- for (const mesh of this._selectedMeshes) {
3560
- if (isPointCloudObject(mesh)) this._applyPointCloudStyle(mesh, true);
3561
- }
3562
- this.uniforms.uSelMix.value = 1;
3563
- this.uniforms.uDim.value = this.style.dimOthers ? 1 : 0;
3564
-
3565
- if (this.style.outline) {
3566
- // 棱线 / 外扩轮廓都是按「面」设计的,点云画不出来也不该画
3567
- const decoMeshes = [...targetMeshes].filter((m) => !isPointCloudObject(m));
3568
- if (decoMeshes.length) {
3569
- if ((this.style.outlineStyle ?? 'edges') === 'edges') {
3570
- this._addSelectionEdges(result, decoMeshes);
3571
- } else {
3572
- this._addSelectionHull(result, decoMeshes);
3573
- }
3574
- }
3575
- }
3576
- this._requestRepaint();
3577
- }
2332
+ /* ---------------------------------------------------------------- 选中 */
3578
2333
 
3579
2334
  /**
3580
- * 选中单体的棱线:只挑这个单体自己的三角面算折边,再画成屏幕空间等宽线。
3581
- * 抽出来的线段数很少(一栋楼通常几百到几千条),所以选中开销可以忽略。
2335
+ * 选中一个单体(或整块网格),高亮与轮廓装饰见 viewer/SelectionDecorator.ts。
2336
+ * 传 `null` 清空选中;`silent` 为真时不触发 `onPick`。
3582
2337
  */
3583
- private _addSelectionEdges(result: PickResult, meshes: Iterable<THREE.Mesh>): void {
3584
- this._syncSelectionStyle();
3585
- for (const mesh of meshes) {
3586
- const ud = mesh.userData as MeshUserData;
3587
- const geoRef = ud.geoRef;
3588
- let segments: PickSegment[] = [];
3589
- // 这块网格上的棱线全部属于选中的那个单体,整批填同一个 batchId,
3590
- // 顶点着色器才能从状态纹理里取到平移 / 拔高——选中跟随移动靠它。
3591
- let batchId = -1;
3592
- if (mesh === result.mesh && result.instanceIndex != null) {
3593
- // 实例化几何没有逐单体的顶点区间,整个 base mesh 就是这一栋的外形
3594
- segments = [{ vertexOffset: 0, vertexCount: mesh.geometry.getAttribute('position')?.count ?? 0 }];
3595
- const pickInfo = geoRef?.pickInfo;
3596
- const entry = (pickInfo as Record<string | number, unknown> | undefined)?.[result.smid ?? ''] ??
3597
- (pickInfo as Record<string | number, unknown> | undefined)?.[String(result.smid)];
3598
- batchId = entry ? pickEntryId(entry as any) : -1;
3599
- } else if (result.smid != null) {
3600
- const pickInfo = geoRef?.pickInfo;
3601
- const entry = (pickInfo as Record<string | number, unknown> | undefined)?.[result.smid] ??
3602
- (pickInfo as Record<string | number, unknown> | undefined)?.[String(result.smid)];
3603
- const bId = entry ? pickEntryId(entry as any) : -1;
3604
- batchId = bId;
3605
- segments = (entry as any)?.segments ?? (bId >= 0 ? resolveFeature(geoRef, bId).segments : []);
3606
- } else if (mesh === result.mesh) {
3607
- const localGeo = (ud.geoByBatchId as Map<number, GeoPackageEntry> | undefined)?.get(result.batchId) ?? geoRef;
3608
- segments = resolveFeature(localGeo, result.batchId).segments;
3609
- batchId = result.batchId;
3610
- }
3611
-
3612
- const pairs = buildFeatureEdgeSegments(mesh.geometry, segments);
3613
- if (pairs && pairs.length > 0) {
3614
- const batchIds = batchId >= 0 ? new Float32Array(pairs.length / 6).fill(batchId) : undefined;
3615
- const lines = new THREE.Mesh(
3616
- buildThickLineGeometry(pairs, batchIds),
3617
- this._createSelectionEdgeMaterial(mesh)
3618
- );
3619
- lines.renderOrder = 4;
3620
- // 屏幕空间外扩后可能超出原包围球,关掉剔除更稳
3621
- lines.frustumCulled = false;
3622
- lines.raycast = () => {};
3623
- (lines.userData as MeshUserData).isSelectionEdge = true;
3624
- if (mesh === result.mesh && result.instanceIndex != null) {
3625
- this._applyInstanceTransform(lines, result);
3626
- }
3627
- mesh.add(lines); // 作为子节点,直接继承世界变换
3628
- this._selectionEdgeMeshes.push(lines);
3629
- }
3630
- }
3631
-
3632
- if (this._selectionEdgeMeshes.length === 0) {
3633
- // 抽不出折边(退化几何 / 没有任何夹角)时退回外扩轮廓,至少保证选中可见
3634
- this._addSelectionHull(result, meshes);
3635
- return;
3636
- }
3637
- this._selectionEdges = this._selectionEdgeMeshes[0] ?? null;
3638
- }
3639
-
3640
- /** 实例化几何:把某个实例的矩阵套到一个普通 Mesh/子节点上(子节点不参与父级的 instanceMatrix) */
3641
- private _applyInstanceTransform(obj: THREE.Object3D, result: PickResult): void {
3642
- const im = result.mesh as THREE.InstancedMesh;
3643
- if (result.instanceIndex == null || !im.isInstancedMesh) return;
3644
- const m = new THREE.Matrix4();
3645
- im.getMatrixAt(result.instanceIndex, m);
3646
- m.decompose(obj.position, obj.quaternion, obj.scale);
3647
- }
3648
-
3649
- /** 旧的描边画法:背面外扩法,整块几何一起上,靠 uSelBatch 只显示选中那栋 */
3650
- private _addSelectionHull(result: PickResult, meshes: Iterable<THREE.Mesh>): void {
3651
- const instanced = result.instanceIndex != null;
3652
- // 实例化:轮廓是这个实例的单独一份副本(普通 Mesh),所以整份都画;
3653
- // 顶点区间型:整块几何一起画,靠 uSelBatch 只显示选中那栋。
3654
- this.outlineMaterial.uniforms.uSelBatch.value = instanced ? -1 : result.batchId;
3655
- this.outlineMaterial.uniforms.uSelWhole.value = instanced || result.batchId < 0 ? 1 : 0;
3656
- this.outlineMaterial.uniforms.uElevation.value = instanced
3657
- ? this._selectionElevation(result)
3658
- : this.style.selectElevation ?? 0;
3659
-
3660
- for (const mesh of meshes) {
3661
- const mat = styledMaterialOf(mesh);
3662
- if (mat?.userData?.uBatchTex) {
3663
- this.outlineMaterial.uniforms.uBatchTex.value = mat.userData.uBatchTex.value as THREE.Texture;
3664
- this.outlineMaterial.uniforms.uBatchTexWidth.value = mat.userData.uBatchTexWidth.value as number;
3665
- this.outlineMaterial.uniforms.uBatchTexActive.value = mat.userData.uBatchTexActive.value as number;
3666
- }
3667
- const outline = new THREE.Mesh(mesh.geometry, this.outlineMaterial);
3668
- outline.renderOrder = 3;
3669
- outline.frustumCulled = false;
3670
- outline.raycast = () => {};
3671
- if (mesh === result.mesh && result.instanceIndex != null) {
3672
- this._applyInstanceTransform(outline, result);
3673
- }
3674
- mesh.add(outline); // 作为子节点,直接继承世界变换
3675
- this._outlineMeshes.push(outline);
3676
- }
3677
- this._outlineMesh = this._outlineMeshes[0] ?? null;
2338
+ select(result: PickResult | null, opts: { silent?: boolean } = {}): void {
2339
+ this._sel.select(result, opts);
3678
2340
  }
3679
2341
 
3680
- /** 只改 uniform:颜色 / 粗细 / 拔高,拖着滑块实时生效,不重建几何 */
3681
- private _syncSelectionStyle(): void {
3682
- const s = this.style;
3683
- const u = this.selectionEdgeMaterial.uniforms;
3684
- u.uColor.value.set(s.outlineColor ?? s.selectColor);
3685
- u.uWidth.value = Math.max(0.1, s.outlineWidthPx ?? 3);
3686
- u.uOpacity.value = clampOpacity(s.opacity);
3687
- // 只加全局 selectElevation:单体自己的拔高已经写在状态纹理第 2 行 z 里,
3688
- // 顶点着色器采样时带出来了,这里再加一次就会双重拔高
3689
- if (u.uElevation) u.uElevation.value = s.selectElevation ?? 0;
3690
-
3691
- // 外扩描边是另一个材质实例,单体状态纹理的开关是建的时候抄过去的。
3692
- // 先选中、再抬高(或反过来)时模板会停留在旧值,这里补一次同步。
3693
- for (const outline of this._outlineMeshes) {
3694
- const parent = outline.parent as THREE.Mesh | null;
3695
- const ud = parent ? styledMaterialOf(parent)?.userData : null;
3696
- if (ud?.uBatchTex) {
3697
- this.outlineMaterial.uniforms.uBatchTex.value = ud.uBatchTex.value;
3698
- this.outlineMaterial.uniforms.uBatchTexWidth.value = ud.uBatchTexWidth.value;
3699
- this.outlineMaterial.uniforms.uBatchTexActive.value = ud.uBatchTexActive.value;
3700
- }
3701
- }
2342
+ /** @internal 选中外观跟随样式变化,见 viewer/SelectionDecorator.ts */
2343
+ _syncSelectionStyle(): void {
2344
+ this._sel.syncStyle();
3702
2345
  }
3703
2346
 
3704
- /** 选中单体的实际拔高 = 全局选中拔高 + 该单体的独立拔高(与着色器口径一致) */
3705
- private _selectionElevation(result: PickResult): number {
3706
- let elev = this.style.selectElevation ?? 0;
3707
- if (result.smid != null) {
3708
- const off = this._featureStyleManager.getFeatureEffectiveOffset(result.smid);
3709
- if (off.elevation) elev += off.elevation;
3710
- }
3711
- return elev;
2347
+ /** @internal 测试用:当前选中 */
2348
+ get _selection(): PickResult | null {
2349
+ return this._sel.selection;
3712
2350
  }
3713
2351
 
3714
- private _clearSelectionDecoration(): void {
3715
- for (const outline of this._outlineMeshes) {
3716
- outline.parent?.remove(outline);
3717
- }
3718
- this._outlineMeshes = [];
3719
- this._outlineMesh = null;
2352
+ /** @internal 测试用:被高亮的网格 */
2353
+ get _selectedMeshes(): THREE.Object3D[] {
2354
+ return this._sel.selectedMeshes;
2355
+ }
3720
2356
 
3721
- for (const edges of this._selectionEdgeMeshes) {
3722
- edges.parent?.remove(edges);
3723
- edges.geometry.dispose();
3724
- }
3725
- this._selectionEdgeMeshes = [];
3726
- this._selectionEdges = null;
2357
+ /** @internal 测试用:选中棱线的网格列表 */
2358
+ get _selectionEdgeMeshes(): THREE.Mesh[] {
2359
+ return this._sel.selectionEdgeMeshes;
3727
2360
  }
3728
2361
 
3729
2362
  /* ---------------------------------------------------------------- 渲染 */
@@ -3734,31 +2367,6 @@ export class S3MViewer {
3734
2367
  * 参数全部来自 style,不缓存:`edgeWidth` 在这里是「采样偏移」,线宽 ≈ 2×它;
3735
2368
  * `edgeThreshold` 是折边敏感度(夹角小于它不描),和 CPU 折边线同一口径。
3736
2369
  */
3737
- private _renderGpuEdges(canvas: HTMLCanvasElement): void {
3738
- if (!this.renderer || !this.scene || !this.camera) return;
3739
- const s = this.style;
3740
- const fade = this.uniforms.uEdgeFade?.value ?? 1;
3741
- const opacity =
3742
- Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) * clampOpacity(s.opacity) * fade;
3743
- if (opacity <= 0.004) return;
3744
-
3745
- if (!this._gpuEdge) this._gpuEdge = new GpuEdgePass();
3746
- if (!this._gpuEdge.supported(this.renderer)) return;
3747
-
3748
- // 度数 → 归一化法线差(|Δn| = 2·sin(θ/2)),乘 0.6 让阈值落在「刚好能描出」的位置
3749
- const deg = Math.min(179, Math.max(1, s.edgeThreshold ?? 55));
3750
- const normalThreshold = 2 * Math.sin((deg * Math.PI) / 360) * 0.6;
3751
-
3752
- this._gpuEdge.render(this.renderer, this.scene, this.camera, canvas.width, canvas.height, {
3753
- color: this._gpuEdgeColor.set(s.edgeColor),
3754
- opacity,
3755
- radius: Math.max(1, s.edgeWidth ?? 1.5),
3756
- normalThreshold,
3757
- depthThreshold: 0.08,
3758
- });
3759
- }
3760
-
3761
- /** 等宽线要按设备像素换算线宽:uResolution 用绘图缓冲尺寸,uDpr 是缓冲 / CSS 的比值 */
3762
2370
  private _updateLineResolution(canvas: HTMLCanvasElement): void {
3763
2371
  const w = canvas.width || 1;
3764
2372
  const h = canvas.height || 1;
@@ -3906,6 +2514,16 @@ export class S3MViewer {
3906
2514
  this._requestRepaint();
3907
2515
  }
3908
2516
 
2517
+ /**
2518
+ * 泼溅实例按视深 back-to-front 重排:alpha 混合是顺序相关的,
2519
+ * 乱序混合就是满屏彩色噪点。相机没动且没有新瓦片时整个跳过;
2520
+ * 单个瓦片顺序本来就对时 sortSplatMesh 内部也会跳过重写。
2521
+ */
2522
+ /** @internal 泼溅按视深重排,见 viewer/SplatLayer.ts */
2523
+ _sortSplats(): void {
2524
+ this._splats.sort(this.camera, this._pointClouds);
2525
+ }
2526
+
3909
2527
  /**
3910
2528
  * 渲染一帧。所有宿主相关量都由调用方喂进来,viewer 不碰任何地图 API:
3911
2529
  *
@@ -3997,16 +2615,19 @@ export class S3MViewer {
3997
2615
 
3998
2616
  // 等宽棱线按设备像素撑开,分辨率每帧同步一次
3999
2617
  this._updateLineResolution(canvas);
2618
+ // 泼溅的最小像素半径 / 抗锯齿低通同样要把 ndc 换算成像素
2619
+ setSplatViewport(canvas.width, canvas.height);
4000
2620
 
4001
2621
  this.renderer.setViewport(0, 0, canvas.width, canvas.height);
4002
2622
  this.renderer.resetState();
4003
2623
  // 完全透明时什么都不画:透明物体照样写深度,画了会在底图上挖出一个洞。
4004
2624
  // 这里跳过的是「本层的绘制」,宿主自己的清屏 / 底图照旧。
4005
2625
  if ((this.uniforms.uOpacity?.value ?? 1) > OPACITY_EPS) {
2626
+ this._sortSplats();
4006
2627
  this.renderer.render(this.scene, this.camera);
4007
2628
  // GPU 描边必须画在主场景之后:它的全屏 quad 参与宿主深度测试,
4008
2629
  // 依赖本帧已经写好的深度(被地形挡住的地方不该描边)
4009
- if (this._edgeModes.gpu) this._renderGpuEdges(canvas);
2630
+ this._edges.renderGpuEdges(canvas);
4010
2631
  }
4011
2632
 
4012
2633
  this._frame++;
@@ -4027,7 +2648,7 @@ export class S3MViewer {
4027
2648
  tilesetChanged;
4028
2649
 
4029
2650
  // 首帧画完、场上有材质了,再把另一种 shading 变体编出来(异步,不挡当前帧)
4030
- if (this._prewarmShadingEnabled && !this._shadingPrewarmed && this._pickables.size > 0) {
2651
+ if (this._prewarmShadingEnabled && !this._shadingPrewarmed && this._picks.pickables.size > 0) {
4031
2652
  void this.prewarmShading();
4032
2653
  }
4033
2654
 
@@ -4053,7 +2674,9 @@ export class S3MViewer {
4053
2674
  programs: 0,
4054
2675
  textures: 0,
4055
2676
  geometries: 0,
2677
+ degradations: [],
4056
2678
  };
2679
+ let degradations: DegradationKind[] | null = null;
4057
2680
  for (const entry of this.datasets.values()) {
4058
2681
  const s = entry.tileset?.stats;
4059
2682
  if (!s) continue;
@@ -4063,7 +2686,15 @@ export class S3MViewer {
4063
2686
  total.meshes += s.meshes;
4064
2687
  total.vertices += s.vertices;
4065
2688
  total.triangles += s.triangles;
2689
+ // 降级项跨数据集去重:同一类只报一次给 UI
2690
+ if (s.degradations?.length) {
2691
+ degradations ??= [];
2692
+ for (const d of s.degradations) {
2693
+ if (!degradations.includes(d)) degradations.push(d);
2694
+ }
2695
+ }
4066
2696
  }
2697
+ if (degradations) total.degradations = degradations;
4067
2698
  // GPU 侧预算观测:draw call / program / 显存驻留。没有渲染器(未 mount /
4068
2699
  // 纯解析场景)时保持 0,不抛错。
4069
2700
  const info = this.renderer?.info;