s3m-three 0.1.0 → 0.2.0

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 (44) hide show
  1. package/LICENSE +202 -202
  2. package/dist/render/DatasetManager.d.ts +10 -3
  3. package/dist/render/DatasetManager.d.ts.map +1 -1
  4. package/dist/render/DatasetManager.js +14 -5
  5. package/dist/render/DatasetManager.js.map +1 -1
  6. package/dist/render/EdgeLines.js +46 -46
  7. package/dist/render/GroundManager.d.ts +9 -0
  8. package/dist/render/GroundManager.d.ts.map +1 -1
  9. package/dist/render/GroundManager.js +13 -1
  10. package/dist/render/GroundManager.js.map +1 -1
  11. package/dist/render/Picker.js +66 -66
  12. package/dist/render/S3MViewer.d.ts +11 -3
  13. package/dist/render/S3MViewer.d.ts.map +1 -1
  14. package/dist/render/S3MViewer.js +18 -3
  15. package/dist/render/S3MViewer.js.map +1 -1
  16. package/dist/render/StyleMaterial.js +265 -265
  17. package/dist/vendor/meshopt_decoder.module.js +118 -118
  18. package/docs/API.md +20 -0
  19. package/package.json +1 -1
  20. package/src/core/BinaryReader.ts +116 -116
  21. package/src/core/DXTTextureDecode.ts +260 -260
  22. package/src/core/FeatureInfo.ts +150 -150
  23. package/src/core/MeshOpt.ts +401 -401
  24. package/src/core/S3MBParser.ts +590 -590
  25. package/src/core/types.ts +222 -222
  26. package/src/render/DatasetManager.ts +17 -6
  27. package/src/render/EdgeLines.ts +343 -343
  28. package/src/render/GeometryBuilder.ts +840 -840
  29. package/src/render/GroundManager.ts +14 -1
  30. package/src/render/Picker.ts +293 -293
  31. package/src/render/S3MViewer.ts +18 -3
  32. package/src/render/StyleMaterial.ts +602 -602
  33. package/src/render/TextureFactory.ts +134 -134
  34. package/src/tileset/S3MTileset.ts +552 -552
  35. package/src/vendor/meshopt_decoder.module.d.ts +31 -31
  36. package/src/vendor/meshopt_decoder.module.js +118 -118
  37. package/dist/render/CoordinateHelper.d.ts +0 -90
  38. package/dist/render/CoordinateHelper.d.ts.map +0 -1
  39. package/dist/render/CoordinateHelper.js +0 -85
  40. package/dist/render/CoordinateHelper.js.map +0 -1
  41. package/dist/render/S3MLayer.d.ts +0 -457
  42. package/dist/render/S3MLayer.d.ts.map +0 -1
  43. package/dist/render/S3MLayer.js +0 -1860
  44. package/dist/render/S3MLayer.js.map +0 -1
