s3m-three 0.4.23 → 0.4.25

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 (67) hide show
  1. package/dist/analysis/clipping.d.ts +63 -0
  2. package/dist/analysis/clipping.d.ts.map +1 -0
  3. package/dist/analysis/clipping.js +69 -0
  4. package/dist/analysis/clipping.js.map +1 -0
  5. package/dist/analysis/depth.d.ts +55 -0
  6. package/dist/analysis/depth.d.ts.map +1 -0
  7. package/dist/analysis/depth.js +99 -0
  8. package/dist/analysis/depth.js.map +1 -0
  9. package/dist/analysis/index.d.ts +21 -0
  10. package/dist/analysis/index.d.ts.map +1 -0
  11. package/dist/analysis/index.js +17 -0
  12. package/dist/analysis/index.js.map +1 -0
  13. package/dist/analysis/lineOfSight.d.ts +60 -0
  14. package/dist/analysis/lineOfSight.d.ts.map +1 -0
  15. package/dist/analysis/lineOfSight.js +56 -0
  16. package/dist/analysis/lineOfSight.js.map +1 -0
  17. package/dist/analysis/measure.d.ts +56 -0
  18. package/dist/analysis/measure.d.ts.map +1 -0
  19. package/dist/analysis/measure.js +95 -0
  20. package/dist/analysis/measure.js.map +1 -0
  21. package/dist/analysis/skyline.d.ts +55 -0
  22. package/dist/analysis/skyline.d.ts.map +1 -0
  23. package/dist/analysis/skyline.js +78 -0
  24. package/dist/analysis/skyline.js.map +1 -0
  25. package/dist/analysis/viewshed.d.ts +48 -0
  26. package/dist/analysis/viewshed.d.ts.map +1 -0
  27. package/dist/analysis/viewshed.js +115 -0
  28. package/dist/analysis/viewshed.js.map +1 -0
  29. package/dist/browser/assets/{s3mWorker-DvAUA8NJ.js → s3mWorker-jc9Oly5K.js} +1 -1
  30. package/dist/browser/s3m-three.iife.ext-three.js +21 -4
  31. package/dist/browser/s3m-three.iife.js +21 -4
  32. package/dist/core/DXTTextureDecode.d.ts +5 -2
  33. package/dist/core/DXTTextureDecode.d.ts.map +1 -1
  34. package/dist/core/DXTTextureDecode.js +17 -7
  35. package/dist/core/DXTTextureDecode.js.map +1 -1
  36. package/dist/index.d.ts +3 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +3 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/render/S3MViewer.d.ts +106 -0
  41. package/dist/render/S3MViewer.d.ts.map +1 -1
  42. package/dist/render/S3MViewer.js +174 -1
  43. package/dist/render/S3MViewer.js.map +1 -1
  44. package/dist/render/analysis/DepthPass.d.ts +78 -0
  45. package/dist/render/analysis/DepthPass.d.ts.map +1 -0
  46. package/dist/render/analysis/DepthPass.js +209 -0
  47. package/dist/render/analysis/DepthPass.js.map +1 -0
  48. package/dist/render/viewer/PickService.d.ts +17 -0
  49. package/dist/render/viewer/PickService.d.ts.map +1 -1
  50. package/dist/render/viewer/PickService.js +42 -1
  51. package/dist/render/viewer/PickService.js.map +1 -1
  52. package/dist/worker/s3mWorker.js +1 -1
  53. package/dist/worker/s3mWorker.js.map +1 -1
  54. package/docs/API.md +100 -0
  55. package/package.json +1 -1
  56. package/src/analysis/clipping.ts +135 -0
  57. package/src/analysis/depth.ts +126 -0
  58. package/src/analysis/index.ts +47 -0
  59. package/src/analysis/lineOfSight.ts +110 -0
  60. package/src/analysis/measure.ts +119 -0
  61. package/src/analysis/skyline.ts +125 -0
  62. package/src/analysis/viewshed.ts +160 -0
  63. package/src/core/DXTTextureDecode.ts +17 -7
  64. package/src/index.ts +5 -0
  65. package/src/render/S3MViewer.ts +233 -2
  66. package/src/render/analysis/DepthPass.ts +278 -0
  67. package/src/render/viewer/PickService.ts +48 -1
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 天际线:从一张「从相机看出去」的深度图,按列抽出天际线曲线。
3
+ *
4
+ * 为什么是「按列」:天际线在屏幕上就是每列最上面那个几何像素的轮廓。
5
+ * 所以不需要额外的几何体 —— 一次深度 pass + 一次逐列扫描就够了,
6
+ * 结果直接是一串(方位角,仰角),画成 2D 曲线比生成 3D 多边形便宜得多。
7
+ *
8
+ * 只做数学,不碰 WebGL:深度图由 `render/analysis/DepthPass` 产出。
9
+ */
10
+ import { azimuthElevation, pixelToNdc, unpackRGBADepth, unprojectNdc } from './depth.js';
11
+ import type { Vec3 } from './measure.js';
12
+
13
+ export interface SkylineArgs {
14
+ /** RGBA8 深度图(three 的 RGBADepthPacking) */
15
+ rgba: ArrayLike<number>;
16
+ width: number;
17
+ height: number;
18
+ /** 视图投影矩阵的逆(列主序,与 `THREE.Matrix4.elements` 同序) */
19
+ inverseViewProj: ArrayLike<number>;
20
+ /** 相机在场景 ENU 里的位置 */
21
+ cameraPos: Vec3;
22
+ /**
23
+ * 背景判定阈值:深度 >= 它的像素视为天空(默认 0.9999)。
24
+ * 用 0.9999 而不是 1:远处地平线可能因为精度落在 0.99999 上。
25
+ */
26
+ skyDepth?: number;
27
+ /** 每隔多少列取一次(默认 1,即逐列) */
28
+ step?: number;
29
+ }
30
+
31
+ export interface SkylineResult {
32
+ /** 方位角(度,0 = 正北,顺时针),长度 = ceil(width / step) */
33
+ azimuths: Float32Array;
34
+ /** 对应列的仰角(度,0 = 水平) */
35
+ elevations: Float32Array;
36
+ /** 整条天际线的最大仰角(度) */
37
+ maxElevation: number;
38
+ /** 最小仰角(度);全是天空时为 0 */
39
+ minElevation: number;
40
+ /** 没有几何、直通天空的列占比(0~1)—— 开敞度的最粗口径 */
41
+ openRatio: number;
42
+ /**
43
+ * 每列天际线在**屏幕上的位置**(归一化 0~1,左上角为原点),
44
+ * 布局是 `[x0, y0, x1, y1, …]`,长度 = `cols * 2`。
45
+ *
46
+ * 这是画「地图上的天际线」最直接的输入:深度图本来就是屏幕空间的东西,
47
+ * 换算成角度只是为了给人看,换算回去还会多一次投影误差。
48
+ * 归一化是为了让调用方按自己的画布尺寸缩放(捕获分辨率可以比画布小)。
49
+ */
50
+ screen: Float32Array;
51
+ /**
52
+ * 每列是否有几何(1 = 有,0 = 纯天空)。
53
+ *
54
+ * 画线时要在这里断开:把纯天空的列也连进去,线会横穿天空,
55
+ * 看起来像天际线突然掉到画面底部。
56
+ */
57
+ hasGeometry: Uint8Array;
58
+ }
59
+
60
+ /**
61
+ * 逐列取「最上面的几何像素」,反投影成世界坐标后换算成仰角,
62
+ * 同时记下该像素的归一化屏幕位置(给「在地图上描线」用)。
63
+ *
64
+ * 没有几何的列记 0°(地平线)并计入 `openRatio`,而不是记 -90°:
65
+ * 天际线曲线在地平线处收口,画图时不会突然掉到画面底部。
66
+ */
67
+ export function skylineFromDepth(args: SkylineArgs): SkylineResult {
68
+ const { rgba, width, height, inverseViewProj, cameraPos } = args;
69
+ const skyDepth = args.skyDepth ?? 0.9999;
70
+ const step = Math.max(1, Math.floor(args.step ?? 1));
71
+ const cols = Math.ceil(width / step);
72
+
73
+ const azimuths = new Float32Array(cols);
74
+ const elevations = new Float32Array(cols);
75
+ const screen = new Float32Array(cols * 2);
76
+ const hasGeometry = new Uint8Array(cols);
77
+ let maxElevation = -90;
78
+ let minElevation = 90;
79
+ let open = 0;
80
+
81
+ for (let c = 0; c < cols; c++) {
82
+ const x = Math.min(width - 1, c * step);
83
+ let found = false;
84
+ for (let y = 0; y < height; y++) {
85
+ const off = (y * width + x) * 4;
86
+ const depth = unpackRGBADepth(rgba, off);
87
+ if (depth >= skyDepth) continue;
88
+
89
+ const [ndcX, ndcY] = pixelToNdc(x, y, width, height);
90
+ const p = unprojectNdc(ndcX, ndcY, depth, inverseViewProj);
91
+ const { azimuth, elevation } = azimuthElevation(cameraPos, p);
92
+ azimuths[c] = azimuth;
93
+ elevations[c] = elevation;
94
+ // 像素中心归一化:与捕获图同尺度,调用方乘自己的画布尺寸即可
95
+ screen[c * 2] = (x + 0.5) / width;
96
+ screen[c * 2 + 1] = (y + 0.5) / height;
97
+ hasGeometry[c] = 1;
98
+ if (elevation > maxElevation) maxElevation = elevation;
99
+ if (elevation < minElevation) minElevation = elevation;
100
+ found = true;
101
+ break;
102
+ }
103
+ if (!found) {
104
+ // 这一列没有几何:天际线落在水平线上
105
+ open++;
106
+ azimuths[c] = 0;
107
+ elevations[c] = 0;
108
+ screen[c * 2] = (x + 0.5) / width;
109
+ screen[c * 2 + 1] = 1; // 贴到画面底部,画线时按 hasGeometry 断开
110
+ hasGeometry[c] = 0;
111
+ if (0 > maxElevation) maxElevation = 0;
112
+ if (0 < minElevation) minElevation = 0;
113
+ }
114
+ }
115
+
116
+ return {
117
+ azimuths,
118
+ elevations,
119
+ maxElevation,
120
+ minElevation,
121
+ openRatio: cols > 0 ? open / cols : 0,
122
+ screen,
123
+ hasGeometry,
124
+ };
125
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * 360° 可视域:地面栅格版。
3
+ *
4
+ * 思路与「从观察点做阴影贴图」一致,但**判定在 CPU 上做**:
5
+ * GPU 只负责产出一个 `方向 → 最近遮挡距离` 的采样函数(6 面立方体深度图),
6
+ * 这里拿它在观察点周围的地面网格上逐格比较距离。
7
+ *
8
+ * 为什么不在片元里逐像素判定(建筑立面着色版):
9
+ * 那要把立方体深度注入到每种材质(pbr / lambert / 棱线 / 点云 / 泼溅)的 shader 里,
10
+ * 成本和风险高一个量级;地面栅格版能覆盖绝大多数规划类需求,且结果可以直接
11
+ * 当成一张贴图/热力图叠在地面上。
12
+ *
13
+ * 纯数学,不碰 WebGL:采样函数由 `render/analysis/DepthPass` 提供,单测里用假的。
14
+ */
15
+ import { unpackRGBADepth } from './depth.js';
16
+ import type { Vec3 } from './measure.js';
17
+
18
+ export interface ViewshedOptions {
19
+ /** 分析半径(米) */
20
+ radius: number;
21
+ /** 栅格边长(格数,默认 96;96² = 9216 个采样点,CPU 侧约 1~3 ms) */
22
+ grid?: number;
23
+ /**
24
+ * 遮挡容差(米,默认 0.5)。
25
+ *
26
+ * 采样点到观察点的距离要**小于等于**该方向上的遮挡距离才算可见;
27
+ * 留一点容差是为了吸收深度图的离散化误差与地面自身的起伏。
28
+ */
29
+ bias?: number;
30
+ /** 地面高程(场景 ENU 的 z,默认 0) */
31
+ groundZ?: number;
32
+ }
33
+
34
+ export interface ViewshedResult {
35
+ /**
36
+ * `grid × grid` 的可见性,行优先(`y * grid + x`):
37
+ * `1` = 可见,`0` = 被遮挡,`-1` = 超出半径或没有数据。
38
+ */
39
+ raster: Float32Array;
40
+ grid: number;
41
+ /** 栅格左下角(场景 ENU 米) */
42
+ minX: number;
43
+ minY: number;
44
+ /** 每格边长(米) */
45
+ cellSize: number;
46
+ /** 有效格子里可见的比例(0~1) */
47
+ visibleRatio: number;
48
+ }
49
+
50
+ /**
51
+ * @param origin 观察点(场景 ENU 米,已含眼高)
52
+ * @param sampleDistance 沿单位方向取「最近的遮挡距离」;该方向没有几何时返回 `Infinity`
53
+ */
54
+ export function computeViewshed(
55
+ origin: Vec3,
56
+ sampleDistance: (dir: Vec3) => number,
57
+ opts: ViewshedOptions
58
+ ): ViewshedResult {
59
+ const radius = Math.max(1e-3, opts.radius);
60
+ const grid = Math.max(2, Math.floor(opts.grid ?? 96));
61
+ const bias = Math.max(0, opts.bias ?? 0.5);
62
+ const groundZ = opts.groundZ ?? 0;
63
+
64
+ const cellSize = (radius * 2) / grid;
65
+ const minX = origin[0] - radius;
66
+ const minY = origin[1] - radius;
67
+ const raster = new Float32Array(grid * grid);
68
+
69
+ let visible = 0;
70
+ let counted = 0;
71
+
72
+ for (let iy = 0; iy < grid; iy++) {
73
+ const wy = minY + (iy + 0.5) * cellSize;
74
+ for (let ix = 0; ix < grid; ix++) {
75
+ const wx = minX + (ix + 0.5) * cellSize;
76
+ const dx = wx - origin[0];
77
+ const dy = wy - origin[1];
78
+ const dz = groundZ - origin[2];
79
+ const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
80
+ const idx = iy * grid + ix;
81
+
82
+ if (dist > radius || dist < 1e-6) {
83
+ raster[idx] = -1;
84
+ continue;
85
+ }
86
+
87
+ const dir: Vec3 = [dx / dist, dy / dist, dz / dist];
88
+ const blockedAt = sampleDistance(dir);
89
+ const ok = dist <= blockedAt + bias;
90
+ raster[idx] = ok ? 1 : 0;
91
+ counted++;
92
+ if (ok) visible++;
93
+ }
94
+ }
95
+
96
+ return {
97
+ raster,
98
+ grid,
99
+ minX,
100
+ minY,
101
+ cellSize,
102
+ visibleRatio: counted > 0 ? visible / counted : 0,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * 从 6 面立方体深度图构造 `sampleDistance`。
108
+ *
109
+ * 立方体每个面是 `size × size` 的 RGBA8,存的是 `距离 / maxDist` 的打包值
110
+ * (打包方式同 three 的 `packDepthToRGBA`,与 `RGBADepthPacking` 一致)。
111
+ * 采样用最近邻 —— 可视域本身是栅格化的,插值不会让结果更准。
112
+ *
113
+ * 面序与 three 的 `WebGLCubeRenderTarget` / `CubeCamera` 一致:
114
+ * `+x, -x, +y, -y, +z, -z`。
115
+ */
116
+ export function makeCubeDistanceSampler(
117
+ faces: readonly (ArrayLike<number> | null | undefined)[],
118
+ size: number,
119
+ maxDist: number
120
+ ): (dir: Vec3) => number {
121
+ return (dir) => {
122
+ const [x, y, z] = dir;
123
+ const ax = Math.abs(x);
124
+ const ay = Math.abs(y);
125
+ const az = Math.abs(z);
126
+
127
+ // 主轴决定落在哪个面:|x| 最大 → ±x 面,依此类推
128
+ let faceIndex: number;
129
+ let u: number;
130
+ let v: number;
131
+ if (ax >= ay && ax >= az) {
132
+ faceIndex = x >= 0 ? 0 : 1;
133
+ const d = ax;
134
+ // three 的立方体面约定:+x 面 u = -z/d, v = -y/d
135
+ u = x >= 0 ? -z / d : z / d;
136
+ v = -y / d;
137
+ } else if (ay >= az) {
138
+ faceIndex = y >= 0 ? 2 : 3;
139
+ const d = ay;
140
+ u = x / d;
141
+ v = y >= 0 ? z / d : -z / d;
142
+ } else {
143
+ faceIndex = z >= 0 ? 4 : 5;
144
+ const d = az;
145
+ // 面朝向相反时 u 轴也翻向,否则 -z 面会左右镜像(可视域会镜像到错误一侧)
146
+ u = z >= 0 ? x / d : -x / d;
147
+ v = -y / d;
148
+ }
149
+
150
+ const face = faces[faceIndex];
151
+ if (!face) return Infinity;
152
+ const px = Math.min(size - 1, Math.max(0, Math.floor(((u + 1) / 2) * size)));
153
+ const py = Math.min(size - 1, Math.max(0, Math.floor(((v + 1) / 2) * size)));
154
+ const d = unpackRGBADepth(face, (py * size + px) * 4);
155
+ // 打包值是归一化距离:1 = 远平面(该方向没有几何)。three 的 pack 对 v>=1
156
+ // 直接返回全白,解包正好是 1.0,所以阈值取 0.9999 就够。
157
+ if (d >= 0.9999) return Infinity;
158
+ return d * maxDist;
159
+ };
160
+ }
@@ -2,10 +2,13 @@
2
2
  * DXT1 / DXT3 / DXT5 纹理解码(squish 算法的 JS 移植)
3
3
  *
4
4
  * 来源:SuperMap iClient3D S3M SDK(Apache-2.0)。
5
- * 迁移到 TS 时做了两件事,算法本身未改:
5
+ * 迁移到 TS 时做了三件事,算法本身未改:
6
6
  * 1. 补齐类型;
7
7
  * 2. 修掉 `DecompressAlphaDxt3` 里把 `block` 误写成未定义全局 `bytes` 的笔误
8
- * (DXT3 纹理此前会直接 ReferenceError)。
8
+ * (DXT3 纹理此前会直接 ReferenceError);
9
+ * 3. 修掉 `DecompressImage565` 的两处 flipY 笔误(见下方函数内注释:
10
+ * 块行下标少减 1 让 format ≤ 11 的 DXT1 顶部块行整行变黑;块内 4 行
11
+ * 没倒着写,导致行序和 `DecompressImage` 主路径对不上)。
9
12
  */
10
13
 
11
14
  const kDxt1 = 1 << 0;
@@ -158,7 +161,13 @@ function DecompressImage565(rgb565: Uint16Array, width: number, height: number,
158
161
  const blockHeight = height / 4;
159
162
  for (let blockY = 0; blockY < blockHeight; blockY++) {
160
163
  for (let blockX = 0; blockX < blockWidth; blockX++) {
161
- i = 4 * ((blockHeight - blockY) * blockWidth + blockX);
164
+ // flipY 分两级做,才和 DecompressImage 的 height - 1 - sy 真正同口径:
165
+ // ① 块行倒着取(blockHeight - 1 - blockY);
166
+ // ② 块内 4 行也倒着写(下面 dstI 从块底行起、逐行 -= width)。
167
+ // 只做 ① 会变成「块带倒序、带内行正序」的错位图,和主路径对不上。
168
+ // 写成 blockHeight - blockY 则会让 blockY=0 读到数组界外(Uint16Array 越界读
169
+ // 得 undefined,位运算后归 0),顶部块行解成全黑、其余块行整体错位一行。
170
+ i = 4 * ((blockHeight - 1 - blockY) * blockWidth + blockX);
162
171
  c[0] = blocks[i];
163
172
  c[1] = blocks[i + 1];
164
173
  r0 = c[0] & 0x1f;
@@ -172,23 +181,24 @@ function DecompressImage565(rgb565: Uint16Array, width: number, height: number,
172
181
  c[2] = ((5 * r0 + 3 * r1) >> 3) | (((5 * g0 + 3 * g1) >> 3) & 0x7e0) | (((5 * b0 + 3 * b1) >> 3) & 0xf800);
173
182
  c[3] = ((5 * r1 + 3 * r0) >> 3) | (((5 * g1 + 3 * g0) >> 3) & 0x7e0) | (((5 * b1 + 3 * b0) >> 3) & 0xf800);
174
183
  m = blocks[i + 2];
175
- dstI = blockY * 4 * width + blockX * 4;
184
+ // 块内第 0 行写到块底行,逐行往上走 —— 逐行 flipY
185
+ dstI = (blockY * 4 + 3) * width + blockX * 4;
176
186
  dst[dstI] = c[m & 0x3];
177
187
  dst[dstI + 1] = c[(m >> 2) & 0x3];
178
188
  dst[dstI + 2] = c[(m >> 4) & 0x3];
179
189
  dst[dstI + 3] = c[(m >> 6) & 0x3];
180
- dstI += width;
190
+ dstI -= width;
181
191
  dst[dstI] = c[(m >> 8) & 0x3];
182
192
  dst[dstI + 1] = c[(m >> 10) & 0x3];
183
193
  dst[dstI + 2] = c[(m >> 12) & 0x3];
184
194
  dst[dstI + 3] = c[(m >> 14) & 0x3];
185
195
  m = blocks[i + 3];
186
- dstI += width;
196
+ dstI -= width;
187
197
  dst[dstI] = c[m & 0x3];
188
198
  dst[dstI + 1] = c[(m >> 2) & 0x3];
189
199
  dst[dstI + 2] = c[(m >> 4) & 0x3];
190
200
  dst[dstI + 3] = c[(m >> 6) & 0x3];
191
- dstI += width;
201
+ dstI -= width;
192
202
  dst[dstI] = c[(m >> 8) & 0x3];
193
203
  dst[dstI + 1] = c[(m >> 10) & 0x3];
194
204
  dst[dstI + 2] = c[(m >> 12) & 0x3];
package/src/index.ts CHANGED
@@ -138,6 +138,11 @@ export { setMaterialQuality, getMaterialQuality } from './render/StyleMaterial.j
138
138
 
139
139
  export { Picker } from './render/Picker.js';
140
140
  export { GpuEdgePass } from './render/GpuEdgePass.js';
141
+
142
+ /* 三维空间分析内核:纯数学,不依赖 WebGL,可单独用于服务端 / Worker */
143
+ export * from './analysis/index.js';
144
+ export { captureSkylineDepth, captureRadialCube, buildCubeCameras, CUBE_FACES } from './render/analysis/DepthPass.js';
145
+ export type { DepthCaptureContext, SkylineCapture } from './render/analysis/DepthPass.js';
141
146
  export type { GpuEdgeParams } from './render/GpuEdgePass.js';
142
147
  export type { PickHit } from './render/Picker.js';
143
148
 
@@ -70,7 +70,30 @@ import type { DegradationKind, UnsupportedPolicy } from '../core/degradations.js
70
70
  import { Picker } from './Picker.js';
71
71
  import { GpuEdgePass } from './GpuEdgePass.js';
72
72
  import { Animator } from '../anim/Animator.js';
73
- import type { Channel, TransitionOptions } from '../anim/Animator.js';
73
+ import {
74
+ buildClipPlanes,
75
+ lineOfSight as computeLineOfSight,
76
+ measureSegment,
77
+ polygonArea,
78
+ polygonAreaHorizontal,
79
+ polygonPerimeter,
80
+ polylineLength,
81
+ polylineLengthHorizontal,
82
+ } from '../analysis/index.js';
83
+ import type {
84
+ ClipOptions,
85
+ LineOfSightOptions,
86
+ LineOfSightResult,
87
+ LngLatAlt,
88
+ MeasureResult,
89
+ SkylineResult,
90
+ Vec3,
91
+ ViewshedResult,
92
+ } from '../analysis/index.js';
93
+ import { skylineFromDepth } from '../analysis/skyline.js';
94
+ import { computeViewshed, makeCubeDistanceSampler } from '../analysis/viewshed.js';
95
+ import { captureRadialCube, captureSkylineDepth } from './analysis/DepthPass.js';
96
+ import type { DepthCaptureContext } from './analysis/DepthPass.js';import type { Channel, TransitionOptions } from '../anim/Animator.js';
74
97
  import { Path } from '../anim/Path.js';
75
98
  import type { EasingFn, EasingName } from '../anim/easing.js';
76
99
  import type { LineSharedUniforms, ThickLineMaterial } from './EdgeLines.js';
@@ -88,7 +111,7 @@ import type {
88
111
  StyleUniforms,
89
112
  } from '../core/types.js';
90
113
 
91
- import { lngLatFromWorld, type GeoAdapter } from './GeoAdapter.js';
114
+ import { lngLatFromWorld, worldFromLngLat, type GeoAdapter } from './GeoAdapter.js';
92
115
 
93
116
  import {
94
117
  DatasetManager,
@@ -282,6 +305,8 @@ export class S3MViewer {
282
305
  private _colorPipeline: ColorPipeline | undefined;
283
306
  /** fade-through 编排的代次:中途再切一次,旧编排作废(与 setMode 同一套) */
284
307
  private _pipelineGen = 0;
308
+ /** 当前裁剪配置;renderer 就绪前后都要能设,所以单独存一份(见 `_applyClipping`) */
309
+ private _clip: ClipOptions | null = null;
285
310
  /** 单体样式、专题着色与状态纹理管理器 */
286
311
  private readonly _featureStyleManager: FeatureStyleManager;
287
312
  /** GPU 路径动画管理器 */
@@ -566,6 +591,8 @@ export class S3MViewer {
566
591
  this.renderer = this._createRenderer(canvas, gl);
567
592
  setMaxAnisotropy(this.renderer.capabilities.getMaxAnisotropy(), this.options.anisotropyCap ?? Infinity);
568
593
  this._syncTextureCapabilities(gl);
594
+ // 裁剪是构造期就能设的(还没 mount 时 `_applyClipping` 会先跳过),这里补挂一次
595
+ this._applyClipping();
569
596
 
570
597
  this.scene = new THREE.Scene();
571
598
  this.scene.add(this.groundManager.group);
@@ -608,6 +635,8 @@ export class S3MViewer {
608
635
  this.renderer = this._createRenderer(this.canvas, gl);
609
636
  setMaxAnisotropy(this.renderer.capabilities.getMaxAnisotropy(), this.options.anisotropyCap ?? Infinity);
610
637
  this._syncTextureCapabilities(gl);
638
+ // 新 renderer 的 clippingPlanes 是空的,裁剪状态要重新挂上去
639
+ this._applyClipping();
611
640
  this.picker?.dispose();
612
641
  this.picker = new Picker(this.renderer);
613
642
  // GPU 描边的 RT / 材质绑定在旧上下文上,整体丢弃,下一帧按需重建
@@ -2446,6 +2475,208 @@ export class S3MViewer {
2446
2475
  return this._picks.getFeatureBounds(smid);
2447
2476
  }
2448
2477
 
2478
+ /* -------------------------------------------------------------- 三维空间分析 */
2479
+
2480
+ /**
2481
+ * 经纬度 + 高程 → 场景 ENU 米制坐标。
2482
+ *
2483
+ * 场景锚点可能还没就绪(没加数据集),此时按世界原点处理 —— 量算 API 会因此
2484
+ * 给出「以原点为基准」的结果,但不会抛错。真实使用里锚点在 addDataset 之后必然存在。
2485
+ */
2486
+ private _sceneFromLngLat(p: LngLatAlt): Vec3 {
2487
+ const anchor = this.sceneAnchor;
2488
+ if (!anchor) return [0, 0, 0];
2489
+ const s = anchor.meterInMercator;
2490
+ const w = worldFromLngLat(this.geo, p.lng, p.lat, p.altitude);
2491
+ // 注意 y 取反:three 的 y 向上,墨卡托 y 向下(与 PickService.pickPosition 互逆)
2492
+ return [(w.x - anchor.x) / s, -(w.y - anchor.y) / s, p.altitude - (anchor.altitude ?? 0)];
2493
+ }
2494
+
2495
+ /**
2496
+ * 经纬度 + 绝对高程 → 场景 ENU 米制坐标。
2497
+ *
2498
+ * 外围要做「分析代理几何」(底图建筑挤出)、或者在 three 里画自定义标注时,
2499
+ * 都得先把经纬度落到这个坐标系里 —— 场景的 ENU 原点在 `sceneAnchor`,
2500
+ * 不是经纬度,所以必须走这个换算。
2501
+ */
2502
+ lngLatToPoint(p: LngLatAlt): Vec3 {
2503
+ return this._sceneFromLngLat(p);
2504
+ }
2505
+
2506
+ /** 场景 ENU 米制坐标 → 经纬度 + 绝对高程(`pointToLngLat` 的逆) */
2507
+ pointToLngLat(v: Vec3): LngLatAlt {
2508
+ return this._lngLatFromScene(v);
2509
+ }
2510
+
2511
+ /** 场景 ENU 米制坐标 → 经纬度 + 绝对高程 */
2512
+ private _lngLatFromScene(v: Vec3): LngLatAlt {
2513
+ const anchor = this.sceneAnchor;
2514
+ if (!anchor) return { lng: 0, lat: 0, altitude: v[2] };
2515
+ const s = anchor.meterInMercator;
2516
+ const [lng, lat] = lngLatFromWorld(this.geo, v[0] * s + anchor.x, -v[1] * s + anchor.y);
2517
+ return { lng, lat, altitude: v[2] + (anchor.altitude ?? 0) };
2518
+ }
2519
+
2520
+ /**
2521
+ * 两点量算:三维距离 / 水平距离 / 高差。
2522
+ *
2523
+ * 输入是经纬度 + 绝对高程(`pickPosition` 的返回值直接能用),
2524
+ * 内部换算到 ENU 米再算,所以不会受投影单位影响。
2525
+ */
2526
+ measureDistance(a: LngLatAlt, b: LngLatAlt): MeasureResult {
2527
+ return measureSegment(this._sceneFromLngLat(a), this._sceneFromLngLat(b));
2528
+ }
2529
+
2530
+ /** 折线长度(三维 + 水平投影两套口径) */
2531
+ measurePolyline(points: readonly LngLatAlt[]): { length3D: number; lengthHorizontal: number } {
2532
+ const scene = points.map((p) => this._sceneFromLngLat(p));
2533
+ return { length3D: polylineLength(scene), lengthHorizontal: polylineLengthHorizontal(scene) };
2534
+ }
2535
+
2536
+ /**
2537
+ * 多边形量算:坡面真实面积 / 水平投影面积 / 周长。
2538
+ *
2539
+ * 建筑面积、用地面积按 `areaHorizontal` 算;坡面覆盖面积按 `area3D`。
2540
+ */
2541
+ measurePolygon(points: readonly LngLatAlt[]): {
2542
+ area3D: number;
2543
+ areaHorizontal: number;
2544
+ perimeter: number;
2545
+ } {
2546
+ const scene = points.map((p) => this._sceneFromLngLat(p));
2547
+ return {
2548
+ area3D: polygonArea(scene),
2549
+ areaHorizontal: polygonAreaHorizontal(scene),
2550
+ perimeter: polygonPerimeter(scene),
2551
+ };
2552
+ }
2553
+
2554
+ /**
2555
+ * 通视分析:两点之间是否被已加载的 S3M 几何挡住。
2556
+ *
2557
+ * 走 CPU 射线求交(`Raycaster` 打全部 pickable mesh),几十万面量级是几十毫秒,
2558
+ * 适合点击式查询;要拖拽时逐帧刷新,得等深度图版本的批量采样。
2559
+ *
2560
+ * 注意范围:**只认 S3M 场景里的几何**。MapLibre 底图地形与 MVT 建筑不在这个场景里,
2561
+ * 它们不参与遮挡(见 `docs/API.md` 的「分析范围」)。
2562
+ */
2563
+ lineOfSight(a: LngLatAlt, b: LngLatAlt, opts: LineOfSightOptions = {}): LineOfSightResult {
2564
+ const from = this._sceneFromLngLat(a);
2565
+ const to = this._sceneFromLngLat(b);
2566
+ return computeLineOfSight(from, to, (origin, dir, maxDistance) => {
2567
+ const hit = this._picks.raycastDistance(origin, dir, maxDistance);
2568
+ if (!hit) return null;
2569
+ return { distance: hit.distance, point: hit.point };
2570
+ }, opts);
2571
+ }
2572
+
2573
+ /**
2574
+ * 裁剪 / 剖切(平面、Box、十字)。
2575
+ *
2576
+ * 平面列表挂在**本图层的 renderer** 上(每个 `S3MLayer` 一个 `WebGLRenderer`),
2577
+ * 所以天然是图层级:同页别的图层不受影响。传 `null` 取消裁剪。
2578
+ *
2579
+ * 已知边界:只做「切掉」,不做**封口**——剖开后看到的是空壳/背面;
2580
+ * 凹多边形裁剪需要多组半空间并集,three 的 `clippingPlanes` 表达不了(只能凸)。
2581
+ */
2582
+ setClipping(opt: ClipOptions | null): void {
2583
+ this._clip = opt;
2584
+ this._applyClipping();
2585
+ this._requestRepaint();
2586
+ }
2587
+
2588
+ /** 当前裁剪配置(`null` = 未裁剪) */
2589
+ get clipping(): ClipOptions | null {
2590
+ return this._clip;
2591
+ }
2592
+
2593
+ private _applyClipping(): void {
2594
+ const r = this.renderer;
2595
+ if (!r) return; // mount 前先记着,`_createRenderer` 之后会补一次
2596
+ if (!this._clip) {
2597
+ r.clippingPlanes = [];
2598
+ return;
2599
+ }
2600
+ r.clippingPlanes = buildClipPlanes(this._clip).map(
2601
+ (p) => new THREE.Plane(new THREE.Vector3(p.normal[0], p.normal[1], p.normal[2]), p.constant)
2602
+ );
2603
+ }
2604
+
2605
+ /** 分析用的离屏采集上下文(renderer / scene / camera 都可能在 mount 前为空) */
2606
+ private _depthCtx(): DepthCaptureContext {
2607
+ return {
2608
+ renderer: () => this.renderer,
2609
+ scene: () => this.scene,
2610
+ camera: () => this.camera,
2611
+ };
2612
+ }
2613
+
2614
+ /**
2615
+ * 天际线分析:用当前视角渲染一张深度图,按列抽出「方位角 → 仰角」曲线。
2616
+ *
2617
+ * 一次调用 = 一趟额外渲染 + 一次全图回读,**不要每帧调**。
2618
+ * 典型用法是相机停下来(`moveend`)后算一次。
2619
+ *
2620
+ * @param opts.width 采集分辨率(默认 640)。精度由它决定:
2621
+ * 640 宽 ≈ 0.56°/列,1280 宽 ≈ 0.28°/列。
2622
+ */
2623
+ skyline(opts: {
2624
+ width?: number;
2625
+ height?: number;
2626
+ step?: number;
2627
+ /** 额外参与遮挡的代理几何(底图建筑 / 地形网格),只在这一趟深度渲染里存在 */
2628
+ include?: readonly THREE.Object3D[];
2629
+ } = {}): SkylineResult | null {
2630
+ const cap = captureSkylineDepth(
2631
+ this._depthCtx(),
2632
+ opts.width ?? 640,
2633
+ opts.height ?? 360,
2634
+ opts.include
2635
+ );
2636
+ if (!cap) return null;
2637
+ return skylineFromDepth({
2638
+ rgba: cap.rgba,
2639
+ width: cap.width,
2640
+ height: cap.height,
2641
+ inverseViewProj: cap.inverseViewProj,
2642
+ cameraPos: cap.cameraPos,
2643
+ step: opts.step,
2644
+ });
2645
+ }
2646
+
2647
+ /**
2648
+ * 360° 可视域(地面栅格版):从观察点朝 6 个方向各渲染一次深度,
2649
+ * 再在 CPU 上对地面网格逐格判定可见性。
2650
+ *
2651
+ * 一次调用 = 6 趟额外渲染 + 6 次回读,成本明显高于天际线,
2652
+ * 只在点击/松手时算。返回的 `raster` 可以直接当贴图画在地面上。
2653
+ */
2654
+ viewshed(opts: {
2655
+ /** 观察点(经纬度 + 地面高程;眼高由 `eyeHeight` 另加) */
2656
+ origin: LngLatAlt;
2657
+ /** 分析半径(米) */
2658
+ radius: number;
2659
+ /** 栅格边长(默认 96) */
2660
+ grid?: number;
2661
+ /** 眼高(米,默认 1.7) */
2662
+ eyeHeight?: number;
2663
+ /** 立方体深度图分辨率(默认 128;越大边缘越准,回读越慢) */
2664
+ size?: number;
2665
+ /** 额外参与遮挡的代理几何(底图建筑 / 地形网格),只在这一趟深度渲染里存在 */
2666
+ include?: readonly THREE.Object3D[];
2667
+ }): ViewshedResult | null {
2668
+ const eye = opts.eyeHeight ?? 1.7;
2669
+ const origin = this._sceneFromLngLat({ ...opts.origin, altitude: opts.origin.altitude + eye });
2670
+ const size = opts.size ?? 128;
2671
+ const faces = captureRadialCube(this._depthCtx(), origin, size, opts.radius, opts.include);
2672
+ if (!faces) return null;
2673
+ return computeViewshed(origin, makeCubeDistanceSampler(faces, size, opts.radius), {
2674
+ radius: opts.radius,
2675
+ grid: opts.grid,
2676
+ groundZ: 0,
2677
+ });
2678
+ }
2679
+
2449
2680
  /** @internal 见 viewer/PickService.ts */
2450
2681
  _intersectMeshAtRay(mesh: THREE.Mesh, ray: THREE.Ray, instanceIndex?: number): THREE.Vector3 | null {
2451
2682
  return this._picks.intersectMeshAtRay(mesh, ray, instanceIndex);