Skip to main content

squid_n_core/model/
wall_plate.rs

1//! 壁版(`WallPlate`)。
2//!
3//! 壁領域([`WallRegion`]、[`super::wall`])は柱・梁が囲む鉛直構面内の閉領域そのもので、
4//! 版の仕様は持たない。版の仕様(断面・開口)は本モジュールの `WallPlate` が持つ。
5//! 1 つの壁領域は、壁領域内が間柱でさらに細かい壁パネルに分かれていれば複数の
6//! `WallPlate` を持ちうる([`WallRegion::wall_plate_ids`]。E5。床側の `FloorRegion`/
7//! [`super::Slab`] と同じ関係)。パラペット・腰壁・垂れ壁・自立壁はどの壁領域からも
8//! 参照されない、独立した `WallPlate` として存在する。
9//!
10//! # 参入レベル(構造壁・n倍法・重量のみ)は型で区別しない
11//!
12//! 壁が解析にどう参入するか(4 節点要素として剛性・保有水平耐力に算入する「構造壁」、
13//! n倍法で偏心率にのみ寄与する「雑壁剛性」、自重のみの「重量のみ」)は、`WallPlate`
14//! 自身に列挙型を持たせて利用者に選ばせるのではなく、既存の暗黙規則をそのまま踏襲する
15//! (dig Q4=B)。**`section` の有無と、所属する `WallRegion` の種別(囲まれた領域か
16//! 取り付き領域か)の組み合わせで、生成ロジック(Step 8・D5)側が決める。**
17//!
18//! # 躯体の自重は必ず断面参照から求める
19//!
20//! 板厚と主材料は `section` から解決し、面重量を直接入力する経路は持たない
21//! ([`super::Slab`]/[`super::SlabPlate`] と同じ規約)。断面未割当は躯体自重 0 と
22//! し、解析前チェックが止める(既定厚で補わない)。
23//!
24//! # 仕上げ・増打ちは面荷重として持つ
25//!
26//! コンクリート壁は増打ちを伴うのが常で、仕上げ(タイル・モルタル)も無視できない
27//! 重さを持つ。どちらも断面の板厚には含めない(打ち継ぎで一体性が保証されないため
28//! 構造厚ではなく、剛性・耐力にも算入しない)ので、[`WallPlate::loads`] が面荷重
29//! [N/mm²] として受け持つ。床板の [`super::SlabPlate::loads`] と同型である。
30
31use super::*;
32
33/// 壁版の形。[`super::SlabShape`] と同型(囲まれた領域 / 主架構・床領域に取り付く領域)。
34#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
35pub enum WallPlateShape {
36    /// 柱・梁が囲む鉛直構面内の領域。境界は [`super::WallRegion`] の境界そのもの、
37    /// または間柱で分割した場合はそのサブ境界(節点列。反時計回り、始点は繰り返さない)。
38    Enclosed { boundary: Vec<NodeId> },
39    /// 主架構・床領域に取り付く領域(パラペット・腰壁・垂れ壁・自立壁)。
40    ///
41    /// [`RegionAnchor::Line`] の場合、`extent` は D15 の「立ち上がり高さ」
42    /// `[d_i, d_j]`(区間の始端側・終端側の高さ [mm])で、床側(跳ね出し長さ)とは
43    /// 張り出す向きが異なる(床は取付き線の左向き法線方向、壁は鉛直上向き)。
44    /// [`RegionAnchor::FloorRegion`] の場合も同じ意味(`extent` は高さ、`nodes` は
45    /// 壁自体の平面上の始点・終点)。[`RegionAnchor::Point`] は壁の取付き先としては
46    /// 使わない(D14 の対応表に壁の用例がなく、出隅スラブ専用のため。
47    /// [`WallPlate::boundary_coords`] はこの組み合わせで `None` を返す)。
48    ///
49    /// **`extent` が `None` のときは階高いっぱいの壁である**
50    /// ([`Model::wall_plate_extent`] が壁の下端から直上の階レベルまでの高さへ
51    /// 解決する)。階高は設計中に何度も変わるため、全高の壁を絶対寸法で書くと
52    /// 変更に追随せず、上階との間に隙間が残る。数値としては破綻しないので黙って
53    /// 残る種類の誤りである。
54    ///
55    /// `None` を許すのは [`RegionAnchor::FloorRegion`](自立壁)だけである。
56    /// 囲む柱梁があるなら壁の高さは幾何から決まるので、全高の壁は
57    /// [`WallPlateShape::Enclosed`](境界=壁領域の節点列)で表す。線アンカーで
58    /// `None` を許すと、`squid_n_element::wall::misc_wall` が階高分の腰壁せいを取付き先の
59    /// 梁 1 本へ丸ごと算入し(反対側の梁が無い扱いになる)、梁の剛性を過大に、
60    /// 変形を過小に見る危険側の評価になる。`Model::validate` が弾く。
61    Attached {
62        anchor: RegionAnchor,
63        #[serde(default)]
64        extent: Option<[f64; 2]>,
65    },
66}
67
68/// 壁版。柱・梁が囲む鉛直構面内の版、または主架構・床領域に取り付く版
69/// (パラペット・腰壁・垂れ壁・自立壁)ごとに 1 つ。
70#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
71pub struct WallPlate {
72    /// 壁版 ID(`Model::wall_plates` の配列インデックスと一致すること)。
73    pub id: WallPlateId,
74    pub shape: WallPlateShape,
75    /// 断面(板厚・材料・開口低減の解決元)。`None` は未割当(自重 0。解析前
76    /// チェックが止める。モジュール doc 参照)。
77    #[serde(default)]
78    pub section: Option<SectionId>,
79    /// 開口面積の合計 [mm²]([`super::WallAttr::opening_area`] の後継)。
80    /// `openings`(個別開口)が非空の場合はそちらの面積和を優先し、本フィールドは
81    /// 無視される([`WallPlate::total_opening_area`] 参照)。
82    #[serde(default)]
83    pub opening_area: f64,
84    /// 開口部(サッシ等)の重量 [N]([`super::WallAttr::opening_weight`] の後継)。
85    /// 開口面積控除後の自重に加算する。
86    #[serde(default)]
87    pub opening_weight: f64,
88    /// 個別開口の寸法リスト([`super::WallOpening`])。自重控除・開口周比・
89    /// 耐震壁検定の開口供給に使う。構造壁でない壁版(n倍法・重量のみ)でも、
90    /// 自重控除には意味を持つため共通で持たせる。非空の場合は面積評価を
91    /// 優先する(`opening_area` へのフォールバック規約は `WallAttr` と同じ)。
92    #[serde(default)]
93    pub openings: Vec<WallOpening>,
94    /// 仕上げ・増打ち等の面荷重([`super::AreaLoad`])。**版自身の躯体自重は
95    /// 含まない**(躯体は `section` から求める)。モジュール doc 参照。
96    #[serde(default)]
97    pub loads: Vec<AreaLoad>,
98    /// 耐震スリット(辺ごとの縁切り)。[`WallSlit`] 参照。
99    #[serde(default)]
100    pub slit: WallSlit,
101}
102
103/// 耐震スリット。壁版の 4 辺それぞれについて、周辺部材との縁を切ったかを持つ。
104///
105/// スリットは辺ごとに入れるものなので、辺ごとに持つ。三方スリットは柱際 2 辺と
106/// 上下いずれか 1 辺、完全スリットは 4 辺すべてが切れた状態として表す。
107///
108/// **垂れ壁・腰壁とは別の概念である。** 垂れ壁は上の梁からぶら下がる短い壁で、
109/// 下端に壁そのものが無い([`WallPlateShape::Attached`] で表す)。一方、下辺に
110/// スリットを入れた壁は構面いっぱいの全高の壁であり、下の梁と接してはいるが縁が
111/// 切れている。形が違うので、どちらか一方では表せない。
112///
113/// 規則は 2 つある。**剛性は切れていない辺の部材にだけ算入し**(袖壁は柱際、
114/// 腰壁・垂れ壁は梁際)、**自重は切れていない辺へ伝える**。下辺が切れて上辺が
115/// 一体なら、自重は全量が上の梁へ向かう。
116///
117/// 4 辺すべてが一体でなければ耐震壁として成立しない
118/// (`squid_n_element::wall::misc_wall::wall_is_seismic`)。切れた辺があると、負担した
119/// 面内せん断を周辺の柱梁へ伝えられないためである。
120///
121/// 境界が 4 節点の囲まれた壁版でのみ意味を持つ。取り付く壁版は柱・梁と接する
122/// 4 辺を持たないため参照しない。
123#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
124pub struct WallSlit {
125    /// 柱際(左右の鉛直辺)。添字は [`WallPlate::column_face_nodes`] が返す
126    /// 2 節点に対応する。
127    #[serde(default)]
128    pub column_face: [bool; 2],
129    /// 梁際(0: 下辺、1: 上辺)。
130    #[serde(default)]
131    pub beam_face: [bool; 2],
132}
133
134impl WallSlit {
135    /// いずれかの辺が切れているか。耐震壁の成立判定に用いる。
136    pub fn any(&self) -> bool {
137        self.column_face
138            .iter()
139            .chain(self.beam_face.iter())
140            .any(|&s| s)
141    }
142
143    /// 上下の梁際がともに切れているか。
144    ///
145    /// この壁は自重の伝達先を持たない(柱際は壁の重量を受けない)。入力として
146    /// ありえないので、解析前チェックがエラーで止める。
147    pub fn both_beam_faces(&self) -> bool {
148        self.beam_face[0] && self.beam_face[1]
149    }
150}
151
152impl WallPlate {
153    /// 取り付く壁版か。
154    pub fn is_attached(&self) -> bool {
155        matches!(self.shape, WallPlateShape::Attached { .. })
156    }
157
158    /// 柱・梁で囲まれた壁版(`Enclosed`)の境界が、壁エレメント(壁柱+剛梁変換に
159    /// よる4節点24自由度モデル。`docs/calc_basis/04_要素剛性/05_壁エレメントモデル.md`
160    /// 参照)を組み立てられる形か。境界がちょうど4節点のときだけ `true`。
161    ///
162    /// 5節点以上(T字取り付き等。他の梁・壁が境界の辺の途中に接続することで
163    /// 生じる)や3節点以下は要素を生成しない(Q6=C)。壁エレメントは下辺2節点・
164    /// 上辺2節点を前提とした剛体変換の定式化であり、任意の多角形へ一般化する
165    /// ことは定式化そのものを崩すため行わない(実データによる検証ができないまま
166    /// 「按分」等で無理に4節点へ落とし込むと、根拠不明な近似を耐力評価へ持ち込む
167    /// ことになる。dev_docs/handoff/床領域・壁領域の再設計_申し送り.md §9 参照)。
168    /// 取り付く壁版(`Attached`)は境界を持たないため常に `false`。
169    ///
170    /// 解析要素生成(`squid_n_load::wall_expand`)・解析前診断
171    /// (`squid-n-solver::precheck`)・ST-Bridge 取り込み(`squid-n-io`)が
172    /// 判定を共有する(重複実装の統合)。
173    pub fn has_quad_boundary(&self) -> bool {
174        matches!(&self.shape, WallPlateShape::Enclosed { boundary } if boundary.len() == 4)
175    }
176
177    /// 柱際スリット [`WallSlit::column_face`] の添字に対応する境界節点。
178    ///
179    /// 柱際の鉛直辺は、境界のうち標高の低い 2 節点(下辺)から立ち上がる。
180    /// そこで下辺の 2 節点を**境界の並び順**で返し、`column_face[0]` を
181    /// 1 つ目、`[1]` を 2 つ目の柱際に対応させる。
182    ///
183    /// **添字の対応規則はここ 1 か所に置く。** 剛性算入
184    /// (`squid_n_element::wall::misc_wall`)が袖壁を辺ごとに評価するとき、および GUI が
185    /// どちらのスリットかを節点番号で示すときに、同じ並びを見る必要があるためである。
186    ///
187    /// 境界が 4 節点でない壁版・取り付く壁版・節点を引けない壁版は `None`。
188    pub fn column_face_nodes(&self, model: &Model) -> Option<[NodeId; 2]> {
189        let boundary = self.boundary_nodes()?;
190        if boundary.len() != 4 {
191            return None;
192        }
193        let z: Vec<f64> = boundary
194            .iter()
195            .map(|n| model.nodes.get(n.index()).map(|nd| nd.coord[2]))
196            .collect::<Option<_>>()?;
197        let mut order: Vec<usize> = (0..4).collect();
198        order.sort_by(|&a, &b| z[a].total_cmp(&z[b]));
199        // 低い方の 2 点を取り、境界の並び順へ戻す。
200        let mut bottom = [order[0], order[1]];
201        bottom.sort_unstable();
202        Some([boundary[bottom[0]], boundary[bottom[1]]])
203    }
204
205    /// 境界の節点列。**柱・梁が囲む壁版のみ**(取り付く壁版は自由端に節点を
206    /// 持たないため `None`)。
207    pub fn boundary_nodes(&self) -> Option<&[NodeId]> {
208        match &self.shape {
209            WallPlateShape::Enclosed { boundary } => Some(boundary),
210            WallPlateShape::Attached { .. } => None,
211        }
212    }
213
214    /// 境界多角形の座標列 [mm](4 点)。取り付く壁版は取付き先と張り出し量
215    /// (鉛直上向きの高さ)から算出する。節点が引けない、または壁の取付き先として
216    /// 使わない組み合わせ(`RegionAnchor::Point`)の場合は `None`。
217    pub fn boundary_coords(&self, model: &Model) -> Option<Vec<[f64; 3]>> {
218        self.boundary_coords_with(model, |n| model.nodes.get(n.index()).map(|n| n.coord))
219    }
220
221    /// 境界多角形の座標列 [mm] を、節点座標の引き方を差し替えて求める。
222    ///
223    /// [`WallPlate::boundary_coords`] はモデルの節点座標をそのまま使うが、変形図・
224    /// モード形・時刻歴は**変形後の節点座標**で描く必要がある。形状の組み立て方
225    /// (取付き線の内挿・鉛直上向きへの立ち上げ)は同じなので、座標の引き方だけを
226    /// 差し替えられるようにして実装を 1 つに保つ(床側の
227    /// [`super::Slab::boundary_coords_with`] と同じ規約)。
228    /// `model` は立ち上がり高さの解決([`Model::wall_plate_extent`]。`extent` が
229    /// `None` の壁は階高いっぱい)にだけ使う。階レベルは変形しないので、変形後の
230    /// 座標を渡す呼び出しでも元の `model` を渡してよい。
231    pub fn boundary_coords_with(
232        &self,
233        model: &Model,
234        coord_of: impl Fn(NodeId) -> Option<[f64; 3]>,
235    ) -> Option<Vec<[f64; 3]>> {
236        match &self.shape {
237            WallPlateShape::Enclosed { boundary } => {
238                boundary.iter().map(|n| coord_of(*n)).collect()
239            }
240            WallPlateShape::Attached { anchor, .. } => {
241                let extent = model.wall_plate_extent(self)?;
242                match anchor {
243                    RegionAnchor::Line { nodes, span, .. } => {
244                        let a = coord_of(nodes[0])?;
245                        let b = coord_of(nodes[1])?;
246                        Self::extrude_up(a, b, *span, extent)
247                    }
248                    RegionAnchor::FloorRegion { nodes, .. } => {
249                        let a = coord_of(nodes[0])?;
250                        let b = coord_of(nodes[1])?;
251                        Self::extrude_up(a, b, [0.0, 1.0], extent)
252                    }
253                    // 壁の取付き先としては使わない(モジュール doc 参照)。
254                    RegionAnchor::Point(_) => None,
255                }
256            }
257        }
258    }
259
260    /// 取付き線(`a`→`b`)の無次元区間 `span` を底辺とし、両端の高さ `extent`
261    /// (鉛直上向き)だけ立ち上げた 4 点(反時計回り)を返す。
262    fn extrude_up(
263        a: [f64; 3],
264        b: [f64; 3],
265        span: [f64; 2],
266        extent: [f64; 2],
267    ) -> Option<Vec<[f64; 3]>> {
268        let lerp = |t: f64| {
269            [
270                a[0] + (b[0] - a[0]) * t,
271                a[1] + (b[1] - a[1]) * t,
272                a[2] + (b[2] - a[2]) * t,
273            ]
274        };
275        let p0 = lerp(span[0]);
276        let p1 = lerp(span[1]);
277        Some(vec![
278            p0,
279            p1,
280            [p1[0], p1[1], p1[2] + extent[1]],
281            [p0[0], p0[1], p0[2] + extent[0]],
282        ])
283    }
284
285    /// 壁版の面積 [mm²]。囲まれた壁版は [`crate::geom::polygon::area_3d`](ニューエル)。
286    /// 取り付く壁版は底辺長 × ∫|立ち上がり高さ|([`crate::geom::abs_lerp_integral`])。
287    /// 4 点多角形にすると、両端で高さが符号反転する壁は自己交差(蝶ネクタイ)になり、
288    /// Newell の面積が打ち消されて 0 近くになる(自重が黙って消える危険側)。
289    /// 座標が引けない場合は 0。
290    pub fn area(&self, model: &Model) -> f64 {
291        match &self.shape {
292            WallPlateShape::Enclosed { .. } => self
293                .boundary_coords(model)
294                .map(|pts| crate::geom::polygon::area_3d(&pts))
295                .unwrap_or(0.0),
296            WallPlateShape::Attached { .. } => {
297                let Some(pts) = self.boundary_coords(model) else {
298                    return 0.0;
299                };
300                let Some(extent) = model.wall_plate_extent(self) else {
301                    return 0.0;
302                };
303                if pts.len() < 2 {
304                    return 0.0;
305                }
306                let len = crate::geom::vec3::dist(pts[0], pts[1]);
307                len * crate::geom::abs_lerp_integral(extent[0], extent[1], 0.0, 1.0)
308            }
309        }
310    }
311
312    /// 開口の合計面積 [mm²]。個別開口 `openings` が非空ならその面積和、
313    /// 空なら `opening_area` を返す(`WallAttr::total_opening_area` と同じ規約。
314    /// 全消費側はこのメソッドを経由すること)。
315    pub fn total_opening_area(&self) -> f64 {
316        if self.openings.is_empty() {
317            self.opening_area.max(0.0)
318        } else {
319            self.openings.iter().map(WallOpening::area).sum()
320        }
321    }
322
323    /// 仕上げ・増打ち等の面荷重強度 [N/mm²](`loads` の合算)。
324    /// **版自身の躯体自重は含まない**([`super::SlabPlate::finish_intensity`] と同じ規約)。
325    pub fn finish_intensity(&self) -> f64 {
326        self.loads.iter().map(|l| l.value).sum()
327    }
328}
329
330impl Model {
331    /// 壁版 ID から壁版を引く。存在しなければ `None`。
332    pub fn wall_plate(&self, id: WallPlateId) -> Option<&WallPlate> {
333        match self.wall_plates.get(id.index()) {
334            Some(p) if p.id == id => Some(p),
335            _ => self.wall_plates.iter().find(|p| p.id == id),
336        }
337    }
338
339    /// 壁版が**壁領域全体を覆う 4 節点の壁版**か。
340    ///
341    /// 壁エレメント(`squid_n_load::wall_expand`)を生成できるのはこの形の壁版だけ
342    /// である。壁エレメントは下辺 2 節点・上辺 2 節点を両端ピンの剛梁で結ぶ
343    /// 4 節点 24 自由度モデルなので、
344    ///
345    /// - 壁領域の境界が 5 節点以上(上下の大梁が中間節点で分割されている等)なら、
346    ///   剛梁が四隅しか結ばず、中間節点が壁にめり込む向きへ動けてしまう。
347    /// - 壁領域が間柱で複数の壁版へ分割されているなら、壁版ごとに要素を作ると
348    ///   1 本の長い壁柱が複数の細い壁柱に割れる。
349    ///
350    /// いずれも定式化の前提が崩れるため、覆っていない壁版は要素にしない
351    /// (=荷重だけを持つ壁版になる。異常ではない)。
352    ///
353    /// **判定はここ 1 か所に置く。** 要素生成(`wall_expand`)・自重の分配
354    /// (`squid_n_load::wall_plate_load`)・フレーム内雑壁の剛性算入
355    /// (`squid_n_element`)が同じ答えを見る必要があるためである。
356    pub fn wall_plate_covers_region(&self, plate: &WallPlate) -> bool {
357        let Some(boundary) = plate.boundary_nodes() else {
358            return false; // 取り付く壁版は壁領域に属さない。
359        };
360        if boundary.len() != 4 {
361            return false;
362        }
363        self.wall_regions
364            .iter()
365            .filter(|r| r.wall_plate_ids.contains(&plate.id))
366            .any(|r| r.boundary.len() == 4 && r.boundary.iter().all(|n| boundary.contains(n)))
367    }
368
369    /// 壁版から壁エレメント(`ElementKind::Wall`)が生成されるか。
370    ///
371    /// 壁領域全体を覆う 4 節点であること([`Model::wall_plate_covers_region`])に
372    /// 加えて、**断面が割り当たっていること**を要する。断面が無い壁版は板厚・材料が
373    /// 引けず要素を組み立てられないためである(`squid_n_load::wall_expand` は
374    /// `skipped_no_section` として数える)。
375    ///
376    /// `covers_region` との違いはこの 1 点だけだが、両者を取り違えると
377    /// 「断面未割当の壁版が、要素でもなく荷重だけの壁版でもない」という
378    /// どこからも扱われない状態が生まれる。要素が生成されるかを問うときは
379    /// 必ず本メソッドを使うこと。
380    ///
381    /// **生成の可否を決めるのはここ 1 か所である。** `wall_expand` の生成ゲート・
382    /// 3D ビューの壁版描画(要素として描かれる壁版を除くため)・
383    /// モデルタブ「壁版」の「解析要素」列が同じ答えを見る。
384    pub fn wall_plate_becomes_element(&self, plate: &WallPlate) -> bool {
385        self.wall_plate_covers_region(plate) && plate.section.is_some()
386    }
387
388    /// 壁版へ割り当てた断面。未割当・ダングリングは `None`。
389    pub fn wall_plate_section(&self, plate: &WallPlate) -> Option<&Section> {
390        plate.section.and_then(|sid| self.sections.get(sid.index()))
391    }
392
393    /// 壁版の板厚 [mm](断面の [`Section::thickness`]。[`Model::slab_plate_thickness`]
394    /// と同じ規約)。断面未割当、または断面が板厚を持たない場合は `None`。
395    pub fn wall_plate_thickness(&self, plate: &WallPlate) -> Option<f64> {
396        self.wall_plate_section(plate)
397            .and_then(|s| s.thickness)
398            .filter(|t| *t > 0.0)
399    }
400
401    /// 壁版の主材料。断面未割当・材料未割当は `None`。
402    pub fn wall_plate_material(&self, plate: &WallPlate) -> Option<&Material> {
403        self.wall_plate_section(plate)
404            .and_then(|s| s.material)
405            .and_then(|mid| self.materials.get(mid.index()))
406    }
407
408    /// 取り付く壁版の立ち上がり高さ `[d_i, d_j]` [mm]。
409    ///
410    /// `extent` が `Some` ならその値をそのまま返す。`None`(=階高いっぱい)は、
411    /// 壁の下端から**直上の階レベル**までの高さへ解決する
412    /// ([`WallPlateShape::Attached`] のドキュメント参照)。
413    ///
414    /// 次のいずれかでは高さが決まらないため `None` を返し、解析前チェックが止める。
415    /// 0 を返すと壁が面積 0 になり、自重が黙って消える。
416    ///
417    /// - 取り付く壁版でない
418    /// - `extent` が `None` なのに取付き先が線([`RegionAnchor::FloorRegion`] 以外)。
419    ///   [`Model::validate`] が弾く組み合わせである
420    /// - 下端の節点が引けない
421    /// - 下端より上に階レベルが無い(最上階の壁)。パラペットのように最上階で上へ
422    ///   伸ばす壁は、上端を決める階が無いので絶対寸法で指定する
423    pub fn wall_plate_extent(&self, plate: &WallPlate) -> Option<[f64; 2]> {
424        let WallPlateShape::Attached { anchor, extent } = &plate.shape else {
425            return None;
426        };
427        if let Some(e) = extent {
428            return Some(*e);
429        }
430        let RegionAnchor::FloorRegion { nodes } = anchor else {
431            return None;
432        };
433        // 壁が載るレベルは下端線分の平均標高とする
434        // ([`Model::self_standing_wall_coverage`] と同じ規約。両端の標高が違う壁で
435        // 帰属レベルの求め方が 2 通りに割れないようにする)。
436        let a = self.nodes.get(nodes[0].index())?.coord[2];
437        let b = self.nodes.get(nodes[1].index())?.coord[2];
438        let h = self.story_height_above((a + b) / 2.0)?;
439        Some([h, h])
440    }
441
442    /// レベル `z` [mm] から**直上の階レベル**までの高さ [mm]。
443    ///
444    /// 階レベルは床レベル列([`Story::elevation`])なので、`z` より上にある最も低い
445    /// 階レベルとの差がその位置の階高になる。`z` と同じレベルの階は、床そのもので
446    /// あって「上」ではないため対象にしない([`crate::model::DIAPHRAGM_LEVEL_TOL_MM`]
447    /// の許容差で同一とみなす)。直上に階が無ければ `None`。
448    pub fn story_height_above(&self, z: f64) -> Option<f64> {
449        let tol = crate::model::DIAPHRAGM_LEVEL_TOL_MM;
450        self.stories
451            .iter()
452            .map(|s| s.elevation)
453            .filter(|e| *e > z + tol)
454            .fold(None::<f64>, |acc, e| Some(acc.map_or(e, |a: f64| a.min(e))))
455            .map(|e| e - z)
456    }
457
458    /// 壁版の自重 [N]。躯体(開口控除後の正味面積 × 板厚 × 主材料の密度 ×
459    /// 重力加速度)+ 仕上げ・増打ち(正味面積 × [`WallPlate::finish_intensity`])
460    /// + 開口部(サッシ等)の重量。
461    ///
462    /// 仕上げ・増打ちを躯体と同じ**正味面積**に乗じるのは、開口部にはコンクリートも
463    /// 仕上げも無いためである。開口周りの見込み・額縁の重さは `opening_weight`
464    /// (開口部の重量)で見る場所が別にあり、面積側で二重に見ない。
465    ///
466    /// 断面または主材料が未割当のとき、躯体分は 0 とする(既定厚で補わない。
467    /// 解析前チェックが止める)。仕上げ・増打ちは断面に依らないので、この場合も
468    /// そのまま計上する。壁版の高さが決まらないときだけ `None` を返す
469    /// ([`Model::wall_plate_extent`])。開口面積が壁の面積を超える場合は正味面積を 0 とする。
470    pub fn wall_plate_self_weight(&self, plate: &WallPlate, model_for_area: &Model) -> Option<f64> {
471        if plate.is_attached() && model_for_area.wall_plate_extent(plate).is_none() {
472            return None;
473        }
474        let area = plate.area(model_for_area);
475        let net_area = (area - plate.total_opening_area()).max(0.0);
476        let structural = match (
477            self.wall_plate_thickness(plate),
478            self.wall_plate_material(plate),
479        ) {
480            (Some(t), Some(mat)) => mat.density * t * net_area * crate::units::GRAVITY_MM_S2,
481            _ => 0.0,
482        };
483        let finish = plate.finish_intensity() * net_area;
484        Some((structural + finish + plate.opening_weight).max(0.0))
485    }
486
487    /// 自立壁([`RegionAnchor::FloorRegion`] の取り付く壁版)が、どの床領域の上に
488    /// どれだけ載っているかを求める(D17 の分配先の解決)。
489    ///
490    /// 床領域は主架構から作り直される派生的な入力(D10)で `FloorRegionId` が
491    /// 面走査順に振り直されるため、**分配先は保存せず常にここで解決する**
492    /// ([`RegionAnchor::FloorRegion`] のドキュメント参照)。
493    ///
494    /// # 解決の規則
495    ///
496    /// 壁の下端線分(`nodes` の 2 点)を XY 平面へ落とし、**荷重を流せる床領域**
497    /// (壁と同じレベルにあり、床板の XY 投影面積が正である床領域)の多角形で
498    /// 厳密にクリップして区間へ分ける。床領域は L 形など非凸になりうるため、
499    /// 全交点を求めて区間へ分け、各区間の中点がどの床領域に内包されるかで
500    /// 帰属を決める(面走査が返す最小面は重ならないため、内包する床領域は高々 1 つ)。
501    ///
502    /// 各区間の重量比は、その区間の **∫|立ち上がり高さ|**(`extent` は始端から終端へ
503    /// 線形。符号が反転するときは高さ 0 で折れる)が全体に占める割合とする。
504    /// 端点の絶対値を結んだ台形にすると、符号反転時に分母が過大になり重量比の
505    /// 合計が 1 を下回る(危険側)。開口は総重量のスケールにだけ効き、開口位置は
506    /// 見ない(§5.25 の既知の近似と同じ)。
507    ///
508    /// # 戻り値
509    ///
510    /// 自立壁でない、または節点が引けない場合は `None`。それ以外は
511    /// [`SelfStandingWallCoverage`] を返す。**どの床領域にも載らない区間**は
512    /// `uncovered` に集計する(荷重の行き先が無いモデルの不備。解析前チェックが
513    /// エラーにする)。
514    pub fn self_standing_wall_coverage(
515        &self,
516        plate: &WallPlate,
517    ) -> Option<SelfStandingWallCoverage> {
518        let WallPlateShape::Attached { anchor, .. } = &plate.shape else {
519            return None;
520        };
521        let RegionAnchor::FloorRegion { nodes } = anchor else {
522            return None;
523        };
524        let extent = &self.wall_plate_extent(plate)?;
525        let a = self.nodes.get(nodes[0].index())?.coord;
526        let b = self.nodes.get(nodes[1].index())?.coord;
527        let (p0, p1) = ([a[0], a[1]], [b[0], b[1]]);
528        if (p1[0] - p0[0]).hypot(p1[1] - p0[1]) <= crate::geom::MEMBER_AXIS_TOL_MM {
529            // 長さのない壁は面積 0。分配するものが無い。
530            return Some(SelfStandingWallCoverage::default());
531        }
532        // 壁の載るレベルは下端線分の平均標高。床領域のレベル一致判定は
533        // `region_rebuild` と同じ `LEVEL_TOL_MM` を用いる。
534        let z = (a[2] + b[2]) / 2.0;
535
536        // 荷重を流せる床領域だけを候補にする(床板を 1 枚も持たない床領域、
537        // および床板の XY 面積が 0 の床領域は、等価面荷重へならす先が無い)。
538        let candidates: Vec<(FloorRegionId, Vec<[f64; 2]>)> = self
539            .floor_regions
540            .iter()
541            .filter(|r| {
542                r.level(self)
543                    .is_some_and(|rz| (rz - z).abs() <= crate::geom::LEVEL_TOL_MM)
544            })
545            .filter(|r| self.floor_region_slab_xy_area(r) > 0.0)
546            .filter_map(|r| {
547                let poly: Vec<[f64; 2]> = r
548                    .boundary_coords(self)?
549                    .iter()
550                    .map(|c| [c[0], c[1]])
551                    .collect();
552                (poly.len() >= 3).then_some((r.id, poly))
553            })
554            .collect();
555
556        // 区間の切れ目: 線分と全候補多角形の辺との交点パラメータ。
557        let mut cuts: Vec<f64> = vec![0.0, 1.0];
558        for (_, poly) in &candidates {
559            for i in 0..poly.len() {
560                let q0 = poly[i];
561                let q1 = poly[(i + 1) % poly.len()];
562                if let Some(t) = segment_intersection_t(p0, p1, q0, q1) {
563                    cuts.push(t);
564                }
565            }
566        }
567        cuts.retain(|t| t.is_finite() && (0.0..=1.0).contains(t));
568        cuts.sort_by(|x, y| x.total_cmp(y));
569        cuts.dedup_by(|x, y| (*x - *y).abs() <= 1e-12);
570
571        let lerp = |t: f64| [p0[0] + (p1[0] - p0[0]) * t, p0[1] + (p1[1] - p0[1]) * t];
572        // 立ち上がり高さは始端から終端へ線形。重量比は ∫|h|(符号反転はゼロ交差で折る)。
573        let (h0, h1) = (extent[0], extent[1]);
574        if h0 * h1 < 0.0 && h0.abs() > 1e-15 && h1.abs() > 1e-15 {
575            cuts.push((-h0 / (h1 - h0)).clamp(0.0, 1.0));
576            cuts.sort_by(|x, y| x.total_cmp(y));
577            cuts.dedup_by(|x, y| (*x - *y).abs() <= 1e-12);
578        }
579        let total_area = crate::geom::abs_lerp_integral(h0, h1, 0.0, 1.0);
580
581        let mut cov = SelfStandingWallCoverage::default();
582        for w in cuts.windows(2) {
583            let (t0, t1) = (w[0], w[1]);
584            if t1 - t0 <= 1e-12 {
585                continue;
586            }
587            let frac = if total_area > 0.0 {
588                crate::geom::abs_lerp_integral(h0, h1, t0, t1) / total_area
589            } else {
590                // 高さ 0 の壁は重量 0。長さ比で持たせても総和は 0 のまま。
591                t1 - t0
592            };
593            let mid = lerp((t0 + t1) / 2.0);
594            match candidates
595                .iter()
596                .find(|(_, poly)| crate::geom::polygon::contains_excluding_boundary(poly, mid))
597            {
598                Some((id, _)) => match cov.per_region.iter_mut().find(|(r, _)| r == id) {
599                    Some((_, f)) => *f += frac,
600                    None => cov.per_region.push((*id, frac)),
601                },
602                None => cov.uncovered += frac,
603            }
604        }
605        Some(cov)
606    }
607
608    /// 床領域に属する床板の XY 投影面積の合計 [mm²]。
609    /// 等価面荷重の分母([`Self::self_standing_wall_coverage`] の候補判定)に使う。
610    fn floor_region_slab_xy_area(&self, region: &FloorRegion) -> f64 {
611        region
612            .slab_ids
613            .iter()
614            .filter_map(|&id| self.slab(id))
615            .filter_map(|s| s.boundary_coords(self))
616            .map(|pts| {
617                // 床の分配と同じ XY 投影面積(`squid-n-load` の `floor::polygon_area`
618                // と同じ規約)。3 次元面積で測ると、面荷重強度を掛ける側が XY 面積を
619                // 使うため総重量が縮み、傾斜床で危険側になる。
620                let xy: Vec<[f64; 2]> = pts.iter().map(|c| [c[0], c[1]]).collect();
621                crate::geom::polygon::area(&xy)
622            })
623            .sum()
624    }
625}
626
627/// 自立壁がどの床領域の上にどれだけ載っているか
628/// ([`Model::self_standing_wall_coverage`] の結果)。
629///
630/// 比率はいずれも壁の全重量に対する割合で、`per_region` の合計と `uncovered` を
631/// 足すと 1 になる(丸め誤差を除く)。
632#[derive(Clone, Debug, Default, PartialEq)]
633pub struct SelfStandingWallCoverage {
634    /// 載っている床領域と、その床領域が受け持つ重量比。
635    pub per_region: Vec<(FloorRegionId, f64)>,
636    /// どの床領域にも載らない部分の重量比。**0 でなければモデルの不備**
637    /// (荷重の行き先が無い。解析前チェックがエラーにする)。
638    pub uncovered: f64,
639}
640
641impl SelfStandingWallCoverage {
642    /// 荷重の行き先が無い部分を持つか(判定のしきい値をここに集約する)。
643    ///
644    /// 交点計算の丸め、および壁の端がちょうど床領域の境界に載る場合の
645    /// ごく短い区間を拾わないよう、全体の 0.1% を下回る取りこぼしは無視する。
646    pub fn has_uncovered(&self) -> bool {
647        self.uncovered > 1e-3
648    }
649}
650
651/// 線分 `p0`→`p1` と線分 `q0`→`q1` の交点の、`p` 側のパラメータ `t`(0〜1)。
652/// 平行・端点の縮退・交差しない場合は `None`。
653fn segment_intersection_t(p0: [f64; 2], p1: [f64; 2], q0: [f64; 2], q1: [f64; 2]) -> Option<f64> {
654    let r = [p1[0] - p0[0], p1[1] - p0[1]];
655    let s = [q1[0] - q0[0], q1[1] - q0[1]];
656    let denom = r[0] * s[1] - r[1] * s[0];
657    if denom.abs() <= f64::EPSILON {
658        return None; // 平行または縮退(重なりは区間の中点判定側で拾う)。
659    }
660    let qp = [q0[0] - p0[0], q0[1] - p0[1]];
661    let t = (qp[0] * s[1] - qp[1] * s[0]) / denom;
662    let u = (qp[0] * r[1] - qp[1] * r[0]) / denom;
663    ((0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u)).then_some(t)
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669    use crate::ids::{FloorRegionId, MaterialId, NodeId, SectionId, SlabId, WallPlateId};
670
671    fn model_with_nodes(pts: &[[f64; 3]]) -> Model {
672        let mut m = Model::default();
673        for (i, p) in pts.iter().enumerate() {
674            m.nodes.push(Node {
675                id: NodeId(i as u32),
676                coord: *p,
677                restraint: Default::default(),
678                mass: None,
679                story: None,
680                support_spring: None,
681            });
682        }
683        m
684    }
685
686    /// `boundary_coords_with` は渡した座標をそのまま使う。変形図・モード形が
687    /// 変形後の節点座標で壁版を描くための入口であり、モデルの元座標へ落ちない
688    /// ことを固定する(落ちると壁版だけが変形前の位置に取り残される)。
689    #[test]
690    fn test_boundary_coords_with_uses_supplied_coords() {
691        let m = model_with_nodes(&[
692            [0.0, 0.0, 0.0],
693            [4000.0, 0.0, 0.0],
694            [4000.0, 0.0, 3000.0],
695            [0.0, 0.0, 3000.0],
696        ]);
697        let p = WallPlate {
698            id: WallPlateId(0),
699            shape: WallPlateShape::Enclosed {
700                boundary: vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3)],
701            },
702            section: None,
703            opening_area: 0.0,
704            opening_weight: 0.0,
705            openings: Vec::new(),
706            loads: vec![],
707            slit: WallSlit::default(),
708        };
709        // 全節点を +100 mm ずらした「変形後」の座標を渡す。
710        let moved: Vec<[f64; 3]> = m
711            .nodes
712            .iter()
713            .map(|n| [n.coord[0] + 100.0, n.coord[1], n.coord[2]])
714            .collect();
715        let coords = p
716            .boundary_coords_with(&m, |n| moved.get(n.index()).copied())
717            .expect("境界座標");
718        assert_eq!(coords[0], [100.0, 0.0, 0.0]);
719        assert_eq!(coords[1], [4100.0, 0.0, 0.0]);
720
721        // 取り付く壁版も取付き先の節点座標に追従する。
722        let attached = WallPlate {
723            id: WallPlateId(1),
724            shape: WallPlateShape::Attached {
725                anchor: RegionAnchor::Line {
726                    nodes: [NodeId(0), NodeId(1)],
727                    span: [0.0, 1.0],
728                    transfer: Default::default(),
729                },
730                extent: Some([900.0, 900.0]),
731            },
732            section: None,
733            opening_area: 0.0,
734            opening_weight: 0.0,
735            openings: Vec::new(),
736            loads: vec![],
737            slit: WallSlit::default(),
738        };
739        let coords = attached
740            .boundary_coords_with(&m, |n| moved.get(n.index()).copied())
741            .expect("境界座標");
742        assert_eq!(coords[0], [100.0, 0.0, 0.0]);
743        assert_eq!(coords[2], [4100.0, 0.0, 900.0]);
744    }
745
746    /// `wall_plate_becomes_element` は断面の有無まで見る。壁領域を覆っていても
747    /// 断面が無ければ要素にならないため、3D ビューは壁版として描く必要がある。
748    #[test]
749    fn test_becomes_element_requires_section() {
750        let mut m = model_with_nodes(&[
751            [0.0, 0.0, 0.0],
752            [4000.0, 0.0, 0.0],
753            [4000.0, 0.0, 3000.0],
754            [0.0, 0.0, 3000.0],
755        ]);
756        let boundary = vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3)];
757        m.wall_plates.push(WallPlate {
758            id: WallPlateId(0),
759            shape: WallPlateShape::Enclosed {
760                boundary: boundary.clone(),
761            },
762            section: None,
763            opening_area: 0.0,
764            opening_weight: 0.0,
765            openings: Vec::new(),
766            loads: vec![],
767            slit: WallSlit::default(),
768        });
769        m.wall_regions.push(crate::model::WallRegion {
770            id: crate::ids::WallRegionId(0),
771            name: String::new(),
772            boundary,
773            wall_plate_ids: vec![WallPlateId(0)],
774            posts: Vec::new(),
775        });
776
777        // 覆ってはいるが断面が無い。
778        assert!(m.wall_plate_covers_region(&m.wall_plates[0]));
779        assert!(!m.wall_plate_becomes_element(&m.wall_plates[0]));
780
781        m.wall_plates[0].section = Some(SectionId(0));
782        assert!(m.wall_plate_becomes_element(&m.wall_plates[0]));
783    }
784
785    #[test]
786    fn test_enclosed_boundary_coords_and_area() {
787        let m = model_with_nodes(&[
788            [0.0, 0.0, 0.0],
789            [4000.0, 0.0, 0.0],
790            [4000.0, 0.0, 3000.0],
791            [0.0, 0.0, 3000.0],
792        ]);
793        let p = WallPlate {
794            id: WallPlateId(0),
795            shape: WallPlateShape::Enclosed {
796                boundary: vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3)],
797            },
798            section: None,
799            opening_area: 0.0,
800            opening_weight: 0.0,
801            openings: Vec::new(),
802            loads: vec![],
803            slit: WallSlit::default(),
804        };
805        let coords = p.boundary_coords(&m).expect("境界座標");
806        assert_eq!(coords.len(), 4);
807        assert!((p.area(&m) - 4000.0 * 3000.0).abs() < 1e-6);
808    }
809
810    /// `has_quad_boundary` は境界がちょうど4節点の `Enclosed` 壁版のみ `true`。
811    /// 5節点以上(T字取り付き等)・3節点以下・`Attached` は `false`
812    /// (Q6=C。壁エレメントの定式化を崩す一般化は行わない)。
813    #[test]
814    fn test_has_quad_boundary() {
815        let quad = WallPlate {
816            id: WallPlateId(0),
817            shape: WallPlateShape::Enclosed {
818                boundary: vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3)],
819            },
820            section: None,
821            opening_area: 0.0,
822            opening_weight: 0.0,
823            openings: Vec::new(),
824            loads: vec![],
825            slit: WallSlit::default(),
826        };
827        assert!(quad.has_quad_boundary());
828
829        let mut pentagon = quad.clone();
830        pentagon.shape = WallPlateShape::Enclosed {
831            boundary: vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3), NodeId(4)],
832        };
833        assert!(!pentagon.has_quad_boundary(), "5節点は false");
834
835        let mut triangle = quad.clone();
836        triangle.shape = WallPlateShape::Enclosed {
837            boundary: vec![NodeId(0), NodeId(1), NodeId(2)],
838        };
839        assert!(!triangle.has_quad_boundary(), "3節点は false");
840
841        let mut attached = quad.clone();
842        attached.shape = WallPlateShape::Attached {
843            anchor: RegionAnchor::Line {
844                nodes: [NodeId(0), NodeId(1)],
845                span: [0.0, 1.0],
846                transfer: LoadTransfer::Anchor,
847            },
848            extent: Some([900.0, 900.0]),
849        };
850        assert!(
851            !attached.has_quad_boundary(),
852            "Attached は境界を持たないため false"
853        );
854    }
855
856    /// 取付き線アンカーは、床の取り付く床板(左向き法線方向へ張り出す)とは異なり、
857    /// 鉛直上向きへ立ち上げる(D15「壁は立ち上がり高さ」)。
858    #[test]
859    fn test_attached_line_extrudes_upward_not_sideways() {
860        let m = model_with_nodes(&[[0.0, 0.0, 3000.0], [4000.0, 0.0, 3000.0]]);
861        let p = WallPlate {
862            id: WallPlateId(0),
863            shape: WallPlateShape::Attached {
864                anchor: RegionAnchor::Line {
865                    nodes: [NodeId(0), NodeId(1)],
866                    span: [0.0, 1.0],
867                    transfer: LoadTransfer::Anchor,
868                },
869                extent: Some([900.0, 900.0]),
870            },
871            section: None,
872            opening_area: 0.0,
873            opening_weight: 0.0,
874            openings: Vec::new(),
875            loads: vec![],
876            slit: WallSlit::default(),
877        };
878        let coords = p.boundary_coords(&m).expect("境界座標");
879        // 4点とも Y=0(左向き法線方向へは動かない)、上 2 点は Z=3900(+900 立ち上げ)。
880        for c in &coords {
881            assert_eq!(c[1], 0.0, "Y 方向へは張り出さない: {coords:?}");
882        }
883        assert_eq!(coords[2][2], 3900.0);
884        assert_eq!(coords[3][2], 3900.0);
885        assert!((p.area(&m) - 4000.0 * 900.0).abs() < 1e-6);
886    }
887
888    /// 両端で立ち上がり高さの符号が反転する取り付く壁版は、蝶ネクタイの Newell
889    /// 面積(≈0)ではなく、2 つの三角形の和を自重面積にする。
890    #[test]
891    fn attached_area_uses_abs_height_when_extent_sign_reverses() {
892        let m = model_with_nodes(&[[0.0, 0.0, 3000.0], [4000.0, 0.0, 3000.0]]);
893        let p = WallPlate {
894            id: WallPlateId(0),
895            shape: WallPlateShape::Attached {
896                anchor: RegionAnchor::Line {
897                    nodes: [NodeId(0), NodeId(1)],
898                    span: [0.0, 1.0],
899                    transfer: LoadTransfer::Anchor,
900                },
901                extent: Some([2000.0, -2000.0]),
902            },
903            section: None,
904            opening_area: 0.0,
905            opening_weight: 0.0,
906            openings: Vec::new(),
907            loads: vec![],
908            slit: WallSlit::default(),
909        };
910        let expected = 4000.0 * 2000.0 * 0.5;
911        assert!(
912            (p.area(&m) - expected).abs() < 1e-6,
913            "area={} expected={expected}",
914            p.area(&m)
915        );
916    }
917
918    /// 床領域アンカーは、アンカー自身が持つ節点対を壁の平面上の始点・終点として使う
919    /// (dig Q6=B)。所属先の床領域 ID は幾何計算には関与しない。
920    #[test]
921    fn test_attached_floor_region_anchor_uses_its_own_nodes_for_length() {
922        let m = model_with_nodes(&[[0.0, 0.0, 3000.0], [2000.0, 0.0, 3000.0]]);
923        let p = WallPlate {
924            id: WallPlateId(0),
925            shape: WallPlateShape::Attached {
926                anchor: RegionAnchor::FloorRegion {
927                    nodes: [NodeId(0), NodeId(1)],
928                },
929                extent: Some([2500.0, 2500.0]),
930            },
931            section: None,
932            opening_area: 0.0,
933            opening_weight: 0.0,
934            openings: Vec::new(),
935            loads: vec![],
936            slit: WallSlit::default(),
937        };
938        assert!((p.area(&m) - 2000.0 * 2500.0).abs() < 1e-6);
939    }
940
941    /// 壁の取付き先として `RegionAnchor::Point` は使わない(D14 の対応表参照)。
942    #[test]
943    fn test_attached_point_anchor_is_unsupported_for_wall() {
944        let m = model_with_nodes(&[[0.0, 0.0, 3000.0]]);
945        let p = WallPlate {
946            id: WallPlateId(0),
947            shape: WallPlateShape::Attached {
948                anchor: RegionAnchor::Point(NodeId(0)),
949                extent: Some([900.0, 900.0]),
950            },
951            section: None,
952            opening_area: 0.0,
953            opening_weight: 0.0,
954            openings: Vec::new(),
955            loads: vec![],
956            slit: WallSlit::default(),
957        };
958        assert_eq!(p.boundary_coords(&m), None);
959        assert_eq!(p.area(&m), 0.0);
960    }
961
962    /// 断面が無い壁版の躯体自重は 0 になる(既定厚では補わない)。仕上げ・増打ちは
963    /// 断面に依らないので、断面が無くてもそのまま計上する。ここで丸ごと `None` を
964    /// 返すと、入力された仕上げの重さが黙って落ちる。
965    #[test]
966    fn test_self_weight_without_section_counts_only_finish() {
967        let m = model_with_nodes(&[
968            [0.0, 0.0, 0.0],
969            [4000.0, 0.0, 0.0],
970            [4000.0, 0.0, 3000.0],
971            [0.0, 0.0, 3000.0],
972        ]);
973        let mut p = WallPlate {
974            id: WallPlateId(0),
975            shape: WallPlateShape::Enclosed {
976                boundary: vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3)],
977            },
978            section: None,
979            opening_area: 0.0,
980            opening_weight: 0.0,
981            openings: Vec::new(),
982            loads: vec![],
983            slit: WallSlit::default(),
984        };
985        assert_eq!(m.wall_plate_self_weight(&p, &m), Some(0.0));
986
987        p.loads.push(AreaLoad {
988            kind: "増打ち".into(),
989            value: 1.0e-3,
990        });
991        let w = m.wall_plate_self_weight(&p, &m).expect("自重が求まる");
992        assert!((w - 1.0e-3 * 4000.0 * 3000.0).abs() < 1e-6, "{w}");
993    }
994
995    /// 仕上げ・増打ちも躯体と同じ正味面積(開口控除後)に乗る。
996    #[test]
997    fn test_finish_load_deducts_opening_area() {
998        let m = model_with_nodes(&[
999            [0.0, 0.0, 0.0],
1000            [4000.0, 0.0, 0.0],
1001            [4000.0, 0.0, 3000.0],
1002            [0.0, 0.0, 3000.0],
1003        ]);
1004        let p = WallPlate {
1005            id: WallPlateId(0),
1006            shape: WallPlateShape::Enclosed {
1007                boundary: vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3)],
1008            },
1009            section: None,
1010            opening_area: 2.0e6,
1011            opening_weight: 0.0,
1012            openings: Vec::new(),
1013            loads: vec![AreaLoad {
1014                kind: "仕上げ".into(),
1015                value: 1.0e-3,
1016            }],
1017            slit: WallSlit::default(),
1018        };
1019        let w = m.wall_plate_self_weight(&p, &m).expect("自重が求まる");
1020        assert!((w - 1.0e-3 * (4000.0 * 3000.0 - 2.0e6)).abs() < 1e-6, "{w}");
1021    }
1022
1023    /// 高さを階高いっぱいとした自立壁は、直上の階レベルまでの高さへ解決する。
1024    /// 直上に階が無ければ高さが決まらず `None`(解析前チェックが止める)。
1025    #[test]
1026    fn test_story_height_extent_resolves_from_stories() {
1027        use crate::ids::StoryId;
1028        let mut m = model_with_nodes(&[[0.0, 0.0, 0.0], [4000.0, 0.0, 0.0]]);
1029        for (i, z) in [0.0_f64, 3200.0].into_iter().enumerate() {
1030            m.stories.push(Story {
1031                id: StoryId(i as u32),
1032                name: format!("{i}F"),
1033                elevation: z,
1034                node_ids: vec![],
1035                seismic_weight: None,
1036                weight_override: None,
1037                structure: Default::default(),
1038                level_kind: Default::default(),
1039            });
1040        }
1041        let p = WallPlate {
1042            id: WallPlateId(0),
1043            shape: WallPlateShape::Attached {
1044                anchor: RegionAnchor::FloorRegion {
1045                    nodes: [NodeId(0), NodeId(1)],
1046                },
1047                extent: None,
1048            },
1049            section: None,
1050            opening_area: 0.0,
1051            opening_weight: 0.0,
1052            openings: Vec::new(),
1053            loads: vec![],
1054            slit: WallSlit::default(),
1055        };
1056        assert_eq!(m.wall_plate_extent(&p), Some([3200.0, 3200.0]));
1057
1058        // 最上階の床に立つ壁は上端を決める階が無い。
1059        m.stories.truncate(1);
1060        assert_eq!(m.wall_plate_extent(&p), None);
1061        assert_eq!(m.wall_plate_self_weight(&p, &m), None);
1062    }
1063
1064    #[test]
1065    fn test_self_weight_deducts_opening_area() {
1066        let mut m = model_with_nodes(&[
1067            [0.0, 0.0, 0.0],
1068            [4000.0, 0.0, 0.0],
1069            [4000.0, 0.0, 3000.0],
1070            [0.0, 0.0, 3000.0],
1071        ]);
1072        m.materials.push(Material {
1073            strength_factor: None,
1074            concrete_class: Default::default(),
1075            id: MaterialId(0),
1076            name: "Fc24".into(),
1077            category: MaterialCategory::Concrete,
1078            young: 23000.0,
1079            poisson: 0.2,
1080            density: 2.4e-9,
1081            shear: None,
1082            fc: Some(24.0),
1083            fy: None,
1084        });
1085        m.sections.push(Section {
1086            id: SectionId(0),
1087            name: "壁 t150".into(),
1088            area: 150.0 * 3000.0,
1089            iy: 1.0,
1090            iz: 1.0,
1091            j: 1.0,
1092            depth: 3000.0,
1093            width: 150.0,
1094            as_y: 1.0,
1095            as_z: 1.0,
1096            floor: None,
1097            panel_thickness: None,
1098            thickness: Some(150.0),
1099            shape: None,
1100            material: Some(MaterialId(0)),
1101            rebar_material: None,
1102            shear_rebar_material: None,
1103            steel_material: None,
1104        });
1105        let p = WallPlate {
1106            id: WallPlateId(0),
1107            shape: WallPlateShape::Enclosed {
1108                boundary: vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3)],
1109            },
1110            section: Some(SectionId(0)),
1111            opening_area: 0.0,
1112            opening_weight: 0.0,
1113            openings: vec![WallOpening {
1114                width: 900.0,
1115                height: 1200.0,
1116                offset: Some([1550.0, 0.0]),
1117            }],
1118            loads: vec![],
1119            slit: WallSlit::default(),
1120        };
1121        let gross_area = 4000.0 * 3000.0;
1122        let opening_area = 900.0 * 1200.0;
1123        let expected = 150.0 * 2.4e-9 * (gross_area - opening_area) * crate::units::GRAVITY_MM_S2;
1124        let w = m.wall_plate_self_weight(&p, &m).expect("自重が求まる");
1125        assert!(
1126            (w - expected).abs() / expected < 1e-9,
1127            "自重 {w}(期待値 {expected})"
1128        );
1129    }
1130
1131    fn model_with_wall_section() -> Model {
1132        let mut m = model_with_nodes(&[
1133            [0.0, 0.0, 0.0],
1134            [4000.0, 0.0, 0.0],
1135            [4000.0, 0.0, 3000.0],
1136            [0.0, 0.0, 3000.0],
1137        ]);
1138        m.materials.push(Material {
1139            strength_factor: None,
1140            concrete_class: Default::default(),
1141            id: MaterialId(0),
1142            name: "Fc24".into(),
1143            category: MaterialCategory::Concrete,
1144            young: 23000.0,
1145            poisson: 0.2,
1146            density: 2.4e-9,
1147            shear: None,
1148            fc: Some(24.0),
1149            fy: None,
1150        });
1151        m.sections.push(Section {
1152            id: SectionId(0),
1153            name: "壁 t150".into(),
1154            area: 150.0 * 3000.0,
1155            iy: 1.0,
1156            iz: 1.0,
1157            j: 1.0,
1158            depth: 3000.0,
1159            width: 150.0,
1160            as_y: 1.0,
1161            as_z: 1.0,
1162            floor: None,
1163            panel_thickness: None,
1164            thickness: Some(150.0),
1165            shape: None,
1166            material: Some(MaterialId(0)),
1167            rebar_material: None,
1168            shear_rebar_material: None,
1169            steel_material: None,
1170        });
1171        m
1172    }
1173
1174    /// `openings`(個別開口)が空の場合は `opening_area`(合計面積のみ入力)に
1175    /// フォールバックする(`WallAttr::total_opening_area` と同じ規約)。
1176    #[test]
1177    fn test_total_opening_area_falls_back_to_opening_area_field() {
1178        let p = WallPlate {
1179            id: WallPlateId(0),
1180            shape: WallPlateShape::Enclosed {
1181                boundary: vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3)],
1182            },
1183            section: None,
1184            opening_area: 999.0,
1185            opening_weight: 0.0,
1186            openings: Vec::new(),
1187            loads: vec![],
1188            slit: WallSlit::default(),
1189        };
1190        assert!((p.total_opening_area() - 999.0).abs() < 1e-9);
1191    }
1192
1193    /// 開口部(サッシ等)の重量は、開口面積控除後の自重に加算する。
1194    #[test]
1195    fn test_self_weight_adds_opening_weight() {
1196        let m = model_with_wall_section();
1197        let p = WallPlate {
1198            id: WallPlateId(0),
1199            shape: WallPlateShape::Enclosed {
1200                boundary: vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3)],
1201            },
1202            section: Some(SectionId(0)),
1203            opening_area: 0.0,
1204            opening_weight: 1234.0,
1205            openings: Vec::new(),
1206            loads: vec![],
1207            slit: WallSlit::default(),
1208        };
1209        let gross_area = 4000.0 * 3000.0;
1210        let base = 150.0 * 2.4e-9 * gross_area * crate::units::GRAVITY_MM_S2;
1211        let expected = base + 1234.0;
1212        let w = m.wall_plate_self_weight(&p, &m).expect("自重が求まる");
1213        assert!(
1214            (w - expected).abs() / expected < 1e-9,
1215            "自重 {w}(期待値 {expected})"
1216        );
1217    }
1218
1219    /// 床領域 2 つ(4m ずつ、Z=3000)と、それぞれに床板を持つモデル。
1220    /// X = 0..8000, Y = 0..4000 を X=4000 で 2 分割した並びとする。
1221    fn model_two_regions() -> Model {
1222        // 床領域の境界節点(0..5)と、自立壁の両端(6, 7)。
1223        let mut m = model_with_nodes(&[
1224            [0.0, 0.0, 3000.0],
1225            [4000.0, 0.0, 3000.0],
1226            [8000.0, 0.0, 3000.0],
1227            [8000.0, 4000.0, 3000.0],
1228            [4000.0, 4000.0, 3000.0],
1229            [0.0, 4000.0, 3000.0],
1230        ]);
1231        for (ri, b) in [
1232            vec![NodeId(0), NodeId(1), NodeId(4), NodeId(5)],
1233            vec![NodeId(1), NodeId(2), NodeId(3), NodeId(4)],
1234        ]
1235        .into_iter()
1236        .enumerate()
1237        {
1238            let mut r = FloorRegion::new(FloorRegionId(ri as u32), b.clone());
1239            r.slab_ids.push(SlabId(ri as u32));
1240            m.floor_regions.push(r);
1241            m.slabs.push(Slab {
1242                id: SlabId(ri as u32),
1243                shape: SlabShape::Enclosed { boundary: b },
1244                plate: SlabPlate::default(),
1245            });
1246        }
1247        m
1248    }
1249
1250    fn push_self_standing(m: &mut Model, a: [f64; 3], b: [f64; 3], extent: [f64; 2]) -> WallPlate {
1251        let i = m.nodes.len() as u32;
1252        for p in [a, b] {
1253            m.nodes.push(Node {
1254                id: NodeId(m.nodes.len() as u32),
1255                coord: p,
1256                restraint: Default::default(),
1257                mass: None,
1258                story: None,
1259                support_spring: None,
1260            });
1261        }
1262        WallPlate {
1263            id: WallPlateId(0),
1264            shape: WallPlateShape::Attached {
1265                anchor: RegionAnchor::FloorRegion {
1266                    nodes: [NodeId(i), NodeId(i + 1)],
1267                },
1268                extent: Some(extent),
1269            },
1270            section: None,
1271            opening_area: 0.0,
1272            opening_weight: 0.0,
1273            openings: vec![],
1274            loads: vec![],
1275            slit: WallSlit::default(),
1276        }
1277    }
1278
1279    /// 1 つの床領域に収まる自立壁は、その床領域が全量を受け持つ。
1280    #[test]
1281    fn coverage_within_single_region() {
1282        let mut m = model_two_regions();
1283        let p = push_self_standing(
1284            &mut m,
1285            [500.0, 1000.0, 3000.0],
1286            [3500.0, 1000.0, 3000.0],
1287            [2000.0, 2000.0],
1288        );
1289        let cov = m.self_standing_wall_coverage(&p).expect("自立壁");
1290        assert!(!cov.has_uncovered(), "{cov:?}");
1291        assert_eq!(cov.per_region.len(), 1);
1292        assert_eq!(cov.per_region[0].0, FloorRegionId(0));
1293        assert!((cov.per_region[0].1 - 1.0).abs() < 1e-9, "{cov:?}");
1294    }
1295
1296    /// 床領域をまたぐ自立壁は、境界で分割して長さ比で配る(矩形なら長さ比=重量比)。
1297    #[test]
1298    fn coverage_splits_across_regions_by_length() {
1299        let mut m = model_two_regions();
1300        // X=2000..6000 の壁。X=4000 の境界で 2:2 に分かれる。
1301        let p = push_self_standing(
1302            &mut m,
1303            [2000.0, 1000.0, 3000.0],
1304            [6000.0, 1000.0, 3000.0],
1305            [2000.0, 2000.0],
1306        );
1307        let cov = m.self_standing_wall_coverage(&p).expect("自立壁");
1308        assert!(!cov.has_uncovered(), "{cov:?}");
1309        assert_eq!(cov.per_region.len(), 2, "{cov:?}");
1310        for (_, f) in &cov.per_region {
1311            assert!((f - 0.5).abs() < 1e-9, "{cov:?}");
1312        }
1313        let sum: f64 = cov.per_region.iter().map(|(_, f)| f).sum();
1314        assert!((sum - 1.0).abs() < 1e-9, "総和が保存すること: {cov:?}");
1315    }
1316
1317    /// 台形(立ち上がり高さが両端で異なる)は、長さ比ではなく台形面積比で配る。
1318    #[test]
1319    fn coverage_splits_by_trapezoid_area_not_length() {
1320        let mut m = model_two_regions();
1321        // X=0..8000 を X=4000 で 2 分割。高さは 0 → 4000 の線形。
1322        // 前半の面積比 = (0+2000)/2*0.5 / ((0+4000)/2) = 500/2000 = 0.25
1323        let p = push_self_standing(
1324            &mut m,
1325            [10.0, 1000.0, 3000.0],
1326            [7990.0, 1000.0, 3000.0],
1327            [0.0, 4000.0],
1328        );
1329        let cov = m.self_standing_wall_coverage(&p).expect("自立壁");
1330        assert!(!cov.has_uncovered(), "{cov:?}");
1331        let f0 = cov
1332            .per_region
1333            .iter()
1334            .find(|(r, _)| *r == FloorRegionId(0))
1335            .expect("領域0")
1336            .1;
1337        assert!((f0 - 0.25).abs() < 2e-3, "台形面積比になること: {f0}");
1338        let sum: f64 = cov.per_region.iter().map(|(_, f)| f).sum();
1339        assert!((sum - 1.0).abs() < 1e-9, "総和が保存すること: {cov:?}");
1340    }
1341
1342    /// 立ち上がり高さの符号が両端で反転する自立壁は、ゼロ交差で折れた ∫|h| で
1343    /// 配る。端点絶対値の台形を分母にすると比率の合計が 0.5 になり重量が消える。
1344    #[test]
1345    fn coverage_sign_reversal_preserves_ratio_sum() {
1346        let mut m = model_two_regions();
1347        // X=2000..6000。X=4000 の境界かつ高さ 0。各側は同じ三角形。
1348        let p = push_self_standing(
1349            &mut m,
1350            [2000.0, 1000.0, 3000.0],
1351            [6000.0, 1000.0, 3000.0],
1352            [2000.0, -2000.0],
1353        );
1354        let cov = m.self_standing_wall_coverage(&p).expect("自立壁");
1355        assert!(!cov.has_uncovered(), "{cov:?}");
1356        let sum: f64 = cov.per_region.iter().map(|(_, f)| f).sum::<f64>() + cov.uncovered;
1357        assert!(
1358            (sum - 1.0).abs() < 1e-9,
1359            "符号反転でも比率の合計は 1: {cov:?}"
1360        );
1361        for (_, f) in &cov.per_region {
1362            assert!((f - 0.5).abs() < 1e-9, "{cov:?}");
1363        }
1364    }
1365
1366    /// 床領域の外へはみ出す自立壁は、その分を `uncovered` に集計する。
1367    #[test]
1368    fn coverage_reports_uncovered_outside_regions() {
1369        let mut m = model_two_regions();
1370        // X=6000..10000。X=8000 より外は床領域が無い。
1371        let p = push_self_standing(
1372            &mut m,
1373            [6000.0, 1000.0, 3000.0],
1374            [10000.0, 1000.0, 3000.0],
1375            [2000.0, 2000.0],
1376        );
1377        let cov = m.self_standing_wall_coverage(&p).expect("自立壁");
1378        assert!(cov.has_uncovered(), "{cov:?}");
1379        assert!((cov.uncovered - 0.5).abs() < 1e-9, "{cov:?}");
1380    }
1381
1382    /// 床板を持たない床領域は「力を流す先が無い」ため候補にしない。
1383    #[test]
1384    fn coverage_ignores_regions_without_slabs() {
1385        let mut m = model_two_regions();
1386        m.floor_regions[0].slab_ids.clear();
1387        let p = push_self_standing(
1388            &mut m,
1389            [500.0, 1000.0, 3000.0],
1390            [3500.0, 1000.0, 3000.0],
1391            [2000.0, 2000.0],
1392        );
1393        let cov = m.self_standing_wall_coverage(&p).expect("自立壁");
1394        assert!(
1395            cov.has_uncovered(),
1396            "版なし床領域は覆いとみなさない: {cov:?}"
1397        );
1398        assert!((cov.uncovered - 1.0).abs() < 1e-9, "{cov:?}");
1399    }
1400
1401    /// レベルが床領域と一致しない自立壁は、どの床領域にも載らない。
1402    #[test]
1403    fn coverage_requires_level_match() {
1404        let mut m = model_two_regions();
1405        let p = push_self_standing(
1406            &mut m,
1407            [500.0, 1000.0, 6000.0],
1408            [3500.0, 1000.0, 6000.0],
1409            [2000.0, 2000.0],
1410        );
1411        let cov = m.self_standing_wall_coverage(&p).expect("自立壁");
1412        assert!(cov.has_uncovered(), "{cov:?}");
1413        assert!((cov.uncovered - 1.0).abs() < 1e-9, "{cov:?}");
1414    }
1415
1416    /// 取り付く壁版でない(囲まれた)壁版は対象外。
1417    #[test]
1418    fn coverage_is_none_for_enclosed() {
1419        let m = model_two_regions();
1420        let p = WallPlate {
1421            id: WallPlateId(0),
1422            shape: WallPlateShape::Enclosed {
1423                boundary: vec![NodeId(0), NodeId(1), NodeId(4), NodeId(5)],
1424            },
1425            section: None,
1426            opening_area: 0.0,
1427            opening_weight: 0.0,
1428            openings: vec![],
1429            loads: vec![],
1430            slit: WallSlit::default(),
1431        };
1432        assert!(m.self_standing_wall_coverage(&p).is_none());
1433    }
1434
1435    /// L 形(非凸)の床領域 1 つ。欠けているのは X=4000..8000, Y=4000..8000。
1436    fn model_l_region() -> Model {
1437        let mut m = model_with_nodes(&[
1438            [0.0, 0.0, 3000.0],
1439            [8000.0, 0.0, 3000.0],
1440            [8000.0, 4000.0, 3000.0],
1441            [4000.0, 4000.0, 3000.0],
1442            [4000.0, 8000.0, 3000.0],
1443            [0.0, 8000.0, 3000.0],
1444        ]);
1445        let b = vec![
1446            NodeId(0),
1447            NodeId(1),
1448            NodeId(2),
1449            NodeId(3),
1450            NodeId(4),
1451            NodeId(5),
1452        ];
1453        let mut r = FloorRegion::new(FloorRegionId(0), b.clone());
1454        r.slab_ids.push(SlabId(0));
1455        m.floor_regions.push(r);
1456        m.slabs.push(Slab {
1457            id: SlabId(0),
1458            shape: SlabShape::Enclosed { boundary: b },
1459            plate: SlabPlate::default(),
1460        });
1461        m
1462    }
1463
1464    /// L 形の底辺に収まる自立壁は、非凸でも全量をその床領域が受け持つ。
1465    #[test]
1466    fn coverage_l_shape_interior_is_fully_covered() {
1467        let mut m = model_l_region();
1468        let p = push_self_standing(
1469            &mut m,
1470            [500.0, 1000.0, 3000.0],
1471            [7500.0, 1000.0, 3000.0],
1472            [2000.0, 2000.0],
1473        );
1474        let cov = m.self_standing_wall_coverage(&p).expect("自立壁");
1475        assert!(!cov.has_uncovered(), "{cov:?}");
1476        assert_eq!(cov.per_region.len(), 1);
1477        assert_eq!(cov.per_region[0].0, FloorRegionId(0));
1478        assert!((cov.per_region[0].1 - 1.0).abs() < 1e-9, "{cov:?}");
1479    }
1480
1481    /// L 形の欠けへはみ出す壁は、欠け側を `uncovered` にする(中点内包で帰属)。
1482    #[test]
1483    fn coverage_l_shape_notch_is_uncovered() {
1484        let mut m = model_l_region();
1485        // Y=1000..7000。Y=4000 より上が欠け(X=6000)。
1486        let p = push_self_standing(
1487            &mut m,
1488            [6000.0, 1000.0, 3000.0],
1489            [6000.0, 7000.0, 3000.0],
1490            [2000.0, 2000.0],
1491        );
1492        let cov = m.self_standing_wall_coverage(&p).expect("自立壁");
1493        assert!(cov.has_uncovered(), "{cov:?}");
1494        assert!((cov.uncovered - 0.5).abs() < 1e-9, "{cov:?}");
1495        let covered: f64 = cov.per_region.iter().map(|(_, f)| f).sum();
1496        assert!((covered - 0.5).abs() < 1e-9, "{cov:?}");
1497    }
1498
1499    /// 床領域の辺上に乗った壁は、厳密内包判定により覆われていない。
1500    #[test]
1501    fn coverage_on_region_boundary_is_uncovered() {
1502        let mut m = model_two_regions();
1503        // 領域 0 の南辺(Y=0, X=500..3500)にぴったり載せる。
1504        let p = push_self_standing(
1505            &mut m,
1506            [500.0, 0.0, 3000.0],
1507            [3500.0, 0.0, 3000.0],
1508            [2000.0, 2000.0],
1509        );
1510        let cov = m.self_standing_wall_coverage(&p).expect("自立壁");
1511        assert!(
1512            cov.has_uncovered(),
1513            "辺上は内包しない(大梁真上は線アンカーへ): {cov:?}"
1514        );
1515        assert!((cov.uncovered - 1.0).abs() < 1e-9, "{cov:?}");
1516    }
1517}