Skip to main content

squid_n_core/
wall_region_rebuild.rs

1//! 壁領域の作り直し(取り込み後・解析前・荷重同期前)。`region_rebuild.rs`(床側)の
2//! 壁側版。
3//!
4//! 柱・梁が囲む鉛直構面内の閉領域([`crate::region_gen::wall::WallRegionBoundary`])
5//! から壁領域([`WallRegion`])を再生成し、既存の壁領域と構面・重心・面積で
6//! 対応付けて名前を引き継ぐ。構面判定は候補の全頂点で行い、内包判定には
7//! 局所座標の多角形重心を用いる。**壁版([`WallPlate`])は畳まない**。柱・梁で
8//! 囲まれた壁版(`WallPlateShape::Enclosed`)の帰属(どの壁領域に属すか)を、
9//! 壁版の重心が入る壁領域へ付け替えるだけである(`rebuild_floor_regions` と同じ方針)。
10//! 取り付く壁版(パラペット・腰壁・垂れ壁・自立壁)はどの壁領域からも参照されない
11//! 独立した壁版のまま、素通しする。
12//!
13//! **床側との違い**: 床は「同じレベル Z」で新旧の床領域を対応付けるが、壁は構面
14//! (直線)ごとに閉領域が現れるため、候補の全頂点が「同じ構面上にあるか」
15//! ([`WallRegionBoundary::is_same_plane`])で絞り込んでから、構面内の局所座標 `(s, z)`
16//! で多角形重心・内包を判定する。間柱は D7 により毎回入れ直す。
17//!
18//! **床側 D20 に相当する自動変換**: どの壁領域にも収まらない、柱・梁で囲まれた
19//! 壁版(`WallPlateShape::Enclosed`)のうち、**水平な辺(上辺・下辺)がちょうど 1 つ**
20//! 同一レベルの大梁に全長覆われていれば、その辺を取付き線とする取り付く壁版
21//! (`Attached`/`RegionAnchor::Line`)へ変換する(パラペット・腰壁・垂れ壁)。
22//! 判定対象は水平な辺のみで、鉛直な辺(柱沿い)は対象外とする(D13/D14 に
23//! 「柱だけに取り付く壁」の用例がないため)。張り出し量 `extent` は取付き辺からの
24//! 鉛直方向の高さ差で、符号つき(下方向が負)である。`RegionAnchor::FloorRegion`
25//! (自立壁)は自動検出しない。ST-Bridge から自立壁を判別できる情報源がなく、
26//! 手動作成専用とする(利用者との dig で確定。
27//! `dev_docs/handoff/床領域・壁領域の再設計_申し送り.md` §9)。
28//!
29//! 変換で境界が取付き線 2 点だけに縮んだぶん、参照が 0 になった節点は削除する
30//! (D21。床側と同じ規約で、このリビルドが縮めた境界の先端節点だけを対象とし、
31//! 他の理由で未使用の既存節点は対象外とする)。
32//! 自由端は取付き辺上にない頂点が 1〜2 点のときに限り変換する。同じ高さでも
33//! 辺から外れた頂点は自由端に数える。変換後の取り付く壁版は解析要素にせず、
34//! 自重の自動分配(取付き線への分布)も行わない。
35
36use crate::geom::polygon::{self, BOUNDARY_TOL_MM};
37use crate::geom::LEVEL_TOL_MM;
38use crate::ids::{NodeId, WallRegionId};
39use crate::model::{LoadTransfer, Model, RegionAnchor, WallPlateShape, WallRegion};
40use crate::region_gen::wall::{scan_wall_region_boundaries, WallRegionBoundary};
41use crate::region_rebuild::{
42    delete_unref_nodes, edge_fully_covered, horizontal_girders, GirderSeg,
43};
44
45/// 重心照合で面積が近いとみなす相対許容(新旧の床領域の面積比)。
46/// [`crate::region_rebuild::CENTROID_MATCH_AREA_REL`] と同じ値・同じ意味。
47pub const CENTROID_MATCH_AREA_REL: f64 = 1e-3;
48
49/// [`rebuild_wall_regions`] の件数報告。
50#[derive(Clone, Debug, Default, PartialEq, Eq)]
51pub struct WallRegionRebuildReport {
52    /// 検出した壁領域(柱・梁が囲む鉛直構面内の閉領域)の数。
53    pub regions: usize,
54    /// 旧壁領域から名前を引き継いだ数。
55    pub inherited: usize,
56    /// 対応する旧壁領域が見つからなかった新規の床領域の数。
57    pub new_regions: usize,
58    /// 重心が新しい床領域に入らなかった旧壁領域(名前を引き継げなかった)の数。
59    pub unmatched_old_regions: usize,
60    /// 壁領域へ帰属し直した壁版の数。
61    pub wall_plates_assigned: usize,
62    /// どの壁領域にも収まらず、取り付く壁版(`Attached`/`Line`)へ変換した数。
63    pub wall_plates_converted_to_attached: usize,
64    /// どの壁領域にも収まらず、変換もできなかった壁版の数(警告対象。削除しない)。
65    pub unassigned_wall_plates: usize,
66    /// 中点がちょうど 1 つの壁領域に厳密内包されなかった間柱(Post)の本数。
67    pub unassigned_posts: usize,
68    /// 取り付く壁版への変換で境界が縮み、参照が 0 になり削除した節点の数。
69    pub deleted_nodes: usize,
70}
71
72/// 現状の壁領域で、中点がちょうど 1 領域に厳密内包されない間柱の本数。
73pub fn unassigned_post_count(model: &Model) -> usize {
74    model.unassigned_posts.len()
75}
76
77/// 壁領域を柱・梁が囲む鉛直構面内の閉領域から作り直し、名前を引き継ぎ、
78/// 壁版の帰属を付け替える。
79///
80/// 壁版そのもの(`model.wall_plates`)は畳まない。どの壁領域にも収まらない
81/// 柱・梁で囲まれた壁版は、水平な辺がちょうど 1 つ大梁に全長覆われ自由端が
82/// 1〜2 点なら取り付く壁版へ変換し、それもできなければ帰属なしのまま残す
83/// (警告。落とさない。モジュール doc 参照)。
84pub fn rebuild_wall_regions(model: &mut Model) -> WallRegionRebuildReport {
85    let scan = scan_wall_region_boundaries(model);
86    // 壁領域を作り直す前に、領域内間柱を未割当へ集約する(assign_posts が再配分する)。
87    for r in &mut model.wall_regions {
88        model.unassigned_posts.append(&mut r.posts);
89    }
90    let old_regions = std::mem::take(&mut model.wall_regions);
91    let mut report = WallRegionRebuildReport::default();
92
93    // 1. 新しい壁領域を床領域ごとに作り、旧壁領域と構面・重心・面積で対応付けて
94    //    名前を引き継ぐ(D10 と同じ方針)。間柱は後段で D7 により入れ直す。
95    let mut matched_old = vec![false; old_regions.len()];
96    let mut new_regions: Vec<WallRegion> = Vec::with_capacity(scan.boundaries.len());
97    for rb in &scan.boundaries {
98        let mut region = WallRegion::new(WallRegionId(0), rb.boundary.clone());
99        let rb_area = rb.area(model);
100        if let Some((oi, old_area)) = old_regions
101            .iter()
102            .enumerate()
103            .filter(|(oi, _)| !matched_old[*oi])
104            .filter_map(|(oi, old)| match_candidate(model, rb, &old.boundary, oi))
105            .min_by(|(_, a), (_, b)| a.total_cmp(b))
106        {
107            matched_old[oi] = true;
108            let denom = rb_area.abs().max(f64::EPSILON);
109            if (old_area - rb_area).abs() / denom < CENTROID_MATCH_AREA_REL {
110                region.name = old_regions[oi].name.clone();
111                report.inherited += 1;
112            } else {
113                report.new_regions += 1;
114            }
115        } else {
116            report.new_regions += 1;
117        }
118        new_regions.push(region);
119    }
120    report.regions = new_regions.len();
121    report.unmatched_old_regions = matched_old.iter().filter(|m| !**m).count();
122
123    // 2. 壁版の帰属を、重心が入る壁領域へ付け替える。収まらない壁版は、水平な辺が
124    //    1 つだけ大梁に全長覆われていれば取り付く壁版へ変換し(床側 D20 に相当。
125    //    モジュール doc 参照)、それもできなければ帰属なしのまま残す。
126    let beams = horizontal_girders(model);
127    let mut owner: Vec<Option<usize>> = vec![None; model.wall_plates.len()];
128    for (pi, plate) in model.wall_plates.iter().enumerate() {
129        let WallPlateShape::Enclosed { boundary } = &plate.shape else {
130            continue; // 取り付く壁版は素通し(どの壁領域にも属さない)。
131        };
132        if let Some((ri, _)) = scan
133            .boundaries
134            .iter()
135            .enumerate()
136            .filter_map(|(ri, rb)| match_candidate(model, rb, boundary, ri))
137            .min_by(|(_, a), (_, b)| a.total_cmp(b))
138        {
139            owner[pi] = Some(ri);
140            report.wall_plates_assigned += 1;
141        }
142    }
143    let mut converted = Vec::new();
144    // 変換すると、旧境界のうち取付き線 2 点以外の節点が参照 0 になりうる
145    // (D21・床側と同じ理由)。それ以外の既存節点は削除対象にしない。
146    let mut discarded_by_conversion: Vec<NodeId> = Vec::new();
147    for (pi, plate) in model.wall_plates.iter().enumerate() {
148        if owner[pi].is_some() {
149            continue;
150        }
151        let WallPlateShape::Enclosed { boundary } = &plate.shape else {
152            continue;
153        };
154        if let Some(attached) = try_convert_wall_attached(model, boundary, &beams) {
155            discarded_by_conversion.extend(boundary.iter().copied());
156            converted.push((pi, attached));
157            report.wall_plates_converted_to_attached += 1;
158        } else {
159            report.unassigned_wall_plates += 1;
160        }
161    }
162    for (pi, shape) in converted {
163        model.wall_plates[pi].shape = shape;
164    }
165    for (pi, ri) in owner.into_iter().enumerate() {
166        if let Some(ri) = ri {
167            new_regions[ri]
168                .wall_plate_ids
169                .push(model.wall_plates[pi].id);
170        }
171    }
172
173    for (i, r) in new_regions.iter_mut().enumerate() {
174        r.id = WallRegionId(i as u32);
175    }
176    model.wall_regions = new_regions;
177    report.unassigned_posts = assign_posts(model, &scan.boundaries);
178    report.deleted_nodes = delete_unref_nodes(model, &discarded_by_conversion);
179
180    report
181}
182
183/// 柱・梁で囲まれた壁版の境界のうち、**水平な辺(両端の Z が一致する辺)**が
184/// ちょうど 1 つだけ同一レベルの大梁に全長覆われていれば、その辺を取付き線とする
185/// 取り付く壁版の形へ変換する(床側 D20 に相当。モジュール doc 参照)。
186/// 鉛直な辺(柱沿い)は判定対象にしない。変換できなければ `None`。
187fn try_convert_wall_attached(
188    model: &Model,
189    boundary: &[NodeId],
190    beams: &[GirderSeg],
191) -> Option<WallPlateShape> {
192    if boundary.len() < 3 {
193        return None;
194    }
195    let pts: Vec<[f64; 3]> = boundary
196        .iter()
197        .map(|id| model.nodes.get(id.index()).map(|n| n.coord))
198        .collect::<Option<_>>()?;
199    let n = boundary.len();
200    let mut covered = Vec::new();
201    for i in 0..n {
202        let a = pts[i];
203        let b = pts[(i + 1) % n];
204        if (a[2] - b[2]).abs() > LEVEL_TOL_MM {
205            continue; // 鉛直な辺(柱沿い)は対象外。
206        }
207        let z = (a[2] + b[2]) / 2.0;
208        if edge_fully_covered([a[0], a[1]], [b[0], b[1]], z, beams) {
209            covered.push(i);
210        }
211    }
212    if covered.len() != 1 {
213        return None;
214    }
215    let ei = covered[0];
216    let n0 = boundary[ei];
217    let n1 = boundary[(ei + 1) % n];
218    let a = pts[ei];
219    let b = pts[(ei + 1) % n];
220    let edge_z = (a[2] + b[2]) / 2.0;
221    let dx = b[0] - a[0];
222    let dy = b[1] - a[1];
223    let len = (dx * dx + dy * dy).sqrt();
224    if len <= f64::EPSILON {
225        return None;
226    }
227    let ux = dx / len;
228    let uy = dy / len;
229
230    // 取付き辺上の点(分割点)だけを自由端から除く。同じ高さでも辺から外れた
231    // 頂点は自由端に数える(床側 D20 の辺までの距離判定と同じ。高さだけで
232    // 除外すると、梁レベルに折れ曲がった頂点を持つ輪郭を誤って変換する)。
233    let mut free = Vec::new();
234    for p in &pts {
235        let on_edge = (p[2] - edge_z).abs() <= LEVEL_TOL_MM
236            && polygon::point_segment_dist([p[0], p[1]], [a[0], a[1]], [b[0], b[1]])
237                <= BOUNDARY_TOL_MM;
238        if on_edge {
239            continue;
240        }
241        let t = (p[0] - a[0]) * ux + (p[1] - a[1]) * uy;
242        free.push((t, p[2] - edge_z));
243    }
244    let extent = match free.len() {
245        1 => [free[0].1, free[0].1],
246        2 => {
247            if free[0].0 <= free[1].0 {
248                [free[0].1, free[1].1]
249            } else {
250                [free[1].1, free[0].1]
251            }
252        }
253        _ => return None,
254    };
255
256    Some(WallPlateShape::Attached {
257        anchor: RegionAnchor::Line {
258            nodes: [n0, n1],
259            span: [0.0, 1.0],
260            transfer: LoadTransfer::Anchor,
261        },
262        // 取付き線に取り付く壁版なので、高さは必ず明示する(`WallPlateShape::Attached`
263        // のドキュメント参照)。ここでは輪郭の自由端から幾何的に決まる。
264        extent: Some(extent),
265    })
266}
267
268/// `candidate`(旧壁領域または壁版の境界節点列)が構面 `rb` に属すか判定する。
269/// 属すなら `(候補を識別する index, candidate の実面積 [mm²])` を返す。
270///
271/// 判定は 2 段階: (1) `candidate` の全頂点が `rb` と同じ構面上にあること
272/// ([`WallRegionBoundary::is_same_plane`])、(2) `candidate` の多角形重心
273/// (`rb` の局所座標 `(s, z)` へ射影したもの)が `rb` の内部にあること
274/// ([`WallRegionBoundary::contains`])。面積は実座標(3 次元)から求める(§3.2 E3。
275/// 局所座標への射影はトポロジー判定にのみ使う近似であり、面積には使わない)。
276fn match_candidate(
277    model: &Model,
278    rb: &WallRegionBoundary,
279    candidate: &[NodeId],
280    index: usize,
281) -> Option<(usize, f64)> {
282    let coords: Vec<[f64; 3]> = candidate
283        .iter()
284        .map(|n| model.nodes.get(n.index()).map(|nd| nd.coord))
285        .collect::<Option<_>>()?;
286    if coords.len() < 3 {
287        return None;
288    }
289    if !coords.iter().all(|c| rb.is_same_plane([c[0], c[1]])) {
290        return None;
291    }
292    let projected: Vec<[f64; 2]> = coords.iter().map(|c| rb.project(*c)).collect();
293    let local_centroid = polygon::centroid(&projected);
294    if !rb.contains(model, local_centroid) {
295        return None;
296    }
297    let area = polygon::area_3d(&coords);
298    Some((index, area))
299}
300
301fn assign_posts(model: &mut Model, boundaries: &[WallRegionBoundary]) -> usize {
302    let posts: Vec<_> = model
303        .wall_regions
304        .iter_mut()
305        .flat_map(|r| r.posts.drain(..))
306        .chain(model.unassigned_posts.drain(..))
307        .collect();
308    for region in &mut model.wall_regions {
309        region.posts.clear();
310    }
311    let mut unassigned = 0;
312    for sm in posts {
313        let nodes = sm.nodes;
314        let Some(a) = model.nodes.get(nodes[0].index()).map(|n| n.coord) else {
315            model.unassigned_posts.push(sm);
316            unassigned += 1;
317            continue;
318        };
319        let Some(b) = model.nodes.get(nodes[1].index()).map(|n| n.coord) else {
320            model.unassigned_posts.push(sm);
321            unassigned += 1;
322            continue;
323        };
324        let midpoint = [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5];
325        let hits: Vec<usize> = boundaries
326            .iter()
327            .enumerate()
328            .filter(|(_, rb)| rb.is_same_plane(midpoint))
329            .filter(|(_, rb)| {
330                let coord = [midpoint[0], midpoint[1], (a[2] + b[2]) * 0.5];
331                rb.contains(model, rb.project(coord))
332            })
333            .map(|(i, _)| i)
334            .collect();
335        if hits.len() == 1 {
336            model.wall_regions[hits[0]].posts.push(sm);
337        } else {
338            model.unassigned_posts.push(sm);
339            unassigned += 1;
340        }
341    }
342    unassigned
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::ids::{ElemId, WallPlateId};
349    use crate::model::{
350        ElementData, ElementKind, EndCondition, ForceRegime, LocalAxis, Node, WallPlate,
351    };
352
353    fn node(id: u32, x: f64, y: f64, z: f64) -> Node {
354        Node {
355            id: NodeId(id),
356            coord: [x, y, z],
357            restraint: Default::default(),
358            mass: None,
359            story: None,
360            support_spring: None,
361        }
362    }
363
364    fn beam(id: u32, i: u32, j: u32) -> ElementData {
365        ElementData {
366            id: ElemId(id),
367            kind: ElementKind::Beam,
368            nodes: [NodeId(i), NodeId(j)].into_iter().collect(),
369            section: None,
370            local_axis: LocalAxis {
371                ref_vector: [0.0, 0.0, 1.0],
372            },
373            end_cond: [EndCondition::Fixed, EndCondition::Fixed],
374            force_regime: ForceRegime::Auto,
375            rigid_zone: Default::default(),
376            plastic_zone: None,
377            spring: None,
378        }
379    }
380
381    /// 柱 2 本・梁 2 本(頂部・柱脚)で閉じた矩形(4m×3m、Y=0 面)を持つ最小モデル。
382    fn one_bay_wall_model() -> Model {
383        let mut model = Model::default();
384        model.nodes.push(node(0, 0.0, 0.0, 0.0));
385        model.nodes.push(node(1, 4000.0, 0.0, 0.0));
386        model.nodes.push(node(2, 4000.0, 0.0, 3000.0));
387        model.nodes.push(node(3, 0.0, 0.0, 3000.0));
388        model.elements.push(beam(0, 0, 3)); // 柱(左)
389        model.elements.push(beam(1, 1, 2)); // 柱(右)
390        model.elements.push(beam(2, 3, 2)); // 頂部梁
391        model.elements.push(beam(3, 0, 1)); // 柱脚間の梁
392        model
393    }
394
395    #[test]
396    fn test_rebuild_creates_wall_region_from_boundary() {
397        let mut model = one_bay_wall_model();
398        let report = rebuild_wall_regions(&mut model);
399        assert_eq!(report.regions, 1);
400        assert_eq!(report.new_regions, 1);
401        assert_eq!(report.inherited, 0);
402        assert_eq!(model.wall_regions.len(), 1);
403        assert_eq!(model.wall_regions[0].id, WallRegionId(0));
404        assert_eq!(model.wall_regions[0].boundary.len(), 4);
405        assert!(model.validate().is_ok(), "{:?}", model.validate());
406    }
407
408    /// 再構成しても、同じ構面・同じ重心・同じ面積の壁領域には旧壁領域の名前を
409    /// 引き継ぐ(D10)。間柱は再走査で帰属し、旧領域からは引き継がない(D7)。
410    #[test]
411    fn test_rebuild_inherits_name_without_post_ids() {
412        let mut model = one_bay_wall_model();
413        rebuild_wall_regions(&mut model);
414        model.wall_regions[0].name = "西面耐震壁".into();
415        model.unassigned_posts.push(crate::model::SecondaryMember {
416            kind: crate::model::SecondaryMemberKind::Post,
417            nodes: [NodeId(0), NodeId(3)],
418            section: None,
419            name: "P1".into(),
420        });
421
422        // 名前・間柱を付けたあとで、モデル自体は変えずに再構成する。
423        let report = rebuild_wall_regions(&mut model);
424        assert_eq!(report.inherited, 1, "同じ床領域は引き継がれるはず");
425        assert_eq!(model.wall_regions[0].name, "西面耐震壁");
426        assert!(model.wall_regions[0].posts.is_empty());
427        assert!(report.unassigned_posts >= 1);
428    }
429
430    #[test]
431    fn test_match_candidate_requires_all_vertices_on_same_plane() {
432        let mut model = one_bay_wall_model();
433        model.nodes.push(node(4, 4000.0, 3000.0, 3000.0));
434        let rb = scan_wall_region_boundaries(&model).boundaries[0].clone();
435
436        let candidate = vec![NodeId(0), NodeId(1), NodeId(4), NodeId(3)];
437
438        assert!(match_candidate(&model, &rb, &candidate, 0).is_none());
439    }
440
441    #[test]
442    fn test_match_candidate_uses_area_centroid_for_l_shape() {
443        let mut model = Model::default();
444        for (id, (s, z)) in [
445            (0, (0.0, 0.0)),
446            (1, (4000.0, 0.0)),
447            (2, (4000.0, 1500.0)),
448            (3, (2000.0, 1500.0)),
449            (4, (2000.0, 4000.0)),
450            (5, (0.0, 4000.0)),
451        ] {
452            model.nodes.push(node(id, s, 0.0, z));
453        }
454        let candidate = vec![
455            NodeId(0),
456            NodeId(1),
457            NodeId(2),
458            NodeId(3),
459            NodeId(4),
460            NodeId(5),
461        ];
462        let rb = WallRegionBoundary {
463            plane_origin: [0.0, 0.0],
464            plane_direction: [1.0, 0.0],
465            boundary: candidate.clone(),
466            edges: (0..6).map(ElemId).collect(),
467        };
468
469        assert!(!rb.contains(&model, [2000.0, 1833.3333333333]));
470        assert!(match_candidate(&model, &rb, &candidate, 0).is_some());
471    }
472
473    #[test]
474    fn test_rebuild_assigns_internal_post_only() {
475        let mut model = one_bay_wall_model();
476        model.nodes.push(node(4, 2000.0, 0.0, 0.0));
477        model.nodes.push(node(5, 2000.0, 0.0, 3000.0));
478        model.unassigned_posts.push(crate::model::SecondaryMember {
479            kind: crate::model::SecondaryMemberKind::Post,
480            nodes: [NodeId(4), NodeId(5)],
481            section: None,
482            name: "内部間柱".into(),
483        });
484
485        let report = rebuild_wall_regions(&mut model);
486
487        assert_eq!(report.unassigned_posts, 0);
488        assert_eq!(model.wall_regions.len(), 1);
489        assert_eq!(model.wall_regions[0].posts.len(), 1);
490        assert_eq!(model.wall_regions[0].posts[0].nodes, [NodeId(4), NodeId(5)]);
491    }
492
493    #[test]
494    fn test_rebuild_assigns_enclosed_wall_plate_to_matching_region() {
495        let mut model = one_bay_wall_model();
496        model.wall_plates.push(WallPlate {
497            id: WallPlateId(0),
498            shape: crate::model::WallPlateShape::Enclosed {
499                boundary: vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3)],
500            },
501            section: None,
502            opening_area: 0.0,
503            opening_weight: 0.0,
504            openings: vec![],
505            loads: vec![],
506            slit: Default::default(),
507        });
508        let report = rebuild_wall_regions(&mut model);
509        assert_eq!(report.wall_plates_assigned, 1);
510        assert_eq!(report.unassigned_wall_plates, 0);
511        assert_eq!(model.wall_regions[0].wall_plate_ids, vec![WallPlateId(0)]);
512        assert!(model.validate().is_ok(), "{:?}", model.validate());
513    }
514
515    /// 壁版の境界が実在の構面(柱・梁の閉路)と対応せず、かつどの辺も大梁に
516    /// 全長覆われていない(=取り付く壁版へも変換できない)場合、どの壁領域にも
517    /// 収まらず「帰属なし」のまま残る(削除しない)。
518    #[test]
519    fn test_rebuild_reports_unassigned_wall_plate_off_any_plane() {
520        let mut model = one_bay_wall_model();
521        // Y=3000(実在しない構面)に浮いた壁版。
522        model.nodes.push(node(4, 0.0, 3000.0, 0.0));
523        model.nodes.push(node(5, 4000.0, 3000.0, 0.0));
524        model.nodes.push(node(6, 4000.0, 3000.0, 3000.0));
525        model.nodes.push(node(7, 0.0, 3000.0, 3000.0));
526        model.wall_plates.push(WallPlate {
527            id: WallPlateId(0),
528            shape: crate::model::WallPlateShape::Enclosed {
529                boundary: vec![NodeId(4), NodeId(5), NodeId(6), NodeId(7)],
530            },
531            section: None,
532            opening_area: 0.0,
533            opening_weight: 0.0,
534            openings: vec![],
535            loads: vec![],
536            slit: Default::default(),
537        });
538        let report = rebuild_wall_regions(&mut model);
539        assert_eq!(report.wall_plates_assigned, 0);
540        assert_eq!(report.unassigned_wall_plates, 1);
541        assert!(model.wall_regions[0].wall_plate_ids.is_empty());
542        // 壁版そのものは畳まれず残る。
543        assert_eq!(model.wall_plates.len(), 1);
544        assert!(model.validate().is_ok(), "{:?}", model.validate());
545    }
546
547    /// 頂部梁(Z=3000)の上に立つパラペット(Z=3000〜4500)は、どの壁領域にも
548    /// 収まらず、下辺(頂部梁と重なる水平な辺)を取付き線とする取り付く壁版へ
549    /// 自動変換される(床側 D20 に相当)。
550    #[test]
551    fn test_parapet_on_top_beam_converts_to_attached_line() {
552        let mut model = one_bay_wall_model();
553        model.nodes.push(node(4, 0.0, 0.0, 4500.0));
554        model.nodes.push(node(5, 4000.0, 0.0, 4500.0));
555        model.wall_plates.push(WallPlate {
556            id: WallPlateId(0),
557            shape: WallPlateShape::Enclosed {
558                // 下辺 3→2 は頂部梁(beam(2, 3, 2))と同じ節点対・向き。
559                boundary: vec![NodeId(3), NodeId(2), NodeId(5), NodeId(4)],
560            },
561            section: None,
562            opening_area: 0.0,
563            opening_weight: 0.0,
564            openings: vec![],
565            loads: vec![],
566            slit: Default::default(),
567        });
568        let report = rebuild_wall_regions(&mut model);
569        assert_eq!(report.wall_plates_assigned, 0);
570        assert_eq!(report.wall_plates_converted_to_attached, 1);
571        assert_eq!(report.unassigned_wall_plates, 0);
572
573        let plate = &model.wall_plates[0];
574        assert!(plate.is_attached());
575        match &plate.shape {
576            WallPlateShape::Attached {
577                anchor:
578                    RegionAnchor::Line {
579                        nodes,
580                        span,
581                        transfer,
582                    },
583                extent,
584            } => {
585                assert_eq!(*span, [0.0, 1.0]);
586                assert_eq!(*transfer, LoadTransfer::Anchor);
587                let a = model.nodes[nodes[0].index()].coord;
588                let b = model.nodes[nodes[1].index()].coord;
589                assert!((a[2] - 3000.0).abs() < 1e-6, "取付き線は頂部梁の高さ");
590                assert!((b[2] - 3000.0).abs() < 1e-6);
591                let e = extent.expect("取付き線に取り付く壁版は高さを明示する");
592                assert!((e[0] - 1500.0).abs() < 1e-6, "上向き {extent:?}");
593                assert!((e[1] - 1500.0).abs() < 1e-6, "{extent:?}");
594            }
595            other => panic!("Line の Attached ではない: {other:?}"),
596        }
597        // パラペット先端(Z=4500)の節点は参照 0 になり削除される(D21)。
598        assert!(
599            !model
600                .nodes
601                .iter()
602                .any(|n| (n.coord[2] - 4500.0).abs() < 1e-6),
603            "先端節点は削除されるはず"
604        );
605        assert!(report.deleted_nodes >= 2);
606        assert!(model.validate().is_ok(), "{:?}", model.validate());
607    }
608
609    /// T 字取り付き(5 節点以上)の壁版は、水平な辺が複数覆われていても
610    /// 自由端が 2 点を超えるため自動変換されず、帰属なしのまま残る。
611    #[test]
612    fn test_wall_plate_with_more_than_two_free_vertices_is_not_converted() {
613        let mut model = one_bay_wall_model();
614        model.nodes.push(node(4, 0.0, 0.0, 4500.0));
615        model.nodes.push(node(5, 1500.0, 0.0, 5200.0));
616        model.nodes.push(node(6, 4000.0, 0.0, 4500.0));
617        model.wall_plates.push(WallPlate {
618            id: WallPlateId(0),
619            shape: WallPlateShape::Enclosed {
620                boundary: vec![NodeId(3), NodeId(2), NodeId(6), NodeId(5), NodeId(4)],
621            },
622            section: None,
623            opening_area: 0.0,
624            opening_weight: 0.0,
625            openings: vec![],
626            loads: vec![],
627            slit: Default::default(),
628        });
629        let report = rebuild_wall_regions(&mut model);
630        assert_eq!(report.wall_plates_converted_to_attached, 0);
631        assert_eq!(report.unassigned_wall_plates, 1);
632        assert!(matches!(
633            model.wall_plates[0].shape,
634            WallPlateShape::Enclosed { .. }
635        ));
636    }
637
638    /// 敵対的レビューで発覚した回帰: パラペット変換で境界が縮み、D21 により
639    /// 低インデックスの節点(パラペットの自由端)が削除・圧縮されると、
640    /// 既存の(別の)壁領域の境界がその圧縮に追随せず、ダングリング参照へ
641    /// 壊れることがあった(`Model::visit_node_ids` が `wall_regions` を
642    /// 走査していなかったため)。パラペットの自由端より高いインデックスの節点を
643    /// 持つ壁領域を用意し、変換後も壁領域の境界座標が元の4隅と一致することを固定する。
644    #[test]
645    fn test_wall_region_boundary_survives_node_compaction_from_attached_conversion() {
646        let mut model = Model::default();
647        // 低インデックス: パラペットの自由端(変換後に参照 0 になり削除される節点)。
648        model.nodes.push(node(0, 0.0, 0.0, 4500.0));
649        model.nodes.push(node(1, 4000.0, 0.0, 4500.0));
650        // 高インデックス: 実在の柱・梁の区画の4隅。
651        model.nodes.push(node(2, 0.0, 0.0, 0.0));
652        model.nodes.push(node(3, 4000.0, 0.0, 0.0));
653        model.nodes.push(node(4, 4000.0, 0.0, 3000.0));
654        model.nodes.push(node(5, 0.0, 0.0, 3000.0));
655        model.elements.push(beam(0, 2, 5)); // 柱(左)
656        model.elements.push(beam(1, 3, 4)); // 柱(右)
657        model.elements.push(beam(2, 5, 4)); // 頂部梁
658        model.elements.push(beam(3, 2, 3)); // 柱脚間の梁
659
660        // パラペット: 下辺 5->4 が頂部梁と一致、自由端は 1,0(Z=4500)。
661        model.wall_plates.push(WallPlate {
662            id: WallPlateId(0),
663            shape: WallPlateShape::Enclosed {
664                boundary: vec![NodeId(5), NodeId(4), NodeId(1), NodeId(0)],
665            },
666            section: None,
667            opening_area: 0.0,
668            opening_weight: 0.0,
669            openings: vec![],
670            loads: vec![],
671            slit: Default::default(),
672        });
673
674        let before: Vec<[f64; 3]> = vec![
675            model.nodes[2].coord,
676            model.nodes[3].coord,
677            model.nodes[4].coord,
678            model.nodes[5].coord,
679        ];
680
681        let report = rebuild_wall_regions(&mut model);
682        assert_eq!(report.wall_plates_converted_to_attached, 1);
683        assert!(report.deleted_nodes >= 1, "自由端の節点が削除されるはず");
684
685        assert_eq!(model.wall_regions.len(), 1);
686        let after: Vec<[f64; 3]> = model.wall_regions[0]
687            .boundary
688            .iter()
689            .map(|n| model.nodes[n.index()].coord)
690            .collect();
691        for c in &before {
692            assert!(
693                after
694                    .iter()
695                    .any(|a| (0..3).all(|k| (a[k] - c[k]).abs() < 1e-6)),
696                "壁領域の境界座標が節点圧縮の前後で保たれていない({c:?} が {after:?} にない)"
697            );
698        }
699        assert!(model.validate().is_ok(), "{:?}", model.validate());
700    }
701
702    /// 柱脚梁の下へ垂れる壁版は、上辺が大梁に載り、張り出し量が負の取り付く壁版へ
703    /// 変換される(垂れ壁)。
704    #[test]
705    fn test_hanging_wall_below_bottom_beam_converts_with_negative_extent() {
706        let mut model = one_bay_wall_model();
707        model.nodes.push(node(4, 0.0, 0.0, -1200.0));
708        model.nodes.push(node(5, 4000.0, 0.0, -1200.0));
709        model.wall_plates.push(WallPlate {
710            id: WallPlateId(0),
711            shape: WallPlateShape::Enclosed {
712                boundary: vec![NodeId(0), NodeId(1), NodeId(5), NodeId(4)],
713            },
714            section: None,
715            opening_area: 0.0,
716            opening_weight: 0.0,
717            openings: vec![],
718            loads: vec![],
719            slit: Default::default(),
720        });
721        let report = rebuild_wall_regions(&mut model);
722        assert_eq!(report.wall_plates_converted_to_attached, 1);
723        match &model.wall_plates[0].shape {
724            WallPlateShape::Attached { extent, .. } => {
725                let e = extent.expect("取付き線に取り付く壁版は高さを明示する");
726                assert!((e[0] + 1200.0).abs() < 1e-6, "{extent:?}");
727                assert!((e[1] + 1200.0).abs() < 1e-6, "{extent:?}");
728            }
729            other => panic!("{other:?}"),
730        }
731        assert!(model.validate().is_ok(), "{:?}", model.validate());
732    }
733
734    /// 梁レベルに折れ曲がった頂点(取付き辺上ではない)を持つ輪郭は、自由端が
735    /// 3 点になるため変換しない。高さだけで自由端を除外すると誤変換していた。
736    #[test]
737    fn test_jog_at_beam_level_is_not_converted() {
738        let mut model = one_bay_wall_model();
739        model.nodes.push(node(4, 0.0, 0.0, 4500.0));
740        model.nodes.push(node(5, 4000.0, 0.0, 4500.0));
741        // 頂部梁と同じ高さだが、構面から Y 方向へ折れた頂点。
742        model.nodes.push(node(6, 2000.0, 800.0, 3000.0));
743        model.wall_plates.push(WallPlate {
744            id: WallPlateId(0),
745            shape: WallPlateShape::Enclosed {
746                boundary: vec![NodeId(3), NodeId(2), NodeId(6), NodeId(5), NodeId(4)],
747            },
748            section: None,
749            opening_area: 0.0,
750            opening_weight: 0.0,
751            openings: vec![],
752            loads: vec![],
753            slit: Default::default(),
754        });
755        let report = rebuild_wall_regions(&mut model);
756        assert_eq!(report.wall_plates_converted_to_attached, 0);
757        assert_eq!(report.unassigned_wall_plates, 1);
758        assert!(matches!(
759            model.wall_plates[0].shape,
760            WallPlateShape::Enclosed { .. }
761        ));
762    }
763}