Skip to main content

squid_n_core/region_gen/
floor.rs

1//! 主架構(水平な大梁)が囲む閉領域(床領域の境界)の検出。
2//!
3//! 床領域は「大梁で囲まれた領域ごとに 1 つ」と定める。本モジュールは、その閉領域を
4//! 主架構のトポロジーから求める。壁領域(柱と梁が囲む鉛直構面内の閉領域)は
5//! [`super::wall`] が同じ面走査エンジン([`super::scan_faces`])を使って求める。
6//!
7//! # 生成規則
8//!
9//! 1. **対象**は水平な 2 節点の梁要素([`ElementKind::Beam`])のみ。両端の Z 差が
10//!    [`crate::geom::LEVEL_TOL_MM`] 以内のものを水平とみなす。柱・ブレース・壁・二次部材は対象にしない
11//!    (床領域の境界は大梁であるという定義による)。
12//! 2. **レベルごと**に分けて面を求める。同じ Z([`crate::geom::LEVEL_TOL_MM`] 以内)の梁を 1 つの
13//!    平面グラフとして扱う。
14//! 3. **面走査**は半辺(有向辺)をたどる定型手法による([`super::scan_faces`])。
15//! 4. **外周面は符号付き面積が負**になるため、これを捨てる。面積の大小では判別しない
16//!    (中庭のある建物では、面積最大の内部面が外周面より大きくなりうる)。
17//! 5. **行き止まりの辺**(片持ち梁など、片端が他の梁と繋がらない梁)は、面走査が
18//!    往復してその場に戻るため面を作らない。往復ぶんは外周面に吸収される。
19//!
20//! # 平面グラフであることの前提
21//!
22//! 面走査は、辺どうしが節点でのみ接することを前提とする。節点を共有せずに交差する梁が
23//! あると、検出される床領域は実際とずれるが、走査自体はエラーにならず黙って通る。
24//! [`scan_region_boundaries`] はその組を [`RegionBoundaryScan::crossings`] として報告する
25//! (検出は続行する。モデルの不備として利用者へ知らせるための情報である)。
26//!
27//! # 扱わないもの
28//!
29//! - **穴(中庭)を持つ面**。グラフが非連結で、ある閉路の内側に独立した閉路がある場合、
30//!   外側の面は内側の存在を無視した多角形として返る。実建物では中庭のまわりに梁が
31//!   通るため(=連結する)、この形は稀である。
32//! - **段差床**。レベルで分けるため、同じ階でもレベルが違えば別の平面グラフになる。
33//!   段差部の梁は両方のレベルのどちらにも属さない(両端の Z が違うため水平とみなされない)。
34
35use super::{scan_faces, Edge};
36use crate::geom::polygon;
37use crate::geom::{vec3, LEVEL_TOL_MM, MEMBER_AXIS_TOL_MM};
38use crate::ids::{ElemId, NodeId};
39use crate::model::{ElementKind, Model};
40
41/// 面走査の対象とする梁の最小長さ [mm]。これ未満は方位角が定まらないため除外する。
42const MIN_EDGE_LEN_MM: f64 = 1.0;
43
44/// 主架構が囲む閉領域(床領域の境界)1 つ。
45#[derive(Clone, Debug, PartialEq)]
46pub struct RegionBoundary {
47    /// 面のレベル Z [mm](構成する節点の Z の平均)。
48    pub level: f64,
49    /// 境界の節点列(反時計回り。始点は繰り返さない)。
50    pub boundary: Vec<NodeId>,
51    /// 境界をなす梁要素(`boundary` の辺と同順。辺 i は `boundary[i]`→`boundary[i+1]`)。
52    pub edges: Vec<ElemId>,
53}
54
55impl RegionBoundary {
56    /// 境界節点の XY 座標列。節点が引けない場合は `None`。
57    fn polygon(&self, model: &Model) -> Option<Vec<[f64; 2]>> {
58        self.boundary
59            .iter()
60            .map(|n| model.nodes.get(n.index()).map(|n| [n.coord[0], n.coord[1]]))
61            .collect()
62    }
63
64    /// 平面多角形の面積 [mm²](シューレース公式)。
65    pub fn area(&self, model: &Model) -> f64 {
66        self.polygon(model)
67            .map(|pts| polygon::area(&pts))
68            .unwrap_or(0.0)
69    }
70
71    /// 点 `p`(XY)がこの境界の内部にあるか。**辺上([`crate::geom::polygon::BOUNDARY_TOL_MM`] 以内)は含めない。**
72    ///
73    /// 版や二次部材をこの境界へ割り当てる用途を想定する。辺上の点は隣接する境界の双方に
74    /// 該当してしまうため含めない(所属を一意に決められるようにする)。
75    ///
76    /// レイキャストは辺上の点の扱いが定まらない(辺の向きしだいで内側にも外側にもなる)ため、
77    /// 辺までの距離による判定を先に行う。
78    pub fn contains(&self, model: &Model, p: [f64; 2]) -> bool {
79        let Some(poly) = self.polygon(model) else {
80            return false;
81        };
82        polygon::contains_excluding_boundary(&poly, p)
83    }
84
85    /// この境界と同じレベルか([`crate::geom::LEVEL_TOL_MM`] 以内)。
86    pub fn is_same_level(&self, z: f64) -> bool {
87        (self.level - z).abs() <= LEVEL_TOL_MM
88    }
89}
90
91/// 面走査の結果。床領域の境界に加え、平面グラフとして矛盾がある兆候を持ち帰る。
92#[derive(Clone, Debug, Default, PartialEq)]
93pub struct RegionBoundaryScan {
94    /// 検出した床領域の境界。レベルの昇順、同一レベル内は面積の降順。
95    pub boundaries: Vec<RegionBoundary>,
96    /// 節点を共有せずに交差している水平梁の組。
97    ///
98    /// 面走査は平面グラフ(辺どうしが節点でのみ接する)を前提とする。交差する梁があると
99    /// 検出される境界は実際の床領域とずれるが、走査自体はエラーにならず黙って通る。
100    /// **モデルの不備として利用者へ知らせるための情報であり、境界の検出は続行する。**
101    pub crossings: Vec<(ElemId, ElemId)>,
102    /// 閉じずに終わった面走査の数。
103    ///
104    /// 各半辺の後続は一意に定まるため、正しく組めていれば必ず 0 になる(不変条件の番人)。
105    /// 0 でない場合は面走査の実装かグラフの構築に誤りがある。
106    pub unclosed: usize,
107}
108
109/// 主架構(水平な大梁)が囲む閉領域を、レベルごとに検出する。
110///
111/// モデルは変更しない。生成規則はモジュールドキュメントを参照。
112/// 平面グラフとして矛盾がある兆候(交差する梁など)も併せて返す。
113pub fn scan_region_boundaries(model: &Model) -> RegionBoundaryScan {
114    let mut scan = RegionBoundaryScan::default();
115    for (level, edges) in horizontal_beams_by_level(model) {
116        scan.crossings.extend(crossing_pairs(model, &edges));
117        let (boundaries, unclosed) = faces_of_level(model, level, &edges);
118        scan.boundaries.extend(boundaries);
119        scan.unclosed += unclosed;
120    }
121    scan
122}
123
124/// [`scan_region_boundaries`] の境界だけを取り出す薄いラッパ。
125pub fn generate_region_boundaries(model: &Model) -> Vec<RegionBoundary> {
126    scan_region_boundaries(model).boundaries
127}
128
129/// 節点を共有せずに交差している水平大梁の組を、レベルごとに集めて返す。
130///
131/// 面走査は平面グラフ(辺どうしが節点でのみ接する)を前提とするため、交差する梁があると
132/// 検出される床領域が実際とずれる。**モデルの不備として利用者へ知らせるための情報**であり、
133/// 境界の検出自体は続行する([`scan_region_boundaries`] 参照)。
134///
135/// 境界を組まずに交差だけを知りたい場合(診断など)は、面走査を伴わないこちらを使う。
136pub fn crossing_beams(model: &Model) -> Vec<(ElemId, ElemId)> {
137    let mut out = Vec::new();
138    for (_, edges) in horizontal_beams_by_level(model) {
139        out.extend(crossing_pairs(model, &edges));
140    }
141    out
142}
143
144/// 同一レベルの梁のうち、節点を共有せずに交差している組を返す。
145///
146/// 端点で接する(T 字・十字に節点を共有する)ものは交差としない。
147/// 一方の端点が他方の内部に載る(節点を共有しない T 字)場合も交差として報告する。
148/// 面走査がその節点を分岐として扱えず、床領域がつながってしまうためである。
149fn crossing_pairs(model: &Model, edges: &[Edge]) -> Vec<(ElemId, ElemId)> {
150    let coords = |n: NodeId| model.nodes.get(n.index()).map(|x| [x.coord[0], x.coord[1]]);
151
152    // 総当たりは梁の本数の 2 乗になる(実測で 32,800 本・約 530ms)。準備計算と
153    // 解析前チェックの両方から毎回呼ばれるため、まず境界矩形が重なる組だけに絞る。
154    // X の下限で並べ、X 区間が離れた時点で内側の走査を打ち切る(走査線法)。
155    struct Box {
156        idx: usize,
157        min: [f64; 2],
158        max: [f64; 2],
159    }
160    let mut boxes: Vec<Box> = Vec::with_capacity(edges.len());
161    for (idx, e) in edges.iter().enumerate() {
162        let (Some(a), Some(b)) = (coords(e.a), coords(e.b)) else {
163            continue;
164        };
165        boxes.push(Box {
166            idx,
167            min: [
168                a[0].min(b[0]) - MEMBER_AXIS_TOL_MM,
169                a[1].min(b[1]) - MEMBER_AXIS_TOL_MM,
170            ],
171            max: [
172                a[0].max(b[0]) + MEMBER_AXIS_TOL_MM,
173                a[1].max(b[1]) + MEMBER_AXIS_TOL_MM,
174            ],
175        });
176    }
177    boxes.sort_by(|x, y| x.min[0].total_cmp(&y.min[0]));
178
179    let mut out = Vec::new();
180    for (i, bi) in boxes.iter().enumerate() {
181        for bj in boxes.iter().skip(i + 1) {
182            if bj.min[0] > bi.max[0] {
183                break; // 以降は X 区間が離れる(下限の昇順に並んでいる)。
184            }
185            if bj.min[1] > bi.max[1] || bi.min[1] > bj.max[1] {
186                continue; // Y 区間が離れている。
187            }
188            let (e1, e2) = (&edges[bi.idx], &edges[bj.idx]);
189            if e1.a == e2.a || e1.a == e2.b || e1.b == e2.a || e1.b == e2.b {
190                continue; // 節点を共有する組は交差ではない。
191            }
192            let (Some(p1), Some(p2), Some(q1), Some(q2)) =
193                (coords(e1.a), coords(e1.b), coords(e2.a), coords(e2.b))
194            else {
195                continue;
196            };
197            if segments_touch(p1, p2, q1, q2) {
198                out.push((e1.elem, e2.elem));
199            }
200        }
201    }
202    out.sort_by_key(|(a, b)| (a.0, b.0));
203    out
204}
205
206/// 2 線分が交わるか(端点どうしの共有は上位で除外済み)。
207///
208/// 一方の端点が相手の材軸上に載る(節点を共有しない T 字)判定には、
209/// [`crate::geom::MEMBER_AXIS_TOL_MM`] を用いる。荷重を梁へ割り付ける側が拾う近さと
210/// そろえるためで、外積のしきい値で見ると長い部材ほど厳しくなり、
211/// 「荷重は載るのに診断には出ない」ずれが生じる。
212fn segments_touch(p1: [f64; 2], p2: [f64; 2], q1: [f64; 2], q2: [f64; 2]) -> bool {
213    let d = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
214        (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
215    };
216    let (d1, d2, d3, d4) = (d(p1, p2, q1), d(p1, p2, q2), d(q1, q2, p1), d(q1, q2, p2));
217    if ((d1 > 0.0) != (d2 > 0.0)) && ((d3 > 0.0) != (d4 > 0.0)) {
218        return true;
219    }
220    // 端点が相手の線分上に載る(節点を共有しない T 字・重なり)。
221    let on = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
222        polygon::point_segment_dist(c, a, b) <= MEMBER_AXIS_TOL_MM
223    };
224    on(p1, p2, q1) || on(p1, p2, q2) || on(q1, q2, p1) || on(q1, q2, p2)
225}
226
227/// 水平な 2 節点梁を、レベルごとに集める。返り値はレベルの昇順。
228fn horizontal_beams_by_level(model: &Model) -> Vec<(f64, Vec<Edge>)> {
229    let mut buckets: Vec<(f64, Vec<Edge>)> = Vec::new();
230    for e in &model.elements {
231        if e.kind != ElementKind::Beam || e.nodes.len() != 2 {
232            continue;
233        }
234        let (Some(na), Some(nb)) = (
235            model.nodes.get(e.nodes[0].index()),
236            model.nodes.get(e.nodes[1].index()),
237        ) else {
238            continue;
239        };
240        if (na.coord[2] - nb.coord[2]).abs() > LEVEL_TOL_MM {
241            continue; // 段差部の梁・傾斜梁は水平面に属さない。
242        }
243        if vec3::dist(na.coord, nb.coord) < MIN_EDGE_LEN_MM {
244            continue;
245        }
246        let z = (na.coord[2] + nb.coord[2]) / 2.0;
247        let edge = Edge {
248            a: e.nodes[0],
249            b: e.nodes[1],
250            elem: e.id,
251        };
252        match buckets
253            .iter_mut()
254            .find(|(level, _)| (*level - z).abs() <= LEVEL_TOL_MM)
255        {
256            Some((_, list)) => list.push(edge),
257            None => buckets.push((z, vec![edge])),
258        }
259    }
260    buckets.sort_by(|a, b| a.0.total_cmp(&b.0));
261    buckets
262}
263
264/// 1 レベルぶんの平面グラフから内部面を取り出す。返り値は(面, 閉じなかった走査の数)。
265fn faces_of_level(model: &Model, level: f64, edges: &[Edge]) -> (Vec<RegionBoundary>, usize) {
266    let proj = |n: NodeId| {
267        model
268            .nodes
269            .get(n.index())
270            .map(|nd| [nd.coord[0], nd.coord[1]])
271    };
272    let (faces, unclosed) = scan_faces(edges, proj);
273
274    // 外周面は符号付き面積が負になる。行き止まりの辺だけを往復した閉路は面積 0。
275    let mut boundaries: Vec<RegionBoundary> = faces
276        .into_iter()
277        .filter(|f| f.signed_area > 0.0)
278        .map(|f| RegionBoundary {
279            level,
280            boundary: f.boundary,
281            edges: f.edges,
282        })
283        .collect();
284
285    boundaries.sort_by(|a, b| b.area(model).total_cmp(&a.area(model)));
286    (boundaries, unclosed)
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::model::{ElementData, EndCondition, ForceRegime, LocalAxis, Node};
293
294    fn node(id: u32, x: f64, y: f64, z: f64) -> Node {
295        Node {
296            id: NodeId(id),
297            coord: [x, y, z],
298            restraint: Default::default(),
299            mass: None,
300            story: None,
301            support_spring: None,
302        }
303    }
304
305    fn beam(id: u32, i: u32, j: u32) -> ElementData {
306        ElementData {
307            id: ElemId(id),
308            kind: ElementKind::Beam,
309            nodes: [NodeId(i), NodeId(j)].into_iter().collect(),
310            section: None,
311            local_axis: LocalAxis {
312                ref_vector: [0.0, 0.0, 1.0],
313            },
314            end_cond: [EndCondition::Fixed, EndCondition::Fixed],
315            force_regime: ForceRegime::Auto,
316            rigid_zone: Default::default(),
317            plastic_zone: None,
318            spring: None,
319        }
320    }
321
322    /// 節点を格子状に並べたモデル。`nx`×`ny` 個の格子区画の梁格子を張る。
323    fn grid(nx: usize, ny: usize, pitch: f64, z: f64) -> Model {
324        let mut model = Model::default();
325        let idx = |ix: usize, iy: usize| (iy * (nx + 1) + ix) as u32;
326        for iy in 0..=ny {
327            for ix in 0..=nx {
328                model
329                    .nodes
330                    .push(node(idx(ix, iy), ix as f64 * pitch, iy as f64 * pitch, z));
331            }
332        }
333        let mut eid = 0;
334        for iy in 0..=ny {
335            for ix in 0..nx {
336                model.elements.push(beam(eid, idx(ix, iy), idx(ix + 1, iy)));
337                eid += 1;
338            }
339        }
340        for ix in 0..=nx {
341            for iy in 0..ny {
342                model.elements.push(beam(eid, idx(ix, iy), idx(ix, iy + 1)));
343                eid += 1;
344            }
345        }
346        model
347    }
348
349    #[test]
350    fn test_single_boundary() {
351        let model = grid(1, 1, 4000.0, 0.0);
352        let boundaries = generate_region_boundaries(&model);
353        assert_eq!(boundaries.len(), 1, "1 床領域なら面は 1 つ");
354        assert_eq!(boundaries[0].boundary.len(), 4);
355        assert!((boundaries[0].area(&model) - 4000.0 * 4000.0).abs() < 1.0);
356        assert!((boundaries[0].level - 0.0).abs() < 1e-9);
357        assert_eq!(boundaries[0].edges.len(), 4, "境界の梁が辺と同数");
358    }
359
360    #[test]
361    fn test_grid_boundaries() {
362        let model = grid(3, 2, 3000.0, 4000.0);
363        let boundaries = generate_region_boundaries(&model);
364        assert_eq!(boundaries.len(), 6, "3×2 の格子は 6 面");
365        for p in &boundaries {
366            assert!((p.area(&model) - 3000.0 * 3000.0).abs() < 1.0);
367            assert!((p.level - 4000.0).abs() < 1e-9);
368        }
369    }
370
371    /// 面の頂点列は反時計回り(符号付き面積が正)で返る。
372    #[test]
373    fn test_boundary_is_counter_clockwise() {
374        let model = grid(1, 1, 4000.0, 0.0);
375        let p = &generate_region_boundaries(&model)[0];
376        let pts: Vec<[f64; 2]> = p
377            .boundary
378            .iter()
379            .map(|n| {
380                let c = model.nodes[n.index()].coord;
381                [c[0], c[1]]
382            })
383            .collect();
384        assert!(polygon::signed_area(&pts) > 0.0, "反時計回りで返る");
385    }
386
387    /// 片持ち梁(行き止まりの辺)は面を作らず、囲まれた面の数も変えない。
388    #[test]
389    fn test_dangling_beam_makes_no_boundary() {
390        let mut model = grid(1, 1, 4000.0, 0.0);
391        model.nodes.push(node(4, 6000.0, 0.0, 0.0));
392        let eid = model.elements.len() as u32;
393        model.elements.push(beam(eid, 1, 4)); // 節点 1 から外へ跳ね出す片持ち梁
394        let boundaries = generate_region_boundaries(&model);
395        assert_eq!(boundaries.len(), 1, "片持ち梁は面を作らない");
396        assert!((boundaries[0].area(&model) - 4000.0 * 4000.0).abs() < 1.0);
397    }
398
399    /// レベルが違う梁は別の平面グラフとして扱う。
400    #[test]
401    fn test_levels_are_separated() {
402        let mut model = grid(1, 1, 4000.0, 0.0);
403        let base_nodes = model.nodes.len() as u32;
404        let base_elems = model.elements.len() as u32;
405        let upper = grid(1, 1, 4000.0, 4000.0);
406        for (k, n) in upper.nodes.iter().enumerate() {
407            model.nodes.push(node(
408                base_nodes + k as u32,
409                n.coord[0],
410                n.coord[1],
411                n.coord[2],
412            ));
413        }
414        for (k, e) in upper.elements.iter().enumerate() {
415            model.elements.push(beam(
416                base_elems + k as u32,
417                base_nodes + e.nodes[0].0,
418                base_nodes + e.nodes[1].0,
419            ));
420        }
421        let boundaries = generate_region_boundaries(&model);
422        assert_eq!(boundaries.len(), 2, "レベルごとに 1 面ずつ");
423        assert!(
424            (boundaries[0].level - 0.0).abs() < 1e-9,
425            "レベルの昇順で返る"
426        );
427        assert!((boundaries[1].level - 4000.0).abs() < 1e-9);
428    }
429
430    /// 柱・ブレース・傾斜梁は境界に使わない。
431    #[test]
432    fn test_only_horizontal_beams_are_used() {
433        let mut model = grid(1, 1, 4000.0, 0.0);
434        // 節点 0 から立ち上がる柱。
435        model.nodes.push(node(4, 0.0, 0.0, 4000.0));
436        let eid = model.elements.len() as u32;
437        model.elements.push(beam(eid, 0, 4));
438        // 傾斜梁(両端の Z が違う)。
439        model.nodes.push(node(5, 4000.0, 0.0, 2000.0));
440        model.elements.push(beam(eid + 1, 1, 5));
441        assert_eq!(generate_region_boundaries(&model).len(), 1);
442    }
443
444    /// 節点を共有せずに交差する梁は、モデルの不備として報告する。
445    #[test]
446    fn test_crossing_beams_are_reported() {
447        let mut model = grid(1, 1, 4000.0, 0.0);
448        // 床領域の内側を斜めに横切る 2 本。互いに交差し、外周とも節点を共有しない。
449        model.nodes.push(node(4, 1000.0, 1000.0, 0.0));
450        model.nodes.push(node(5, 3000.0, 3000.0, 0.0));
451        model.nodes.push(node(6, 1000.0, 3000.0, 0.0));
452        model.nodes.push(node(7, 3000.0, 1000.0, 0.0));
453        let eid = model.elements.len() as u32;
454        model.elements.push(beam(eid, 4, 5));
455        model.elements.push(beam(eid + 1, 6, 7));
456
457        let scan = scan_region_boundaries(&model);
458        assert_eq!(scan.crossings.len(), 1, "交差する 1 組を報告する");
459        assert_eq!(scan.unclosed, 0, "走査自体は閉じる");
460        // 交差があっても境界の検出は続行する(外周の 1 面は取れる)。
461        assert_eq!(scan.boundaries.len(), 1);
462    }
463
464    /// 節点を共有せずに一方の端点が他方の途中へ載る梁(T 字)も報告する。
465    #[test]
466    fn test_touching_beam_without_shared_node_is_reported() {
467        let mut model = grid(1, 1, 4000.0, 0.0);
468        // 辺 0-1(y=0)の中間へ、節点を共有せずに突き当たる梁。
469        model.nodes.push(node(4, 2000.0, 0.0, 0.0));
470        model.nodes.push(node(5, 2000.0, 2000.0, 0.0));
471        let eid = model.elements.len() as u32;
472        model.elements.push(beam(eid, 4, 5));
473
474        let scan = scan_region_boundaries(&model);
475        assert_eq!(scan.crossings.len(), 1);
476    }
477
478    /// 節点を共有する組(十字に交わる格子)は交差として報告しない。
479    #[test]
480    fn test_shared_node_is_not_a_crossing() {
481        let model = grid(2, 2, 3000.0, 0.0);
482        let scan = scan_region_boundaries(&model);
483        assert!(scan.crossings.is_empty(), "{:?}", scan.crossings);
484        assert_eq!(scan.boundaries.len(), 4);
485        assert_eq!(scan.unclosed, 0);
486    }
487
488    /// 材軸からわずかにずれて突き当たる梁も、交差として報告する。
489    ///
490    /// 荷重の割り付けは `MEMBER_AXIS_TOL_MM` 以内のずれを「梁に載っている」と扱うため、
491    /// 診断も同じ近さで知らせないと「荷重は載るのに診断には出ない」状態になる。
492    #[test]
493    fn test_near_collinear_touch_is_reported() {
494        let mut model = grid(1, 1, 4000.0, 0.0);
495        // 辺 0-1(y=0)の中間へ、5mm 手前で止まる梁(節点は共有しない)。
496        model.nodes.push(node(4, 2000.0, 5.0, 0.0));
497        model.nodes.push(node(5, 2000.0, 2000.0, 0.0));
498        let eid = model.elements.len() as u32;
499        model.elements.push(beam(eid, 4, 5));
500        assert_eq!(
501            scan_region_boundaries(&model).crossings.len(),
502            1,
503            "5mm のずれは交差として報告する"
504        );
505
506        // 100mm 離れていれば、突き当たっているとはみなさない。
507        model.nodes[4].coord[1] = 100.0;
508        assert!(scan_region_boundaries(&model).crossings.is_empty());
509    }
510
511    /// 境界の内外判定(辺上は内部に含めない)。
512    #[test]
513    fn test_region_boundary_contains() {
514        let model = grid(1, 1, 4000.0, 0.0);
515        let p = &generate_region_boundaries(&model)[0];
516        assert!(p.contains(&model, [2000.0, 2000.0]), "内部");
517        assert!(!p.contains(&model, [5000.0, 2000.0]), "外部");
518        assert!(!p.contains(&model, [2000.0, 0.0]), "辺上は含めない");
519        assert!(p.is_same_level(0.0));
520        assert!(!p.is_same_level(4000.0));
521    }
522
523    /// L 形(凹多角形)の面も 1 つの面として取れる。
524    #[test]
525    fn test_concave_boundary() {
526        // 2×2 の格子から 1 床領域ぶんの梁を落として L 形の面を作る。
527        let mut model = Model::default();
528        let pts = [
529            (0.0, 0.0),
530            (4000.0, 0.0),
531            (8000.0, 0.0),
532            (0.0, 4000.0),
533            (4000.0, 4000.0),
534            (8000.0, 4000.0),
535            (0.0, 8000.0),
536            (4000.0, 8000.0),
537        ];
538        for (i, (x, y)) in pts.iter().enumerate() {
539            model.nodes.push(node(i as u32, *x, *y, 0.0));
540        }
541        // 外周: 0-1-2-5-4-7-6-0(L 形)
542        let ring = [(0, 1), (1, 2), (2, 5), (5, 4), (4, 7), (7, 6), (6, 0)];
543        for (k, (i, j)) in ring.iter().enumerate() {
544            model.elements.push(beam(k as u32, *i, *j));
545        }
546        let boundaries = generate_region_boundaries(&model);
547        assert_eq!(boundaries.len(), 1, "L 形でも面は 1 つ");
548        // 面積 = 8000×4000 + 4000×4000 = 48,000,000 mm²
549        assert!((boundaries[0].area(&model) - 48.0e6).abs() < 1.0);
550        assert_eq!(boundaries[0].boundary.len(), 7);
551    }
552}