Skip to main content

squid_n_core/region_gen/
wall.rs

1//! 柱・梁が囲む鉛直構面内の閉領域(壁領域の境界)の検出。
2//!
3//! 壁領域は「柱と梁が囲む鉛直構面内の閉領域ごとに 1 つ」と定める(D1)。床の面走査
4//! ([`super::floor`])は「同じレベル Z」で対象を絞り込んでから面走査にかけるが、
5//! 壁は「同じ鉛直構面(= XY 平面上の同一直線の真上)」で絞り込む必要があり、
6//! この絞り込み自体が新規の幾何判定になる。通り芯(`AxisGroup`。表示専用のデータで
7//! 構造計算には一切使わない)には依存しない。直交グリッドか斜めかを問わず同じロジックで
8//! 扱う(`dev_docs/handoff/床領域・壁領域の再設計_申し送り.md` §3.2 E1)。
9//!
10//! # 生成規則
11//!
12//! 1. **鉛直構面の候補**は、全柱(`ElementKind::Beam` のうち
13//!    [`crate::geom::is_vertical_pair`] を満たすもの)の柱脚位置(XY)から作る。
14//!    柱位置を [`crate::geom::MEMBER_AXIS_TOL_MM`] 以内で重複排除したうえで全ペアから
15//!    直線を作り、既に見つかっている直線と同一とみなせるものは統合する。
16//!
17//!    **同一直線とみなす判定は、角度の許容差ではなく、柱位置から直線までの実距離 [mm]
18//!    で行う。** 角度の許容差は直線が長いほど遠方での位置ずれが拡大するため使わない
19//!    (梁の交差診断を同じ理由で角度の外積しきい値から `MEMBER_AXIS_TOL_MM` へ
20//!    統一した経緯(§8.3)と同じ判断)。例えば 0.5° の角度許容差は 50m 先で 436mm の
21//!    ずれになり、10mm の許容差としては粗すぎる。
22//! 2. **各直線に乗る部材**は、両端の XY 射影が直線から `MEMBER_AXIS_TOL_MM` 以内にある
23//!    `ElementKind::Beam` の要素(柱・梁の区別、鉛直・水平・斜めの区別は問わない)。
24//!    ブレース・壁要素・二次部材・パネルゾーンは対象にしない。
25//! 3. **面走査**は直線ごとに、局所座標 `(s, z)`(s=直線に沿った位置、z=グローバル Z)へ
26//!    射影して行う([`super::scan_faces`]、床と共通のエンジン)。1 本の直線は建物の
27//!    全高さをまとめて 1 つの平面グラフとして扱うため、複数階にまたがる壁面は
28//!    階ごとの内部面として現れる。
29//!
30//! # 非平面な壁面は複数の境界へ自然に分解される
31//!
32//! 壁領域の境界検出は常に厳密な平面(1 つの直線の真上)を対象とする。芯ずれ・
33//! 折れ曲がり・傾きがある壁面は、1 つの [`WallRegionBoundary`] にはならず、
34//! 平面ごとに複数の境界へ自然に分解される。**この前提は緩めない**
35//! (複数面の節点をまとめて 1 つの境界にする近道を取らないこと。§3.2 E2)。
36//!
37//! # 面積・自重の計算について
38//!
39//! [`WallRegionBoundary::area`] は、実座標から [`crate::geom::polygon::area_3d`]
40//! (ニューエルの公式)で求めた 3 次元面積である。壁・シェル要素の自重算定と
41//! スラブ・壁の数量拾いが共通で使う既存の関数をそのまま使い、自前実装を持たない。
42//! トポロジー(面走査・外周面の判別)は直線への射影という近似で行ってよいが
43//! (`MEMBER_AXIS_TOL_MM` 以内の芯ずれ・非平面性は面走査の結果を左右しない)、
44//! 面積・荷重は理想平面へ投影せず実座標から直接求める(§3.2 E3)。芯ずれが
45//! `MEMBER_AXIS_TOL_MM` 相当あると、投影面積は自重計算で無視できない誤差になりうるため。
46//!
47//! # 計算量について
48//!
49//! 柱脚位置の全ペアから直線候補を作るため、柱の本数を N とすると候補数は O(N²) になる。
50//! 実建物の柱本数(数百〜数千本程度)では実用上問題にならないが、極端に柱本数が多い
51//! モデルでは遅くなりうる。梁の交差診断([`super::floor::crossing_beams`])も当初は
52//! 総当たりで実装し、実測して初めて走査線法へ最適化した経緯があり(§8.3)、
53//! 本モジュールも同じ方針(先に正しさを固め、実測してから最適化する)を踏襲する。
54//!
55//! # 未結線
56//!
57//! 本モジュールは幾何の検出のみを提供する。`WallRegion`/`WallPlate` への統合、
58//! モデルとの結線(要素生成・交差診断の接続)は別の作業(Step 7+8)で行う
59//! (`dev_docs/handoff/床領域・壁領域の再設計_申し送り.md` §9)。
60
61use super::{scan_faces, Edge};
62use crate::geom::{is_vertical_pair, MEMBER_AXIS_TOL_MM};
63use crate::ids::{ElemId, NodeId};
64use crate::model::{ElementKind, Model};
65use std::collections::HashMap;
66
67/// 局所座標 `(s, z)` での部材の最小長さ [mm]。3 次元長さではなく射影後の長さで判定する
68/// (構面にほぼ垂直な短い部材が射影で長さ 0 に潰れ、方位角の並べ替えを乱すのを防ぐ)。
69///
70/// **この経路(構面上にはあるが射影後の長さがほぼ 0 の部材が、既存の辺と近い方位角に
71/// 紛れ込んで並べ替えを乱す具体的なケース)は、まだテストで再現できていない。**
72/// `test_perpendicular_beam_does_not_change_face_count` は「構面から離れた部材が
73/// 距離判定で除外される」経路を確認しているだけで、本フィルタ(射影後の長さ)が
74/// 実際に効く経路とは異なる。角度の近接を意図的に作るテストは、部材端点の座標を
75/// 高い精度で作り込む必要があり複雑になるため見送った(`dev_docs/handoff/
76/// 床領域・壁領域の再設計_申し送り.md` §5.7)。
77///
78/// 再現するなら `test_isolated_dangling_column_does_not_create_spurious_boundary`
79/// と同じ手筋(非軸並行の方位・実データ規模の大きな絶対座標)が有効なはず。この
80/// フィルタも丸め誤差起因の面走査の乱れが対象であり、綺麗な数値の合成フィクスチャ
81/// では誤差が閾値を超えず再現に失敗しやすい。
82const MIN_PROJECTED_EDGE_LEN_MM: f64 = 1.0;
83
84/// 外周面(行き止まりの部材が往復して戻る退化した閉路を含む)を判別する際の
85/// 面積のしきい値 [mm²]。**`0.0` ではなく正の値にする必要がある。**
86///
87/// 建物の柱は複数階にわたって節点が分割されるため、同一の柱に属する節点は
88/// XY が完全に一致する(Z のみ異なる)。行き止まりの柱(この構面上で他のどの
89/// 部材とも接続しない孤立した柱)を面走査がたどると、往復閉路の射影後の座標
90/// `(s, z)` は全節点で `s` が完全に一致し、理論上の符号付き面積は厳密に 0 になる。
91/// しかし `s`(`v・direction`)の計算に使う実座標の値が大きい(実測で 1〜2 万mm)と、
92/// シューレース公式の丸め誤差が `1e-8`〜`1e-7`mm² 程度残り、`> 0.0` の判定では
93/// この浮動小数点の残差をそのまま「正の面積」として拾ってしまう
94/// (実建物データで実際に発生し、`crates/squid-n-app/tests/full_model.rs::
95/// region_gen_finds_wall_bounded_regions` の面積正値チェックで検出した)。
96///
97/// `MEMBER_AXIS_TOL_MM²`(100mm²)を採用したのは、既存の許容差定数を再利用しつつ、
98/// 観測された丸め誤差(`1e-8` 程度)より 9 桁以上大きく、実在の壁面積
99/// (小さくても数百万mm² オーダー)より 4 桁以上小さいためである。
100const MIN_FACE_AREA_MM2: f64 = MEMBER_AXIS_TOL_MM * MEMBER_AXIS_TOL_MM;
101
102/// 柱・梁が囲む鉛直構面内の閉領域(壁領域の境界)1 つ。
103#[derive(Clone, Debug, PartialEq)]
104pub struct WallRegionBoundary {
105    /// この境界が乗る構面(直線)の基準点(柱脚位置の XY 平面上)。
106    pub plane_origin: [f64; 2],
107    /// 構面の方向単位ベクトル(XY 平面上)。見出し角は `[0, π)` に正規化済み。
108    pub plane_direction: [f64; 2],
109    /// 境界の節点列(局所座標 `(s, z)` で反時計回り。始点は繰り返さない)。
110    pub boundary: Vec<NodeId>,
111    /// 境界をなす部材(`boundary` の辺と同順。辺 i は `boundary[i]`→`boundary[i+1]`)。
112    pub edges: Vec<ElemId>,
113}
114
115impl WallRegionBoundary {
116    /// 境界節点の実座標列 [mm](3 次元)。節点が引けない場合は `None`。
117    fn coords(&self, model: &Model) -> Option<Vec<[f64; 3]>> {
118        self.boundary
119            .iter()
120            .map(|n| model.nodes.get(n.index()).map(|n| n.coord))
121            .collect()
122    }
123
124    /// 面積 [mm²](ニューエルの公式による 3 次元面積。理想平面への投影を経由しない。
125    /// モジュールドキュメント参照)。節点が引けない場合は 0。
126    pub fn area(&self, model: &Model) -> f64 {
127        self.coords(model)
128            .map(|pts| crate::geom::polygon::area_3d(&pts))
129            .unwrap_or(0.0)
130    }
131
132    /// 実座標 `coord` をこの境界の構面の局所座標 `(s, z)` へ射影する。
133    /// s は構面方向([`WallRegionBoundary::plane_direction`])に沿った
134    /// [`WallRegionBoundary::plane_origin`] からの距離、z はグローバル Z。
135    ///
136    /// **構面の局所座標は境界ごとに決まる**ため、射影は境界自身に持たせている
137    /// (`origin` と `direction` を呼び出し側が別々に渡す形だと、別の構面の
138    /// 基準点と方向を取り違えても型では防げない)。[`WallRegionBoundary::contains`]
139    /// が受け取る点も、この関数で射影したものでなければならない。
140    pub fn project(&self, coord: [f64; 3]) -> [f64; 2] {
141        project(self.plane_origin, self.plane_direction, coord)
142    }
143
144    /// 境界節点を局所座標 `(s, z)` へ射影した多角形。節点が引けない場合は `None`。
145    fn local_polygon(&self, model: &Model) -> Option<Vec<[f64; 2]>> {
146        self.boundary
147            .iter()
148            .map(|n| model.nodes.get(n.index()).map(|nd| self.project(nd.coord)))
149            .collect()
150    }
151
152    /// 局所座標 `(s, z)` の点 `p` がこの境界の内部にあるか(辺上は含まない厳密内包)。
153    /// `p` はこの境界と同じ構面([`WallRegionBoundary::is_same_plane`])上にあることを
154    /// 前提とする(呼び出し側で確認すること。射影後の座標系が異なる構面どうしを
155    /// 比較しても意味を持たない)。
156    pub fn contains(&self, model: &Model, p: [f64; 2]) -> bool {
157        let Some(poly) = self.local_polygon(model) else {
158            return false;
159        };
160        crate::geom::polygon::contains_excluding_boundary(&poly, p)
161    }
162
163    /// 与えた点 `origin` がこの境界と同じ構面(直線)上にあるか
164    /// ([`crate::geom::MEMBER_AXIS_TOL_MM`] 以内の実距離。[`super::floor::
165    /// RegionBoundary::is_same_level`] の壁側版)。**直線の方向ベクトルの数値的な
166    /// 一致は求めない**(同じ物理的な直線でも、由来する候補点の組が異なれば
167    /// 正規化後の方向ベクトルが浮動小数点の誤差だけずれうるため。位置の実距離
168    /// のほうが頑健な同一性判定になる。`wall_planes` の候補統合と同じ判断)。
169    pub fn is_same_plane(&self, origin: [f64; 2]) -> bool {
170        is_same_line(self.plane_origin, self.plane_direction, &[origin])
171    }
172}
173
174/// 面走査の結果。壁領域の境界を持つ。
175///
176/// [`super::floor::RegionBoundaryScan::crossings`] に相当する交差診断はまだ持たない
177/// (未着手。`dev_docs/handoff/床領域・壁領域の再設計_申し送り.md` §9 残課題)。
178#[derive(Clone, Debug, Default, PartialEq)]
179pub struct WallRegionBoundaryScan {
180    /// 検出した壁領域の境界。構面(直線)ごと、同一構面内は面積の降順。
181    pub boundaries: Vec<WallRegionBoundary>,
182    /// 閉じずに終わった面走査の数([`super::floor::RegionBoundaryScan::unclosed`] と同じ意味)。
183    pub unclosed: usize,
184}
185
186/// 柱・梁(`ElementKind::Beam`)が囲む鉛直構面内の閉領域を検出する。
187///
188/// モデルは変更しない。生成規則はモジュールドキュメントを参照。
189pub fn scan_wall_region_boundaries(model: &Model) -> WallRegionBoundaryScan {
190    let mut scan = WallRegionBoundaryScan::default();
191    for (origin, direction) in wall_planes(model) {
192        let edges = members_on_plane(model, origin, direction);
193        let proj = |n: NodeId| -> Option<[f64; 2]> {
194            model
195                .nodes
196                .get(n.index())
197                .map(|nd| project(origin, direction, nd.coord))
198        };
199        let (faces, unclosed) = scan_faces(&edges, proj);
200        scan.unclosed += unclosed;
201
202        let mut boundaries: Vec<WallRegionBoundary> = faces
203            .into_iter()
204            .filter(|f| f.signed_area > MIN_FACE_AREA_MM2)
205            .map(|f| WallRegionBoundary {
206                plane_origin: origin,
207                plane_direction: direction,
208                boundary: f.boundary,
209                edges: f.edges,
210            })
211            .collect();
212        boundaries.sort_by(|a, b| b.area(model).total_cmp(&a.area(model)));
213        scan.boundaries.extend(boundaries);
214    }
215    scan
216}
217
218/// [`scan_wall_region_boundaries`] の境界だけを取り出す薄いラッパ。
219pub fn generate_wall_region_boundaries(model: &Model) -> Vec<WallRegionBoundary> {
220    scan_wall_region_boundaries(model).boundaries
221}
222
223/// 柱脚位置(XY)から鉛直構面の候補直線を検出する。戻り値は `(origin, direction)`。
224/// `direction` は見出し角 `[0, π)` に正規化した単位ベクトル。
225///
226/// **候補ペアの重複判定にはグリッド索引([`LineIndex`])を使う。** 候補ペアは
227/// 柱本数 N に対して O(N²) 生じ、格子状の柱配置では格子点を結ぶ斜め方向の候補が
228/// 大量に残るため、見つかっている直線の本数 L も N の増加とともに増える。
229/// 素朴に「既存の全直線と実距離で比較する」実装(O(N²·L))は、実測で
230/// 900 本の柱を持つ 30×30 格子で 19 秒を超えた(`crates/squid-n-core/tests/perf_probe.rs`)。
231/// グリッド索引は候補を粗く絞り込む役割のみを持ち、**最終的な同一直線の判定は
232/// 必ず [`is_same_line`](実距離)で行う**ため、索引の取りこぼし・衝突は
233/// 正しさを損なわない(性能上のヒントにすぎない)。
234fn wall_planes(model: &Model) -> Vec<([f64; 2], [f64; 2])> {
235    let footprints = column_footprints(model);
236    if footprints.len() < 2 {
237        return Vec::new();
238    }
239    let mut lines: Vec<([f64; 2], [f64; 2])> = Vec::new();
240    let mut index = LineIndex::new(&footprints);
241    for i in 0..footprints.len() {
242        for j in (i + 1)..footprints.len() {
243            let (p, q) = (footprints[i], footprints[j]);
244            let Some(direction) = canonical_direction(p, q) else {
245                continue; // 重複点(距離ほぼ 0)は方向が定まらない。
246            };
247            let found = index
248                .nearby(p, direction)
249                .into_iter()
250                .find(|&idx| is_same_line(lines[idx].0, lines[idx].1, &[p, q]));
251            if found.is_some() {
252                continue;
253            }
254            let idx = lines.len();
255            lines.push((p, direction));
256            index.insert(idx, p, direction);
257        }
258    }
259    lines
260}
261
262/// [`wall_planes`] の候補直線をグリッドで粗く索引する構造体。
263///
264/// キーは `(2θ の単位円上の位置, 基準点からの符号なし距離)` を [`MEMBER_AXIS_TOL_MM`]
265/// 相当の刻みで量子化したもの。`2θ`(見出し角の 2 倍)を使うのは、直線の向きには
266/// ±180° の符号の曖昧さがあり、これを素朴な角度(mod π)でバケット化すると 0°/180°
267/// の境界で不連続になる(179.99° と 0.01° はほぼ同じ直線だが、実数直線上のバケットでは
268/// 両端に離れて割り当たる)ためである。角度を 2 倍すると、この符号の曖昧さがバケット
269/// キーの計算自体に現れなくなり(`cos 2(θ+π) = cos 2θ`)、単位円上の連続な埋め込みとして
270/// 素直にグリッド量子化できる。オフセット([`point_to_line_dist`])はもとから符号を
271/// 持たないため、同様の問題は生じない。
272struct LineIndex {
273    /// 直線の代表点からの距離を測る基準点(柱脚位置の重心)。
274    reference: [f64; 2],
275    /// 見出し角 `θ` の量子化刻み(`2θ` 側は `2 * ang_res`)。
276    ang_res: f64,
277    buckets: HashMap<(i64, i64, i64), Vec<usize>>,
278}
279
280impl LineIndex {
281    fn new(footprints: &[[f64; 2]]) -> Self {
282        let reference = centroid(footprints);
283        let r_max = footprints
284            .iter()
285            .map(|&p| dist2(p, reference).sqrt())
286            .fold(0.0_f64, f64::max)
287            .max(MEMBER_AXIS_TOL_MM);
288        // 基準点から最も遠い柱脚位置でも、角度の量子化による位置ずれが
289        // MEMBER_AXIS_TOL_MM 以下になるようにする。
290        let ang_res = MEMBER_AXIS_TOL_MM / r_max;
291        LineIndex {
292            reference,
293            ang_res,
294            buckets: HashMap::new(),
295        }
296    }
297
298    fn key(&self, line_point: [f64; 2], direction: [f64; 2]) -> (i64, i64, i64) {
299        let theta = direction[1].atan2(direction[0]);
300        let two_theta = 2.0 * theta;
301        let cell = (2.0 * self.ang_res).max(1e-12);
302        let bc = (two_theta.cos() / cell).floor() as i64;
303        let bs = (two_theta.sin() / cell).floor() as i64;
304        let offset = point_to_line_dist(line_point, direction, self.reference);
305        let bo = (offset / MEMBER_AXIS_TOL_MM).floor() as i64;
306        (bc, bs, bo)
307    }
308
309    /// このキーの近傍バケット(角度・オフセットとも隣接セルを含む)にある候補の
310    /// 直線インデックス一覧。近傍を含めるのは、量子化の境界をまたぐ場合を拾うため。
311    fn nearby(&self, line_point: [f64; 2], direction: [f64; 2]) -> Vec<usize> {
312        let (bc, bs, bo) = self.key(line_point, direction);
313        let mut out = Vec::new();
314        for dc in -1..=1 {
315            for ds in -1..=1 {
316                for doff in -1..=1 {
317                    if let Some(v) = self.buckets.get(&(bc + dc, bs + ds, bo + doff)) {
318                        out.extend_from_slice(v);
319                    }
320                }
321            }
322        }
323        out
324    }
325
326    fn insert(&mut self, idx: usize, line_point: [f64; 2], direction: [f64; 2]) {
327        let key = self.key(line_point, direction);
328        self.buckets.entry(key).or_default().push(idx);
329    }
330}
331
332/// 点群の重心。
333fn centroid(pts: &[[f64; 2]]) -> [f64; 2] {
334    let n = pts.len().max(1) as f64;
335    let sum = pts
336        .iter()
337        .fold([0.0, 0.0], |a, p| [a[0] + p[0], a[1] + p[1]]);
338    [sum[0] / n, sum[1] / n]
339}
340
341/// 柱の柱脚位置(XY)を [`crate::geom::MEMBER_AXIS_TOL_MM`] 以内で重複排除して集める。
342///
343/// **重複排除は `model.elements` の並び順に依存する(先勝ち)。** 例えば 3 本の柱が
344/// 8mm 間隔で一直線に並ぶ場合、生き残る代表点は `model.elements` の並び順しだいで
345/// 変わりうる。生き残った点が候補直線の原点([`wall_planes`] の `origin`)になるため、
346/// 検出される構面の原点も並び順の影響を受ける(構面自体・面走査の結果は変わらない。
347/// 影響するのは `WallRegionBoundary::plane_origin` の値のみ)。
348fn column_footprints(model: &Model) -> Vec<[f64; 2]> {
349    let mut pts: Vec<[f64; 2]> = Vec::new();
350    for e in &model.elements {
351        if e.kind != ElementKind::Beam || e.nodes.len() != 2 {
352            continue;
353        }
354        let (Some(a), Some(b)) = (
355            model.nodes.get(e.nodes[0].index()),
356            model.nodes.get(e.nodes[1].index()),
357        ) else {
358            continue;
359        };
360        if !is_vertical_pair(a.coord, b.coord) {
361            continue;
362        }
363        let p = [a.coord[0], a.coord[1]];
364        let dup = pts
365            .iter()
366            .any(|&q| dist2(p, q) <= MEMBER_AXIS_TOL_MM * MEMBER_AXIS_TOL_MM);
367        if !dup {
368            pts.push(p);
369        }
370    }
371    pts
372}
373
374/// 2 点から見出し角 `[0, π)` に正規化した単位方向を作る。距離がほぼ 0 なら `None`。
375///
376/// 角度の mod 演算(`atan2` の結果を `π` で割った余りを取る等)は 0°/180° の境界で
377/// 不連続になるため使わない。ベクトルの符号反転による正規化は、この境界の前後で
378/// 連続に振る舞う(179.99° 付近と 0.01° 付近はどちらも符号反転後にほぼ同じ方向になる)。
379fn canonical_direction(p: [f64; 2], q: [f64; 2]) -> Option<[f64; 2]> {
380    let d = [q[0] - p[0], q[1] - p[1]];
381    let len = (d[0] * d[0] + d[1] * d[1]).sqrt();
382    if len <= f64::EPSILON {
383        return None;
384    }
385    let mut u = [d[0] / len, d[1] / len];
386    if u[0] < 0.0 || (u[0] == 0.0 && u[1] < 0.0) {
387        u = [-u[0], -u[1]];
388    }
389    Some(u)
390}
391
392/// 直線 `(origin, direction)` が、`defining_points`(新たな候補直線を定義した柱脚位置)と
393/// 同一の構面とみなせるか。
394///
395/// 角度の許容差ではなく、各点から既存直線までの実距離 [mm] で判定する
396/// (モジュールドキュメント参照)。
397fn is_same_line(origin: [f64; 2], direction: [f64; 2], defining_points: &[[f64; 2]]) -> bool {
398    defining_points
399        .iter()
400        .all(|&p| point_to_line_dist(origin, direction, p) <= MEMBER_AXIS_TOL_MM)
401}
402
403/// 点から直線(`origin` を通り `direction` 方向)までの距離 [mm]。
404fn point_to_line_dist(origin: [f64; 2], direction: [f64; 2], p: [f64; 2]) -> f64 {
405    let v = [p[0] - origin[0], p[1] - origin[1]];
406    (v[0] * direction[1] - v[1] * direction[0]).abs()
407}
408
409fn dist2(a: [f64; 2], b: [f64; 2]) -> f64 {
410    (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)
411}
412
413/// 直線 `(origin, direction)` に乗る `ElementKind::Beam` を辺として集める。
414/// 両端の XY 射影が直線から `MEMBER_AXIS_TOL_MM` 以内にあるものを対象とする。
415fn members_on_plane(model: &Model, origin: [f64; 2], direction: [f64; 2]) -> Vec<Edge> {
416    let mut edges = Vec::new();
417    for e in &model.elements {
418        if e.kind != ElementKind::Beam || e.nodes.len() != 2 {
419            continue;
420        }
421        let (Some(a), Some(b)) = (
422            model.nodes.get(e.nodes[0].index()),
423            model.nodes.get(e.nodes[1].index()),
424        ) else {
425            continue;
426        };
427        let (pa, pb) = ([a.coord[0], a.coord[1]], [b.coord[0], b.coord[1]]);
428        if point_to_line_dist(origin, direction, pa) > MEMBER_AXIS_TOL_MM
429            || point_to_line_dist(origin, direction, pb) > MEMBER_AXIS_TOL_MM
430        {
431            continue;
432        }
433        let (sa, sb) = (
434            project(origin, direction, a.coord),
435            project(origin, direction, b.coord),
436        );
437        let proj_len = ((sa[0] - sb[0]).powi(2) + (sa[1] - sb[1]).powi(2)).sqrt();
438        if proj_len < MIN_PROJECTED_EDGE_LEN_MM {
439            continue; // 直線にほぼ垂直な短い部材は射影で潰れるため除外する。
440        }
441        edges.push(Edge {
442            a: e.nodes[0],
443            b: e.nodes[1],
444            elem: e.id,
445        });
446    }
447    edges
448}
449
450/// 実座標を構面 `(origin, direction)` の局所座標 `(s, z)` へ射影する。
451///
452/// 構面が確定していて境界も引けているなら [`WallRegionBoundary::project`] を使う。
453/// 本関数は、境界を作る前の面走査([`scan_wall_region_boundaries`])のように、
454/// まだ `WallRegionBoundary` が存在しない段階のためにある。
455fn project(origin: [f64; 2], direction: [f64; 2], coord: [f64; 3]) -> [f64; 2] {
456    let v = [coord[0] - origin[0], coord[1] - origin[1]];
457    let s = v[0] * direction[0] + v[1] * direction[1];
458    [s, coord[2]]
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::model::{ElementData, EndCondition, ForceRegime, LocalAxis, Node};
465
466    fn node(id: u32, x: f64, y: f64, z: f64) -> Node {
467        Node {
468            id: NodeId(id),
469            coord: [x, y, z],
470            restraint: Default::default(),
471            mass: None,
472            story: None,
473            support_spring: None,
474        }
475    }
476
477    fn beam(id: u32, i: u32, j: u32) -> ElementData {
478        ElementData {
479            id: ElemId(id),
480            kind: ElementKind::Beam,
481            nodes: [NodeId(i), NodeId(j)].into_iter().collect(),
482            section: None,
483            local_axis: LocalAxis {
484                ref_vector: [0.0, 0.0, 1.0],
485            },
486            end_cond: [EndCondition::Fixed, EndCondition::Fixed],
487            force_regime: ForceRegime::Auto,
488            rigid_zone: Default::default(),
489            plastic_zone: None,
490            spring: None,
491        }
492    }
493
494    /// [`add_plane_frame`] の仕様(引数が多くなるため 1 つにまとめる)。
495    struct PlaneFrameSpec {
496        /// XY 平面上の起点。
497        origin: [f64; 2],
498        /// 柱列の見出し角 [度]。
499        heading_deg: f64,
500        /// 柱の水平ピッチ [mm]。
501        bay: f64,
502        /// 階高 [mm]。
503        story_h: f64,
504        /// スパン数。
505        n_bay: usize,
506        /// 階数。
507        n_story: usize,
508    }
509
510    /// `spec.n_bay`×`spec.n_story` の平面骨組を、XY 平面上の `spec.origin` を起点に
511    /// 見出し角 `spec.heading_deg` 方向へ柱列を並べて作る。節点・要素は既存モデルへ追加する。
512    fn add_plane_frame(model: &mut Model, next_id: &mut u32, spec: PlaneFrameSpec) {
513        let PlaneFrameSpec {
514            origin,
515            heading_deg,
516            bay,
517            story_h,
518            n_bay,
519            n_story,
520        } = spec;
521        let theta = heading_deg.to_radians();
522        let (dx, dy) = (theta.cos(), theta.sin());
523        let base_node = model.nodes.len() as u32;
524        let idx = |ix: usize, iz: usize| base_node + (iz * (n_bay + 1) + ix) as u32;
525        for iz in 0..=n_story {
526            for ix in 0..=n_bay {
527                let s = ix as f64 * bay;
528                model.nodes.push(node(
529                    idx(ix, iz),
530                    origin[0] + s * dx,
531                    origin[1] + s * dy,
532                    iz as f64 * story_h,
533                ));
534            }
535        }
536        // 柱(各 ix 列を iz 方向につなぐ)。
537        for ix in 0..=n_bay {
538            for iz in 0..n_story {
539                model
540                    .elements
541                    .push(beam(*next_id, idx(ix, iz), idx(ix, iz + 1)));
542                *next_id += 1;
543            }
544        }
545        // 梁(各 iz レベルを ix 方向につなぐ)。
546        for iz in 0..=n_story {
547            for ix in 0..n_bay {
548                model
549                    .elements
550                    .push(beam(*next_id, idx(ix, iz), idx(ix + 1, iz)));
551                *next_id += 1;
552            }
553        }
554    }
555
556    /// X 方向(見出し角 0°)の壁面。床の格子と同じ形の骨組が壁でも同様に検出できる。
557    #[test]
558    fn test_x_direction_plane() {
559        let mut model = Model::default();
560        let mut next_id = 0u32;
561        add_plane_frame(
562            &mut model,
563            &mut next_id,
564            PlaneFrameSpec {
565                origin: [0.0, 0.0],
566                heading_deg: 0.0,
567                bay: 4000.0,
568                story_h: 3000.0,
569                n_bay: 2,
570                n_story: 2,
571            },
572        );
573        let scan = scan_wall_region_boundaries(&model);
574        assert_eq!(scan.unclosed, 0, "半辺の後続は一意に定まるはず");
575        assert_eq!(scan.boundaries.len(), 4, "2×2 の壁構面は 4 面");
576        for b in &scan.boundaries {
577            assert!(
578                (b.area(&model) - 4000.0 * 3000.0).abs() < 1.0,
579                "面積 {}",
580                b.area(&model)
581            );
582        }
583    }
584
585    /// Y 方向(見出し角 90°)の壁面。`canonical_direction` の境界ケース(dx=0)を含む。
586    #[test]
587    fn test_y_direction_plane() {
588        let mut model = Model::default();
589        let mut next_id = 0u32;
590        add_plane_frame(
591            &mut model,
592            &mut next_id,
593            PlaneFrameSpec {
594                origin: [0.0, 0.0],
595                heading_deg: 90.0,
596                bay: 4000.0,
597                story_h: 3000.0,
598                n_bay: 2,
599                n_story: 2,
600            },
601        );
602        let scan = scan_wall_region_boundaries(&model);
603        assert_eq!(scan.unclosed, 0, "半辺の後続は一意に定まるはず");
604        assert_eq!(scan.boundaries.len(), 4, "2×2 の壁構面は 4 面");
605        for b in &scan.boundaries {
606            assert!(
607                (b.area(&model) - 4000.0 * 3000.0).abs() < 1.0,
608                "面積 {}",
609                b.area(&model)
610            );
611        }
612    }
613
614    /// 斜め構面(見出し角 30°)。直交グリッドでない構面も同じロジックで検出できる。
615    #[test]
616    fn test_oblique_plane() {
617        let mut model = Model::default();
618        let mut next_id = 0u32;
619        add_plane_frame(
620            &mut model,
621            &mut next_id,
622            PlaneFrameSpec {
623                origin: [0.0, 0.0],
624                heading_deg: 30.0,
625                bay: 4000.0,
626                story_h: 3000.0,
627                n_bay: 2,
628                n_story: 2,
629            },
630        );
631        let scan = scan_wall_region_boundaries(&model);
632        assert_eq!(scan.unclosed, 0, "半辺の後続は一意に定まるはず");
633        assert_eq!(scan.boundaries.len(), 4, "2×2 の壁構面は 4 面(斜め)");
634        for b in &scan.boundaries {
635            assert!(
636                (b.area(&model) - 4000.0 * 3000.0).abs() < 1.0,
637                "面積 {}",
638                b.area(&model)
639            );
640        }
641    }
642
643    /// 面の頂点列は正しく閉じ、境界の梁が辺と同数になる。
644    #[test]
645    fn test_single_boundary_shape() {
646        let mut model = Model::default();
647        let mut next_id = 0u32;
648        add_plane_frame(
649            &mut model,
650            &mut next_id,
651            PlaneFrameSpec {
652                origin: [0.0, 0.0],
653                heading_deg: 0.0,
654                bay: 4000.0,
655                story_h: 3000.0,
656                n_bay: 1,
657                n_story: 1,
658            },
659        );
660        let boundaries = generate_wall_region_boundaries(&model);
661        assert_eq!(boundaries.len(), 1);
662        assert_eq!(boundaries[0].boundary.len(), 4);
663        assert_eq!(boundaries[0].edges.len(), 4);
664    }
665
666    /// 同一直線とみなす判定は角度ではなく実距離で行う。50m の直線に対し、
667    /// 遠端で 9mm ずれた点は同一直線、11mm ずれた点は別の直線とみなす。
668    #[test]
669    fn test_is_same_line_uses_absolute_distance_not_angle() {
670        let origin = [0.0, 0.0];
671        let direction = [1.0, 0.0];
672        let near = [50_000.0, 9.0];
673        let far = [50_000.0, 11.0];
674        assert!(is_same_line(origin, direction, &[near]), "9mm は同一直線");
675        assert!(!is_same_line(origin, direction, &[far]), "11mm は別の直線");
676    }
677
678    /// 180°付近の見出し角も、点の与え方(順序)によらず同じ正規化方向になる
679    /// (0°/180°の境界で不連続にならないことの確認)。
680    #[test]
681    fn test_canonical_direction_is_order_independent_near_seam() {
682        let p = [0.0, 0.0];
683        let q = [-1000.0, 0.2]; // 見出し角 ~179.99°
684        let d1 = canonical_direction(p, q).expect("方向が求まる");
685        let d2 = canonical_direction(q, p).expect("方向が求まる");
686        assert!((d1[0] - d2[0]).abs() < 1e-9 && (d1[1] - d2[1]).abs() < 1e-9);
687    }
688
689    /// 構面にほぼ垂直に取り付く部材があっても、面の数は変わらない
690    /// (両端が構面から離れるため対象部材の絞り込みで除外される)。
691    #[test]
692    fn test_perpendicular_beam_does_not_change_face_count() {
693        let mut model = Model::default();
694        let mut next_id = 0u32;
695        add_plane_frame(
696            &mut model,
697            &mut next_id,
698            PlaneFrameSpec {
699                origin: [0.0, 0.0],
700                heading_deg: 0.0,
701                bay: 4000.0,
702                story_h: 3000.0,
703                n_bay: 1,
704                n_story: 1,
705            },
706        );
707        // 境界節点 (4000,0,3000) から構面に垂直(Y方向)へ張り出す梁。
708        let extra_node = model.nodes.len() as u32;
709        model.nodes.push(node(extra_node, 4000.0, 2000.0, 3000.0));
710        model.elements.push(beam(next_id, 3, extra_node));
711        let boundaries = generate_wall_region_boundaries(&model);
712        assert_eq!(boundaries.len(), 1, "構面に垂直な部材は面の数を変えない");
713    }
714
715    /// 平面図形の面積は、ニューエルの公式による 3 次元面積で求まり、投影面積とは異なる
716    /// (境界上の 1 節点が理想平面から外れている場合。§3.2 E3 の確認)。
717    ///
718    /// 2 スパン・1 層の骨組で、中央の柱(ix=1)の柱頭だけを Y 方向へ 5mm ずらして
719    /// 非平面な境界を作る。**柱自身をそろってずらすと(柱脚・柱頭とも同じだけ動かすと)、
720    /// 2 本の鉛直線が張る面は常に平面になり非平面を作れない**(鉛直な 2 直線は
721    /// オフセットの大小によらず必ず 1 つの平面を張るため)。柱頭・柱脚のどちらか
722    /// 片方だけをずらして初めて非平面になる。
723    #[test]
724    fn test_area_uses_3d_newell_not_projected_area() {
725        let mut model = Model::default();
726        let mut next_id = 0u32;
727        add_plane_frame(
728            &mut model,
729            &mut next_id,
730            PlaneFrameSpec {
731                origin: [0.0, 0.0],
732                heading_deg: 0.0,
733                bay: 4000.0,
734                story_h: 3000.0,
735                n_bay: 2,
736                n_story: 1,
737            },
738        );
739        let boundaries = generate_wall_region_boundaries(&model);
740        assert_eq!(boundaries.len(), 2, "2 スパンの壁構面は 2 面");
741        for b in &boundaries {
742            assert!(
743                (b.area(&model) - 4000.0 * 3000.0).abs() < 1.0,
744                "平面のときは投影面積と一致: {}",
745                b.area(&model)
746            );
747        }
748
749        // 中央柱(ix=1)の柱頭ノードだけを Y+5mm ずらす(柱自身の鉛直判定
750        // `VERTICAL_TOL_MM`=1mm は超えるため footprint の抽出元からは外れるが、
751        // 構面の許容差 `MEMBER_AXIS_TOL_MM`=10mm には収まるため部材としては引き続き拾われる)。
752        let mid_top = 4; // idx(1, 1) = 1*(n_bay+1)+1 = 1*3+1
753        model.nodes[mid_top].coord[1] += 5.0;
754        let boundaries = generate_wall_region_boundaries(&model);
755        assert_eq!(boundaries.len(), 2, "5mm のずれは面走査の結果を左右しない");
756        let newell_areas: Vec<f64> = boundaries.iter().map(|b| b.area(&model)).collect();
757        assert!(
758            newell_areas
759                .iter()
760                .any(|a| (a - 4000.0 * 3000.0).abs() > 1.0),
761            "少なくとも 1 面はニューエル面積が投影面積からずれる: {newell_areas:?}"
762        );
763    }
764
765    /// 孤立した柱(この構面上で他のどの部材とも接続しない、複数階に分割された柱)が
766    /// 面走査を往復するだけの退化した閉路を作っても、境界を検出しない
767    /// (`MIN_FACE_AREA_MM2` による回帰テスト)。
768    ///
769    /// 建物の柱は複数階にわたって節点が分割されるため、同一の柱に属する節点は
770    /// XY が完全に一致する(Z のみ異なる)。この柱が構面上で孤立している
771    /// (他のどの部材とも接続しない)場合、面走査は柱を往復する退化した閉路を
772    /// たどる。射影後の座標 `(s, z)` は全節点で `s` が完全に一致するため理論上の
773    /// 符号付き面積は厳密に 0 だが、`s`(`v・direction`)の計算に使う実座標の値が
774    /// 大きく、かつ方向ベクトルが軸方向(見出し角 0°・90°)でない場合、シューレース
775    /// 公式の丸め誤差により厳密な `> 0.0` 判定を通過しうる。
776    ///
777    /// 座標は実建物データ(`crates/squid-n-app/tests/fixtures/model.stb`)で
778    /// 実際に発生したケースをそのまま再現する(もう一方の柱脚位置 (1000,1000) との
779    /// 差分 (12800, 6400) が 2:1 の勾配となり、実測で観測された方向ベクトル
780    /// `[0.8944271909999159, 0.4472135954999579]` と一致する)。軸方向の見出し角
781    /// (0°・90°)では `s` の計算が単純な代入(掛け算の一方が 0 か 1)になり、
782    /// 丸め誤差なしに厳密に 0.0 となってこのテストでは何も検出できない
783    /// (`region_gen_finds_wall_bounded_regions` の面積正値チェックで検出した実バグ)。
784    #[test]
785    fn test_isolated_dangling_column_does_not_create_spurious_boundary() {
786        let mut model = Model::default();
787        // 直線の方向を定める、もう一方の柱脚位置(1 節点だけの孤立した柱。
788        // これ単体は境界長 2 で `scan_faces` が捨てるため、退化閉路を作らない)。
789        model.nodes.push(node(0, 1000.0, 1000.0, 0.0));
790        model.nodes.push(node(1, 1000.0, 1000.0, 3000.0));
791        model.elements.push(beam(0, 0, 1));
792
793        // 孤立した柱(この構面上で他のどの部材とも接続しない)。4 節点・3 部材。
794        for (k, z) in [200.0, 4700.0, 8700.0, 12700.0].into_iter().enumerate() {
795            model.nodes.push(node(2 + k as u32, 13800.0, 7400.0, z));
796        }
797        for k in 0..3u32 {
798            model.elements.push(beam(1 + k, 2 + k, 3 + k));
799        }
800
801        let boundaries = generate_wall_region_boundaries(&model);
802        assert!(
803            boundaries.is_empty(),
804            "互いに孤立した柱どうしは境界を作らない: {boundaries:?}"
805        );
806    }
807}