Skip to main content

squid_n_core/
region_rebuild.rs

1//! 床領域の作り直し(取り込み後・解析前・荷重同期前)。
2//!
3//! 大梁の区画([`crate::region_gen::RegionBoundary`])から床領域([`FloorRegion`]、
4//! 大梁の 1 スパン区画)を再生成し、既存の床領域と重心・レベルで対応付けて
5//! 名前を引き継ぐ。**床板([`Slab`])は畳まない**。
6//! 各床板の帰属(どの床領域に属すか)を、床板の重心が入る床領域へ付け替えるだけである
7//! (申し送り Step 3 改訂 / D7・D10・D20・D21)。取り付く床板(片持ち・バルコニー等)は
8//! どの床領域からも参照されない独立した床板のまま、素通しする。
9
10use crate::dof::Dof6Mask;
11use crate::geom::polygon::{self, BOUNDARY_TOL_MM};
12use crate::geom::{LEVEL_TOL_MM, MEMBER_AXIS_TOL_MM};
13use crate::ids::{FloorRegionId, NodeId};
14use crate::model::{ElementKind, FloorRegion, LoadTransfer, Model, RegionAnchor, SlabShape};
15use crate::region_gen::{generate_region_boundaries, scan_region_boundaries};
16
17/// 重心照合で面積が近いとみなす相対許容(新旧の床領域の面積比)。
18pub const CENTROID_MATCH_AREA_REL: f64 = 1e-3;
19
20/// [`rebuild_floor_regions`] の件数報告。
21#[derive(Clone, Debug, Default, PartialEq, Eq)]
22pub struct FloorRegionRebuildReport {
23    /// 検出した床領域(大梁の区画)の数。
24    pub regions: usize,
25    /// 旧床領域から名前を引き継いだ数。
26    pub inherited: usize,
27    /// 対応する旧床領域が見つからなかった新規の床領域の数。
28    pub new_regions: usize,
29    /// 重心が新しい床領域に入らなかった旧床領域(名前を引き継げなかった)の数。
30    pub unmatched_old_regions: usize,
31    /// 床領域へ帰属し直した床板の数。
32    pub slabs_assigned: usize,
33    /// どの床領域にも収まらず、取り付く床板へ変換した数。
34    pub slabs_converted_to_attached: usize,
35    /// どの床領域にも収まらず、変換もできなかった床板の数(警告対象。削除しない)。
36    pub unassigned_slabs: usize,
37    /// 中点がちょうど 1 つの床領域に厳密内包されなかった小梁の本数。
38    pub unassigned_joists: usize,
39    /// 参照が 0 になり削除した節点の数。
40    pub deleted_nodes: usize,
41}
42
43/// 床領域を大梁の区画から作り直し、名前を引き継ぎ、床板の帰属を
44/// 付け替え、小梁を D7 で入れ直し、参照 0 節点を削除する。
45///
46/// 床板そのもの(`model.slabs`)は畳まない。どの床領域にも収まらない床板は、
47/// 1 辺が大梁に全長覆われていれば取り付く床板へ変換し(D20)、それもできなければ
48/// 帰属なしのまま残す(警告。落とさない)。
49pub fn rebuild_floor_regions(model: &mut Model) -> FloorRegionRebuildReport {
50    let scan = scan_region_boundaries(model);
51    // 床領域を作り直す前に、領域内小梁を未割当へ集約する(assign_joists が再配分する)。
52    for r in &mut model.floor_regions {
53        model.unassigned_joists.append(&mut r.secondary_joists);
54    }
55    let old_regions = std::mem::take(&mut model.floor_regions);
56    let mut report = FloorRegionRebuildReport::default();
57
58    // 1. 新しい床領域を床領域ごとに作り、旧床領域と重心・レベルで対応付けて
59    //    名前を引き継ぐ(D10)。
60    let mut matched_old = vec![false; old_regions.len()];
61    let mut new_regions: Vec<FloorRegion> = Vec::with_capacity(scan.boundaries.len());
62    for rb in &scan.boundaries {
63        let mut region = FloorRegion::new(FloorRegionId(0), rb.boundary.clone());
64        let rb_area = rb.area(model);
65        if let Some((oi, old_area)) = old_regions
66            .iter()
67            .enumerate()
68            .filter(|(oi, _)| !matched_old[*oi])
69            .filter_map(|(oi, old)| {
70                let (cxy, z, area) = boundary_centroid_area(model, &old.boundary)?;
71                (rb.is_same_level(z) && rb.contains(model, cxy)).then_some((oi, area))
72            })
73            .min_by(|(_, a), (_, b)| a.total_cmp(b))
74        {
75            matched_old[oi] = true;
76            let denom = rb_area.abs().max(f64::EPSILON);
77            if (old_area - rb_area).abs() / denom < CENTROID_MATCH_AREA_REL {
78                region.name = old_regions[oi].name.clone();
79                report.inherited += 1;
80            } else {
81                report.new_regions += 1;
82            }
83        } else {
84            report.new_regions += 1;
85        }
86        new_regions.push(region);
87    }
88    report.regions = new_regions.len();
89    report.unmatched_old_regions = matched_old.iter().filter(|m| !**m).count();
90
91    // 2. 床板の帰属を、重心が入る床領域へ付け替える。収まらない床板は、1 辺が大梁に
92    //    全長覆われていれば取り付く床板へ変換し(D20)、それもできなければ帰属なしのまま残す。
93    let beams = horizontal_girders(model);
94    let mut owner: Vec<Option<usize>> = vec![None; model.slabs.len()];
95    for (si, slab) in model.slabs.iter().enumerate() {
96        let SlabShape::Enclosed { boundary } = &slab.shape else {
97            continue; // 取り付く床板は素通し(どの床領域にも属さない)。
98        };
99        let Some((cxy, z, _)) = boundary_centroid_area(model, boundary) else {
100            continue;
101        };
102        if let Some((ri, _)) = new_regions
103            .iter()
104            .enumerate()
105            .filter(|(_, r)| region_is_same_level(model, r, z) && region_contains(model, r, cxy))
106            .min_by(|(_, a), (_, b)| region_area(model, a).total_cmp(&region_area(model, b)))
107        {
108            owner[si] = Some(ri);
109            report.slabs_assigned += 1;
110        }
111    }
112    let mut converted = Vec::new();
113    // 片持ちへ変換すると、旧境界の先端節点が Attached の取付き線 2 点だけに
114    // 縮む(D15)。参照 0 になりうるのはこの縮んだぶんの節点だけであり、
115    // それ以外(他の理由で未使用の既存節点)まで削除対象にしてはならない(D21)。
116    let mut discarded_by_conversion: Vec<NodeId> = Vec::new();
117    for (si, slab) in model.slabs.iter().enumerate() {
118        if owner[si].is_some() || slab.is_attached() {
119            continue;
120        }
121        let SlabShape::Enclosed { boundary } = &slab.shape else {
122            continue;
123        };
124        if let Some(attached) = try_convert_cantilever(model, boundary, &beams) {
125            discarded_by_conversion.extend(boundary.iter().copied());
126            converted.push((si, attached));
127            report.slabs_converted_to_attached += 1;
128        } else {
129            report.unassigned_slabs += 1;
130        }
131    }
132    for (si, shape) in converted {
133        model.slabs[si].shape = shape;
134    }
135    for (si, ri) in owner.into_iter().enumerate() {
136        if let Some(ri) = ri {
137            new_regions[ri].slab_ids.push(model.slabs[si].id);
138        }
139    }
140
141    for (i, r) in new_regions.iter_mut().enumerate() {
142        r.id = FloorRegionId(i as u32);
143    }
144    model.floor_regions = new_regions;
145
146    // 3. 小梁を D7(中点の厳密内包+レベル一致)で入れ直す。
147    report.unassigned_joists = assign_joists(model);
148
149    // 4. 片持ち変換で縮んだ境界の先端節点のうち、参照が 0 になったものを削除する
150    // (D21)。それ以外の既存節点は、たとえ現状どこからも参照されていなくても
151    // このリビルドの対象外(利用者が別の理由で置いた節点かもしれない)。
152    report.deleted_nodes = delete_unref_nodes(model, &discarded_by_conversion);
153
154    report
155}
156
157/// 現状の床領域で、中点がちょうど 1 領域に厳密内包されない小梁の本数。
158pub fn unassigned_joist_count(model: &Model) -> usize {
159    model.unassigned_joists.len()
160}
161
162/// 大梁または小梁で囲まれた床板(`Enclosed`)で、重心がどの床領域にも入らないものの件数。
163pub fn floating_slab_count(model: &Model) -> usize {
164    let regions = generate_region_boundaries(model);
165    model
166        .slabs
167        .iter()
168        .filter(|s| matches!(s.shape, SlabShape::Enclosed { .. }))
169        .filter(|s| {
170            let Some((cxy, z, _)) = s
171                .boundary_nodes()
172                .and_then(|b| boundary_centroid_area(model, b))
173            else {
174                return true;
175            };
176            !regions
177                .iter()
178                .any(|r| r.is_same_level(z) && r.contains(model, cxy))
179        })
180        .count()
181}
182
183/// 節点列(境界)の XY 重心・レベル Z・面積を返す。3 点未満は `None`。
184fn boundary_centroid_area(model: &Model, boundary: &[NodeId]) -> Option<([f64; 2], f64, f64)> {
185    let mut pts = Vec::with_capacity(boundary.len());
186    let mut z_sum = 0.0;
187    for id in boundary {
188        let n = model.nodes.get(id.index())?;
189        pts.push([n.coord[0], n.coord[1]]);
190        z_sum += n.coord[2];
191    }
192    if pts.len() < 3 {
193        return None;
194    }
195    let area = polygon::signed_area(&pts);
196    let cxy = polygon::centroid(&pts);
197    let z = z_sum / pts.len() as f64;
198    Some((cxy, z, area.abs()))
199}
200
201fn region_is_same_level(model: &Model, region: &FloorRegion, z: f64) -> bool {
202    region
203        .level(model)
204        .is_some_and(|rz| (rz - z).abs() <= LEVEL_TOL_MM)
205}
206
207fn region_contains(model: &Model, region: &FloorRegion, p: [f64; 2]) -> bool {
208    let Some(coords) = region.boundary_coords(model) else {
209        return false;
210    };
211    let poly: Vec<[f64; 2]> = coords.iter().map(|c| [c[0], c[1]]).collect();
212    polygon::contains_excluding_boundary(&poly, p)
213}
214
215fn region_area(model: &Model, region: &FloorRegion) -> f64 {
216    boundary_centroid_area(model, &region.boundary)
217        .map(|(_, _, a)| a)
218        .unwrap_or(f64::MAX)
219}
220
221/// 水平な大梁の 1 本ぶん(XY 線分 + レベル)。壁側の D20 相当判定
222/// ([`crate::wall_region_rebuild`])も同じ「同一レベルの大梁に全長覆われているか」を
223/// 使うため `pub(crate)` にしている。
224pub(crate) struct GirderSeg {
225    a: [f64; 2],
226    b: [f64; 2],
227    z: f64,
228}
229
230pub(crate) fn horizontal_girders(model: &Model) -> Vec<GirderSeg> {
231    let mut out = Vec::new();
232    for e in &model.elements {
233        if e.kind != ElementKind::Beam || e.nodes.len() != 2 {
234            continue;
235        }
236        let (Some(na), Some(nb)) = (
237            model.nodes.get(e.nodes[0].index()),
238            model.nodes.get(e.nodes[1].index()),
239        ) else {
240            continue;
241        };
242        if (na.coord[2] - nb.coord[2]).abs() > LEVEL_TOL_MM {
243            continue;
244        }
245        out.push(GirderSeg {
246            a: [na.coord[0], na.coord[1]],
247            b: [nb.coord[0], nb.coord[1]],
248            z: (na.coord[2] + nb.coord[2]) / 2.0,
249        });
250    }
251    out
252}
253
254/// 大梁または小梁で囲まれた床板の境界のうち、1 辺だけが大梁に全長覆われていれば、
255/// その辺を取付き線とする取り付く床板の形へ変換する(D20)。変換できなければ `None`。
256fn try_convert_cantilever(
257    model: &Model,
258    boundary: &[NodeId],
259    beams: &[GirderSeg],
260) -> Option<SlabShape> {
261    if boundary.len() < 3 {
262        return None;
263    }
264    let mut xy = Vec::with_capacity(boundary.len());
265    let mut z_sum = 0.0;
266    for id in boundary {
267        let n = model.nodes.get(id.index())?;
268        xy.push([n.coord[0], n.coord[1]]);
269        z_sum += n.coord[2];
270    }
271    let z = z_sum / xy.len() as f64;
272    let n = boundary.len();
273    let mut covered = Vec::new();
274    for i in 0..n {
275        let a = xy[i];
276        let b = xy[(i + 1) % n];
277        if edge_fully_covered(a, b, z, beams) {
278            covered.push(i);
279        }
280    }
281    if covered.len() != 1 {
282        return None;
283    }
284    let ei = covered[0];
285    let n0 = boundary[ei];
286    let n1 = boundary[(ei + 1) % n];
287    let a = xy[ei];
288    let b = xy[(ei + 1) % n];
289    let dx = b[0] - a[0];
290    let dy = b[1] - a[1];
291    let len = (dx * dx + dy * dy).sqrt();
292    if len <= f64::EPSILON {
293        return None;
294    }
295    let ux = dx / len;
296    let uy = dy / len;
297    // 取付き線の左側が正。
298    let nx = -uy;
299    let ny = ux;
300
301    let mut free = Vec::new();
302    for p in &xy {
303        if polygon::point_segment_dist(*p, a, b) <= BOUNDARY_TOL_MM {
304            continue;
305        }
306        let d = (p[0] - a[0]) * nx + (p[1] - a[1]) * ny;
307        let t = (p[0] - a[0]) * ux + (p[1] - a[1]) * uy;
308        free.push((t, d));
309    }
310    let extent = match free.len() {
311        1 => [free[0].1, free[0].1],
312        2 => {
313            if free[0].0 <= free[1].0 {
314                [free[0].1, free[1].1]
315            } else {
316                [free[1].1, free[0].1]
317            }
318        }
319        _ => return None,
320    };
321
322    Some(SlabShape::Attached {
323        anchor: RegionAnchor::Line {
324            nodes: [n0, n1],
325            span: [0.0, 1.0],
326            transfer: LoadTransfer::Anchor,
327        },
328        extent,
329    })
330}
331
332pub(crate) fn edge_fully_covered(a: [f64; 2], b: [f64; 2], z: f64, beams: &[GirderSeg]) -> bool {
333    let dx = b[0] - a[0];
334    let dy = b[1] - a[1];
335    let len = (dx * dx + dy * dy).sqrt();
336    if len <= f64::EPSILON {
337        return false;
338    }
339    let ux = dx / len;
340    let uy = dy / len;
341    let nx = -uy;
342    let ny = ux;
343    let mut intervals: Vec<(f64, f64)> = Vec::new();
344    for beam in beams {
345        if (beam.z - z).abs() > LEVEL_TOL_MM {
346            continue;
347        }
348        let da = (beam.a[0] - a[0]) * nx + (beam.a[1] - a[1]) * ny;
349        let db = (beam.b[0] - a[0]) * nx + (beam.b[1] - a[1]) * ny;
350        if da.abs() > MEMBER_AXIS_TOL_MM || db.abs() > MEMBER_AXIS_TOL_MM {
351            continue;
352        }
353        let ta = (beam.a[0] - a[0]) * ux + (beam.a[1] - a[1]) * uy;
354        let tb = (beam.b[0] - a[0]) * ux + (beam.b[1] - a[1]) * uy;
355        let t0 = ta.min(tb).clamp(0.0, len);
356        let t1 = ta.max(tb).clamp(0.0, len);
357        if t1 > t0 {
358            intervals.push((t0, t1));
359        }
360    }
361    if intervals.is_empty() {
362        return false;
363    }
364    intervals.sort_by(|x, y| x.0.total_cmp(&y.0));
365    let mut merged = vec![intervals[0]];
366    for &(s, e) in intervals.iter().skip(1) {
367        let last = merged.last_mut().expect("merged is non-empty");
368        if s <= last.1 + MEMBER_AXIS_TOL_MM {
369            last.1 = last.1.max(e);
370        } else {
371            merged.push((s, e));
372        }
373    }
374    let covered: f64 = merged.iter().map(|(s, e)| e - s).sum();
375    covered >= len - MEMBER_AXIS_TOL_MM
376}
377
378fn joist_midpoint(model: &Model, nodes: [NodeId; 2]) -> Option<([f64; 2], f64)> {
379    let a = model.nodes.get(nodes[0].index())?;
380    let b = model.nodes.get(nodes[1].index())?;
381    Some((
382        [
383            (a.coord[0] + b.coord[0]) * 0.5,
384            (a.coord[1] + b.coord[1]) * 0.5,
385        ],
386        (a.coord[2] + b.coord[2]) * 0.5,
387    ))
388}
389
390fn regions_containing(model: &Model, regions: &[FloorRegion], xy: [f64; 2], z: f64) -> Vec<usize> {
391    let mut hits = Vec::new();
392    for (i, r) in regions.iter().enumerate() {
393        if !region_is_same_level(model, r, z) {
394            continue;
395        }
396        if region_contains(model, r, xy) {
397            hits.push(i);
398        }
399    }
400    hits
401}
402
403fn assign_joists(model: &mut Model) -> usize {
404    let joists: Vec<_> = model
405        .floor_regions
406        .iter_mut()
407        .flat_map(|r| r.secondary_joists.drain(..))
408        .chain(model.unassigned_joists.drain(..))
409        .collect();
410    for r in &mut model.floor_regions {
411        r.secondary_joists.clear();
412    }
413    let mut unassigned = 0;
414    for sm in joists {
415        let nodes = sm.nodes;
416        let Some((xy, z)) = joist_midpoint(model, nodes) else {
417            model.unassigned_joists.push(sm);
418            unassigned += 1;
419            continue;
420        };
421        let hits = regions_containing(model, &model.floor_regions, xy, z);
422        if hits.len() == 1 {
423            model.floor_regions[hits[0]].secondary_joists.push(sm);
424        } else {
425            model.unassigned_joists.push(sm);
426            unassigned += 1;
427        }
428    }
429    unassigned
430}
431
432/// 節点 `id` が部材・二次部材・床領域・床板・壁領域・壁版・拘束・節点荷重・
433/// 支点(固定・ばね)・質量・剛床マスターのいずれかから参照されているか。
434///
435/// 階の節点一覧(`Story::node_ids`)と通り芯(`AxisGroup`)は含めない
436/// ([`delete_unref_nodes`] の呼び出し側で扱う節点削除の判定にのみ用いるため。
437/// 階の節点一覧は準備計算のたびに階に属する全節点で埋め直されるため、これを
438/// 参照とみなすと削除対象の節点がほぼ必ず「参照あり」になってしまい、この関数
439/// 自体が実質的に無効化される。通り芯も構造計算に用いない表示専用データである)。
440///
441/// 床領域・床板・壁領域・壁版・二次部材・拘束・節点荷重の判定は
442/// [`Model::node_referenced_by_regions_or_plates`] へ委譲する(`Model::node_in_use`
443/// の削除ガードと共有。**`NodeId` を持つフィールドを `Model` へ新設したときに
444/// 更新すべき箇所を1箇所へ集約する**のが狙いで、敵対的レビューで見つかった
445/// 「`wall_regions` を一切見ていなかった」回帰を踏まえた是正である)。
446fn node_has_structural_ref(model: &Model, id: NodeId) -> bool {
447    if model.elements.iter().any(|e| e.nodes.contains(&id)) {
448        return true;
449    }
450    if model.node_referenced_by_regions_or_plates(id) {
451        return true;
452    }
453    if let Some(node) = model.nodes.get(id.index()) {
454        if node.restraint != Dof6Mask::FREE || node.mass.is_some() || node.support_spring.is_some()
455        {
456            return true;
457        }
458    }
459    if model.generated_masters.contains(&id) {
460        return true;
461    }
462    false
463}
464
465/// `candidates` に挙がった節点のうち、参照が 0 になったものだけを削除する。
466///
467/// `candidates` 以外の節点は、たとえ現状どこからも参照されていなくても対象外とする
468/// (D21。このリビルドが縮めた境界の先端節点だけを削除し、利用者が別の理由で
469/// 置いた既存の未使用節点まで巻き込まない)。
470pub(crate) fn delete_unref_nodes(model: &mut Model, candidates: &[NodeId]) -> usize {
471    let n = model.nodes.len();
472    if n == 0 {
473        return 0;
474    }
475    let candidate_set: std::collections::HashSet<NodeId> = candidates.iter().copied().collect();
476    let keep: Vec<bool> = (0..n)
477        .map(|i| {
478            let id = NodeId(i as u32);
479            !candidate_set.contains(&id) || node_has_structural_ref(model, id)
480        })
481        .collect();
482    let deleted = keep.iter().filter(|k| !*k).count();
483    if deleted == 0 {
484        return 0;
485    }
486
487    for story in &mut model.stories {
488        story
489            .node_ids
490            .retain(|id| keep.get(id.index()).copied().unwrap_or(false));
491    }
492    for group in &mut model.axes {
493        for axis in &mut group.axes {
494            axis.nodes
495                .retain(|id| keep.get(id.index()).copied().unwrap_or(false));
496        }
497    }
498
499    let mut map: Vec<Option<u32>> = vec![None; n];
500    let mut new_i = 0u32;
501    for i in 0..n {
502        if keep[i] {
503            map[i] = Some(new_i);
504            new_i += 1;
505        }
506    }
507    model.nodes = model
508        .nodes
509        .drain(..)
510        .enumerate()
511        .filter(|(i, _)| keep[*i])
512        .map(|(_, node)| node)
513        .collect();
514    model.visit_node_ids(|id| {
515        if let Some(new) = map.get(id.0 as usize).and_then(|m| *m) {
516            id.0 = new;
517        }
518    });
519    deleted
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use crate::ids::{ElemId, FloorRegionId, NodeId, SectionId, SlabId};
526    use crate::model::{
527        AreaLoad, DistributionMethod, ElementData, ElementKind, EndCondition, ForceRegime,
528        LocalAxis, Node, RegionAnchor, SecondaryMember, SecondaryMemberKind, Slab, SlabPlate,
529    };
530    use crate::region_gen::generate_region_boundaries;
531    use crate::section_shape::SectionShape;
532
533    fn node(id: u32, x: f64, y: f64, z: f64) -> Node {
534        Node {
535            id: NodeId(id),
536            coord: [x, y, z],
537            restraint: Default::default(),
538            mass: None,
539            story: None,
540            support_spring: None,
541        }
542    }
543
544    fn beam(id: u32, i: u32, j: u32) -> ElementData {
545        ElementData {
546            id: ElemId(id),
547            kind: ElementKind::Beam,
548            nodes: [NodeId(i), NodeId(j)].into_iter().collect(),
549            section: None,
550            local_axis: LocalAxis {
551                ref_vector: [0.0, 0.0, 1.0],
552            },
553            end_cond: [EndCondition::Fixed, EndCondition::Fixed],
554            force_regime: ForceRegime::Auto,
555            rigid_zone: Default::default(),
556            plastic_zone: None,
557            spring: None,
558        }
559    }
560
561    fn plate(section: Option<SectionId>, loads: Vec<AreaLoad>) -> SlabPlate {
562        SlabPlate {
563            section,
564            loads,
565            usage: None,
566            method: DistributionMethod::TriTrapezoid,
567            one_way: None,
568        }
569    }
570
571    fn enclosed_slab(id: u32, boundary: Vec<u32>, plate: SlabPlate) -> Slab {
572        Slab {
573            id: SlabId(id),
574            shape: SlabShape::Enclosed {
575                boundary: boundary.into_iter().map(NodeId).collect(),
576            },
577            plate,
578        }
579    }
580
581    fn push_slab_section(model: &mut Model, thickness: f64) -> SectionId {
582        let id = SectionId(model.sections.len() as u32);
583        model
584            .sections
585            .push(SectionShape::RcSlab { thickness }.to_section(id, format!("S{thickness:.0}")));
586        id
587    }
588
589    fn joist(id: u32, i: u32, j: u32) -> SecondaryMember {
590        SecondaryMember {
591            kind: SecondaryMemberKind::Joist,
592            nodes: [NodeId(i), NodeId(j)],
593            section: None,
594            name: format!("J{id}"),
595        }
596    }
597
598    fn has_xy(model: &Model, x: f64, y: f64) -> bool {
599        model.nodes.iter().any(|n| {
600            (n.coord[0] - x).abs() < 1e-6
601                && (n.coord[1] - y).abs() < 1e-6
602                && n.coord[2].abs() < 1e-6
603        })
604    }
605
606    /// 4 辺の大梁で閉じた 1 面。上下辺の中間節点に小梁 1 本、床板 2 枚(両方とも保たれる)。
607    fn two_piece_square() -> Model {
608        let mut model = Model::default();
609        for (i, (x, y)) in [
610            (0.0, 0.0),
611            (2000.0, 0.0),
612            (4000.0, 0.0),
613            (4000.0, 4000.0),
614            (2000.0, 4000.0),
615            (0.0, 4000.0),
616        ]
617        .into_iter()
618        .enumerate()
619        {
620            model.nodes.push(node(i as u32, x, y, 0.0));
621        }
622        model.elements.extend([
623            beam(0, 0, 1),
624            beam(1, 1, 2),
625            beam(2, 2, 3),
626            beam(3, 3, 4),
627            beam(4, 4, 5),
628            beam(5, 5, 0),
629        ]);
630        let sid = push_slab_section(&mut model, 150.0);
631        model.slabs.push(enclosed_slab(
632            0,
633            vec![0, 1, 4, 5],
634            plate(Some(sid), Vec::new()),
635        ));
636        model.slabs.push(enclosed_slab(
637            1,
638            vec![1, 2, 3, 4],
639            plate(Some(sid), Vec::new()),
640        ));
641        model.unassigned_joists.push(joist(0, 1, 4));
642        model
643    }
644
645    fn cantilever_rect() -> Model {
646        let mut model = Model::default();
647        model.nodes.push(node(0, 0.0, 0.0, 0.0));
648        model.nodes.push(node(1, 4000.0, 0.0, 0.0));
649        model.nodes.push(node(2, 4000.0, 1500.0, 0.0));
650        model.nodes.push(node(3, 0.0, 1500.0, 0.0));
651        model.elements.push(beam(0, 0, 1));
652        let sid = push_slab_section(&mut model, 150.0);
653        model.slabs.push(enclosed_slab(
654            0,
655            vec![0, 1, 2, 3],
656            plate(Some(sid), Vec::new()),
657        ));
658        model
659    }
660
661    #[test]
662    fn test_two_piece_square_keeps_both_slabs_in_one_region() {
663        let mut model = two_piece_square();
664        assert_eq!(generate_region_boundaries(&model).len(), 1);
665        let report = rebuild_floor_regions(&mut model);
666        assert_eq!(model.floor_regions.len(), 1, "床領域は 1 つ");
667        assert_eq!(model.slabs.len(), 2, "床板は畳まず 2 枚のまま");
668        assert_eq!(
669            model.floor_regions[0].slab_ids.len(),
670            2,
671            "2 枚とも同じ床領域へ帰属"
672        );
673        assert_eq!(
674            model.floor_regions[0].secondary_joists.len(),
675            1,
676            "中央小梁が属する"
677        );
678        assert_eq!(
679            model.floor_regions[0].secondary_joists[0].nodes,
680            [NodeId(1), NodeId(4)]
681        );
682        assert_eq!(report.regions, 1);
683        assert_eq!(report.slabs_assigned, 2);
684    }
685
686    #[test]
687    fn test_rebuild_is_idempotent_on_two_piece_square() {
688        let mut model = two_piece_square();
689        rebuild_floor_regions(&mut model);
690        let first_regions = model.floor_regions.clone();
691        let first_slabs = model.slabs.clone();
692        let first_nodes = model.nodes.len();
693        rebuild_floor_regions(&mut model);
694        assert_eq!(model.floor_regions, first_regions);
695        assert_eq!(model.slabs, first_slabs);
696        assert_eq!(model.nodes.len(), first_nodes);
697    }
698
699    #[test]
700    fn test_rebuild_preserves_secondary_joists_across_runs() {
701        let mut model = two_piece_square();
702        rebuild_floor_regions(&mut model);
703        assert_eq!(model.joists().count(), 1);
704        let first = model
705            .floor_regions
706            .iter()
707            .flat_map(|r| r.secondary_joists.clone())
708            .collect::<Vec<_>>();
709        rebuild_floor_regions(&mut model);
710        assert_eq!(model.joists().count(), 1);
711        let second = model
712            .floor_regions
713            .iter()
714            .flat_map(|r| r.secondary_joists.clone())
715            .collect::<Vec<_>>();
716        assert_eq!(first, second);
717    }
718
719    #[test]
720    fn test_courtyard_two_internal_regions_no_outer_region() {
721        let mut model = Model::default();
722        let outer = [(0.0, 0.0), (8000.0, 0.0), (8000.0, 8000.0), (0.0, 8000.0)];
723        let inner = [
724            (2000.0, 2000.0),
725            (6000.0, 2000.0),
726            (6000.0, 6000.0),
727            (2000.0, 6000.0),
728        ];
729        for (i, (x, y)) in outer.into_iter().enumerate() {
730            model.nodes.push(node(i as u32, x, y, 0.0));
731        }
732        for (i, (x, y)) in inner.into_iter().enumerate() {
733            model.nodes.push(node(4 + i as u32, x, y, 0.0));
734        }
735        model.elements.extend([
736            beam(0, 0, 1),
737            beam(1, 1, 2),
738            beam(2, 2, 3),
739            beam(3, 3, 0),
740            beam(4, 4, 5),
741            beam(5, 5, 6),
742            beam(6, 6, 7),
743            beam(7, 7, 4),
744        ]);
745        let regions = generate_region_boundaries(&model);
746        assert_eq!(regions.len(), 2, "内部面は外周と中庭の 2 つ");
747        let report = rebuild_floor_regions(&mut model);
748        assert_eq!(model.floor_regions.len(), regions.len());
749        assert_eq!(report.regions, regions.len());
750    }
751
752    #[test]
753    fn test_cantilever_rebuild_node_ids_are_compact() {
754        let mut model = cantilever_rect();
755        rebuild_floor_regions(&mut model);
756        assert!(model.validate().is_ok(), "{:?}", model.validate().err());
757        for (i, n) in model.nodes.iter().enumerate() {
758            assert_eq!(n.id, NodeId(i as u32), "nodes[{i}].id");
759        }
760    }
761
762    #[test]
763    fn test_cantilever_rect_converts_to_attached_line() {
764        let mut model = cantilever_rect();
765        let report = rebuild_floor_regions(&mut model);
766        assert_eq!(model.floor_regions.len(), 0, "囲む大梁がないため床領域は 0");
767        assert_eq!(model.slabs.len(), 1);
768        let s = &model.slabs[0];
769        assert!(s.is_attached());
770        match &s.shape {
771            SlabShape::Attached {
772                anchor:
773                    RegionAnchor::Line {
774                        nodes,
775                        span,
776                        transfer,
777                    },
778                extent,
779            } => {
780                assert_eq!(*span, [0.0, 1.0]);
781                assert_eq!(*transfer, LoadTransfer::Anchor);
782                let a = model.nodes[nodes[0].index()].coord;
783                let b = model.nodes[nodes[1].index()].coord;
784                let dx = b[0] - a[0];
785                let dy = b[1] - a[1];
786                assert!(dx.abs() > dy.abs(), "取付き線は +X");
787                assert!((extent[0] - 1500.0).abs() < 1e-6, "左正 {extent:?}");
788                assert!((extent[1] - 1500.0).abs() < 1e-6, "{extent:?}");
789            }
790            other => panic!("Line の Attached ではない: {other:?}"),
791        }
792        assert!(has_xy(&model, 0.0, 0.0));
793        assert!(has_xy(&model, 4000.0, 0.0));
794        assert!(!has_xy(&model, 4000.0, 1500.0), "先端節点は削除");
795        assert!(!has_xy(&model, 0.0, 1500.0), "先端節点は削除");
796        assert!(report.slabs_converted_to_attached >= 1);
797        assert!(report.deleted_nodes >= 2);
798    }
799
800    #[test]
801    fn test_inherits_name_and_joists_when_boundary_nodes_change() {
802        let mut model = Model::default();
803        for (i, (x, y)) in [(0.0, 0.0), (4000.0, 0.0), (4000.0, 4000.0), (0.0, 4000.0)]
804            .into_iter()
805            .enumerate()
806        {
807            model.nodes.push(node(i as u32, x, y, 0.0));
808        }
809        model
810            .elements
811            .extend([beam(0, 0, 1), beam(1, 1, 2), beam(2, 2, 3), beam(3, 3, 0)]);
812        let sid = push_slab_section(&mut model, 150.0);
813        model.slabs.push(enclosed_slab(
814            0,
815            vec![0, 1, 2, 3],
816            plate(
817                Some(sid),
818                vec![AreaLoad {
819                    kind: "仕上げ".into(),
820                    value: 0.001,
821                }],
822            ),
823        ));
824        let mut r = FloorRegion::new(
825            FloorRegionId(0),
826            vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3)],
827        );
828        r.name = "階段室".into();
829        model.floor_regions.push(r);
830
831        model.nodes.push(node(4, 2000.0, 0.0, 0.0));
832        model.elements[0] = beam(0, 0, 4);
833        model.elements.push(beam(4, 4, 1));
834
835        rebuild_floor_regions(&mut model);
836        assert_eq!(model.floor_regions.len(), 1);
837        assert_eq!(model.floor_regions[0].name, "階段室");
838        assert_eq!(model.slabs.len(), 1, "床板は畳まずそのまま残る");
839        assert_eq!(
840            model.slabs[0].plate.loads,
841            vec![AreaLoad {
842                kind: "仕上げ".into(),
843                value: 0.001,
844            }]
845        );
846    }
847
848    #[test]
849    fn test_joist_outside_building_is_unassigned() {
850        let mut model = two_piece_square();
851        model.nodes.push(node(6, 10000.0, 0.0, 0.0));
852        model.nodes.push(node(7, 10000.0, 4000.0, 0.0));
853        model.unassigned_joists.push(joist(1, 6, 7));
854        let report = rebuild_floor_regions(&mut model);
855        assert_eq!(
856            model.floor_regions[0].secondary_joists.len(),
857            1,
858            "領域内小梁"
859        );
860        assert_eq!(
861            model.unassigned_joists.len(),
862            1,
863            "所属なし小梁は unassigned へ"
864        );
865        assert_eq!(report.unassigned_joists, 1);
866    }
867
868    #[test]
869    fn test_floating_slab_stays_enclosed_and_unassigned() {
870        let mut model = Model::default();
871        for (i, (x, y)) in [(0.0, 0.0), (4000.0, 0.0), (4000.0, 4000.0), (0.0, 4000.0)]
872            .into_iter()
873            .enumerate()
874        {
875            model.nodes.push(node(i as u32, x, y, 0.0));
876        }
877        let sid = push_slab_section(&mut model, 150.0);
878        model.slabs.push(enclosed_slab(
879            0,
880            vec![0, 1, 2, 3],
881            plate(Some(sid), Vec::new()),
882        ));
883        let report = rebuild_floor_regions(&mut model);
884        assert_eq!(model.floor_regions.len(), 0, "囲む大梁がないため床領域は 0");
885        assert_eq!(model.slabs.len(), 1, "床板は削除しない");
886        assert!(
887            model.slabs[0].shape
888                == SlabShape::Enclosed {
889                    boundary: vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3)],
890                }
891        );
892        assert_eq!(report.unassigned_slabs, 1);
893    }
894
895    #[test]
896    fn test_slab_on_two_girder_edges_stays_enclosed_and_unassigned() {
897        let mut model = Model::default();
898        model.nodes.push(node(0, 0.0, 0.0, 0.0));
899        model.nodes.push(node(1, 4000.0, 0.0, 0.0));
900        model.nodes.push(node(2, 4000.0, 4000.0, 0.0));
901        model.nodes.push(node(3, 0.0, 4000.0, 0.0));
902        model.elements.push(beam(0, 0, 1));
903        model.elements.push(beam(1, 0, 3));
904        let sid = push_slab_section(&mut model, 150.0);
905        model.slabs.push(enclosed_slab(
906            0,
907            vec![0, 1, 2, 3],
908            plate(Some(sid), Vec::new()),
909        ));
910        let report = rebuild_floor_regions(&mut model);
911        assert_eq!(model.slabs.len(), 1);
912        assert!(
913            matches!(model.slabs[0].shape, SlabShape::Enclosed { .. }),
914            "出隅相当は Enclosed のまま"
915        );
916        assert_eq!(report.slabs_converted_to_attached, 0);
917        assert_eq!(report.unassigned_slabs, 1);
918    }
919
920    #[test]
921    fn test_cantilever_tip_that_is_also_girder_end_is_kept() {
922        let mut free = cantilever_rect();
923        rebuild_floor_regions(&mut free);
924        assert!(
925            !has_xy(&free, 4000.0, 1500.0) && !has_xy(&free, 0.0, 1500.0),
926            "参照 0 の先端は消える"
927        );
928
929        let mut shared = cantilever_rect();
930        shared.nodes.push(node(4, 4000.0, 3000.0, 0.0));
931        shared.elements.push(beam(1, 2, 4));
932        let report = rebuild_floor_regions(&mut shared);
933        assert!(
934            has_xy(&shared, 4000.0, 1500.0),
935            "別の大梁端でもある先端は残る"
936        );
937        assert!(!has_xy(&shared, 0.0, 1500.0), "参照 0 の先端だけ消える");
938        assert!(report.slabs_converted_to_attached >= 1);
939    }
940
941    /// 階の節点一覧(`Story::node_ids`)に載っているだけでは参照とみなさない。
942    ///
943    /// ST-Bridge 取り込みは `rebuild_floor_regions` を呼ぶ前に全節点の階所属を
944    /// 確定させ、`Story::node_ids` へ登録する。ここを参照とみなすと、削除対象の
945    /// 節点がほぼ必ず「参照あり」になり、片持ち変換の先端節点削除が実運用の
946    /// モデルでは常に無効化されてしまう。
947    #[test]
948    fn test_story_node_list_membership_does_not_block_deletion() {
949        use crate::model::Story;
950
951        let mut model = cantilever_rect();
952        model.stories.push(Story {
953            id: crate::ids::StoryId(0),
954            name: "1F".into(),
955            elevation: 0.0,
956            node_ids: model.nodes.iter().map(|n| n.id).collect(),
957            seismic_weight: None,
958            weight_override: None,
959            structure: Default::default(),
960            level_kind: Default::default(),
961        });
962
963        let report = rebuild_floor_regions(&mut model);
964        assert!(!has_xy(&model, 4000.0, 1500.0), "先端は消える");
965        assert!(!has_xy(&model, 0.0, 1500.0), "先端は消える");
966        assert!(report.deleted_nodes >= 2);
967        // 削除した節点は Story::node_ids からも落ちている(ダングリング防止)。
968        assert!(model.stories[0]
969            .node_ids
970            .iter()
971            .all(|id| model.nodes.iter().any(|n| n.id == *id)));
972    }
973}