@@ -38,6 +38,8 @@ export class GroundManager {
38
38
  gridSize: number;
39
39
  excavationBounds: [number, number, number, number] | null = null;
40
40
  size: number;
41
+ /** 物理地表网格的高度(米,场景 ENU Z 坐标),默认 0 */
42
+ groundHeight = 0;
41
43
 
42
44
  readonly group: THREE.Group;
43
45
  private _mesh: THREE.Mesh | null = null;
@@ -153,7 +155,18 @@ export class GroundManager {
153
155
  */
154
156
  update(camera?: THREE.Camera | null): void {
155
157
  if (!camera || this.mode === 'none') return;
156
- this.group.position.set(camera.position.x, camera.position.y, 0);
158
+ this.group.position.set(camera.position.x, camera.position.y, this.groundHeight);
159
+ }
160
+
161
+ /**
162
+ * 设置物理地表网格的高度(米,场景 ENU Z 坐标)
163
+ *
164
+ * 数据集被抬到真实地面高程后,地表网格需要同步抬高,
165
+ * 否则「地表覆盖 / 地下透视」的物理关系会与被抬升的模型脱节。
166
+ */
167
+ setGroundHeight(height: number): void {
168
+ this.groundHeight = height;
169
+ this.group.position.z = height;
157
170
  }
158
171
 
159
172
  setMode(mode: GroundMode): void {
@@ -1,293 +1,293 @@
1
- /**
2
- * 单体级 GPU picking
3
- *
4
- * 思路:把「物体编号 + batchId」编码成 RGBA 渲染到离屏目标,点击时读那一个像素。
5
- * 相比射线求交,代价与三角面数无关——30 万面和 3 千面一样快,且天然只命中
6
- * 最前面的可见面(深度测试在 GPU 上完成了)。
7
- *
8
- * 编码约定(每个通道一个字节):
9
- * R,G = 物体编号 + 1
10
- * B,A = batchId + 1 (+1 是为了让「什么都没点到」全 0)
11
- *
12
- * 渲染目标只有 size×size 个像素,靠平移 viewport 把「点击点那一格」挪到目标里:
13
- * viewport 原点设成 (0 - px, 0 - (h-1-py))、尺寸仍是整块画布,于是光栅化结果和全屏
14
- * 那一趟逐像素一致,只是只有一个像素落在目标上。原来那句「每次点击全屏渲染一趟」
15
- * 的代价(移动端尤其明显)就没了。
16
- */
17
- import * as THREE from 'three';
18
-
19
- const PICK_VERT = /* glsl */ `
20
- attribute float aBatchId;
21
- #ifdef USE_INSTANCING
22
- // 实例化:单体粒度在实例上,顶点属性里没有可用的 batchId
23
- attribute float aInstanceBatchId;
24
- attribute float aInstanceIndex;
25
- #endif
26
- uniform float uObjectId;
27
- uniform sampler2D uBatchTex;
28
- uniform float uBatchTexWidth;
29
- uniform int uBatchTexActive;
30
- uniform float uSelBatch;
31
- uniform float uSelWhole;
32
- uniform float uSelElevation;
33
- varying float vBatch;
34
-
35
- void main() {
36
- float s3mBatch;
37
- #ifdef USE_INSTANCING
38
- s3mBatch = aInstanceBatchId;
39
- // 编码「实例序号」而不是控制位:CPU 侧要靠它拿到实例矩阵(描边 / 中心点)
40
- vBatch = aInstanceIndex;
41
- #else
42
- s3mBatch = aBatchId;
43
- vBatch = aBatchId;
44
- #endif
45
-
46
- // 单体状态(平移 / 隐藏)必须和主着色器用同一份数据、同一套算法:
47
- // 平移只发生在顶点着色器里,拾取这一趟如果还按原始坐标画,移位的楼就点不中。
48
- vec3 p = position;
49
- if ( uBatchTexActive > 0 && s3mBatch >= 0.0 ) {
50
- vec4 state = texture2D( uBatchTex, vec2( ( s3mBatch + 0.5 ) / uBatchTexWidth, 0.25 ) );
51
- if ( state.a < -0.5 ) {
52
- p = vec3( 0.0 );
53
- } else {
54
- vec4 offset = texture2D( uBatchTex, vec2( ( s3mBatch + 0.5 ) / uBatchTexWidth, 0.75 ) );
55
- p += offset.xyz;
56
- if ( offset.z == 0.0 && state.a > 0.0 ) {
57
- p.z += state.a;
58
- }
59
- }
60
- }
61
-
62
- // 选中单体自身的拔高(selectElevation)
63
- float sel = max( step( 0.0, uSelBatch ) * step( abs( s3mBatch - uSelBatch ), 0.5 ), uSelWhole );
64
- p.z += sel * uSelElevation;
65
-
66
- #ifdef USE_INSTANCING
67
- gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4( p, 1.0 );
68
- #else
69
- gl_Position = projectionMatrix * modelViewMatrix * vec4( p, 1.0 );
70
- #endif
71
- }
72
- `;
73
-
74
- const PICK_FRAG = /* glsl */ `
75
- uniform float uObjectId;
76
- varying float vBatch;
77
- void main() {
78
- float o = uObjectId + 1.0;
79
- float b = vBatch + 1.0;
80
- gl_FragColor = vec4(
81
- mod( o, 256.0 ) / 255.0,
82
- floor( o / 256.0 ) / 255.0,
83
- mod( b, 256.0 ) / 255.0,
84
- floor( b / 256.0 ) / 255.0
85
- );
86
- }
87
- `;
88
-
89
- export interface PickHit {
90
- /** 由 S3MLayer 分配的网格编号 */
91
- objectId: number;
92
- /** 非实例化几何:单体 batchId;实例化几何这里是 -1(改看 instanceIndex) */
93
- batchId: number;
94
- /** 实例化几何命中到第几个实例(非实例化为 undefined) */
95
- instanceIndex?: number;
96
- }
97
-
98
- /** S3MLayer 挂到 mesh.userData 上的字段,拾取时会读它 */
99
- interface PickUserData {
100
- pickId?: number;
101
- pickMaterial?: THREE.Material;
102
- [key: string]: unknown;
103
- }
104
-
105
- /** 没有单体状态纹理时用的占位(全 0 = 不隐藏、不平移) */
106
- const DUMMY_STATE_TEX = (() => {
107
- const tex = new THREE.DataTexture(new Float32Array([-1, -1, -1, 0, 0, 0, 0, 0]), 1, 2, THREE.RGBAFormat, THREE.FloatType);
108
- tex.minFilter = THREE.NearestFilter;
109
- tex.magFilter = THREE.NearestFilter;
110
- tex.needsUpdate = true;
111
- return tex;
112
- })();
113
-
114
- function createPickMaterial(objectId: number): THREE.ShaderMaterial {
115
- return new THREE.ShaderMaterial({
116
- uniforms: {
117
- uObjectId: { value: objectId },
118
- uBatchTex: { value: DUMMY_STATE_TEX },
119
- uBatchTexWidth: { value: 1 },
120
- uBatchTexActive: { value: 0 },
121
- uSelBatch: { value: -1 },
122
- uSelWhole: { value: 0 },
123
- uSelElevation: { value: 0 },
124
- },
125
- vertexShader: PICK_VERT,
126
- fragmentShader: PICK_FRAG,
127
- side: THREE.DoubleSide,
128
- depthTest: true,
129
- depthWrite: true,
130
- toneMapped: false,
131
- });
132
- }
133
-
134
- export class Picker {
135
- readonly renderer: THREE.WebGLRenderer;
136
- /** 拾取用的渲染目标边长(像素),默认 1 —— 只要点击点那一格 */
137
- readonly size: number;
138
- private target: THREE.WebGLRenderTarget | null = null;
139
- private readonly buf = new Uint8Array(4);
140
-
141
- constructor(renderer: THREE.WebGLRenderer, options: { size?: number } = {}) {
142
- this.renderer = renderer;
143
- this.size = Math.max(1, Math.floor(options.size ?? 1));
144
- }
145
-
146
- private _ensureTarget(size: number): THREE.WebGLRenderTarget {
147
- if (this.target && this.target.width === size && this.target.height === size) return this.target;
148
- this.target?.dispose();
149
- this.target = new THREE.WebGLRenderTarget(size, size, {
150
- minFilter: THREE.NearestFilter,
151
- magFilter: THREE.NearestFilter,
152
- format: THREE.RGBAFormat,
153
- type: THREE.UnsignedByteType,
154
- depthBuffer: true,
155
- stencilBuffer: false,
156
- });
157
- // 关掉色彩空间转换,否则 ACES / sRGB 会把编码好的 ID 洗掉
158
- this.target.texture.colorSpace = THREE.LinearSRGBColorSpace;
159
- return this.target;
160
- }
161
-
162
- /**
163
- * @param clientX 相对 canvas 左上角的 CSS 像素
164
- * @param clientY 同上
165
- */
166
- pick(scene: THREE.Scene, camera: THREE.Camera, clientX: number, clientY: number): PickHit | null {
167
- const renderer = this.renderer;
168
- const canvas = renderer.domElement;
169
- const w = canvas.width;
170
- const h = canvas.height;
171
- if (!w || !h) return null;
172
-
173
- const px = Math.floor((clientX / canvas.clientWidth) * w);
174
- const py = Math.floor((clientY / canvas.clientHeight) * h);
175
- if (px < 0 || py < 0 || px >= w || py >= h) return null;
176
-
177
- const target = this._ensureTarget(this.size);
178
-
179
- // 点击点落在整块画布的第几行(WebGL 的 y 轴朝上)
180
- const ygl = h - 1 - py;
181
- // 目标里我们读的那一格(size 为偶数时取偏左上那格)
182
- const cx = Math.floor(this.size / 2);
183
- const cy = Math.floor(this.size / 2);
184
- // viewport 尺寸仍是整块画布、原点平移,把 (px, ygl) 挪到目标的 (cx, cy)
185
- target.viewport.set(cx - px, cy - ygl, w, h);
186
-
187
- // ---- 保存渲染器状态
188
- const prevTarget = renderer.getRenderTarget();
189
- const prevViewport = renderer.getViewport(new THREE.Vector4());
190
- const prevTone = renderer.toneMapping;
191
- const prevColorSpace = renderer.outputColorSpace;
192
- const prevAutoClear = renderer.autoClear;
193
- const prevClearColor = renderer.getClearColor(new THREE.Color());
194
- const prevClearAlpha = renderer.getClearAlpha();
195
-
196
- // ---- 换材质
197
- const swapped: Array<[THREE.Mesh, THREE.Material | THREE.Material[]]> = [];
198
- /** 这次拾取里哪些网格是实例化的(它们的 vBatch 编码的是实例序号) */
199
- const instancedPickIds = new Set<number>();
200
- scene.traverseVisible((o) => {
201
- const mesh = o as THREE.Mesh;
202
- if (!mesh.isMesh) return;
203
- const ud = mesh.userData as PickUserData;
204
- if (ud.pickId === undefined) return;
205
- if (!ud.pickMaterial) ud.pickMaterial = createPickMaterial(ud.pickId);
206
- if ((mesh as THREE.InstancedMesh).isInstancedMesh) instancedPickIds.add(ud.pickId);
207
-
208
- // 把当前的「单体状态纹理」抄给拾取材质:抬高量是逐单体写在里面的,
209
- // 抄的是同一个纹理对象,主材质那边更新后这里自动跟着变。
210
- const prev = mesh.material;
211
- const pm = ud.pickMaterial as THREE.ShaderMaterial;
212
- const src = (Array.isArray(prev) ? prev[0] : prev) as THREE.Material & {
213
- userData?: Record<string, THREE.IUniform>;
214
- };
215
- const batchTex = src?.userData?.uBatchTex;
216
- if (batchTex) {
217
- pm.uniforms.uBatchTex.value = batchTex.value;
218
- pm.uniforms.uBatchTexWidth.value = (src?.userData?.uBatchTexWidth?.value as number) ?? 1;
219
- pm.uniforms.uBatchTexActive.value = (src?.userData?.uBatchTexActive?.value as number) ?? 0;
220
- } else {
221
- pm.uniforms.uBatchTex.value = DUMMY_STATE_TEX;
222
- pm.uniforms.uBatchTexWidth.value = 1;
223
- pm.uniforms.uBatchTexActive.value = 0;
224
- }
225
- // 选中那栋自身的拔高也要算,否则选中后再点它会点空
226
- pm.uniforms.uSelBatch.value = (src?.userData?.uSelBatch?.value as number) ?? -1;
227
- pm.uniforms.uSelWhole.value = (src?.userData?.uSelWhole?.value as number) ?? 0;
228
- // uSelElevation 是共享 uniform,挂在材质 uniforms 上而不是 userData
229
- pm.uniforms.uSelElevation.value =
230
- ((src as THREE.ShaderMaterial | undefined)?.uniforms?.uSelElevation?.value as number) ?? 0;
231
-
232
- swapped.push([mesh, prev]);
233
- mesh.material = pm;
234
- });
235
- // 描边 / 棱线不参与拾取
236
- const hidden: THREE.Object3D[] = [];
237
- scene.traverseVisible((o) => {
238
- const mesh = o as THREE.Mesh;
239
- if (mesh.isMesh && (mesh.userData as PickUserData).pickId === undefined) {
240
- hidden.push(mesh);
241
- mesh.visible = false;
242
- }
243
- });
244
-
245
- let hit: PickHit | null = null;
246
- try {
247
- // 关键:拾取是在「正常渲染帧」之外另开的一趟渲染,
248
- // MapLibre / Mapbox 在上一次 draw 之后可能已经改过 GL 状态(深度测试、scissor 等),
249
- // 而 three 的状态缓存还停留在自己的上一次 render。
250
- // 不重置就会出现深度测试失效 → 命中的是「最后画的」而不是「最前面的」。
251
- renderer.resetState();
252
-
253
- renderer.setRenderTarget(target);
254
- renderer.toneMapping = THREE.NoToneMapping;
255
- renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
256
- renderer.autoClear = true;
257
- renderer.setClearColor(0x000000, 0);
258
- renderer.clear();
259
- renderer.render(scene, camera);
260
-
261
- renderer.readRenderTargetPixels(target, cx, cy, 1, 1, this.buf);
262
-
263
- const r = this.buf[0];
264
- const g = this.buf[1];
265
- const b = this.buf[2];
266
- const a = this.buf[3];
267
- const objectId = r + g * 256 - 1;
268
- const rawId = b + a * 256 - 1;
269
- if (objectId >= 0) {
270
- hit = instancedPickIds.has(objectId)
271
- ? { objectId, batchId: -1, instanceIndex: Math.max(0, rawId) }
272
- : { objectId, batchId: rawId };
273
- }
274
- } finally {
275
- for (const [mesh, mat] of swapped) mesh.material = mat;
276
- for (const o of hidden) o.visible = true;
277
- renderer.setRenderTarget(prevTarget);
278
- renderer.setViewport(prevViewport);
279
- renderer.toneMapping = prevTone;
280
- renderer.outputColorSpace = prevColorSpace;
281
- renderer.autoClear = prevAutoClear;
282
- renderer.setClearColor(prevClearColor, prevClearAlpha);
283
- renderer.resetState();
284
- }
285
-
286
- return hit;
287
- }
288
-
289
- dispose(): void {
290
- this.target?.dispose();
291
- this.target = null;
292
- }
293
- }
1
+ /**
2
+ * 单体级 GPU picking
3
+ *
4
+ * 思路:把「物体编号 + batchId」编码成 RGBA 渲染到离屏目标,点击时读那一个像素。
5
+ * 相比射线求交,代价与三角面数无关——30 万面和 3 千面一样快,且天然只命中
6
+ * 最前面的可见面(深度测试在 GPU 上完成了)。
7
+ *
8
+ * 编码约定(每个通道一个字节):
9
+ * R,G = 物体编号 + 1
10
+ * B,A = batchId + 1 (+1 是为了让「什么都没点到」全 0)
11
+ *
12
+ * 渲染目标只有 size×size 个像素,靠平移 viewport 把「点击点那一格」挪到目标里:
13
+ * viewport 原点设成 (0 - px, 0 - (h-1-py))、尺寸仍是整块画布,于是光栅化结果和全屏
14
+ * 那一趟逐像素一致,只是只有一个像素落在目标上。原来那句「每次点击全屏渲染一趟」
15
+ * 的代价(移动端尤其明显)就没了。
16
+ */
17
+ import * as THREE from 'three';
18
+
19
+ const PICK_VERT = /* glsl */ `
20
+ attribute float aBatchId;
21
+ #ifdef USE_INSTANCING
22
+ // 实例化:单体粒度在实例上,顶点属性里没有可用的 batchId
23
+ attribute float aInstanceBatchId;
24
+ attribute float aInstanceIndex;
25
+ #endif
26
+ uniform float uObjectId;
27
+ uniform sampler2D uBatchTex;
28
+ uniform float uBatchTexWidth;
29
+ uniform int uBatchTexActive;
30
+ uniform float uSelBatch;
31
+ uniform float uSelWhole;
32
+ uniform float uSelElevation;
33
+ varying float vBatch;
34
+
35
+ void main() {
36
+ float s3mBatch;
37
+ #ifdef USE_INSTANCING
38
+ s3mBatch = aInstanceBatchId;
39
+ // 编码「实例序号」而不是控制位:CPU 侧要靠它拿到实例矩阵(描边 / 中心点)
40
+ vBatch = aInstanceIndex;
41
+ #else
42
+ s3mBatch = aBatchId;
43
+ vBatch = aBatchId;
44
+ #endif
45
+
46
+ // 单体状态(平移 / 隐藏)必须和主着色器用同一份数据、同一套算法:
47
+ // 平移只发生在顶点着色器里,拾取这一趟如果还按原始坐标画,移位的楼就点不中。
48
+ vec3 p = position;
49
+ if ( uBatchTexActive > 0 && s3mBatch >= 0.0 ) {
50
+ vec4 state = texture2D( uBatchTex, vec2( ( s3mBatch + 0.5 ) / uBatchTexWidth, 0.25 ) );
51
+ if ( state.a < -0.5 ) {
52
+ p = vec3( 0.0 );
53
+ } else {
54
+ vec4 offset = texture2D( uBatchTex, vec2( ( s3mBatch + 0.5 ) / uBatchTexWidth, 0.75 ) );
55
+ p += offset.xyz;
56
+ if ( offset.z == 0.0 && state.a > 0.0 ) {
57
+ p.z += state.a;
58
+ }
59
+ }
60
+ }
61
+
62
+ // 选中单体自身的拔高(selectElevation)
63
+ float sel = max( step( 0.0, uSelBatch ) * step( abs( s3mBatch - uSelBatch ), 0.5 ), uSelWhole );
64
+ p.z += sel * uSelElevation;
65
+
66
+ #ifdef USE_INSTANCING
67
+ gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4( p, 1.0 );
68
+ #else
69
+ gl_Position = projectionMatrix * modelViewMatrix * vec4( p, 1.0 );
70
+ #endif
71
+ }
72
+ `;
73
+
74
+ const PICK_FRAG = /* glsl */ `
75
+ uniform float uObjectId;
76
+ varying float vBatch;
77
+ void main() {
78
+ float o = uObjectId + 1.0;
79
+ float b = vBatch + 1.0;
80
+ gl_FragColor = vec4(
81
+ mod( o, 256.0 ) / 255.0,
82
+ floor( o / 256.0 ) / 255.0,
83
+ mod( b, 256.0 ) / 255.0,
84
+ floor( b / 256.0 ) / 255.0
85
+ );
86
+ }
87
+ `;
88
+
89
+ export interface PickHit {
90
+ /** 由 S3MLayer 分配的网格编号 */
91
+ objectId: number;
92
+ /** 非实例化几何:单体 batchId;实例化几何这里是 -1(改看 instanceIndex) */
93
+ batchId: number;
94
+ /** 实例化几何命中到第几个实例(非实例化为 undefined) */
95
+ instanceIndex?: number;
96
+ }
97
+
98
+ /** S3MLayer 挂到 mesh.userData 上的字段,拾取时会读它 */
99
+ interface PickUserData {
100
+ pickId?: number;
101
+ pickMaterial?: THREE.Material;
102
+ [key: string]: unknown;
103
+ }
104
+
105
+ /** 没有单体状态纹理时用的占位(全 0 = 不隐藏、不平移) */
106
+ const DUMMY_STATE_TEX = (() => {
107
+ const tex = new THREE.DataTexture(new Float32Array([-1, -1, -1, 0, 0, 0, 0, 0]), 1, 2, THREE.RGBAFormat, THREE.FloatType);
108
+ tex.minFilter = THREE.NearestFilter;
109
+ tex.magFilter = THREE.NearestFilter;
110
+ tex.needsUpdate = true;
111
+ return tex;
112
+ })();
113
+
114
+ function createPickMaterial(objectId: number): THREE.ShaderMaterial {
115
+ return new THREE.ShaderMaterial({
116
+ uniforms: {
117
+ uObjectId: { value: objectId },
118
+ uBatchTex: { value: DUMMY_STATE_TEX },
119
+ uBatchTexWidth: { value: 1 },
120
+ uBatchTexActive: { value: 0 },
121
+ uSelBatch: { value: -1 },
122
+ uSelWhole: { value: 0 },
123
+ uSelElevation: { value: 0 },
124
+ },
125
+ vertexShader: PICK_VERT,
126
+ fragmentShader: PICK_FRAG,
127
+ side: THREE.DoubleSide,
128
+ depthTest: true,
129
+ depthWrite: true,
130
+ toneMapped: false,
131
+ });
132
+ }
133
+
134
+ export class Picker {
135
+ readonly renderer: THREE.WebGLRenderer;
136
+ /** 拾取用的渲染目标边长(像素),默认 1 —— 只要点击点那一格 */
137
+ readonly size: number;
138
+ private target: THREE.WebGLRenderTarget | null = null;
139
+ private readonly buf = new Uint8Array(4);
140
+
141
+ constructor(renderer: THREE.WebGLRenderer, options: { size?: number } = {}) {
142
+ this.renderer = renderer;
143
+ this.size = Math.max(1, Math.floor(options.size ?? 1));
144
+ }
145
+
146
+ private _ensureTarget(size: number): THREE.WebGLRenderTarget {
147
+ if (this.target && this.target.width === size && this.target.height === size) return this.target;
148
+ this.target?.dispose();
149
+ this.target = new THREE.WebGLRenderTarget(size, size, {
150
+ minFilter: THREE.NearestFilter,
151
+ magFilter: THREE.NearestFilter,
152
+ format: THREE.RGBAFormat,
153
+ type: THREE.UnsignedByteType,
154
+ depthBuffer: true,
155
+ stencilBuffer: false,
156
+ });
157
+ // 关掉色彩空间转换,否则 ACES / sRGB 会把编码好的 ID 洗掉
158
+ this.target.texture.colorSpace = THREE.LinearSRGBColorSpace;
159
+ return this.target;
160
+ }
161
+
162
+ /**
163
+ * @param clientX 相对 canvas 左上角的 CSS 像素
164
+ * @param clientY 同上
165
+ */
166
+ pick(scene: THREE.Scene, camera: THREE.Camera, clientX: number, clientY: number): PickHit | null {
167
+ const renderer = this.renderer;
168
+ const canvas = renderer.domElement;
169
+ const w = canvas.width;
170
+ const h = canvas.height;
171
+ if (!w || !h) return null;
172
+
173
+ const px = Math.floor((clientX / canvas.clientWidth) * w);
174
+ const py = Math.floor((clientY / canvas.clientHeight) * h);
175
+ if (px < 0 || py < 0 || px >= w || py >= h) return null;
176
+
177
+ const target = this._ensureTarget(this.size);
178
+
179
+ // 点击点落在整块画布的第几行(WebGL 的 y 轴朝上)
180
+ const ygl = h - 1 - py;
181
+ // 目标里我们读的那一格(size 为偶数时取偏左上那格)
182
+ const cx = Math.floor(this.size / 2);
183
+ const cy = Math.floor(this.size / 2);
184
+ // viewport 尺寸仍是整块画布、原点平移,把 (px, ygl) 挪到目标的 (cx, cy)
185
+ target.viewport.set(cx - px, cy - ygl, w, h);
186
+
187
+ // ---- 保存渲染器状态
188
+ const prevTarget = renderer.getRenderTarget();
189
+ const prevViewport = renderer.getViewport(new THREE.Vector4());
190
+ const prevTone = renderer.toneMapping;
191
+ const prevColorSpace = renderer.outputColorSpace;
192
+ const prevAutoClear = renderer.autoClear;
193
+ const prevClearColor = renderer.getClearColor(new THREE.Color());
194
+ const prevClearAlpha = renderer.getClearAlpha();
195
+
196
+ // ---- 换材质
197
+ const swapped: Array<[THREE.Mesh, THREE.Material | THREE.Material[]]> = [];
198
+ /** 这次拾取里哪些网格是实例化的(它们的 vBatch 编码的是实例序号) */
199
+ const instancedPickIds = new Set<number>();
200
+ scene.traverseVisible((o) => {
201
+ const mesh = o as THREE.Mesh;
202
+ if (!mesh.isMesh) return;
203
+ const ud = mesh.userData as PickUserData;
204
+ if (ud.pickId === undefined) return;
205
+ if (!ud.pickMaterial) ud.pickMaterial = createPickMaterial(ud.pickId);
206
+ if ((mesh as THREE.InstancedMesh).isInstancedMesh) instancedPickIds.add(ud.pickId);
207
+
208
+ // 把当前的「单体状态纹理」抄给拾取材质:抬高量是逐单体写在里面的,
209
+ // 抄的是同一个纹理对象,主材质那边更新后这里自动跟着变。
210
+ const prev = mesh.material;
211
+ const pm = ud.pickMaterial as THREE.ShaderMaterial;
212
+ const src = (Array.isArray(prev) ? prev[0] : prev) as THREE.Material & {
213
+ userData?: Record<string, THREE.IUniform>;
214
+ };
215
+ const batchTex = src?.userData?.uBatchTex;
216
+ if (batchTex) {
217
+ pm.uniforms.uBatchTex.value = batchTex.value;
218
+ pm.uniforms.uBatchTexWidth.value = (src?.userData?.uBatchTexWidth?.value as number) ?? 1;
219
+ pm.uniforms.uBatchTexActive.value = (src?.userData?.uBatchTexActive?.value as number) ?? 0;
220
+ } else {
221
+ pm.uniforms.uBatchTex.value = DUMMY_STATE_TEX;
222
+ pm.uniforms.uBatchTexWidth.value = 1;
223
+ pm.uniforms.uBatchTexActive.value = 0;
224
+ }
225
+ // 选中那栋自身的拔高也要算,否则选中后再点它会点空
226
+ pm.uniforms.uSelBatch.value = (src?.userData?.uSelBatch?.value as number) ?? -1;
227
+ pm.uniforms.uSelWhole.value = (src?.userData?.uSelWhole?.value as number) ?? 0;
228
+ // uSelElevation 是共享 uniform,挂在材质 uniforms 上而不是 userData
229
+ pm.uniforms.uSelElevation.value =
230
+ ((src as THREE.ShaderMaterial | undefined)?.uniforms?.uSelElevation?.value as number) ?? 0;
231
+
232
+ swapped.push([mesh, prev]);
233
+ mesh.material = pm;
234
+ });
235
+ // 描边 / 棱线不参与拾取
236
+ const hidden: THREE.Object3D[] = [];
237
+ scene.traverseVisible((o) => {
238
+ const mesh = o as THREE.Mesh;
239
+ if (mesh.isMesh && (mesh.userData as PickUserData).pickId === undefined) {
240
+ hidden.push(mesh);
241
+ mesh.visible = false;
242
+ }
243
+ });
244
+
245
+ let hit: PickHit | null = null;
246
+ try {
247
+ // 关键:拾取是在「正常渲染帧」之外另开的一趟渲染,
248
+ // MapLibre / Mapbox 在上一次 draw 之后可能已经改过 GL 状态(深度测试、scissor 等),
249
+ // 而 three 的状态缓存还停留在自己的上一次 render。
250
+ // 不重置就会出现深度测试失效 → 命中的是「最后画的」而不是「最前面的」。
251
+ renderer.resetState();
252
+
253
+ renderer.setRenderTarget(target);
254
+ renderer.toneMapping = THREE.NoToneMapping;
255
+ renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
256
+ renderer.autoClear = true;
257
+ renderer.setClearColor(0x000000, 0);
258
+ renderer.clear();
259
+ renderer.render(scene, camera);
260
+
261
+ renderer.readRenderTargetPixels(target, cx, cy, 1, 1, this.buf);
262
+
263
+ const r = this.buf[0];
264
+ const g = this.buf[1];
265
+ const b = this.buf[2];
266
+ const a = this.buf[3];
267
+ const objectId = r + g * 256 - 1;
268
+ const rawId = b + a * 256 - 1;
269
+ if (objectId >= 0) {
270
+ hit = instancedPickIds.has(objectId)
271
+ ? { objectId, batchId: -1, instanceIndex: Math.max(0, rawId) }
272
+ : { objectId, batchId: rawId };
273
+ }
274
+ } finally {
275
+ for (const [mesh, mat] of swapped) mesh.material = mat;
276
+ for (const o of hidden) o.visible = true;
277
+ renderer.setRenderTarget(prevTarget);
278
+ renderer.setViewport(prevViewport);
279
+ renderer.toneMapping = prevTone;
280
+ renderer.outputColorSpace = prevColorSpace;
281
+ renderer.autoClear = prevAutoClear;
282
+ renderer.setClearColor(prevClearColor, prevClearAlpha);
283
+ renderer.resetState();
284
+ }
285
+
286
+ return hit;
287
+ }
288
+
289
+ dispose(): void {
290
+ this.target?.dispose();
291
+ this.target = null;
292
+ }
293
+ }
@@ -508,15 +508,18 @@ export class S3MViewer {
508
508
  }
509
509
 
510
510
  /**
511
- * 获取指定数据集的垂直高程偏移(米)。
512
- * 若不传 id,默认获取第一个(或默认)数据集的高程偏移。
511
+ * 获取指定数据集的应用层附加垂直偏移(米,不含 align 基准)。
512
+ * 若不传 id,默认获取第一个(或默认)数据集的偏移。
513
513
  */
514
514
  getDatasetHeight(id?: string): number {
515
515
  return this._datasetManager.getDatasetHeight(id);
516
516
  }
517
517
 
518
518
  /**
519
- * 设置指定数据集的垂直高程偏移(米),正数抬升,负数下降。
519
+ * 设置指定数据集的应用层附加垂直偏移(米),正数抬升,负数下降。
520
+ *
521
+ * 语义为「在当前 align 基准之上再抬升多少米」:贴地(把模型抬到真实地面高程)
522
+ * 不会破坏 align='top' 等对齐语义。
520
523
  */
521
524
  setDatasetHeight(idOrHeight: string | number, heightOffset?: number): void {
522
525
  let id: string;
@@ -529,10 +532,22 @@ export class S3MViewer {
529
532
  h = heightOffset ?? 0;
530
533
  }
531
534
  this._datasetManager.setDatasetHeight(id, h, this.geo);
535
+ this._syncGroundHeight();
532
536
  this._refreshAllHeights();
533
537
  this._requestRepaint();
534
538
  }
535
539
 
540
+ /**
541
+ * 物理地表网格的高度跟随「主数据集的应用层抬升量」,
542
+ * 保证贴地后地表覆盖 / 地下透视的物理关系与被抬升的模型一致。
543
+ */
544
+ private _syncGroundHeight(): void {
545
+ const primaryId = this.datasets.keys().next().value;
546
+ if (!primaryId) return;
547
+ const userOffset = this._datasetManager.getDatasetHeight(primaryId);
548
+ this.groundManager.setGroundHeight(userOffset);
549
+ }
550
+
536
551
  /**
537
552
  * 动态设置指定数据集的垂直对齐基准('origin' | 'bottom' | 'top' | 'center')。
538
553
  */