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
package/docs/API.md CHANGED
@@ -410,6 +410,106 @@ getFeatureBounds(smid: number): [[number, number], [number, number]] | null
410
410
 
411
411
  拾取走 GPU:把「物体编号 + batchId」编码成 RGBA 渲染到 **1×1** 离屏目标再读那一个像素——viewport 仍是整块画布、原点平移到点击点,于是整幅画面按原样光栅化,只有点击点那一格落在目标上。代价与三角面数无关,且天然只命中最前面的可见面。
412
412
 
413
+ ### 三维空间分析(量算 / 通视 / 裁剪)
414
+
415
+ ```ts
416
+ measureDistance(a: LngLatAlt, b: LngLatAlt): { distance3D; distanceHorizontal; heightDiff }
417
+ measurePolyline(points: LngLatAlt[]): { length3D; lengthHorizontal }
418
+ measurePolygon(points: LngLatAlt[]): { area3D; areaHorizontal; perimeter }
419
+
420
+ lineOfSight(a: LngLatAlt, b: LngLatAlt, opts?): {
421
+ visible: boolean; distance: number; blockedDistance: number | null;
422
+ blockedPoint: [number, number, number] | null; visibleRatio: number;
423
+ }
424
+
425
+ setClipping(opt: ClipOptions | null): void // 平面 / Box / 十字
426
+ readonly clipping: ClipOptions | null
427
+
428
+ skyline(opts?: { width?; height?; step? }): SkylineResult | null
429
+ viewshed(opts: { origin: LngLatAlt; radius: number; grid?; eyeHeight?; size? }): ViewshedResult | null
430
+ ```
431
+
432
+ - 入参是**经纬度 + 绝对高程**,直接用 `pickPosition(x, y)` 的返回值;交互(取点、拖拽、标签、撤销)属于应用层,不进库。
433
+ - 纯数学部分(距离 / 面积 / 通视判定 / 半空间生成)导出在 `s3m-three` 根上,**不依赖 WebGL**,可以在 Worker 或服务端复用。
434
+ - 面积有两套口径:`area3D` 是坡面真实面积(Newell 法),`areaHorizontal` 是水平投影(鞋带公式)。建筑面积、用地面积按后者。
435
+ - `lineOfSight` 走 CPU 射线求交(`Raycaster` 打全部 pickable mesh),几十万面是几十毫秒量级,适合点击式查询;要拖拽时逐帧刷新得等深度图批量采样版本。
436
+ - 裁剪是**图层级**的(每个 `S3MLayer` 一个 `WebGLRenderer`,平面挂在它的 `clippingPlanes` 上),同页其它图层不受影响。传 `null` 取消。
437
+ - **两个已知边界**:① 只做「切掉」不做**封口**,剖开后看到的是空壳/背面;② 半空间求交只能表达凸区域,凹多边形裁剪需要多组并集,three 的 `clippingPlanes` 表达不了。
438
+ - **分析范围只包含 S3M 场景里的几何**:MapLibre 底图地形与 MVT 建筑不在这个场景里,不参与遮挡判断。
439
+
440
+ **天际线 / 360° 可视域**(P1)走的是「一次离屏深度 pass + CPU 归约」:
441
+
442
+ - `skyline()`:用当前视角渲染一张 `RGBADepthPacking` 深度图,按列取最上面的几何像素,
443
+ 反投影成(方位角, 仰角)。一次调用 = 一趟额外渲染 + 一次全图回读,**不要每帧调**,
444
+ 相机停下后算一次。精度由 `width` 决定(640 宽 ≈ 0.56°/列)。
445
+ - 结果里除了角度,还有 `screen`(每列天际线像素的**归一化屏幕坐标** `[x0,y0,x1,y1,…]`)
446
+ 和 `hasGeometry`(纯天空列)。要在**地图上描天际线**,直接用这两个:
447
+ 深度图本来就是屏幕空间的产物,换算成角度只为读数,画线再投影一次反而多一层误差。
448
+ `hasGeometry = 0` 的列必须断开,否则线会横穿天空掉到画面底部。
449
+ - 天际线绑定位姿:镜头一动就作废,应用层要在 `movestart` 清掉、`moveend` 按需重算。
450
+ - `viewshed()`:从观察点朝 6 个方向各渲染一次「到观察点的距离」,再在 CPU 上对
451
+ 地面网格逐格比较,返回 `raster`(1 可见 / 0 遮挡 / -1 无效)。一次调用 = 6 趟渲染 + 6 次回读,
452
+ 成本明显更高,只在点击/松手时算。
453
+ - 两者都**不做建筑立面逐像素着色**(那要把立方体深度注入每种材质的 shader)。
454
+ - 已知限制:`overrideMaterial` 换材质时**实例化网格(点云/树木)不参与遮挡**。
455
+
456
+ #### 分析范围:只含 S3M 场景,底图内容要外围自己补
457
+
458
+ `skyline()` / `viewshed()` / `lineOfSight()` 都只看 **S3M 场景里的几何**:
459
+
460
+ - MapLibre 的 `fill-extrusion` 建筑、terrain 地形不在 three 场景里,**不参与遮挡**;
461
+ - raster 底图本来就没有高度,没什么可参与;
462
+ - 也**读不到** MapLibre 的深度:自定义图层画在主 framebuffer 里,帧内拿不出来;而深度 pass 用的是自己的 FBO + `overrideMaterial`,两套深度不共享。
463
+
464
+ 要「整座城市的天际线」,只能**复制一份几何**进来。这一步属于外围,库提供两个必要的原语:
465
+ `lngLatToPoint()`(经纬度 → 场景 ENU)和 `include`(只参与这一趟深度渲染的额外几何)。
466
+
467
+ ```ts
468
+ // 1) 从底图矢量源取当前视野的建筑(高度字段各家不同,见下面的坑)
469
+ const feats = map.querySourceFeatures('openmaptiles', { sourceLayer: 'building' });
470
+
471
+ // 2) 轮廓 → 场景 ENU → 挤出
472
+ const proxy = new THREE.Group();
473
+ for (const f of feats) {
474
+ const h = Number(f.properties.render_height ?? f.properties.height ?? 0);
475
+ if (!(h > 0)) continue;
476
+ const shape = new THREE.Shape(
477
+ f.geometry.coordinates[0].map(([lng, lat]) => {
478
+ const p = layer.lngLatToPoint({ lng, lat, altitude: 0 });
479
+ return new THREE.Vector2(p[0], p[1]);
480
+ })
481
+ );
482
+ proxy.add(new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { depth: h, bevelEnabled: false })));
483
+ }
484
+
485
+ // 3) 只喂给这一趟深度渲染:不进主渲染,不会和 MapLibre 重复画
486
+ const r = layer.skyline({ width: 1280, height: 720, include: [proxy] });
487
+ ```
488
+
489
+ `include` 的语义:对象在采集期间临时挂进 viewer 场景,采集完立刻摘掉
490
+ (已经有 parent 的会被跳过)。`viewshed()` 同样支持这个参数。
491
+
492
+ **三个坑**:
493
+
494
+ 1. **高度字段口径不统一** —— `render_height`(OpenMapTiles)/ `height`(OSM 原始)/ `building:levels × 3`,
495
+ 要和底图样式里 `fill-extrusion-height` 的表达式保持一致,否则会出现「分析里有一栋、画面上没有」。
496
+ 2. **要素会重复** —— `querySourceFeatures` 返回当前视野内所有已加载瓦片的要素,跨瓦片边界的建筑会出现多次,
497
+ 需要按 id 或几何去重,否则代理几何叠在一起(对天际线无害,对统计有害)。
498
+ 3. **代理 ≠ 画面** —— 底图如果用了 `fill-extrusion-height` 插值或 terrain 抬升,代理和实际渲染会有偏差;
499
+ 要完全一致,就得让底图建筑也由自己渲染,那已经超出「分析」的范围了。
500
+
501
+ `demo/analysis.html` 里有可运行的例子:「含底图天际线」(只喂分析)+「显示代理」(把代理塞进画面看效果)。
502
+
503
+ ```ts
504
+ type ClipOptions =
505
+ | { type: 'plane'; normal: [number, number, number]; point: [number, number, number] }
506
+ | { type: 'box'; a: [number, number, number]; b: [number, number, number] }
507
+ | { type: 'cross'; center: [number, number, number]; keepX: 1 | -1; keepY: 1 | -1;
508
+ horizontal?: boolean; keepZ?: 1 | -1 };
509
+ ```
510
+
511
+ 法线一律指向**保留侧**(与 three 的 `Plane` 口径一致:`normal·p + constant >= 0` 保留)。
512
+
413
513
  ### LOD 与统计
