s3m-three 0.4.19 → 0.4.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/browser/assets/{s3mWorker-4iTAmWIu.js → s3mWorker-DvAUA8NJ.js} +1 -1
  2. package/dist/browser/s3m-three.iife.ext-three.js +243 -243
  3. package/dist/browser/s3m-three.iife.js +227 -227
  4. package/dist/core/Draco.d.ts.map +1 -1
  5. package/dist/core/Draco.js +0 -1
  6. package/dist/core/Draco.js.map +1 -1
  7. package/dist/core/S3MBlockParser.d.ts.map +1 -1
  8. package/dist/core/S3MBlockParser.js +27 -14
  9. package/dist/core/S3MBlockParser.js.map +1 -1
  10. package/dist/core/SplatSort.d.ts +14 -1
  11. package/dist/core/SplatSort.d.ts.map +1 -1
  12. package/dist/core/SplatSort.js +51 -4
  13. package/dist/core/SplatSort.js.map +1 -1
  14. package/dist/core/TileGeometry.d.ts +10 -0
  15. package/dist/core/TileGeometry.d.ts.map +1 -1
  16. package/dist/core/TileGeometry.js +17 -14
  17. package/dist/core/TileGeometry.js.map +1 -1
  18. package/dist/render/DatasetManager.d.ts +3 -1
  19. package/dist/render/DatasetManager.d.ts.map +1 -1
  20. package/dist/render/DatasetManager.js +2 -1
  21. package/dist/render/DatasetManager.js.map +1 -1
  22. package/dist/render/FeatureStyleManager.d.ts.map +1 -1
  23. package/dist/render/GeometryBuilder.d.ts +8 -0
  24. package/dist/render/GeometryBuilder.d.ts.map +1 -1
  25. package/dist/render/GeometryBuilder.js +62 -7
  26. package/dist/render/GeometryBuilder.js.map +1 -1
  27. package/dist/render/S3MViewer.d.ts +106 -440
  28. package/dist/render/S3MViewer.d.ts.map +1 -1
  29. package/dist/render/S3MViewer.js +297 -1653
  30. package/dist/render/S3MViewer.js.map +1 -1
  31. package/dist/render/SplatSorter.d.ts.map +1 -1
  32. package/dist/render/SplatSorter.js +8 -1
  33. package/dist/render/SplatSorter.js.map +1 -1
  34. package/dist/render/viewer/EdgeDecorator.d.ts +206 -0
  35. package/dist/render/viewer/EdgeDecorator.d.ts.map +1 -0
  36. package/dist/render/viewer/EdgeDecorator.js +628 -0
  37. package/dist/render/viewer/EdgeDecorator.js.map +1 -0
  38. package/dist/render/viewer/HeightIndex.d.ts +36 -0
  39. package/dist/render/viewer/HeightIndex.d.ts.map +1 -0
  40. package/dist/render/viewer/HeightIndex.js +98 -0
  41. package/dist/render/viewer/HeightIndex.js.map +1 -0
  42. package/dist/render/viewer/LightRig.d.ts +88 -0
  43. package/dist/render/viewer/LightRig.d.ts.map +1 -0
  44. package/dist/render/viewer/LightRig.js +229 -0
  45. package/dist/render/viewer/LightRig.js.map +1 -0
  46. package/dist/render/viewer/PickService.d.ts +109 -0
  47. package/dist/render/viewer/PickService.d.ts.map +1 -0
  48. package/dist/render/viewer/PickService.js +591 -0
  49. package/dist/render/viewer/PickService.js.map +1 -0
  50. package/dist/render/viewer/SelectionDecorator.d.ts +84 -0
  51. package/dist/render/viewer/SelectionDecorator.d.ts.map +1 -0
  52. package/dist/render/viewer/SelectionDecorator.js +339 -0
  53. package/dist/render/viewer/SelectionDecorator.js.map +1 -0
  54. package/dist/render/viewer/SplatLayer.d.ts +26 -0
  55. package/dist/render/viewer/SplatLayer.d.ts.map +1 -0
  56. package/dist/render/viewer/SplatLayer.js +44 -0
  57. package/dist/render/viewer/SplatLayer.js.map +1 -0
  58. package/dist/render/viewer/types.d.ts +256 -0
  59. package/dist/render/viewer/types.d.ts.map +1 -0
  60. package/dist/render/viewer/types.js +3 -0
  61. package/dist/render/viewer/types.js.map +1 -0
  62. package/dist/tileset/S3MTileset.d.ts +30 -3
  63. package/dist/tileset/S3MTileset.d.ts.map +1 -1
  64. package/dist/tileset/S3MTileset.js +99 -5
  65. package/dist/tileset/S3MTileset.js.map +1 -1
  66. package/dist/worker/s3mWorker.js +1 -1
  67. package/dist/worker/s3mWorker.js.map +1 -1
  68. package/docs/API.md +9 -28
  69. package/docs/FORMAT-NOTES.md +38 -14
  70. package/package.json +1 -1
  71. package/src/core/Draco.ts +1 -2
  72. package/src/core/S3MBlockParser.ts +27 -16
  73. package/src/core/SplatSort.ts +67 -5
  74. package/src/core/TileGeometry.ts +24 -11
  75. package/src/render/DatasetManager.ts +5 -1
  76. package/src/render/FeatureStyleManager.ts +1 -1
  77. package/src/render/GeometryBuilder.ts +59 -7
  78. package/src/render/S3MViewer.ts +327 -1916
  79. package/src/render/SplatSorter.ts +8 -1
  80. package/src/render/viewer/EdgeDecorator.ts +722 -0
  81. package/src/render/viewer/HeightIndex.ts +94 -0
  82. package/src/render/viewer/LightRig.ts +267 -0
  83. package/src/render/viewer/PickService.ts +651 -0
  84. package/src/render/viewer/SelectionDecorator.ts +377 -0
  85. package/src/render/viewer/SplatLayer.ts +44 -0
  86. package/src/render/viewer/types.ts +264 -0
  87. package/src/tileset/S3MTileset.ts +106 -6
