s3m-three 0.4.23 → 0.4.24
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.
- package/dist/analysis/clipping.d.ts +63 -0
- package/dist/analysis/clipping.d.ts.map +1 -0
- package/dist/analysis/clipping.js +69 -0
- package/dist/analysis/clipping.js.map +1 -0
- package/dist/analysis/depth.d.ts +55 -0
- package/dist/analysis/depth.d.ts.map +1 -0
- package/dist/analysis/depth.js +99 -0
- package/dist/analysis/depth.js.map +1 -0
- package/dist/analysis/index.d.ts +21 -0
- package/dist/analysis/index.d.ts.map +1 -0
- package/dist/analysis/index.js +17 -0
- package/dist/analysis/index.js.map +1 -0
- package/dist/analysis/lineOfSight.d.ts +60 -0
- package/dist/analysis/lineOfSight.d.ts.map +1 -0
- package/dist/analysis/lineOfSight.js +56 -0
- package/dist/analysis/lineOfSight.js.map +1 -0
- package/dist/analysis/measure.d.ts +56 -0
- package/dist/analysis/measure.d.ts.map +1 -0
- package/dist/analysis/measure.js +95 -0
- package/dist/analysis/measure.js.map +1 -0
- package/dist/analysis/skyline.d.ts +55 -0
- package/dist/analysis/skyline.d.ts.map +1 -0
- package/dist/analysis/skyline.js +78 -0
- package/dist/analysis/skyline.js.map +1 -0
- package/dist/analysis/viewshed.d.ts +48 -0
- package/dist/analysis/viewshed.d.ts.map +1 -0
- package/dist/analysis/viewshed.js +115 -0
- package/dist/analysis/viewshed.js.map +1 -0
- package/dist/browser/s3m-three.iife.ext-three.js +20 -3
- package/dist/browser/s3m-three.iife.js +20 -3
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/render/S3MViewer.d.ts +106 -0
- package/dist/render/S3MViewer.d.ts.map +1 -1
- package/dist/render/S3MViewer.js +174 -1
- package/dist/render/S3MViewer.js.map +1 -1
- package/dist/render/analysis/DepthPass.d.ts +78 -0
- package/dist/render/analysis/DepthPass.d.ts.map +1 -0
- package/dist/render/analysis/DepthPass.js +209 -0
- package/dist/render/analysis/DepthPass.js.map +1 -0
- package/dist/render/viewer/PickService.d.ts +15 -0
- package/dist/render/viewer/PickService.d.ts.map +1 -1
- package/dist/render/viewer/PickService.js +41 -0
- package/dist/render/viewer/PickService.js.map +1 -1
- package/docs/API.md +100 -0
- package/package.json +1 -1
- package/src/analysis/clipping.ts +135 -0
- package/src/analysis/depth.ts +126 -0
- package/src/analysis/index.ts +47 -0
- package/src/analysis/lineOfSight.ts +110 -0
- package/src/analysis/measure.ts +119 -0
- package/src/analysis/skyline.ts +125 -0
- package/src/analysis/viewshed.ts +160 -0
- package/src/index.ts +5 -0
- package/src/render/S3MViewer.ts +233 -2
- package/src/render/analysis/DepthPass.ts +278 -0
- package/src/render/viewer/PickService.ts +47 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|