414
514
 
415
515
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3m-three",
3
- "version": "0.4.23",
3
+ "version": "0.4.25",
4
4
  "description": "SuperMap S3M 三维模型格式的 three.js 渲染内核:浏览器内直接解析 .s3mb 瓦片,无需转 3D Tiles,支持 LOD 调度、白膜/按高度/按单体/动态扫描着色、单体级 GPU 拾取。宿主无关,可单独用于任意 three 场景。",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,135 @@
1
+ /**
2
+ * 裁剪 / 剖切的纯数学部分:把「平面 / Box / 十字」三种交互输入翻译成一组半空间。
3
+ *
4
+ * 与 three 的 `THREE.Plane` 同构(`normal` 单位化,`constant = -normal·point`),
5
+ * 判据是 three 的口径:`normal·p + constant >= 0` 的片元**保留**,否则丢弃。
6
+ * 所以这里的法线一律朝「保留侧」。
7
+ *
8
+ * 不依赖 three,`S3MViewer.setClipping` 再把结果转成 `THREE.Plane` 挂到
9
+ * `renderer.clippingPlanes`(每个图层一个 renderer,天然图层级)。
10
+ *
11
+ * 已知边界:
12
+ * - 半空间求交只能表达**凸**区域,凹多边形要多组并集(three 的 clippingPlanes
13
+ * 是「全部满足才保留」,做不到并集),见 `docs/API.md` 的说明;
14
+ * - 不做封口:剖切后看到的是背面/空壳,封口要靠 stencil 或背面重绘,不在这一层。
15
+ */
16
+ import type { Vec3 } from './measure.js';
17
+
18
+ /** 与 `THREE.Plane` 同构:`normal` 必须单位化 */
19
+ export interface PlaneLike {
20
+ normal: Vec3;
21
+ constant: number;
22
+ }
23
+
24
+ function normalize(v: Vec3): Vec3 {
25
+ const len = Math.hypot(v[0], v[1], v[2]);
26
+ if (len < 1e-9) return [0, 0, 1];
27
+ return [v[0] / len, v[1] / len, v[2] / len];
28
+ }
29
+
30
+ function dot(a: Vec3, b: Vec3): number {
31
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
32
+ }
33
+
34
+ /** 过一个点、法线指向**保留侧**的平面 */
35
+ export function planeFromPointNormal(normal: Vec3, point: Vec3): PlaneLike {
36
+ const n = normalize(normal);
37
+ return { normal: n, constant: -dot(n, point) };
38
+ }
39
+
40
+ /** 点是否在保留侧(含边界);用于调试与单测 */
41
+ export function isPointKept(planes: readonly PlaneLike[], p: Vec3): boolean {
42
+ for (const pl of planes) {
43
+ if (dot(pl.normal, p) + pl.constant < 0) return false;
44
+ }
45
+ return true;
46
+ }
47
+
48
+ /* ------------------------------------------------------------------ 输入形态 */
49
+
50
+ /** 平面裁剪:法线指向保留的一侧,切掉另一侧 */
51
+ export interface ClipPlaneOptions {
52
+ type: 'plane';
53
+ /** 指向保留侧的法线(不必单位化) */
54
+ normal: Vec3;
55
+ /** 平面过的一个点 */
56
+ point: Vec3;
57
+ }
58
+
59
+ /** Box 裁剪:保留盒内的部分 */
60
+ export interface ClipBoxOptions {
61
+ type: 'box';
62
+ /** 对角点(顺序无所谓,内部会取 min/max) */
63
+ a: Vec3;
64
+ b: Vec3;
65
+ }
66
+
67
+ /**
68
+ * 十字剖切:用两个竖直面切掉一个象限,露出两个互相垂直的剖面。
69
+ *
70
+ * `keepX` / `keepY` 指保留哪个方向:`1` 保留 +x 侧,`-1` 保留 -x 侧。
71
+ * 加 `horizontal: true` 再多一个水平面,变成「切掉一个角」的三面剖切。
72
+ */
73
+ export interface ClipCrossOptions {
74
+ type: 'cross';
75
+ center: Vec3;
76
+ keepX: 1 | -1;
77
+ keepY: 1 | -1;
78
+ /** 是否再叠一个水平面(在 center.z 处切掉上方),默认 false */
79
+ horizontal?: boolean;
80
+ /** 水平面保留哪一侧:`1` 保留上方,`-1` 保留下方,默认 -1 */
81
+ keepZ?: 1 | -1;
82
+ }
83
+
84
+ export type ClipOptions = ClipPlaneOptions | ClipBoxOptions | ClipCrossOptions;
85
+
86
+ /* ------------------------------------------------------------------ 生成 */
87
+
88
+ export function planeClipping(opt: Omit<ClipPlaneOptions, 'type'>): PlaneLike[] {
89
+ return [planeFromPointNormal(opt.normal, opt.point)];
90
+ }
91
+
92
+ export function boxClipping(opt: Omit<ClipBoxOptions, 'type'>): PlaneLike[] {
93
+ const min: Vec3 = [
94
+ Math.min(opt.a[0], opt.b[0]),
95
+ Math.min(opt.a[1], opt.b[1]),
96
+ Math.min(opt.a[2], opt.b[2]),
97
+ ];
98
+ const max: Vec3 = [
99
+ Math.max(opt.a[0], opt.b[0]),
100
+ Math.max(opt.a[1], opt.b[1]),
101
+ Math.max(opt.a[2], opt.b[2]),
102
+ ];
103
+ // 六个面,法线一律朝盒内 —— 六个半空间求交就是「盒内」
104
+ return [
105
+ { normal: [1, 0, 0], constant: -min[0] },
106
+ { normal: [-1, 0, 0], constant: max[0] },
107
+ { normal: [0, 1, 0], constant: -min[1] },
108
+ { normal: [0, -1, 0], constant: max[1] },
109
+ { normal: [0, 0, 1], constant: -min[2] },
110
+ { normal: [0, 0, -1], constant: max[2] },
111
+ ];
112
+ }
113
+
114
+ export function crossClipping(opt: Omit<ClipCrossOptions, 'type'>): PlaneLike[] {
115
+ const planes: PlaneLike[] = [
116
+ planeFromPointNormal([opt.keepX, 0, 0], opt.center),
117
+ planeFromPointNormal([0, opt.keepY, 0], opt.center),
118
+ ];
119
+ if (opt.horizontal) {
120
+ planes.push(planeFromPointNormal([0, 0, opt.keepZ ?? -1], opt.center));
121
+ }
122
+ return planes;
123
+ }
124
+
125
+ /** 统一入口:把交互输入翻成半空间列表 */
126
+ export function buildClipPlanes(opt: ClipOptions): PlaneLike[] {
127
+ switch (opt.type) {
128
+ case 'plane':
129
+ return planeClipping(opt);
130
+ case 'box':
131
+ return boxClipping(opt);
132
+ case 'cross':
133
+ return crossClipping(opt);
134
+ }
135
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * 深度图回读的数学部分:解包 + 反投影。
3
+ *
4
+ * GPU 那边只能把深度渲染成 RGBA8(three 的 `RGBADepthPacking`),
5
+ * 回读到 CPU 后要还原成 0~1 的窗口深度,再反投影成世界坐标 —— 这两步都在这里,
6
+ * 与 WebGL 无关,可以用构造出来的矩阵直接单测。
7
+ *
8
+ * 打包/解包的常数照抄 three 的 `packing.glsl.js`,不要自己改:
9
+ * 改一个常数,回读出来的深度就会整体偏移,而画面看起来完全正常。
10
+ */
11
+ import type { Vec3 } from './measure.js';
12
+
13
+ /** three: `PackUpscale = 256 / 255` */
14
+ const PACK_UPSCALE = 256 / 255;
15
+ /** three: `UnpackDownscale = 255 / 256` */
16
+ const UNPACK_DOWNSCALE = 255 / 256;
17
+ /** three: `ShiftRight8 = 1 / 256` */
18
+ const SHIFT_RIGHT_8 = 1 / 256;
19
+ /** three: `PackFactors = (1, 256, 65536, 16777216)` */
20
+ const PACK_FACTOR_A = 16777216;
21
+
22
+ /**
23
+ * 把 0~1 的深度按 three 的 `RGBADepthPacking` 打包成 4 个字节(0~255)。
24
+ *
25
+ * 严格照抄 `packing.glsl.js` 的 `packDepthToRGBA`,**不要凭直觉改**:
26
+ * - `v <= 0` 返回全黑、`v >= 1` 返回**全白**(这两条特判是精度链条的一部分,
27
+ * 也正好让「远平面」和「清屏白」解包出来都是 1.0,背景判定因此简单);
28
+ * - 中间值是 `modf` 链拆出来的 32 位定点,通道顺序是 (粗 → 细) 的 r/g/b/a。
29
+ *
30
+ * 精度:解包误差约 1e-5(受 g 通道 8 位量化限制),换算成 141m 处的位置误差约 0.2m。
31
+ */
32
+ export function packRGBADepth(depth: number): [number, number, number, number] {
33
+ const v = Math.min(1, Math.max(0, depth));
34
+ if (v <= 0) return [0, 0, 0, 0];
35
+ if (v >= 1) return [255, 255, 255, 255];
36
+
37
+ const af = modf(v * PACK_FACTOR_A);
38
+ const b = modf(af.int * SHIFT_RIGHT_8);
39
+ const g = modf(b.int * SHIFT_RIGHT_8);
40
+
41
+ const toByte = (x: number): number => Math.max(0, Math.min(255, Math.round(x * 255)));
42
+ return [
43
+ // r = floor(v*256)/255 —— 乘回 255 就是整数本身
44
+ toByte(g.int / 255),
45
+ toByte(g.frac * PACK_UPSCALE),
46
+ toByte(b.frac * PACK_UPSCALE),
47
+ toByte(af.frac),
48
+ ];
49
+ }
50
+
51
+ /**
52
+ * 解包:RGBA 四个字节 → 0~1 的窗口深度(0 = 近平面,1 = 远平面 / 背景)。
53
+ *
54
+ * 与 `packing.glsl.js` 的 `unpackRGBAToDepth` 同式:
55
+ * `dot(rgba, (UnpackDownscale/1, UnpackDownscale/256, UnpackDownscale/65536, 1/2^24))`。
56
+ *
57
+ * @param rgba 回读出来的像素缓冲
58
+ * @param offset 字节偏移(第 i 个像素就是 i*4)
59
+ */
60
+ export function unpackRGBADepth(rgba: ArrayLike<number>, offset = 0): number {
61
+ const r = rgba[offset]! / 255;
62
+ const g = rgba[offset + 1]! / 255;
63
+ const b = rgba[offset + 2]! / 255;
64
+ const a = rgba[offset + 3]! / 255;
65
+ return (
66
+ r * UNPACK_DOWNSCALE +
67
+ g * (UNPACK_DOWNSCALE / 256) +
68
+ b * (UNPACK_DOWNSCALE / 65536) +
69
+ a / PACK_FACTOR_A
70
+ );
71
+ }
72
+
73
+ /** GLSL `modf`:返回小数部分,整数部分写进 `int` */
74
+ function modf(x: number): { frac: number; int: number } {
75
+ const int = Math.floor(x);
76
+ return { frac: x - int, int };
77
+ }
78
+
79
+ /**
80
+ * NDC + 窗口深度 → 世界坐标。
81
+ *
82
+ * @param ndcX -1~1(屏幕左侧到右侧)
83
+ * @param ndcY -1~1(屏幕底部到顶部;注意与像素行号反向)
84
+ * @param depth01 0~1 的窗口深度
85
+ * @param inverseViewProj 视图投影矩阵的逆(列主序 16 元,与 `THREE.Matrix4.elements` 同序)
86
+ */
87
+ export function unprojectNdc(
88
+ ndcX: number,
89
+ ndcY: number,
90
+ depth01: number,
91
+ inverseViewProj: ArrayLike<number>
92
+ ): Vec3 {
93
+ const z = depth01 * 2 - 1;
94
+ const m = inverseViewProj;
95
+ const x = m[0]! * ndcX + m[4]! * ndcY + m[8]! * z + m[12]!;
96
+ const y = m[1]! * ndcX + m[5]! * ndcY + m[9]! * z + m[13]!;
97
+ const zz = m[2]! * ndcX + m[6]! * ndcY + m[10]! * z + m[14]!;
98
+ const w = m[3]! * ndcX + m[7]! * ndcY + m[11]! * z + m[15]!;
99
+ const iw = Math.abs(w) > 1e-12 ? 1 / w : 1;
100
+ return [x * iw, y * iw, zz * iw];
101
+ }
102
+
103
+ /** 像素坐标(左上角为 0,0)→ NDC */
104
+ export function pixelToNdc(px: number, py: number, width: number, height: number): [number, number] {
105
+ return [((px + 0.5) / width) * 2 - 1, 1 - ((py + 0.5) / height) * 2];
106
+ }
107
+
108
+ /**
109
+ * 把「观察点 → 目标点」拆成方位角 / 仰角(度)。
110
+ *
111
+ * 方位角:0 = 正北,顺时针增大(与地图 bearing 一致),ENU 下 x 向东、y 向北。
112
+ * 仰角:0 = 水平,正值向上,负值向下。
113
+ */
114
+ export function azimuthElevation(
115
+ from: Vec3,
116
+ to: Vec3
117
+ ): { azimuth: number; elevation: number; distance: number } {
118
+ const dx = to[0] - from[0];
119
+ const dy = to[1] - from[1];
120
+ const dz = to[2] - from[2];
121
+ const horizontal = Math.hypot(dx, dy);
122
+ const distance = Math.hypot(horizontal, dz);
123
+ const azimuth = ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360;
124
+ const elevation = (Math.atan2(dz, horizontal) * 180) / Math.PI;
125
+ return { azimuth, elevation, distance };
126
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * 三维空间分析内核(宿主无关、不依赖 WebGL)。
3
+ *
4
+ * 分工:
5
+ * - 本目录只做**纯数学**:量算、通视判定、裁剪半空间生成。可以直接单测。
6
+ * - 需要 GPU 的部分(贴地采样、天际线、可视域、阴影)在 `render/` 下,
7
+ * 复用 `Picker` 的离屏 pass 与状态恢复。
8
+ * - 交互(取点、拖拽、标签绘制)属于应用层,不进这个包。
9
+ */
10
+ export {
11
+ distance3D,
12
+ distanceHorizontal,
13
+ heightDifference,
14
+ polylineLength,
15
+ polylineLengthHorizontal,
16
+ polygonPerimeter,
17
+ polygonArea,
18
+ polygonAreaHorizontal,
19
+ measureSegment,
20
+ } from './measure.js';
21
+ export type { Vec3, LngLatAlt, MeasureResult } from './measure.js';
22
+
23
+ export { lineOfSight } from './lineOfSight.js';
24
+ export type { RayHit, RayCast, LineOfSightOptions, LineOfSightResult } from './lineOfSight.js';
25
+
26
+ export {
27
+ planeFromPointNormal,
28
+ isPointKept,
29
+ planeClipping,
30
+ boxClipping,
31
+ crossClipping,
32
+ buildClipPlanes,
33
+ } from './clipping.js';
34
+ export type {
35
+ PlaneLike,
36
+ ClipPlaneOptions,
37
+ ClipBoxOptions,
38
+ ClipCrossOptions,
39
+ ClipOptions,
40
+ } from './clipping.js';
41
+
42
+ /* 深度图回读(P1:天际线 / 可视域的数学部分,GL 在 render/analysis/DepthPass) */
43
+ export { packRGBADepth, unpackRGBADepth, unprojectNdc, pixelToNdc, azimuthElevation } from './depth.js';
44
+ export { skylineFromDepth } from './skyline.js';
45
+ export type { SkylineArgs, SkylineResult } from './skyline.js';
46
+ export { computeViewshed, makeCubeDistanceSampler } from './viewshed.js';
47
+ export type { ViewshedOptions, ViewshedResult } from './viewshed.js';
@@ -0,0 +1,110 @@
1
+ /**
2
+ * 通视分析(单条视线)。
3
+ *
4
+ * 求交本体由调用方注入 —— 内核只负责「沿视线找第一个遮挡、算可见比例」,
5
+ * 这样不依赖 three,也能在单测里用解析几何伪造求交结果。
6
+ *
7
+ * 场景里实际用的求交是 `THREE.Raycaster` 打全部 pickable mesh(与
8
+ * `PickService.drillPick` 同一条路径),由 `S3MViewer.lineOfSight` 装配。
9
+ */
10
+ import { distance3D, type Vec3 } from './measure.js';
11
+
12
+ /** 一次射线求交的结果:只关心「最近的交点距离」 */
13
+ export interface RayHit {
14
+ /** 沿射线的距离(米) */
15
+ distance: number;
16
+ /** 交点位置(可选,用于画遮挡点标记) */
17
+ point?: Vec3;
18
+ }
19
+
20
+ /**
21
+ * 射线求交函数。
22
+ *
23
+ * @param origin 起点(场景 ENU 米)
24
+ * @param dir 单位方向
25
+ * @param maxDistance 只关心这个距离内的交点,超出可以返回 null(实现可据此提前剪枝)
26
+ */
27
+ export type RayCast = (origin: Vec3, dir: Vec3, maxDistance: number) => RayHit | null;
28
+
29
+ export interface LineOfSightOptions {
30
+ /**
31
+ * 起点外推距离(米,默认 0.05)。
32
+ *
33
+ * 从建筑表面起步时,起点就落在三角面上,浮点误差会让射线立刻打中自己所在的
34
+ * 那个三角面,于是「永远不可见」。沿视线方向先推一小段再求交即可避开。
35
+ */
36
+ originOffset?: number;
37
+ /**
38
+ * 终点内缩距离(米,默认 0.05)。同理:终点若贴在目标表面,不要把目标自身
39
+ * 当成遮挡物。
40
+ */
41
+ targetOffset?: number;
42
+ }
43
+
44
+ export interface LineOfSightResult {
45
+ /** 起终点之间是否通视 */
46
+ visible: boolean;
47
+ /** 起点到终点的直线距离(米) */
48
+ distance: number;
49
+ /** 第一个遮挡点距起点的距离(米);通视时为 null */
50
+ blockedDistance: number | null;
51
+ /** 第一个遮挡点的位置;通视时为 null */
52
+ blockedPoint: Vec3 | null;
53
+ /** 可见段占全长的比例(0~1,1 = 完全通视)—— 直接用来画「绿段 / 红段」 */
54
+ visibleRatio: number;
55
+ }
56
+
57
+ /**
58
+ * 计算两点之间的通视情况。
59
+ *
60
+ * @param from 起点(场景 ENU 米)
61
+ * @param to 终点
62
+ * @param cast 射线求交函数
63
+ */
64
+ export function lineOfSight(
65
+ from: Vec3,
66
+ to: Vec3,
67
+ cast: RayCast,
68
+ opts: LineOfSightOptions = {}
69
+ ): LineOfSightResult {
70
+ const total = distance3D(from, to);
71
+ if (total <= 1e-6) {
72
+ return { visible: true, distance: 0, blockedDistance: null, blockedPoint: null, visibleRatio: 1 };
73
+ }
74
+
75
+ const dir: Vec3 = [
76
+ (to[0] - from[0]) / total,
77
+ (to[1] - from[1]) / total,
78
+ (to[2] - from[2]) / total,
79
+ ];
80
+
81
+ const startOffset = Math.max(0, opts.originOffset ?? 0.05);
82
+ const endOffset = Math.max(0, opts.targetOffset ?? 0.05);
83
+ // 两端各缩一点;太短的视线就不缩了,免得区间翻负
84
+ const shrink = Math.min(startOffset + endOffset, total * 0.5);
85
+ const origin: Vec3 = [
86
+ from[0] + dir[0] * startOffset,
87
+ from[1] + dir[1] * startOffset,
88
+ from[2] + dir[2] * startOffset,
89
+ ];
90
+ const limit = Math.max(0, total - shrink);
91
+
92
+ const hit = cast(origin, dir, limit);
93
+ if (hit && hit.distance < limit) {
94
+ // 距离要把起点外推量加回去,才是「从真实起点算起」
95
+ const blockedDistance = Math.min(total, hit.distance + startOffset);
96
+ return {
97
+ visible: false,
98
+ distance: total,
99
+ blockedDistance,
100
+ blockedPoint: hit.point ?? [
101
+ from[0] + dir[0] * blockedDistance,
102
+ from[1] + dir[1] * blockedDistance,
103
+ from[2] + dir[2] * blockedDistance,
104
+ ],
105
+ visibleRatio: Math.max(0, Math.min(1, blockedDistance / total)),
106
+ };
107
+ }
108
+
109
+ return { visible: true, distance: total, blockedDistance: null, blockedPoint: null, visibleRatio: 1 };
110
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * 量算:距离 / 折线长 / 面积 / 高差。
3
+ *
4
+ * 全部是纯函数,输入输出都是**场景 ENU 米制坐标**(three 世界坐标),
5
+ * 不依赖 three、不依赖 WebGL,可以直接单测。
6
+ *
7
+ * 经纬度 ↔ ENU 的换算在 `S3MViewer` 里做(需要 `sceneAnchor` 与 `GeoAdapter`),
8
+ * 这一层只管算数 —— 这样投影口径变化不会污染数学。
9
+ */
10
+
11
+ /** 场景 ENU 米制坐标(three 世界坐标) */
12
+ export type Vec3 = [number, number, number];
13
+
14
+ /** 经纬度 + 绝对高程(米) */
15
+ export interface LngLatAlt {
16
+ lng: number;
17
+ lat: number;
18
+ /** 绝对高程(米),不是相对场景锚点的高度 */
19
+ altitude: number;
20
+ }
21
+
22
+ export function distance3D(a: Vec3, b: Vec3): number {
23
+ const dx = b[0] - a[0];
24
+ const dy = b[1] - a[1];
25
+ const dz = b[2] - a[2];
26
+ return Math.sqrt(dx * dx + dy * dy + dz * dz);
27
+ }
28
+
29
+ /** 水平距离(忽略高程差)—— 规划类口径常用 */
30
+ export function distanceHorizontal(a: Vec3, b: Vec3): number {
31
+ const dx = b[0] - a[0];
32
+ const dy = b[1] - a[1];
33
+ return Math.sqrt(dx * dx + dy * dy);
34
+ }
35
+
36
+ /** 高差(有符号:b 比 a 高为正) */
37
+ export function heightDifference(a: Vec3, b: Vec3): number {
38
+ return b[2] - a[2];
39
+ }
40
+
41
+ /** 折线长度(逐段求和);点少于 2 个返回 0 */
42
+ export function polylineLength(points: readonly Vec3[]): number {
43
+ let total = 0;
44
+ for (let i = 1; i < points.length; i++) total += distance3D(points[i - 1], points[i]);
45
+ return total;
46
+ }
47
+
48
+ /** 折线水平投影长度 */
49
+ export function polylineLengthHorizontal(points: readonly Vec3[]): number {
50
+ let total = 0;
51
+ for (let i = 1; i < points.length; i++) total += distanceHorizontal(points[i - 1], points[i]);
52
+ return total;
53
+ }
54
+
55
+ /** 多边形周长(首尾自动闭合,不要重复传首点) */
56
+ export function polygonPerimeter(points: readonly Vec3[]): number {
57
+ if (points.length < 2) return 0;
58
+ return polylineLength(points) + distance3D(points[points.length - 1], points[0]);
59
+ }
60
+
61
+ /**
62
+ * 多边形面积(Newell 法)。
63
+ *
64
+ * 对**共面**点给出真实面积;对起伏/不共面的点给出「投影到最佳拟合平面」的面积,
65
+ * 这也是绝大多数量算工具的口径。要水平投影面积用 `polygonAreaHorizontal`。
66
+ *
67
+ * 与点的绕向无关(取向量模长),所以不用担心用户顺时针还是逆时针点。
68
+ */
69
+ export function polygonArea(points: readonly Vec3[]): number {
70
+ const n = points.length;
71
+ if (n < 3) return 0;
72
+ let nx = 0;
73
+ let ny = 0;
74
+ let nz = 0;
75
+ for (let i = 0; i < n; i++) {
76
+ const a = points[i];
77
+ const b = points[(i + 1) % n];
78
+ nx += (a[1] - b[1]) * (a[2] + b[2]);
79
+ ny += (a[2] - b[2]) * (a[0] + b[0]);
80
+ nz += (a[0] - b[0]) * (a[1] + b[1]);
81
+ }
82
+ return Math.sqrt(nx * nx + ny * ny + nz * nz) / 2;
83
+ }
84
+
85
+ /**
86
+ * 水平投影面积(鞋带公式,忽略高程)。
87
+ *
88
+ * 与 `polygonArea` 的区别:一个坡面上的矩形,`polygonArea` 给坡面真实面积,
89
+ * 这里给它在水平面上的投影面积。建筑面积/用地面积按后者算。
90
+ */
91
+ export function polygonAreaHorizontal(points: readonly Vec3[]): number {
92
+ const n = points.length;
93
+ if (n < 3) return 0;
94
+ let sum = 0;
95
+ for (let i = 0; i < n; i++) {
96
+ const a = points[i];
97
+ const b = points[(i + 1) % n];
98
+ sum += a[0] * b[1] - b[0] * a[1];
99
+ }
100
+ return Math.abs(sum) / 2;
101
+ }
102
+
103
+ /** 量算结果:一次给全三种口径,避免调用方自己再算一遍 */
104
+ export interface MeasureResult {
105
+ /** 三维直线距离(米) */
106
+ distance3D: number;
107
+ /** 水平距离(米) */
108
+ distanceHorizontal: number;
109
+ /** 高差(米,有符号) */
110
+ heightDiff: number;
111
+ }
112
+
113
+ export function measureSegment(a: Vec3, b: Vec3): MeasureResult {
114
+ return {
115
+ distance3D: distance3D(a, b),
116
+ distanceHorizontal: distanceHorizontal(a, b),
117
+ heightDiff: heightDifference(a, b),
118
+ };
119
+ }