@@ -0,0 +1,651 @@
1
+ /**
2
+ * 拾取:屏幕坐标 → 射线 → 命中单体 / 位置 / 包围盒。
3
+ *
4
+ * 从 `S3MViewer` 整体搬出来的第二大块。它持有**拾取表**(`pickables`)与
5
+ * 2 字节 pickId 池 —— 这两样本来就是一件事:瓦片注册时分配 id 并登记,
6
+ * 淘汰时回收,拾取时按 id 反查。
7
+ *
8
+ * ## pickId 为什么要回收
9
+ *
10
+ * 拾取结果编码在 RG 两个通道里,只有 65535 个槽。倾斜摄影一块瓦片可能几十个网格,
11
+ * 一个数据集上千块瓦片就是几万个 —— 不回收的话瓦片进出几轮就溢出了。
12
+ *
13
+ * ## 泼溅是独立分支
14
+ *
15
+ * 泼溅既没有 `instanceMatrix`,也没有 `geoRef.pickInfo` 里的单体表。走通用路径会在
16
+ * `_instanceFeature()` 拿到 null 直接返回 —— 表现是「点得中但拿不到结果」。
17
+ */
18
+ import * as THREE from 'three';
19
+ import type { FeatureStyleManager } from '../FeatureStyleManager.js';
20
+ import type { Picker } from '../Picker.js';
21
+ import {
22
+ buildAttributes,
23
+ buildInstanceLookup,
24
+ measureSegments,
25
+ pickEntryId,
26
+ resolveFeature,
27
+ } from '../../core/FeatureInfo.js';
28
+ import type { InstanceFeature } from '../../core/FeatureInfo.js';
29
+ import { resolveRotationMatrix } from '../FeatureStyleManager.js';
30
+ import { splatCenterBounds } from '../SplatMaterial.js';
31
+ import type { GeoPackageEntry, SegmentBox } from '../../core/types.js';
32
+ import type { PickResult, PickableRef } from '../S3MViewer.js';
33
+
34
+ interface MeshUserData {
35
+ pickId?: number;
36
+ datasetId?: string;
37
+ skeletonName?: string;
38
+ isGaussianSplat?: boolean;
39
+ geoRef?: GeoPackageEntry;
40
+ [key: string]: unknown;
41
+ }
42
+
43
+ /** 拾取 ID 只有 2 个字节(RG 两个通道),65534 是安全上限 */
44
+ const MAX_PICK_ID = 65534;
45
+
46
+ export interface PickHost {
47
+ readonly picker: Picker | null;
48
+ readonly camera: THREE.Camera | null;
49
+ readonly scene: THREE.Scene | null;
50
+ readonly canvas: HTMLCanvasElement | null;
51
+ readonly style: { floorHeight: number };
52
+ readonly sceneAnchor: { x: number; y: number; z: number; meterInMercator: number; altitude?: number } | null;
53
+ readonly datasets: Map<string, { name: string }>;
54
+ readonly featureStyleManager: FeatureStyleManager;
55
+ readonly smidMeshes: Map<number, Set<THREE.Mesh>>;
56
+ lngLatFromWorld(x: number, y: number): [number, number];
57
+ gpuPathPoseAt(smid: number): { pos: [number, number, number]; yaw: number } | null;
58
+ isFeatureHidden(smid: number | null, batchId: number): boolean;
59
+ }
60
+
61
+ export class PickService {
62
+ /** 拾取表:pickId → 网格 */
63
+ readonly pickables = new Map<number, PickableRef>();
64
+ private _pickSeq = 0;
65
+ private readonly _freePickIds: number[] = [];
66
+
67
+ constructor(private readonly host: PickHost) {}
68
+
69
+ /** 分配一个 pickId(优先复用回收的) */
70
+ allocPickId(): number {
71
+ const reused = this._freePickIds.pop();
72
+ if (reused !== undefined) return reused;
73
+ if (this._pickSeq > MAX_PICK_ID) {
74
+ for (let id = 0; id <= MAX_PICK_ID; id++) if (!this.pickables.has(id)) return id;
75
+ throw new Error(`[s3m] 可拾取网格超过 ${MAX_PICK_ID + 1} 个,2 字节 ID 编码不够用`);
76
+ }
77
+ return this._pickSeq++;
78
+ }
79
+
80
+ releasePickId(id: number): void {
81
+ this._freePickIds.push(id);
82
+ }
83
+
84
+ /** 全部网格(拾取表的另一种视角,供样式 / 统计遍历) */
85
+ meshes(): THREE.Mesh[] {
86
+ const list: THREE.Mesh[] = [];
87
+ for (const p of this.pickables.values()) list.push(p.mesh);
88
+ return list;
89
+ }
90
+
91
+ clear(): void {
92
+ this.pickables.clear();
93
+ this._freePickIds.length = 0;
94
+ this._pickSeq = 0;
95
+ }
96
+
97
+ /** 获取指定屏幕像素对应的场景射线(用于高精度求交与穿透拾取) */
98
+ getRayAt(x: number, y: number): THREE.Ray | null {
99
+ if (!this.host.camera || !this.host.canvas) return null;
100
+ const canvas = this.host.canvas;
101
+ const w = canvas.clientWidth || canvas.width;
102
+ const h = canvas.clientHeight || canvas.height;
103
+ if (!w || !h) return null;
104
+
105
+ const ndcX = (x / w) * 2 - 1;
106
+ const ndcY = -(y / h) * 2 + 1;
107
+
108
+ const near = new THREE.Vector3(ndcX, ndcY, -1).unproject(this.host.camera);
109
+ const far = new THREE.Vector3(ndcX, ndcY, 1).unproject(this.host.camera);
110
+ const dir = far.clone().sub(near).normalize();
111
+ return new THREE.Ray(near, dir);
112
+ }
113
+
114
+ intersectMeshAtRay(mesh: THREE.Mesh, ray: THREE.Ray, instanceIndex?: number): THREE.Vector3 | null {
115
+ const raycaster = new THREE.Raycaster();
116
+ raycaster.ray.copy(ray);
117
+ const hits = raycaster.intersectObject(mesh, false);
118
+ if (!hits.length) return null;
119
+ if (instanceIndex != null) {
120
+ const match = hits.find((h) => h.instanceId === instanceIndex);
121
+ return match ? match.point : hits[0].point;
122
+ }
123
+ return hits[0].point;
124
+ }
125
+
126
+ buildPickResult(
127
+ hit: { objectId: number; batchId: number; instanceIndex?: number },
128
+ ref: PickableRef,
129
+ exactPoint?: THREE.Vector3,
130
+ ray?: THREE.Ray
131
+ ): PickResult | null {
132
+ const ud = ref.mesh.userData as MeshUserData;
133
+ // 泼溅走独立分支:它既没有 instanceMatrix,也没有 geoRef.pickInfo 里的单体表,
134
+ // 走下面那段会在 `_instanceFeature()` 拿到 null 直接返回 —— 表现是「点得中但拿不到结果」。
135
+ if (ud.isGaussianSplat) return this.buildSplatPickResult(hit, ref, exactPoint, ray);
136
+ let smid: number | null = null;
137
+ let batchId = hit.batchId;
138
+ let instanceIndex = hit.instanceIndex;
139
+ let geoRef = ud.geoRef;
140
+ let segments: Array<{ vertexOffset: number; vertexCount: number }> = [];
141
+
142
+ if (instanceIndex != null) {
143
+ const feature = this.instanceFeature(ref.mesh, geoRef, instanceIndex);
144
+ if (!feature) return null;
145
+ smid = feature.smid;
146
+ batchId = feature.batchId;
147
+ } else {
148
+ geoRef = (ud.geoByBatchId as Map<number, GeoPackageEntry> | undefined)?.get(hit.batchId) ?? ud.geoRef;
149
+ const resolved = resolveFeature(geoRef, hit.batchId);
150
+ smid = resolved.smid;
151
+ segments = resolved.segments;
152
+ }
153
+
154
+ if (this.host.isFeatureHidden(smid, batchId)) return null;
155
+
156
+ let box: SegmentBox | null = null;
157
+ let totalTriangles = 0;
158
+
159
+ if (instanceIndex != null) {
160
+ box = this.instanceBox(ref.mesh, instanceIndex);
161
+ totalTriangles = ref.mesh.geometry.index ? Math.round(ref.mesh.geometry.index.count / 3) : 0;
162
+ } else if (smid != null && this.host.smidMeshes.has(smid)) {
163
+ const meshes = this.host.smidMeshes.get(smid)!;
164
+ let minX = Infinity;
165
+ let minY = Infinity;
166
+ let minZ = Infinity;
167
+ let maxX = -Infinity;
168
+ let maxY = -Infinity;
169
+ let maxZ = -Infinity;
170
+ let count = 0;
171
+ for (const m of meshes) {
172
+ const mUd = m.userData as MeshUserData;
173
+ const mPick = mUd.geoRef?.pickInfo;
174
+ const entry = (mPick as Record<string | number, unknown> | undefined)?.[smid] ??
175
+ (mPick as Record<string | number, unknown> | undefined)?.[String(smid)];
176
+ const bId = entry ? pickEntryId(entry as any) : -1;
177
+ const mSegs = (entry as any)?.segments ?? (bId >= 0 ? resolveFeature(mUd.geoRef, bId).segments : []);
178
+ const mBox = measureSegments(m.geometry, mSegs);
179
+ if (mBox) {
180
+ if (mBox.minX < minX) minX = mBox.minX;
181
+ if (mBox.minY < minY) minY = mBox.minY;
182
+ if (mBox.minZ < minZ) minZ = mBox.minZ;
183
+ if (mBox.maxX > maxX) maxX = mBox.maxX;
184
+ if (mBox.maxY > maxY) maxY = mBox.maxY;
185
+ if (mBox.maxZ > maxZ) maxZ = mBox.maxZ;
186
+ count += mBox.count;
187
+ totalTriangles += mBox.triangles;
188
+ }
189
+ }
190
+ if (count > 0) {
191
+ box = { minX, minY, minZ, maxX, maxY, maxZ, count, triangles: totalTriangles };
192
+ }
193
+ }
194
+
195
+ if (!box) {
196
+ box = measureSegments(ref.mesh.geometry, segments);
197
+ if (!box && ref.mesh.geometry) {
198
+ if (!ref.mesh.geometry.boundingBox) ref.mesh.geometry.computeBoundingBox();
199
+ const bb = ref.mesh.geometry.boundingBox;
200
+ if (bb) {
201
+ box = {
202
+ minX: bb.min.x,
203
+ maxX: bb.max.x,
204
+ minY: bb.min.y,
205
+ maxY: bb.max.y,
206
+ minZ: bb.min.z,
207
+ maxZ: bb.max.z,
208
+ count: 0,
209
+ triangles: 0,
210
+ };
211
+ }
212
+ }
213
+ }
214
+ const geometry = ref.mesh.geometry;
215
+ const triangles = totalTriangles > 0 ? totalTriangles : (geometry.index ? Math.round(geometry.index.count / 3) : 0);
216
+ const attributes = buildAttributes({ smid, box, triangles, floorHeight: this.host.style.floorHeight });
217
+
218
+ let centerLngLat: [number, number] | null = null;
219
+ if (box && this.host.sceneAnchor) {
220
+ const v = new THREE.Vector3((box.minX + box.maxX) / 2, (box.minY + box.maxY) / 2, (box.minZ + box.maxZ) / 2);
221
+ v.applyMatrix4(ref.mesh.matrixWorld);
222
+ const s = this.host.sceneAnchor.meterInMercator;
223
+ centerLngLat = this.host.lngLatFromWorld(v.x * s + this.host.sceneAnchor.x, -v.y * s + this.host.sceneAnchor.y);
224
+ }
225
+
226
+ // 精确命中点求交计算
227
+ let hitPoint: THREE.Vector3 | null = exactPoint ?? null;
228
+ if (!hitPoint && ray) {
229
+ hitPoint = this.intersectMeshAtRay(ref.mesh, ray, instanceIndex);
230
+ }
231
+
232
+ let point: [number, number, number] | null = null;
233
+ let lngLat: [number, number] | null = null;
234
+ let altitude: number | null = null;
235
+
236
+ if (hitPoint && this.host.sceneAnchor) {
237
+ const off = smid != null ? this.host.featureStyleManager.getFeatureEffectiveOffset(smid) : {};
238
+ const trans = off.translation;
239
+ const elev = off.elevation;
240
+ const finalPos = hitPoint.clone();
241
+
242
+ if (off.rotation) {
243
+ const rotMat = resolveRotationMatrix(off.rotation);
244
+ if (rotMat) {
245
+ const m3 = new THREE.Matrix3().set(
246
+ rotMat[0], rotMat[1], rotMat[2],
247
+ rotMat[3], rotMat[4], rotMat[5],
248
+ rotMat[6], rotMat[7], rotMat[8]
249
+ );
250
+ const piv = off.pivot ? new THREE.Vector3(off.pivot[0], off.pivot[1], off.pivot[2]) : new THREE.Vector3(0, 0, 0);
251
+ finalPos.sub(piv).applyMatrix3(m3).add(piv);
252
+ }
253
+ }
254
+
255
+ if (trans) finalPos.add(new THREE.Vector3(trans[0], trans[1], trans[2]));
256
+ else if (elev) finalPos.z += elev;
257
+
258
+ point = [finalPos.x, finalPos.y, finalPos.z];
259
+ const s = this.host.sceneAnchor.meterInMercator;
260
+ lngLat = this.host.lngLatFromWorld(finalPos.x * s + this.host.sceneAnchor.x, -finalPos.y * s + this.host.sceneAnchor.y);
261
+ altitude = finalPos.z + (this.host.sceneAnchor.altitude ?? 0);
262
+ } else if (centerLngLat && box && this.host.sceneAnchor) {
263
+ lngLat = centerLngLat;
264
+ altitude = (box.minZ + box.maxZ) / 2 + (this.host.sceneAnchor.altitude ?? 0);
265
+ }
266
+
267
+ return {
268
+ datasetId: ref.datasetId,
269
+ datasetName: this.host.datasets.get(ref.datasetId)?.name ?? ref.datasetId,
270
+ objectId: hit.objectId,
271
+ batchId,
272
+ instanceIndex,
273
+ skeletonName: ref.skeletonName,
274
+ centerLngLat,
275
+ point,
276
+ lngLat,
277
+ altitude,
278
+ ...attributes,
279
+ mesh: ref.mesh,
280
+ };
281
+ }
282
+
283
+ /**
284
+ * 泼溅的拾取结果。
285
+ *
286
+ * 整块泼溅瓦片当一个单体(没有单体级 smid 可报),命中点由 `splatRaycast` 给出 ——
287
+ * 它按每个高斯的 3σ 球求交,比拾取那一趟为了命中率摊开的 4px 方块准得多。
288
+ * 包围盒必须由 `iCenter` 算:geometry 的 position 只是 [-1,1]² 的四边形角点。
289
+ */
290
+ buildSplatPickResult(
291
+ hit: { objectId: number; batchId: number; instanceIndex?: number },
292
+ ref: PickableRef,
293
+ exactPoint?: THREE.Vector3,
294
+ ray?: THREE.Ray
295
+ ): PickResult | null {
296
+ const anchor = this.host.sceneAnchor;
297
+ if (!anchor) return null;
298
+ const mesh = ref.mesh;
299
+
300
+ let hitPoint = exactPoint ?? null;
301
+ if (!hitPoint && ray) hitPoint = this.intersectMeshAtRay(mesh, ray);
302
+
303
+ const local = splatCenterBounds(mesh.geometry);
304
+ const box: SegmentBox | null = local
305
+ ? {
306
+ minX: local.minX,
307
+ minY: local.minY,
308
+ minZ: local.minZ,
309
+ maxX: local.maxX,
310
+ maxY: local.maxY,
311
+ maxZ: local.maxZ,
312
+ count: local.count,
313
+ triangles: 0,
314
+ }
315
+ : null;
316
+
317
+ const attributes = buildAttributes({
318
+ smid: null,
319
+ box,
320
+ triangles: 0,
321
+ floorHeight: this.host.style.floorHeight,
322
+ });
323
+
324
+ const s = anchor.meterInMercator;
325
+ let point: [number, number, number] | null = null;
326
+ let lngLat: [number, number] | null = null;
327
+ let altitude: number | null = null;
328
+ let centerLngLat: [number, number] | null = null;
329
+
330
+ if (hitPoint) {
331
+ point = [hitPoint.x, hitPoint.y, hitPoint.z];
332
+ lngLat = this.host.lngLatFromWorld(hitPoint.x * s + anchor.x, -hitPoint.y * s + anchor.y);
333
+ altitude = hitPoint.z + (anchor.altitude ?? 0);
334
+ // 泼溅命中的就是一个高斯,没有「单体中心」可言,用命中点当中心
335
+ centerLngLat = lngLat;
336
+ } else if (box) {
337
+ const v = new THREE.Vector3(
338
+ (box.minX + box.maxX) / 2,
339
+ (box.minY + box.maxY) / 2,
340
+ (box.minZ + box.maxZ) / 2
341
+ ).applyMatrix4(mesh.matrixWorld);
342
+ centerLngLat = this.host.lngLatFromWorld(v.x * s + anchor.x, -v.y * s + anchor.y);
343
+ altitude = v.z + (anchor.altitude ?? 0);
344
+ }
345
+
346
+ return {
347
+ datasetId: ref.datasetId,
348
+ datasetName: this.host.datasets.get(ref.datasetId)?.name ?? ref.datasetId,
349
+ objectId: hit.objectId,
350
+ batchId: -1,
351
+ instanceIndex: undefined,
352
+ skeletonName: ref.skeletonName,
353
+ centerLngLat,
354
+ point,
355
+ lngLat,
356
+ altitude,
357
+ ...attributes,
358
+ mesh,
359
+ };
360
+ }
361
+
362
+ /** 按 canvas 左上角的 CSS 像素坐标拾取。 */
363
+ pick(x: number, y: number): PickResult | null {
364
+ if (!this.host.picker || !this.host.scene || !this.host.camera || !this.host.sceneAnchor) return null;
365
+ const hit = this.host.picker.pick(this.host.scene, this.host.camera, x, y);
366
+ if (!hit) return null;
367
+
368
+ const ref = this.pickables.get(hit.objectId);
369
+ if (!ref) return null;
370
+
371
+ const ray = this.getRayAt(x, y);
372
+ return this.buildPickResult(hit, ref, undefined, ray ?? undefined);
373
+ }
374
+
375
+ /**
376
+ * 拾取指定屏幕像素对应的世界坐标与场景位置。
377
+ * 优先与命中的建筑网格精确求交;未命中建筑时若视线与地表相交则返回地面交点。
378
+ */
379
+ pickPosition(x: number, y: number): { lngLat: [number, number]; altitude: number; point: [number, number, number] } | null {
380
+ if (!this.host.camera || !this.host.sceneAnchor) return null;
381
+
382
+ const hit = this.pick(x, y);
383
+ if (hit?.point && hit?.lngLat && hit.altitude != null) {
384
+ return {
385
+ lngLat: hit.lngLat,
386
+ altitude: hit.altitude,
387
+ point: hit.point,
388
+ };
389
+ }
390
+
391
+ const ray = this.getRayAt(x, y);
392
+ if (!ray) return null;
393
+
394
+ const groundPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
395
+ const target = new THREE.Vector3();
396
+ const intersect = ray.intersectPlane(groundPlane, target);
397
+ if (intersect) {
398
+ const s = this.host.sceneAnchor.meterInMercator;
399
+ const lngLat = this.host.lngLatFromWorld(intersect.x * s + this.host.sceneAnchor.x, -intersect.y * s + this.host.sceneAnchor.y);
400
+ const altitude = intersect.z + (this.host.sceneAnchor.altitude ?? 0);
401
+ return {
402
+ lngLat,
403
+ altitude,
404
+ point: [intersect.x, intersect.y, intersect.z],
405
+ };
406
+ }
407
+ return null;
408
+ }
409
+
410
+ /**
411
+ * 沿视线方向穿透拾取多个单体,按由近至远排序返回。
412
+ */
413
+ drillPick(x: number, y: number, limit = 10): PickResult[] {
414
+ if (!this.host.sceneAnchor || !this.host.camera) return [];
415
+ const ray = this.getRayAt(x, y);
416
+ if (!ray) return [];
417
+
418
+ const raycaster = new THREE.Raycaster();
419
+ raycaster.ray.copy(ray);
420
+
421
+ const meshes = [...this.pickables.values()]
422
+ .map((p) => p.mesh)
423
+ .filter((m) => {
424
+ let curr: THREE.Object3D | null = m;
425
+ while (curr) {
426
+ if (!curr.visible) return false;
427
+ curr = curr.parent;
428
+ }
429
+ return true;
430
+ });
431
+ const hits = raycaster.intersectObjects(meshes, false);
432
+ if (!hits.length) return [];
433
+
434
+ const results: PickResult[] = [];
435
+ const seenSmids = new Set<number>();
436
+ const seenBatches = new Set<string>();
437
+
438
+ for (const hit of hits) {
439
+ if (results.length >= limit) break;
440
+ const mesh = hit.object as THREE.Mesh;
441
+ const ud = mesh.userData as MeshUserData & { pickId?: number };
442
+ if (ud.pickId == null) continue;
443
+ const ref = this.pickables.get(ud.pickId);
444
+ if (!ref) continue;
445
+
446
+ let smid: number | null = null;
447
+ let batchId = -1;
448
+ let instanceIndex = hit.instanceId;
449
+
450
+ if (instanceIndex != null) {
451
+ const feature = this.instanceFeature(mesh, ud.geoRef, instanceIndex);
452
+ if (!feature) continue;
453
+ smid = feature.smid;
454
+ batchId = feature.batchId;
455
+ } else if (hit.faceIndex != null) {
456
+ const batchAttr = mesh.geometry.attributes.aBatchId;
457
+ if (batchAttr) {
458
+ const idx = mesh.geometry.index ? mesh.geometry.index.getX(hit.faceIndex * 3) : hit.faceIndex * 3;
459
+ batchId = Math.round(batchAttr.getX(idx));
460
+ const geoRef = (ud.geoByBatchId as Map<number, GeoPackageEntry> | undefined)?.get(batchId) ?? ud.geoRef;
461
+ const resolved = resolveFeature(geoRef, batchId);
462
+ smid = resolved.smid;
463
+ }
464
+ }
465
+
466
+ if (smid != null) {
467
+ if (seenSmids.has(smid)) continue;
468
+ seenSmids.add(smid);
469
+ } else {
470
+ const key = `${ud.pickId}:${batchId}:${instanceIndex ?? -1}`;
471
+ if (seenBatches.has(key)) continue;
472
+ seenBatches.add(key);
473
+ }
474
+
475
+ if (this.host.isFeatureHidden(smid, batchId)) continue;
476
+
477
+ const pickHit = {
478
+ objectId: ud.pickId,
479
+ batchId,
480
+ instanceIndex,
481
+ };
482
+ const res = this.buildPickResult(pickHit, ref, hit.point);
483
+ if (res) results.push(res);
484
+ }
485
+
486
+ return results;
487
+ }
488
+
489
+ /**
490
+ * 获取指定 SMID 单体的经纬度包围盒 [[minLng, minLat], [maxLng, maxLat]]。
491
+ * 若模型尚未加载到场景中或无此单体则返回 null。
492
+ */
493
+ getFeatureBounds(smid: number): [[number, number], [number, number]] | null {
494
+ if (!this.host.sceneAnchor) return null;
495
+ let minLng = Infinity;
496
+ let maxLng = -Infinity;
497
+ let minLat = Infinity;
498
+ let maxLat = -Infinity;
499
+ let found = false;
500
+
501
+ for (const ref of this.pickables.values()) {
502
+ const mesh = ref.mesh;
503
+ const ud = mesh.userData as MeshUserData;
504
+ const geoRef = ud.geoRef;
505
+ const pickInfo = geoRef?.pickInfo;
506
+ if (!pickInfo) continue;
507
+
508
+ let box: SegmentBox | null = null;
509
+ if ((mesh as THREE.InstancedMesh).isInstancedMesh) {
510
+ let lookup = ud.instanceLookup as Map<number, InstanceFeature> | undefined;
511
+ if (!lookup) {
512
+ lookup = buildInstanceLookup(pickInfo);
513
+ ud.instanceLookup = lookup;
514
+ }
515
+ for (const [instIdx, feat] of lookup) {
516
+ if (feat.smid === smid) {
517
+ box = this.instanceBox(mesh, instIdx);
518
+ break;
519
+ }
520
+ }
521
+ } else {
522
+ const infoMap = pickInfo as Record<string | number, unknown>;
523
+ const entry = infoMap[smid] ?? infoMap[String(smid)];
524
+ if (entry) {
525
+ const bId = pickEntryId(entry);
526
+ const resolved = resolveFeature(geoRef, bId);
527
+ box = measureSegments(mesh.geometry, resolved.segments);
528
+ if (!box && mesh.geometry) {
529
+ if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
530
+ const bb = mesh.geometry.boundingBox;
531
+ if (bb) {
532
+ box = {
533
+ minX: bb.min.x,
534
+ maxX: bb.max.x,
535
+ minY: bb.min.y,
536
+ maxY: bb.max.y,
537
+ minZ: bb.min.z,
538
+ maxZ: bb.max.z,
539
+ count: 0,
540
+ triangles: 0,
541
+ };
542
+ }
543
+ }
544
+ }
545
+ }
546
+
547
+ if (box) {
548
+ found = true;
549
+ const corners = [
550
+ new THREE.Vector3(box.minX, box.minY, box.minZ),
551
+ new THREE.Vector3(box.maxX, box.minY, box.minZ),
552
+ new THREE.Vector3(box.minX, box.maxY, box.minZ),
553
+ new THREE.Vector3(box.maxX, box.maxY, box.minZ),
554
+ new THREE.Vector3(box.minX, box.minY, box.maxZ),
555
+ new THREE.Vector3(box.maxX, box.minY, box.maxZ),
556
+ new THREE.Vector3(box.minX, box.maxY, box.maxZ),
557
+ new THREE.Vector3(box.maxX, box.maxY, box.maxZ),
558
+ ];
559
+
560
+ const off = smid != null ? this.host.featureStyleManager.getFeatureEffectiveOffset(smid) : {};
561
+ const trans = off.translation;
562
+ const elev = off.elevation;
563
+
564
+ const rotMat = off.rotation ? resolveRotationMatrix(off.rotation) : null;
565
+ let rotM3: THREE.Matrix3 | null = null;
566
+ let rotPiv: THREE.Vector3 | null = null;
567
+ if (rotMat) {
568
+ rotM3 = new THREE.Matrix3().set(
569
+ rotMat[0], rotMat[1], rotMat[2],
570
+ rotMat[3], rotMat[4], rotMat[5],
571
+ rotMat[6], rotMat[7], rotMat[8]
572
+ );
573
+ rotPiv = off.pivot ? new THREE.Vector3(off.pivot[0], off.pivot[1], off.pivot[2]) : new THREE.Vector3(0, 0, 0);
574
+ }
575
+
576
+ // GPU 路径:位移发生在顶点着色器里,包围盒要按同一套公式在 CPU 复算,
577
+ // 否则「运动中的车」的包围盒会停在路径原点(相机 fitBounds / 属性过滤都会错)。
578
+ const gpuPose = smid != null ? this.host.gpuPathPoseAt(smid) : null;
579
+ let headM3: THREE.Matrix3 | null = null;
580
+ if (gpuPose && gpuPose.yaw !== 0) {
581
+ const c = Math.cos(gpuPose.yaw);
582
+ const s = Math.sin(gpuPose.yaw);
583
+ // 与着色器的 mat3( c, s, 0, -s, c, 0, 0, 0, 1 ) 等价:GLSL 是列优先,
584
+ // three 的 Matrix3.set 是行优先,所以这里写成 (c, -s; s, c) 才是同一个矩阵。
585
+ headM3 = new THREE.Matrix3().set(c, -s, 0, s, c, 0, 0, 0, 1);
586
+ }
587
+
588
+ for (const c of corners) {
589
+ if (rotM3 && rotPiv) c.sub(rotPiv).applyMatrix3(rotM3).add(rotPiv);
590
+ if (headM3) {
591
+ const piv = rotPiv ?? new THREE.Vector3(0, 0, 0);
592
+ c.sub(piv).applyMatrix3(headM3).add(piv);
593
+ }
594
+ if (trans) c.add(new THREE.Vector3(trans[0], trans[1], trans[2]));
595
+ else if (elev) c.z += elev;
596
+ if (gpuPose) c.add(new THREE.Vector3(gpuPose.pos[0], gpuPose.pos[1], gpuPose.pos[2]));
597
+
598
+ c.applyMatrix4(mesh.matrixWorld);
599
+ const s = this.host.sceneAnchor.meterInMercator;
600
+ const [lng, lat] = this.host.lngLatFromWorld(c.x * s + this.host.sceneAnchor.x, -c.y * s + this.host.sceneAnchor.y);
601
+ if (lng < minLng) minLng = lng;
602
+ if (lng > maxLng) maxLng = lng;
603
+ if (lat < minLat) minLat = lat;
604
+ if (lat > maxLat) maxLat = lat;
605
+ }
606
+ }
607
+ }
608
+
609
+ if (!found) return null;
610
+ return [
611
+ [minLng, minLat],
612
+ [maxLng, maxLat],
613
+ ];
614
+ }
615
+
616
+ /** 实例序号 → 所属单体(按网格缓存反查表,实例多的数据不用每次重建) */
617
+ instanceFeature(
618
+ mesh: THREE.Mesh,
619
+ geoRef: GeoPackageEntry | undefined,
620
+ instanceIndex: number
621
+ ): InstanceFeature | null {
622
+ const ud = mesh.userData as MeshUserData & { instanceLookup?: Map<number, InstanceFeature> };
623
+ if (!ud.instanceLookup) ud.instanceLookup = buildInstanceLookup(geoRef?.pickInfo);
624
+ return ud.instanceLookup.get(instanceIndex) ?? null;
625
+ }
626
+
627
+ /** 实例化几何的单体包围盒:base mesh 的包围盒套上该实例的矩阵 */
628
+ instanceBox(mesh: THREE.Mesh, instanceIndex: number): SegmentBox | null {
629
+ const im = mesh as THREE.InstancedMesh;
630
+ if (!im.isInstancedMesh) return null;
631
+ const geometry = mesh.geometry;
632
+ if (!geometry.boundingBox) geometry.computeBoundingBox();
633
+ const bb = geometry.boundingBox;
634
+ if (!bb) return null;
635
+ const m = new THREE.Matrix4();
636
+ im.getMatrixAt(instanceIndex, m);
637
+ const box = bb.clone().applyMatrix4(m);
638
+ return {
639
+ minX: box.min.x,
640
+ minY: box.min.y,
641
+ minZ: box.min.z,
642
+ maxX: box.max.x,
643
+ maxY: box.max.y,
644
+ maxZ: box.max.z,
645
+ count: geometry.getAttribute('position')?.count ?? 0,
646
+ triangles: geometry.index ? Math.round(geometry.index.count / 3) : 0,
647
+ };
648
+ }
649
+
650
+ /** 选中/取消选中单体。`silent` 为 true 时不回调 onPick。 */
651
+ }