1use crate::geom::polygon::{self, BOUNDARY_TOL_MM};
37use crate::geom::LEVEL_TOL_MM;
38use crate::ids::{NodeId, WallRegionId};
39use crate::model::{LoadTransfer, Model, RegionAnchor, WallPlateShape, WallRegion};
40use crate::region_gen::wall::{scan_wall_region_boundaries, WallRegionBoundary};
41use crate::region_rebuild::{
42 delete_unref_nodes, edge_fully_covered, horizontal_girders, GirderSeg,
43};
44
45pub const CENTROID_MATCH_AREA_REL: f64 = 1e-3;
48
49#[derive(Clone, Debug, Default, PartialEq, Eq)]
51pub struct WallRegionRebuildReport {
52 pub regions: usize,
54 pub inherited: usize,
56 pub new_regions: usize,
58 pub unmatched_old_regions: usize,
60 pub wall_plates_assigned: usize,
62 pub wall_plates_converted_to_attached: usize,
64 pub unassigned_wall_plates: usize,
66 pub unassigned_posts: usize,
68 pub deleted_nodes: usize,
70}
71
72pub fn unassigned_post_count(model: &Model) -> usize {
74 model.unassigned_posts.len()
75}
76
77pub fn rebuild_wall_regions(model: &mut Model) -> WallRegionRebuildReport {
85 let scan = scan_wall_region_boundaries(model);
86 for r in &mut model.wall_regions {
88 model.unassigned_posts.append(&mut r.posts);
89 }
90 let old_regions = std::mem::take(&mut model.wall_regions);
91 let mut report = WallRegionRebuildReport::default();
92
93 let mut matched_old = vec![false; old_regions.len()];
96 let mut new_regions: Vec<WallRegion> = Vec::with_capacity(scan.boundaries.len());
97 for rb in &scan.boundaries {
98 let mut region = WallRegion::new(WallRegionId(0), rb.boundary.clone());
99 let rb_area = rb.area(model);
100 if let Some((oi, old_area)) = old_regions
101 .iter()
102 .enumerate()
103 .filter(|(oi, _)| !matched_old[*oi])
104 .filter_map(|(oi, old)| match_candidate(model, rb, &old.boundary, oi))
105 .min_by(|(_, a), (_, b)| a.total_cmp(b))
106 {
107 matched_old[oi] = true;
108 let denom = rb_area.abs().max(f64::EPSILON);
109 if (old_area - rb_area).abs() / denom < CENTROID_MATCH_AREA_REL {
110 region.name = old_regions[oi].name.clone();
111 report.inherited += 1;
112 } else {
113 report.new_regions += 1;
114 }
115 } else {
116 report.new_regions += 1;
117 }
118 new_regions.push(region);
119 }
120 report.regions = new_regions.len();
121 report.unmatched_old_regions = matched_old.iter().filter(|m| !**m).count();
122
123 let beams = horizontal_girders(model);
127 let mut owner: Vec<Option<usize>> = vec![None; model.wall_plates.len()];
128 for (pi, plate) in model.wall_plates.iter().enumerate() {
129 let WallPlateShape::Enclosed { boundary } = &plate.shape else {
130 continue; };
132 if let Some((ri, _)) = scan
133 .boundaries
134 .iter()
135 .enumerate()
136 .filter_map(|(ri, rb)| match_candidate(model, rb, boundary, ri))
137 .min_by(|(_, a), (_, b)| a.total_cmp(b))
138 {
139 owner[pi] = Some(ri);
140 report.wall_plates_assigned += 1;
141 }
142 }
143 let mut converted = Vec::new();
144 let mut discarded_by_conversion: Vec<NodeId> = Vec::new();
147 for (pi, plate) in model.wall_plates.iter().enumerate() {
148 if owner[pi].is_some() {
149 continue;
150 }
151 let WallPlateShape::Enclosed { boundary } = &plate.shape else {
152 continue;
153 };
154 if let Some(attached) = try_convert_wall_attached(model, boundary, &beams) {
155 discarded_by_conversion.extend(boundary.iter().copied());
156 converted.push((pi, attached));
157 report.wall_plates_converted_to_attached += 1;
158 } else {
159 report.unassigned_wall_plates += 1;
160 }
161 }
162 for (pi, shape) in converted {
163 model.wall_plates[pi].shape = shape;
164 }
165 for (pi, ri) in owner.into_iter().enumerate() {
166 if let Some(ri) = ri {
167 new_regions[ri]
168 .wall_plate_ids
169 .push(model.wall_plates[pi].id);
170 }
171 }
172
173 for (i, r) in new_regions.iter_mut().enumerate() {
174 r.id = WallRegionId(i as u32);
175 }
176 model.wall_regions = new_regions;
177 report.unassigned_posts = assign_posts(model, &scan.boundaries);
178 report.deleted_nodes = delete_unref_nodes(model, &discarded_by_conversion);
179
180 report
181}
182
183fn try_convert_wall_attached(
188 model: &Model,
189 boundary: &[NodeId],
190 beams: &[GirderSeg],
191) -> Option<WallPlateShape> {
192 if boundary.len() < 3 {
193 return None;
194 }
195 let pts: Vec<[f64; 3]> = boundary
196 .iter()
197 .map(|id| model.nodes.get(id.index()).map(|n| n.coord))
198 .collect::<Option<_>>()?;
199 let n = boundary.len();
200 let mut covered = Vec::new();
201 for i in 0..n {
202 let a = pts[i];
203 let b = pts[(i + 1) % n];
204 if (a[2] - b[2]).abs() > LEVEL_TOL_MM {
205 continue; }
207 let z = (a[2] + b[2]) / 2.0;
208 if edge_fully_covered([a[0], a[1]], [b[0], b[1]], z, beams) {
209 covered.push(i);
210 }
211 }
212 if covered.len() != 1 {
213 return None;
214 }
215 let ei = covered[0];
216 let n0 = boundary[ei];
217 let n1 = boundary[(ei + 1) % n];
218 let a = pts[ei];
219 let b = pts[(ei + 1) % n];
220 let edge_z = (a[2] + b[2]) / 2.0;
221 let dx = b[0] - a[0];
222 let dy = b[1] - a[1];
223 let len = (dx * dx + dy * dy).sqrt();
224 if len <= f64::EPSILON {
225 return None;
226 }
227 let ux = dx / len;
228 let uy = dy / len;
229
230 let mut free = Vec::new();
234 for p in &pts {
235 let on_edge = (p[2] - edge_z).abs() <= LEVEL_TOL_MM
236 && polygon::point_segment_dist([p[0], p[1]], [a[0], a[1]], [b[0], b[1]])
237 <= BOUNDARY_TOL_MM;
238 if on_edge {
239 continue;
240 }
241 let t = (p[0] - a[0]) * ux + (p[1] - a[1]) * uy;
242 free.push((t, p[2] - edge_z));
243 }
244 let extent = match free.len() {
245 1 => [free[0].1, free[0].1],
246 2 => {
247 if free[0].0 <= free[1].0 {
248 [free[0].1, free[1].1]
249 } else {
250 [free[1].1, free[0].1]
251 }
252 }
253 _ => return None,
254 };
255
256 Some(WallPlateShape::Attached {
257 anchor: RegionAnchor::Line {
258 nodes: [n0, n1],
259 span: [0.0, 1.0],
260 transfer: LoadTransfer::Anchor,
261 },
262 extent: Some(extent),
265 })
266}
267
268fn match_candidate(
277 model: &Model,
278 rb: &WallRegionBoundary,
279 candidate: &[NodeId],
280 index: usize,
281) -> Option<(usize, f64)> {
282 let coords: Vec<[f64; 3]> = candidate
283 .iter()
284 .map(|n| model.nodes.get(n.index()).map(|nd| nd.coord))
285 .collect::<Option<_>>()?;
286 if coords.len() < 3 {
287 return None;
288 }
289 if !coords.iter().all(|c| rb.is_same_plane([c[0], c[1]])) {
290 return None;
291 }
292 let projected: Vec<[f64; 2]> = coords.iter().map(|c| rb.project(*c)).collect();
293 let local_centroid = polygon::centroid(&projected);
294 if !rb.contains(model, local_centroid) {
295 return None;
296 }
297 let area = polygon::area_3d(&coords);
298 Some((index, area))
299}
300
301fn assign_posts(model: &mut Model, boundaries: &[WallRegionBoundary]) -> usize {
302 let posts: Vec<_> = model
303 .wall_regions
304 .iter_mut()
305 .flat_map(|r| r.posts.drain(..))
306 .chain(model.unassigned_posts.drain(..))
307 .collect();
308 for region in &mut model.wall_regions {
309 region.posts.clear();
310 }
311 let mut unassigned = 0;
312 for sm in posts {
313 let nodes = sm.nodes;
314 let Some(a) = model.nodes.get(nodes[0].index()).map(|n| n.coord) else {
315 model.unassigned_posts.push(sm);
316 unassigned += 1;
317 continue;
318 };
319 let Some(b) = model.nodes.get(nodes[1].index()).map(|n| n.coord) else {
320 model.unassigned_posts.push(sm);
321 unassigned += 1;
322 continue;
323 };
324 let midpoint = [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5];
325 let hits: Vec<usize> = boundaries
326 .iter()
327 .enumerate()
328 .filter(|(_, rb)| rb.is_same_plane(midpoint))
329 .filter(|(_, rb)| {
330 let coord = [midpoint[0], midpoint[1], (a[2] + b[2]) * 0.5];
331 rb.contains(model, rb.project(coord))
332 })
333 .map(|(i, _)| i)
334 .collect();
335 if hits.len() == 1 {
336 model.wall_regions[hits[0]].posts.push(sm);
337 } else {
338 model.unassigned_posts.push(sm);
339 unassigned += 1;
340 }
341 }
342 unassigned
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::ids::{ElemId, WallPlateId};
349 use crate::model::{
350 ElementData, ElementKind, EndCondition, ForceRegime, LocalAxis, Node, WallPlate,
351 };
352
353 fn node(id: u32, x: f64, y: f64, z: f64) -> Node {
354 Node {
355 id: NodeId(id),
356 coord: [x, y, z],
357 restraint: Default::default(),
358 mass: None,
359 story: None,
360 support_spring: None,
361 }
362 }
363
364 fn beam(id: u32, i: u32, j: u32) -> ElementData {
365 ElementData {
366 id: ElemId(id),
367 kind: ElementKind::Beam,
368 nodes: [NodeId(i), NodeId(j)].into_iter().collect(),
369 section: None,
370 local_axis: LocalAxis {
371 ref_vector: [0.0, 0.0, 1.0],
372 },
373 end_cond: [EndCondition::Fixed, EndCondition::Fixed],
374 force_regime: ForceRegime::Auto,
375 rigid_zone: Default::default(),
376 plastic_zone: None,
377 spring: None,
378 }
379 }
380
381 fn one_bay_wall_model() -> Model {
383 let mut model = Model::default();
384 model.nodes.push(node(0, 0.0, 0.0, 0.0));
385 model.nodes.push(node(1, 4000.0, 0.0, 0.0));
386 model.nodes.push(node(2, 4000.0, 0.0, 3000.0));
387 model.nodes.push(node(3, 0.0, 0.0, 3000.0));
388 model.elements.push(beam(0, 0, 3)); model.elements.push(beam(1, 1, 2)); model.elements.push(beam(2, 3, 2)); model.elements.push(beam(3, 0, 1)); model
393 }
394
395 #[test]
396 fn test_rebuild_creates_wall_region_from_boundary() {
397 let mut model = one_bay_wall_model();
398 let report = rebuild_wall_regions(&mut model);
399 assert_eq!(report.regions, 1);
400 assert_eq!(report.new_regions, 1);
401 assert_eq!(report.inherited, 0);
402 assert_eq!(model.wall_regions.len(), 1);
403 assert_eq!(model.wall_regions[0].id, WallRegionId(0));
404 assert_eq!(model.wall_regions[0].boundary.len(), 4);
405 assert!(model.validate().is_ok(), "{:?}", model.validate());
406 }
407
408 #[test]
411 fn test_rebuild_inherits_name_without_post_ids() {
412 let mut model = one_bay_wall_model();
413 rebuild_wall_regions(&mut model);
414 model.wall_regions[0].name = "西面耐震壁".into();
415 model.unassigned_posts.push(crate::model::SecondaryMember {
416 kind: crate::model::SecondaryMemberKind::Post,
417 nodes: [NodeId(0), NodeId(3)],
418 section: None,
419 name: "P1".into(),
420 });
421
422 let report = rebuild_wall_regions(&mut model);
424 assert_eq!(report.inherited, 1, "同じ床領域は引き継がれるはず");
425 assert_eq!(model.wall_regions[0].name, "西面耐震壁");
426 assert!(model.wall_regions[0].posts.is_empty());
427 assert!(report.unassigned_posts >= 1);
428 }
429
430 #[test]
431 fn test_match_candidate_requires_all_vertices_on_same_plane() {
432 let mut model = one_bay_wall_model();
433 model.nodes.push(node(4, 4000.0, 3000.0, 3000.0));
434 let rb = scan_wall_region_boundaries(&model).boundaries[0].clone();
435
436 let candidate = vec![NodeId(0), NodeId(1), NodeId(4), NodeId(3)];
437
438 assert!(match_candidate(&model, &rb, &candidate, 0).is_none());
439 }
440
441 #[test]
442 fn test_match_candidate_uses_area_centroid_for_l_shape() {
443 let mut model = Model::default();
444 for (id, (s, z)) in [
445 (0, (0.0, 0.0)),
446 (1, (4000.0, 0.0)),
447 (2, (4000.0, 1500.0)),
448 (3, (2000.0, 1500.0)),
449 (4, (2000.0, 4000.0)),
450 (5, (0.0, 4000.0)),
451 ] {
452 model.nodes.push(node(id, s, 0.0, z));
453 }
454 let candidate = vec![
455 NodeId(0),
456 NodeId(1),
457 NodeId(2),
458 NodeId(3),
459 NodeId(4),
460 NodeId(5),
461 ];
462 let rb = WallRegionBoundary {
463 plane_origin: [0.0, 0.0],
464 plane_direction: [1.0, 0.0],
465 boundary: candidate.clone(),
466 edges: (0..6).map(ElemId).collect(),
467 };
468
469 assert!(!rb.contains(&model, [2000.0, 1833.3333333333]));
470 assert!(match_candidate(&model, &rb, &candidate, 0).is_some());
471 }
472
473 #[test]
474 fn test_rebuild_assigns_internal_post_only() {
475 let mut model = one_bay_wall_model();
476 model.nodes.push(node(4, 2000.0, 0.0, 0.0));
477 model.nodes.push(node(5, 2000.0, 0.0, 3000.0));
478 model.unassigned_posts.push(crate::model::SecondaryMember {
479 kind: crate::model::SecondaryMemberKind::Post,
480 nodes: [NodeId(4), NodeId(5)],
481 section: None,
482 name: "内部間柱".into(),
483 });
484
485 let report = rebuild_wall_regions(&mut model);
486
487 assert_eq!(report.unassigned_posts, 0);
488 assert_eq!(model.wall_regions.len(), 1);
489 assert_eq!(model.wall_regions[0].posts.len(), 1);
490 assert_eq!(model.wall_regions[0].posts[0].nodes, [NodeId(4), NodeId(5)]);
491 }
492
493 #[test]
494 fn test_rebuild_assigns_enclosed_wall_plate_to_matching_region() {
495 let mut model = one_bay_wall_model();
496 model.wall_plates.push(WallPlate {
497 id: WallPlateId(0),
498 shape: crate::model::WallPlateShape::Enclosed {
499 boundary: vec![NodeId(0), NodeId(1), NodeId(2), NodeId(3)],
500 },
501 section: None,
502 opening_area: 0.0,
503 opening_weight: 0.0,
504 openings: vec![],
505 loads: vec![],
506 slit: Default::default(),
507 });
508 let report = rebuild_wall_regions(&mut model);
509 assert_eq!(report.wall_plates_assigned, 1);
510 assert_eq!(report.unassigned_wall_plates, 0);
511 assert_eq!(model.wall_regions[0].wall_plate_ids, vec![WallPlateId(0)]);
512 assert!(model.validate().is_ok(), "{:?}", model.validate());
513 }
514
515 #[test]
519 fn test_rebuild_reports_unassigned_wall_plate_off_any_plane() {
520 let mut model = one_bay_wall_model();
521 model.nodes.push(node(4, 0.0, 3000.0, 0.0));
523 model.nodes.push(node(5, 4000.0, 3000.0, 0.0));
524 model.nodes.push(node(6, 4000.0, 3000.0, 3000.0));
525 model.nodes.push(node(7, 0.0, 3000.0, 3000.0));
526 model.wall_plates.push(WallPlate {
527 id: WallPlateId(0),
528 shape: crate::model::WallPlateShape::Enclosed {
529 boundary: vec![NodeId(4), NodeId(5), NodeId(6), NodeId(7)],
530 },
531 section: None,
532 opening_area: 0.0,
533 opening_weight: 0.0,
534 openings: vec![],
535 loads: vec![],
536 slit: Default::default(),
537 });
538 let report = rebuild_wall_regions(&mut model);
539 assert_eq!(report.wall_plates_assigned, 0);
540 assert_eq!(report.unassigned_wall_plates, 1);
541 assert!(model.wall_regions[0].wall_plate_ids.is_empty());
542 assert_eq!(model.wall_plates.len(), 1);
544 assert!(model.validate().is_ok(), "{:?}", model.validate());
545 }
546
547 #[test]
551 fn test_parapet_on_top_beam_converts_to_attached_line() {
552 let mut model = one_bay_wall_model();
553 model.nodes.push(node(4, 0.0, 0.0, 4500.0));
554 model.nodes.push(node(5, 4000.0, 0.0, 4500.0));
555 model.wall_plates.push(WallPlate {
556 id: WallPlateId(0),
557 shape: WallPlateShape::Enclosed {
558 boundary: vec![NodeId(3), NodeId(2), NodeId(5), NodeId(4)],
560 },
561 section: None,
562 opening_area: 0.0,
563 opening_weight: 0.0,
564 openings: vec![],
565 loads: vec![],
566 slit: Default::default(),
567 });
568 let report = rebuild_wall_regions(&mut model);
569 assert_eq!(report.wall_plates_assigned, 0);
570 assert_eq!(report.wall_plates_converted_to_attached, 1);
571 assert_eq!(report.unassigned_wall_plates, 0);
572
573 let plate = &model.wall_plates[0];
574 assert!(plate.is_attached());
575 match &plate.shape {
576 WallPlateShape::Attached {
577 anchor:
578 RegionAnchor::Line {
579 nodes,
580 span,
581 transfer,
582 },
583 extent,
584 } => {
585 assert_eq!(*span, [0.0, 1.0]);
586 assert_eq!(*transfer, LoadTransfer::Anchor);
587 let a = model.nodes[nodes[0].index()].coord;
588 let b = model.nodes[nodes[1].index()].coord;
589 assert!((a[2] - 3000.0).abs() < 1e-6, "取付き線は頂部梁の高さ");
590 assert!((b[2] - 3000.0).abs() < 1e-6);
591 let e = extent.expect("取付き線に取り付く壁版は高さを明示する");
592 assert!((e[0] - 1500.0).abs() < 1e-6, "上向き {extent:?}");
593 assert!((e[1] - 1500.0).abs() < 1e-6, "{extent:?}");
594 }
595 other => panic!("Line の Attached ではない: {other:?}"),
596 }
597 assert!(
599 !model
600 .nodes
601 .iter()
602 .any(|n| (n.coord[2] - 4500.0).abs() < 1e-6),
603 "先端節点は削除されるはず"
604 );
605 assert!(report.deleted_nodes >= 2);
606 assert!(model.validate().is_ok(), "{:?}", model.validate());
607 }
608
609 #[test]
612 fn test_wall_plate_with_more_than_two_free_vertices_is_not_converted() {
613 let mut model = one_bay_wall_model();
614 model.nodes.push(node(4, 0.0, 0.0, 4500.0));
615 model.nodes.push(node(5, 1500.0, 0.0, 5200.0));
616 model.nodes.push(node(6, 4000.0, 0.0, 4500.0));
617 model.wall_plates.push(WallPlate {
618 id: WallPlateId(0),
619 shape: WallPlateShape::Enclosed {
620 boundary: vec![NodeId(3), NodeId(2), NodeId(6), NodeId(5), NodeId(4)],
621 },
622 section: None,
623 opening_area: 0.0,
624 opening_weight: 0.0,
625 openings: vec![],
626 loads: vec![],
627 slit: Default::default(),
628 });
629 let report = rebuild_wall_regions(&mut model);
630 assert_eq!(report.wall_plates_converted_to_attached, 0);
631 assert_eq!(report.unassigned_wall_plates, 1);
632 assert!(matches!(
633 model.wall_plates[0].shape,
634 WallPlateShape::Enclosed { .. }
635 ));
636 }
637
638 #[test]
645 fn test_wall_region_boundary_survives_node_compaction_from_attached_conversion() {
646 let mut model = Model::default();
647 model.nodes.push(node(0, 0.0, 0.0, 4500.0));
649 model.nodes.push(node(1, 4000.0, 0.0, 4500.0));
650 model.nodes.push(node(2, 0.0, 0.0, 0.0));
652 model.nodes.push(node(3, 4000.0, 0.0, 0.0));
653 model.nodes.push(node(4, 4000.0, 0.0, 3000.0));
654 model.nodes.push(node(5, 0.0, 0.0, 3000.0));
655 model.elements.push(beam(0, 2, 5)); model.elements.push(beam(1, 3, 4)); model.elements.push(beam(2, 5, 4)); model.elements.push(beam(3, 2, 3)); model.wall_plates.push(WallPlate {
662 id: WallPlateId(0),
663 shape: WallPlateShape::Enclosed {
664 boundary: vec![NodeId(5), NodeId(4), NodeId(1), NodeId(0)],
665 },
666 section: None,
667 opening_area: 0.0,
668 opening_weight: 0.0,
669 openings: vec![],
670 loads: vec![],
671 slit: Default::default(),
672 });
673
674 let before: Vec<[f64; 3]> = vec![
675 model.nodes[2].coord,
676 model.nodes[3].coord,
677 model.nodes[4].coord,
678 model.nodes[5].coord,
679 ];
680
681 let report = rebuild_wall_regions(&mut model);
682 assert_eq!(report.wall_plates_converted_to_attached, 1);
683 assert!(report.deleted_nodes >= 1, "自由端の節点が削除されるはず");
684
685 assert_eq!(model.wall_regions.len(), 1);
686 let after: Vec<[f64; 3]> = model.wall_regions[0]
687 .boundary
688 .iter()
689 .map(|n| model.nodes[n.index()].coord)
690 .collect();
691 for c in &before {
692 assert!(
693 after
694 .iter()
695 .any(|a| (0..3).all(|k| (a[k] - c[k]).abs() < 1e-6)),
696 "壁領域の境界座標が節点圧縮の前後で保たれていない({c:?} が {after:?} にない)"
697 );
698 }
699 assert!(model.validate().is_ok(), "{:?}", model.validate());
700 }
701
702 #[test]
705 fn test_hanging_wall_below_bottom_beam_converts_with_negative_extent() {
706 let mut model = one_bay_wall_model();
707 model.nodes.push(node(4, 0.0, 0.0, -1200.0));
708 model.nodes.push(node(5, 4000.0, 0.0, -1200.0));
709 model.wall_plates.push(WallPlate {
710 id: WallPlateId(0),
711 shape: WallPlateShape::Enclosed {
712 boundary: vec![NodeId(0), NodeId(1), NodeId(5), NodeId(4)],
713 },
714 section: None,
715 opening_area: 0.0,
716 opening_weight: 0.0,
717 openings: vec![],
718 loads: vec![],
719 slit: Default::default(),
720 });
721 let report = rebuild_wall_regions(&mut model);
722 assert_eq!(report.wall_plates_converted_to_attached, 1);
723 match &model.wall_plates[0].shape {
724 WallPlateShape::Attached { extent, .. } => {
725 let e = extent.expect("取付き線に取り付く壁版は高さを明示する");
726 assert!((e[0] + 1200.0).abs() < 1e-6, "{extent:?}");
727 assert!((e[1] + 1200.0).abs() < 1e-6, "{extent:?}");
728 }
729 other => panic!("{other:?}"),
730 }
731 assert!(model.validate().is_ok(), "{:?}", model.validate());
732 }
733
734 #[test]
737 fn test_jog_at_beam_level_is_not_converted() {
738 let mut model = one_bay_wall_model();
739 model.nodes.push(node(4, 0.0, 0.0, 4500.0));
740 model.nodes.push(node(5, 4000.0, 0.0, 4500.0));
741 model.nodes.push(node(6, 2000.0, 800.0, 3000.0));
743 model.wall_plates.push(WallPlate {
744 id: WallPlateId(0),
745 shape: WallPlateShape::Enclosed {
746 boundary: vec![NodeId(3), NodeId(2), NodeId(6), NodeId(5), NodeId(4)],
747 },
748 section: None,
749 opening_area: 0.0,
750 opening_weight: 0.0,
751 openings: vec![],
752 loads: vec![],
753 slit: Default::default(),
754 });
755 let report = rebuild_wall_regions(&mut model);
756 assert_eq!(report.wall_plates_converted_to_attached, 0);
757 assert_eq!(report.unassigned_wall_plates, 1);
758 assert!(matches!(
759 model.wall_plates[0].shape,
760 WallPlateShape::Enclosed { .. }
761 ));
762 }
763}