Skip to main content

squid_n_core/geom/
polygon.rs

1//! 多角形の幾何演算(面積・重心・点と辺の距離・内包判定)。
2//!
3//! 床領域・壁領域の検出とリビルド([`crate::region_gen`]・[`crate::region_rebuild`]・
4//! [`crate::wall_region_rebuild`])、床の荷重分配(`squid_n_load::floor`)、
5//! 部材数量の拾い出しが同じ式を必要とするため、ここを唯一の実装とする。
6//!
7//! 2 次元の関数は座標系を問わない(床は全体 XY、壁は構面の局所 `(s, z)`、
8//! 断面は局所 `(y, z)` で使う)。長さは mm、面積は mm² を単位とする。
9//!
10//! # 面積の 3 種
11//!
12//! 多角形の面積には**投影面積と真の面積**があり、用途で使い分ける。
13//!
14//! - [`signed_area`]・[`area`] — 2 次元多角形のシューレース公式。
15//! - [`area_xy`] — 3 次元の頂点を全体 XY 平面へ投影した面積。床は水平面内にあるため、
16//!   床の荷重分配はこちらを使う(分配が見る面積と一致させる)。
17//! - [`area_3d`] — 3 次元の頂点が張る平面上での真の面積(Newell の公式)。
18//!   傾いた壁・シェルの自重はこちらを使う。投影面積は傾きのぶんだけ小さく出るため、
19//!   自重に投影面積を用いると重量を過小評価する**危険側**になる。
20
21/// 辺上とみなす、点から辺までの距離の上限 \[mm\]。
22///
23/// [`contains_excluding_boundary`]・[`contains_including_boundary`] が共有する。
24/// 床領域・壁領域への帰属判定と、床の荷重分配が同じ幅で辺上を判断する
25/// (判定規則の情報源を 1 つに保つ)。
26pub const BOUNDARY_TOL_MM: f64 = 1.0;
27
28/// 縮退とみなす、境界ボックスの最大辺長の 2 乗に対する面積の相対上限
29/// (根拠は [`centroid`] のドキュメントを参照)。
30const DEGENERATE_AREA_REL: f64 = 1e-12;
31
32/// 多角形の符号付き面積 \[mm²\](シューレース公式。反時計回りが正)。頂点が 3 個未満なら 0。
33///
34/// 符号は面走査([`crate::region_gen`])が閉路の向きから外周面を判別するのに使う。
35/// 向きを問わない面積は [`area`] を使う。
36pub fn signed_area(pts: &[[f64; 2]]) -> f64 {
37    shoelace(pts.len(), |i| pts[i])
38}
39
40/// シューレース公式の本体。`xy(i)` は i 番目の頂点の `(x, y)` を返す。
41///
42/// 2 次元の点列と、3 次元の点列を XY へ投影したものの両方から呼ぶため、
43/// 頂点の取り出し方を引数に切り出している([`area_xy`] が同じ式を持ち直さずに済む)。
44fn shoelace(n: usize, xy: impl Fn(usize) -> [f64; 2]) -> f64 {
45    if n < 3 {
46        return 0.0;
47    }
48    let mut sum = 0.0;
49    for i in 0..n {
50        let a = xy(i);
51        let b = xy((i + 1) % n);
52        sum += a[0] * b[1] - b[0] * a[1];
53    }
54    sum / 2.0
55}
56
57/// 多角形の面積 \[mm²\](絶対値。頂点の周り方に依らない)。
58pub fn area(pts: &[[f64; 2]]) -> f64 {
59    signed_area(pts).abs()
60}
61
62/// 3 次元の頂点を全体 XY 平面へ投影した多角形の面積 \[mm²\]。
63///
64/// 床は水平面内にあるという前提で、床板の面積と荷重分配が見る面積をそろえる。
65/// 傾いた面の真の面積が要るときは [`area_3d`] を使う。
66pub fn area_xy(pts: &[[f64; 3]]) -> f64 {
67    shoelace(pts.len(), |i| [pts[i][0], pts[i][1]]).abs()
68}
69
70/// 平面多角形(3 次元座標、頂点が同一平面上と仮定)の面積 \[mm²\]。
71///
72/// Newell の公式 `N = 1/2 Σ(Vi × Vi+1)`, `Area = |N|` による。凸・非凸いずれも、
73/// 頂点が境界を一周する順序で与えられていれば成立する。頂点が 3 個未満なら 0。
74///
75/// 壁・シェル要素の自重算定とスラブ・壁の数量拾いが共通で用いる。
76pub fn area_3d(pts: &[[f64; 3]]) -> f64 {
77    if pts.len() < 3 {
78        return 0.0;
79    }
80    let n = pts.len();
81    let mut normal = [0.0_f64; 3];
82    for i in 0..n {
83        let (p0, p1) = (pts[i], pts[(i + 1) % n]);
84        let c = super::vec3::cross(p0, p1);
85        normal = super::vec3::add(normal, c);
86    }
87    0.5 * super::vec3::norm(normal)
88}
89
90/// 多角形の面積重心。
91///
92/// 縮退した多角形(頂点が 3 個未満、または一直線に並ぶもの)は面積重心が定まらないため、
93/// 頂点の単純平均へフォールバックする。
94///
95/// # 縮退の判定を絶対値で行わない理由
96///
97/// 一直線に並んだ頂点の符号付き面積は厳密には 0 だが、建物座標(~1e4 mm)では
98/// シューレース和の各項が ~1e8 になるため、丸め誤差で |面積| ~1e-8 mm² が残る。
99/// これを 1e-9 や [`f64::EPSILON`] のような絶対値のしきい値と比べても縮退と判定できず、
100/// 残差が `6A` の除算へ入って重心が発散する。
101///
102/// そこで面積を**境界ボックスの最大辺長の 2 乗**と比べる。比は無次元なので、
103/// 建物の平面(~1e4 mm)でも断面の輪郭(~1e2 mm)でも同じ判定が使える。
104/// 境界ボックスの「面積」を基準に採らないのは、縮退した多角形ではそれ自体が 0 へ
105/// 潰れて基準にならないためである。シューレース和の相対丸め誤差は頂点数 n に対し
106/// 概ね `n · f64::EPSILON`(~1e-15)で、しきい値 1e-12 はそれに 3 桁の余裕を持つ。
107pub fn centroid(pts: &[[f64; 2]]) -> [f64; 2] {
108    let vertex_mean = || {
109        if pts.is_empty() {
110            return [0.0, 0.0];
111        }
112        let n = pts.len() as f64;
113        [
114            pts.iter().map(|p| p[0]).sum::<f64>() / n,
115            pts.iter().map(|p| p[1]).sum::<f64>() / n,
116        ]
117    };
118    if pts.len() < 3 {
119        return vertex_mean();
120    }
121    let a = signed_area(pts);
122    if a.abs() <= max_extent_sq(pts) * DEGENERATE_AREA_REL {
123        return vertex_mean();
124    }
125    let mut cx = 0.0;
126    let mut cy = 0.0;
127    for (i, p0) in pts.iter().enumerate() {
128        let p1 = pts[(i + 1) % pts.len()];
129        let cross = p0[0] * p1[1] - p1[0] * p0[1];
130        cx += (p0[0] + p1[0]) * cross;
131        cy += (p0[1] + p1[1]) * cross;
132    }
133    let six_a = 6.0 * a;
134    [cx / six_a, cy / six_a]
135}
136
137/// 点群の境界ボックス `(最小の角, 最大の角)`。空の点群は原点に潰れた箱を返す。
138pub fn bounding_box(pts: &[[f64; 2]]) -> ([f64; 2], [f64; 2]) {
139    if pts.is_empty() {
140        return ([0.0, 0.0], [0.0, 0.0]);
141    }
142    let (mut min_x, mut max_x) = (f64::INFINITY, f64::NEG_INFINITY);
143    let (mut min_y, mut max_y) = (f64::INFINITY, f64::NEG_INFINITY);
144    for p in pts {
145        min_x = min_x.min(p[0]);
146        max_x = max_x.max(p[0]);
147        min_y = min_y.min(p[1]);
148        max_y = max_y.max(p[1]);
149    }
150    ([min_x, min_y], [max_x, max_y])
151}
152
153/// 境界ボックスの長いほうの辺長の 2 乗 \[mm²\]。面積と同じ次元を持つ縮退判定の基準。
154fn max_extent_sq(pts: &[[f64; 2]]) -> f64 {
155    let (lo, hi) = bounding_box(pts);
156    let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]);
157    extent * extent
158}
159
160/// 点 `p` から線分 `a`–`b` までの距離 \[mm\]。
161pub fn point_segment_dist(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
162    point_segment_dist_sq(p, a, b).sqrt()
163}
164
165/// 点 `p` から線分 `a`–`b` までの距離の 2 乗 \[mm²\]。
166///
167/// 距離どうしを比べるだけなら平方根を省ける(比較のしきい値も 2 乗して渡す)。
168pub fn point_segment_dist_sq(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
169    let ab = [b[0] - a[0], b[1] - a[1]];
170    let len2 = ab[0] * ab[0] + ab[1] * ab[1];
171    let t = if len2 <= f64::EPSILON {
172        0.0
173    } else {
174        (((p[0] - a[0]) * ab[0] + (p[1] - a[1]) * ab[1]) / len2).clamp(0.0, 1.0)
175    };
176    let q = [a[0] + t * ab[0], a[1] + t * ab[1]];
177    let (dx, dy) = (p[0] - q[0], p[1] - q[1]);
178    dx * dx + dy * dy
179}
180
181/// 点 `p` が多角形の辺上(頂点を含む)にあるか。辺までの距離が
182/// [`BOUNDARY_TOL_MM`] 以内を辺上とする。頂点が 2 個未満なら偽。
183pub fn on_boundary(poly: &[[f64; 2]], p: [f64; 2]) -> bool {
184    let n = poly.len();
185    if n < 2 {
186        return false;
187    }
188    let tol2 = BOUNDARY_TOL_MM * BOUNDARY_TOL_MM;
189    (0..n).any(|i| point_segment_dist_sq(p, poly[i], poly[(i + 1) % n]) <= tol2)
190}
191
192/// 点 `p` が多角形の内部にあるか。**辺上([`BOUNDARY_TOL_MM`] 以内)は内部に含めない。**
193///
194/// 版や二次部材をどの領域へ帰属させるか決める用途に使う。辺上の点は隣接する領域の
195/// 双方に該当してしまうため、含めると帰属を一意に決められない。
196///
197/// 辺上を含めたいときは [`contains_including_boundary`] を使う。**どちらを選ぶかは
198/// 「辺上の点を落としたいか拾いたいか」で決まり、取り違えると帰属の重複か脱落を招く**
199/// ため、名前で区別できる 2 つの関数に分けている。
200pub fn contains_excluding_boundary(poly: &[[f64; 2]], p: [f64; 2]) -> bool {
201    if poly.len() < 3 || on_boundary(poly, p) {
202        return false;
203    }
204    ray_crossing(poly, p)
205}
206
207/// 点 `p` が多角形の内部または辺上([`BOUNDARY_TOL_MM`] 以内)にあるか。
208///
209/// 床板の縁に載る小梁のように、辺上の点を対象から落としたくない用途に使う。
210/// 辺上を除きたいときは [`contains_excluding_boundary`] を使う。
211pub fn contains_including_boundary(poly: &[[f64; 2]], p: [f64; 2]) -> bool {
212    if poly.len() < 3 {
213        return false;
214    }
215    on_boundary(poly, p) || ray_crossing(poly, p)
216}
217
218/// レイキャスト(偶奇則)だけで内包を判定する。**[`BOUNDARY_TOL_MM`] の帯を一切見ない。**
219///
220/// 辺上ちょうどの点の扱いは辺の向きしだいで内側にも外側にもなるため、**点を
221/// どの領域に帰属させるかを決める用途に使ってはならない**([`contains_excluding_boundary`]
222/// か [`contains_including_boundary`] を使う)。
223///
224/// 本関数は、床の荷重分配が格子のセル中心を「多角形の中か外か」で数え上げる走査
225/// (`squid_n_load::floor`)のように、**点に帰属先を与えるのではなく面積を積む**
226/// 用途のためにある。この走査に公差の帯を持ち込むと、辺から 1 mm 以内に中心を持つ
227/// セルが内外どちらの判定でも数から漏れ、荷重面積が落ちる(**危険側**)。
228pub fn contains_by_ray_crossing(poly: &[[f64; 2]], p: [f64; 2]) -> bool {
229    if poly.len() < 3 {
230        return false;
231    }
232    ray_crossing(poly, p)
233}
234
235/// レイキャスト(偶奇則)の本体。頂点が 3 個以上あることを前提とする。
236fn ray_crossing(poly: &[[f64; 2]], p: [f64; 2]) -> bool {
237    let n = poly.len();
238    let mut inside = false;
239    let mut j = n - 1;
240    for i in 0..n {
241        let (a, b) = (poly[i], poly[j]);
242        if (a[1] > p[1]) != (b[1] > p[1]) {
243            let x = (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0];
244            if p[0] < x {
245                inside = !inside;
246            }
247        }
248        j = i;
249    }
250    inside
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    /// 反時計回りの正方形。
258    fn ccw_square(side: f64) -> Vec<[f64; 2]> {
259        vec![[0.0, 0.0], [side, 0.0], [side, side], [0.0, side]]
260    }
261
262    #[test]
263    fn signed_area_is_positive_for_ccw_and_negative_for_cw() {
264        let ccw = ccw_square(10.0);
265        let mut cw = ccw.clone();
266        cw.reverse();
267        assert_eq!(signed_area(&ccw), 100.0);
268        assert_eq!(signed_area(&cw), -100.0);
269        assert_eq!(area(&cw), 100.0);
270    }
271
272    #[test]
273    fn signed_area_of_degenerate_input_is_zero() {
274        assert_eq!(signed_area(&[]), 0.0);
275        assert_eq!(signed_area(&[[0.0, 0.0], [1.0, 1.0]]), 0.0);
276    }
277
278    #[test]
279    fn area_xy_projects_and_area_3d_keeps_true_area() {
280        // XZ 平面に立つ 1000×1000 の正方形。XY へ投影すると潰れて 0 になる。
281        let pts = [
282            [0.0, 0.0, 0.0],
283            [1000.0, 0.0, 0.0],
284            [1000.0, 0.0, 1000.0],
285            [0.0, 0.0, 1000.0],
286        ];
287        assert_eq!(area_xy(&pts), 0.0);
288        assert!((area_3d(&pts) - 1_000_000.0).abs() < 1e-6);
289    }
290
291    #[test]
292    fn centroid_of_offset_rectangle_is_its_center() {
293        let rect = [
294            [100.0, 200.0],
295            [300.0, 200.0],
296            [300.0, 600.0],
297            [100.0, 600.0],
298        ];
299        let c = centroid(&rect);
300        assert!((c[0] - 200.0).abs() < 1e-9);
301        assert!((c[1] - 400.0).abs() < 1e-9);
302    }
303
304    /// 面積重心は凹多角形でも頂点の単純平均とは一致しない(L 形で確認する)。
305    #[test]
306    fn centroid_of_l_shape_differs_from_vertex_mean() {
307        let l = [
308            [0.0, 0.0],
309            [200.0, 0.0],
310            [200.0, 100.0],
311            [100.0, 100.0],
312            [100.0, 200.0],
313            [0.0, 200.0],
314        ];
315        let c = centroid(&l);
316        // 面積 30000、面積重心は手計算で (250/3, 250/3)。
317        assert!((c[0] - 250.0 / 3.0).abs() < 1e-9, "{c:?}");
318        assert!((c[1] - 250.0 / 3.0).abs() < 1e-9, "{c:?}");
319        let mean_x = l.iter().map(|p| p[0]).sum::<f64>() / l.len() as f64;
320        assert!((c[0] - mean_x).abs() > 1.0, "単純平均と一致してはならない");
321    }
322
323    /// 建物座標の尺度で一直線に並んだ頂点は、丸め誤差で符号付き面積が厳密な 0 に
324    /// ならないことがある。絶対値のしきい値ではこれをすり抜けて重心が発散するため、
325    /// 境界ボックス相対で縮退と判定できることを確かめる。
326    #[test]
327    fn centroid_of_collinear_points_falls_back_to_vertex_mean() {
328        let collinear = [
329            [1234.5, 6789.0],
330            [11234.5, 6789.0],
331            [21234.5, 6789.0],
332            [31234.5, 6789.0],
333        ];
334        let c = centroid(&collinear);
335        let mean_x = collinear.iter().map(|p| p[0]).sum::<f64>() / 4.0;
336        assert!(
337            c[0].is_finite() && c[1].is_finite(),
338            "発散してはならない: {c:?}"
339        );
340        assert!((c[0] - mean_x).abs() < 1e-6, "{c:?}");
341        assert!((c[1] - 6789.0).abs() < 1e-6, "{c:?}");
342    }
343
344    #[test]
345    fn centroid_of_fewer_than_three_points_is_vertex_mean() {
346        assert_eq!(centroid(&[]), [0.0, 0.0]);
347        assert_eq!(centroid(&[[2.0, 4.0]]), [2.0, 4.0]);
348        assert_eq!(centroid(&[[0.0, 0.0], [2.0, 4.0]]), [1.0, 2.0]);
349    }
350
351    #[test]
352    fn point_segment_dist_clamps_to_the_segment_ends() {
353        let (a, b) = ([0.0, 0.0], [10.0, 0.0]);
354        assert!((point_segment_dist([5.0, 3.0], a, b) - 3.0).abs() < 1e-12);
355        // 線分の外側へ出た点は端点までの距離になる。
356        assert!((point_segment_dist([14.0, 3.0], a, b) - 5.0).abs() < 1e-12);
357        // 長さ 0 の線分は始点までの距離。
358        assert!((point_segment_dist([3.0, 4.0], a, a) - 5.0).abs() < 1e-12);
359    }
360
361    #[test]
362    fn point_segment_dist_sq_is_the_square_of_the_distance() {
363        let (p, a, b) = ([5.0, 3.0], [0.0, 0.0], [10.0, 0.0]);
364        let d = point_segment_dist(p, a, b);
365        assert!((point_segment_dist_sq(p, a, b) - d * d).abs() < 1e-12);
366    }
367
368    /// 辺上の点を落とすか拾うかが、3 つの内包判定を分ける唯一の違いである。
369    #[test]
370    fn the_three_containment_rules_differ_only_on_the_boundary_band() {
371        let sq = ccw_square(1000.0);
372        let inner = [500.0, 500.0];
373        let outer = [1500.0, 500.0];
374        // 辺から 0.5mm 内側(BOUNDARY_TOL_MM = 1.0 の帯の中)。
375        let on_band = [500.0, 0.5];
376
377        for p in [inner, outer, on_band] {
378            let excl = contains_excluding_boundary(&sq, p);
379            let incl = contains_including_boundary(&sq, p);
380            let ray = contains_by_ray_crossing(&sq, p);
381            match p {
382                p if p == inner => assert!(excl && incl && ray, "内部は 3 つとも真"),
383                p if p == outer => assert!(!excl && !incl && !ray, "外部は 3 つとも偽"),
384                _ => {
385                    assert!(!excl, "辺上は除外側で偽");
386                    assert!(incl, "辺上は包含側で真");
387                    assert!(ray, "レイキャストは帯を見ないので内部として真");
388                }
389            }
390        }
391    }
392
393    #[test]
394    fn containment_of_fewer_than_three_points_is_false() {
395        let seg = [[0.0, 0.0], [10.0, 0.0]];
396        assert!(!contains_excluding_boundary(&seg, [5.0, 0.0]));
397        assert!(!contains_including_boundary(&seg, [5.0, 0.0]));
398        assert!(!contains_by_ray_crossing(&seg, [5.0, 0.0]));
399    }
400
401    #[test]
402    fn bounding_box_spans_the_point_cloud() {
403        let pts = [[3.0, -1.0], [-2.0, 5.0], [1.0, 2.0]];
404        assert_eq!(bounding_box(&pts), ([-2.0, -1.0], [3.0, 5.0]));
405        assert_eq!(bounding_box(&[]), ([0.0, 0.0], [0.0, 0.0]));
406    }
407
408    #[test]
409    fn on_boundary_covers_vertices_and_edges() {
410        let sq = ccw_square(1000.0);
411        assert!(on_boundary(&sq, [0.0, 0.0]), "頂点");
412        assert!(on_boundary(&sq, [500.0, 0.0]), "辺上");
413        assert!(on_boundary(&sq, [500.0, 0.9]), "帯の内側");
414        assert!(!on_boundary(&sq, [500.0, 1.1]), "帯の外側");
415        assert!(!on_boundary(&[[0.0, 0.0]], [0.0, 0.0]), "頂点 2 個未満は偽");
416    }
417}