1use glam::Vec2;
11use serde::{Deserialize, Serialize};
12use symbios_ground::HeightMap;
13
14use crate::geometry::segment_intersection;
15use crate::graph::RoadGraph;
16
17const DEDUP_TOLERANCE: f32 = 1e-4;
19
20const CENTROID_AREA_EPS: f32 = 1e-8;
22
23const DEGENERATE_SEG_LEN_SQ: f32 = 1e-10;
25
26const POINT_ON_SEG_TOLERANCE: f32 = 1e-3;
28
29const RAY_HIT_EPS: f32 = 1e-4;
31
32const MAX_SUBDIVISION_ASPECT_RATIO: f32 = 20.0;
36
37#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
42pub enum WaterPolicy {
43 #[default]
45 Skip,
46 TagShoreline,
49 CarveFlush {
53 offset: f32,
56 },
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct LotConfig {
62 pub max_lot_area: f32,
64 pub min_lot_area: f32,
66 pub front_setback: f32,
68 pub side_setback: f32,
70 pub rear_setback: f32,
72 pub min_width: f32,
74 pub min_depth: f32,
76 pub water_level: f32,
80 pub water_policy: WaterPolicy,
82}
83
84impl Default for LotConfig {
85 fn default() -> Self {
86 Self {
87 max_lot_area: 400.0,
88 min_lot_area: 50.0,
89 front_setback: 3.0,
90 side_setback: 1.5,
91 rear_setback: 2.0,
92 min_width: 6.0,
93 min_depth: 6.0,
94 water_level: f32::NEG_INFINITY,
95 water_policy: WaterPolicy::Skip,
96 }
97 }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct BuildingLot {
103 pub position: Vec2,
105 pub frontage_center: Vec2,
108 pub rotation: f32,
110 pub width: f32,
112 pub depth: f32,
114 pub is_shoreline: bool,
119}
120
121pub fn extract_lots(
129 graph: &RoadGraph,
130 heightmap: &mut HeightMap,
131 config: &LotConfig,
132) -> Vec<BuildingLot> {
133 let mut lots = Vec::new();
134 for block in &graph.blocks {
135 let polygon: Vec<Vec2> = block
136 .perimeter
137 .iter()
138 .map(|&nid| graph.node_pos(nid))
139 .collect();
140
141 let sub_polys = subdivide_polygon(&polygon, config.max_lot_area, config.min_lot_area, 10);
142
143 for poly in sub_polys {
144 let Some(mut lot) = polygon_to_lot(&poly, &polygon, config) else {
145 continue;
146 };
147
148 let touches_water = lot_touches_water(&lot, heightmap, config.water_level);
149
150 match config.water_policy {
151 WaterPolicy::Skip => {
152 if touches_water {
153 continue;
154 }
155 lots.push(lot);
156 }
157 WaterPolicy::TagShoreline => {
158 lot.is_shoreline = touches_water;
159 lots.push(lot);
160 }
161 WaterPolicy::CarveFlush { offset } => {
162 lot.is_shoreline = touches_water;
163 if touches_water {
164 let target = config.water_level + offset.max(0.0);
165 carve_flush_lot(&lot, heightmap, target);
166 }
167 lots.push(lot);
168 }
169 }
170 }
171 }
172 lots
173}
174
175fn lot_corners(lot: &BuildingLot) -> [Vec2; 4] {
177 let hw = lot.width * 0.5;
178 let hd = lot.depth * 0.5;
179 let cos = lot.rotation.cos();
180 let sin = lot.rotation.sin();
181 let rot = |x: f32, y: f32| Vec2::new(x * cos - y * sin, x * sin + y * cos);
182 [
183 lot.position + rot(hw, hd),
184 lot.position + rot(hw, -hd),
185 lot.position + rot(-hw, -hd),
186 lot.position + rot(-hw, hd),
187 ]
188}
189
190fn lot_touches_water(lot: &BuildingLot, heightmap: &HeightMap, water_level: f32) -> bool {
193 if !water_level.is_finite() {
194 return false;
195 }
196 if heightmap.get_height_at(lot.position.x, lot.position.y) <= water_level {
197 return true;
198 }
199 lot_corners(lot)
200 .iter()
201 .any(|c| heightmap.get_height_at(c.x, c.y) <= water_level)
202}
203
204fn carve_flush_lot(lot: &BuildingLot, heightmap: &mut HeightMap, target_height: f32) {
207 let scale = heightmap.scale();
208 if scale <= 0.0 {
209 return;
210 }
211 let world_w = heightmap.world_width();
212 let world_d = heightmap.world_depth();
213
214 let cos = lot.rotation.cos();
219 let sin = lot.rotation.sin();
220 let hw = lot.width * 0.5 + scale;
221 let hd = lot.depth * 0.5 + scale;
222
223 let corners = lot_corners(lot);
224 let mut min_pt = corners[0];
225 let mut max_pt = corners[0];
226 for &c in &corners[1..] {
227 min_pt = min_pt.min(c);
228 max_pt = max_pt.max(c);
229 }
230 min_pt -= Vec2::splat(scale);
231 max_pt += Vec2::splat(scale);
232 min_pt = min_pt.max(Vec2::ZERO);
233 max_pt = max_pt.min(Vec2::new(world_w, world_d));
234 if min_pt.x >= max_pt.x || min_pt.y >= max_pt.y {
235 return;
236 }
237
238 let cells_x = (world_w / scale) as usize;
239 let cells_z = (world_d / scale) as usize;
240 let x_start = (min_pt.x / scale).floor() as isize;
241 let x_end = (max_pt.x / scale).ceil() as isize;
242 let z_start = (min_pt.y / scale).floor() as isize;
243 let z_end = (max_pt.y / scale).ceil() as isize;
244
245 for cz in z_start..=z_end {
246 if cz < 0 || (cz as usize) >= cells_z {
247 continue;
248 }
249 for cx in x_start..=x_end {
250 if cx < 0 || (cx as usize) >= cells_x {
251 continue;
252 }
253 let wx = cx as f32 * scale;
255 let wz = cz as f32 * scale;
256 let dx = wx - lot.position.x;
257 let dz = wz - lot.position.y;
258 let lx = dx * cos + dz * sin;
260 let lz = -dx * sin + dz * cos;
261 if lx.abs() <= hw && lz.abs() <= hd {
262 let h = heightmap.get(cx as usize, cz as usize);
263 if h < target_height {
264 heightmap.set(cx as usize, cz as usize, target_height);
265 }
266 }
267 }
268 }
269}
270
271fn polygon_area(vertices: &[Vec2]) -> f32 {
276 let n = vertices.len();
277 if n < 3 {
278 return 0.0;
279 }
280 let origin = vertices[0];
282 let mut area = 0.0_f32;
283 for i in 0..n {
284 let a = vertices[i] - origin;
285 let b = vertices[(i + 1) % n] - origin;
286 area += a.x * b.y - b.x * a.y;
287 }
288 (area * 0.5).abs()
289}
290
291fn polygon_centroid(vertices: &[Vec2]) -> Vec2 {
292 let n = vertices.len();
293 if n == 0 {
294 return Vec2::ZERO;
295 }
296 if n < 3 {
297 return vertices.iter().copied().sum::<Vec2>() / n as f32;
298 }
299 let origin = vertices[0];
301 let mut cx = 0.0_f32;
302 let mut cy = 0.0_f32;
303 let mut signed_area_2 = 0.0_f32;
304 for i in 0..n {
305 let a = vertices[i] - origin;
306 let b = vertices[(i + 1) % n] - origin;
307 let cross = a.x * b.y - b.x * a.y;
308 cx += (a.x + b.x) * cross;
309 cy += (a.y + b.y) * cross;
310 signed_area_2 += cross;
311 }
312 if signed_area_2.abs() < CENTROID_AREA_EPS {
313 return vertices.iter().copied().sum::<Vec2>() / n as f32;
314 }
315 let inv = 1.0 / (3.0 * signed_area_2);
316 Vec2::new(cx * inv, cy * inv) + origin
317}
318
319fn longest_edge_index(vertices: &[Vec2]) -> usize {
320 let n = vertices.len();
321 let mut best_idx = 0;
322 let mut best_len_sq = 0.0_f32;
323 for i in 0..n {
324 let len_sq = (vertices[(i + 1) % n] - vertices[i]).length_squared();
325 if len_sq > best_len_sq {
326 best_len_sq = len_sq;
327 best_idx = i;
328 }
329 }
330 best_idx
331}
332
333fn dedup_consecutive(poly: &mut Vec<Vec2>) {
339 poly.dedup_by(|a, b| a.distance(*b) < DEDUP_TOLERANCE);
340 if poly.len() > 1 && poly[0].distance(poly[poly.len() - 1]) < DEDUP_TOLERANCE {
342 poly.pop();
343 }
344}
345
346fn split_polygon_by_line(
347 poly: &[Vec2],
348 line_origin: Vec2,
349 line_dir: Vec2,
350) -> Option<(Vec<Vec2>, Vec<Vec2>)> {
351 let n = poly.len();
352 let (mut min_pt, mut max_pt) = (poly[0], poly[0]);
357 for &v in &poly[1..] {
358 min_pt = min_pt.min(v);
359 max_pt = max_pt.max(v);
360 }
361 let half_extent = (max_pt - min_pt).length() + 1.0;
362 let line_a = line_origin - line_dir * half_extent;
363 let line_b = line_origin + line_dir * half_extent;
364
365 let mut intersections: Vec<(usize, Vec2)> = Vec::new();
366 for i in 0..n {
367 let j = (i + 1) % n;
368 if let Some(pt) = segment_intersection(line_a, line_b, poly[i], poly[j]) {
369 let dominated = intersections
370 .iter()
371 .any(|(_, p)| p.distance(pt) < DEDUP_TOLERANCE);
372 if !dominated {
373 intersections.push((i, pt));
374 }
375 }
376 }
377
378 if intersections.len() < 2 {
379 return None;
380 }
381
382 if intersections.len() > 2 {
387 let mut best_pair: Option<(usize, usize)> = None;
388 let mut best_dist = f32::MAX;
389 for i in 0..intersections.len() {
390 for j in (i + 1)..intersections.len() {
391 let mid = (intersections[i].1 + intersections[j].1) * 0.5;
392 let d = mid.distance_squared(line_origin);
393 if d < best_dist {
394 best_dist = d;
395 best_pair = Some((i, j));
396 }
397 }
398 }
399 let (i, j) = best_pair?;
402 intersections = vec![intersections[i], intersections[j]];
403 }
404
405 intersections.sort_by_key(|(idx, _)| *idx);
406 let (idx_a, pt_a) = intersections[0];
407 let (idx_b, pt_b) = intersections[1];
408
409 let mut poly_a = vec![pt_a];
411 for vertex in &poly[(idx_a + 1)..=idx_b] {
412 poly_a.push(*vertex);
413 }
414 poly_a.push(pt_b);
415
416 let mut poly_b = vec![pt_b];
418 let mut k = (idx_b + 1) % n;
419 loop {
420 poly_b.push(poly[k]);
421 if k == idx_a {
422 break;
423 }
424 k = (k + 1) % n;
425 }
426 poly_b.push(pt_a);
427
428 dedup_consecutive(&mut poly_a);
432 dedup_consecutive(&mut poly_b);
433
434 if poly_a.len() < 3 || poly_b.len() < 3 {
436 return None;
437 }
438
439 Some((poly_a, poly_b))
440}
441
442fn subdivide_polygon(
443 poly: &[Vec2],
444 max_area: f32,
445 min_area: f32,
446 depth_limit: u32,
447) -> Vec<Vec<Vec2>> {
448 let area = polygon_area(poly);
449
450 if area <= max_area || area <= min_area * 2.0 || depth_limit == 0 {
451 return vec![poly.to_vec()];
452 }
453
454 let li = longest_edge_index(poly);
459 let n = poly.len();
460 let edge_a = poly[li];
461 let edge_b = poly[(li + 1) % n];
462 let obb_dir = (edge_b - edge_a).normalize_or_zero();
463 let obb_perp = Vec2::new(-obb_dir.y, obb_dir.x);
464
465 let mut min_along = f32::MAX;
466 let mut max_along = f32::MIN;
467 let mut min_perp = f32::MAX;
468 let mut max_perp = f32::MIN;
469 for &v in poly {
470 let d = v - edge_a;
471 let proj_along = d.dot(obb_dir);
472 let proj_perp = d.dot(obb_perp);
473 min_along = min_along.min(proj_along);
474 max_along = max_along.max(proj_along);
475 min_perp = min_perp.min(proj_perp);
476 max_perp = max_perp.max(proj_perp);
477 }
478 let obb_long = max_along - min_along;
479 let obb_short = max_perp - min_perp;
480 if obb_short > 0.0 && obb_long / obb_short > MAX_SUBDIVISION_ASPECT_RATIO {
481 return vec![poly.to_vec()];
482 }
483
484 let longest = longest_edge_index(poly);
485 let n = poly.len();
486 let edge_a = poly[longest];
487 let edge_b = poly[(longest + 1) % n];
488 let edge_dir = (edge_b - edge_a).normalize();
489
490 let perp = Vec2::new(-edge_dir.y, edge_dir.x);
492 let centroid = polygon_centroid(poly);
493
494 match split_polygon_by_line(poly, centroid, perp) {
495 Some((left, right)) => {
496 let mut result = Vec::new();
497 result.extend(subdivide_polygon(
498 &left,
499 max_area,
500 min_area,
501 depth_limit - 1,
502 ));
503 result.extend(subdivide_polygon(
504 &right,
505 max_area,
506 min_area,
507 depth_limit - 1,
508 ));
509 result
510 }
511 None => vec![poly.to_vec()],
512 }
513}
514
515fn find_frontage(poly: &[Vec2], perimeter: &[Vec2]) -> (usize, f32) {
523 let n = poly.len();
524 let mut best_idx = 0;
525 let mut best_len = 0.0_f32;
526 let mut best_boundary_idx = 0;
527 let mut best_boundary_len = 0.0_f32;
528
529 for i in 0..n {
530 let a = poly[i];
531 let b = poly[(i + 1) % n];
532 let len = (b - a).length();
533
534 if len > best_len {
535 best_len = len;
536 best_idx = i;
537 }
538 if edge_on_perimeter(a, b, perimeter) && len > best_boundary_len {
539 best_boundary_len = len;
540 best_boundary_idx = i;
541 }
542 }
543
544 if best_boundary_len > 0.0 {
545 (best_boundary_idx, best_boundary_len)
546 } else {
547 (best_idx, best_len)
548 }
549}
550
551fn edge_on_perimeter(a: Vec2, b: Vec2, perimeter: &[Vec2]) -> bool {
553 let m = perimeter.len();
554 for j in 0..m {
555 let pa = perimeter[j];
556 let pb = perimeter[(j + 1) % m];
557 if point_on_segment(a, pa, pb) && point_on_segment(b, pa, pb) {
558 return true;
559 }
560 }
561 false
562}
563
564fn point_on_segment(p: Vec2, a: Vec2, b: Vec2) -> bool {
565 let ab = b - a;
566 let len_sq = ab.length_squared();
567 if len_sq < DEGENERATE_SEG_LEN_SQ {
568 return p.distance(a) < POINT_ON_SEG_TOLERANCE;
569 }
570 let t = (p - a).dot(ab) / len_sq;
571 let t_tol = POINT_ON_SEG_TOLERANCE / len_sq.sqrt();
572 if !(-t_tol..=1.0 + t_tol).contains(&t) {
573 return false;
574 }
575 let proj = a + ab * t.clamp(0.0, 1.0);
576 p.distance(proj) < POINT_ON_SEG_TOLERANCE
577}
578
579fn inscribed_box(poly: &[Vec2], frontage_idx: usize) -> Option<(Vec2, f32, f32, f32)> {
580 let n = poly.len();
581 if n < 3 {
582 return None;
583 }
584
585 let fa = poly[frontage_idx];
586 let fb = poly[(frontage_idx + 1) % n];
587
588 let street_dir = (fb - fa).normalize();
589 let inward_dir = Vec2::new(street_dir.y, -street_dir.x);
592
593 let rotation = street_dir.y.atan2(street_dir.x);
594 let width = (fb - fa).length();
595
596 let (mut min_pt, mut max_pt) = (poly[0], poly[0]);
599 for &v in &poly[1..] {
600 min_pt = min_pt.min(v);
601 max_pt = max_pt.max(v);
602 }
603 let ray_extent = (max_pt - min_pt).length() + 1.0;
604
605 let num_uniform = 7;
611 let mut sample_ts: Vec<f32> = (0..=num_uniform)
612 .map(|i| i as f32 / num_uniform as f32)
613 .collect();
614
615 let frontage_vec = fb - fa;
618 let frontage_len_sq = frontage_vec.length_squared();
619 if frontage_len_sq > DEGENERATE_SEG_LEN_SQ {
620 for (k, &vertex) in poly.iter().enumerate() {
621 if k == frontage_idx || k == (frontage_idx + 1) % n {
622 continue;
623 }
624 let t = (vertex - fa).dot(frontage_vec) / frontage_len_sq;
625 if (0.0..=1.0).contains(&t) {
626 sample_ts.push(t);
627 }
628 }
629 }
630
631 let mut min_depth = f32::MAX;
632
633 for t in &sample_ts {
634 let ray_origin = fa.lerp(fb, *t);
635 let ray_end = ray_origin + inward_dir * ray_extent;
636
637 let mut closest_dist = f32::MAX;
638 for j in 0..n {
639 if j == frontage_idx {
640 continue;
641 }
642 let ea = poly[j];
643 let eb = poly[(j + 1) % n];
644 if let Some(hit) = segment_intersection(ray_origin, ray_end, ea, eb) {
645 let d = (hit - ray_origin).dot(inward_dir);
646 if d > RAY_HIT_EPS && d < closest_dist {
647 closest_dist = d;
648 }
649 }
650 }
651 if closest_dist < min_depth {
652 min_depth = closest_dist;
653 }
654 }
655
656 if min_depth <= 0.0 || min_depth == f32::MAX {
657 return None;
658 }
659
660 let back_center = (fa + fb) * 0.5 + inward_dir * min_depth;
663 let mut half_width_limit = width * 0.5;
664
665 for &sign in &[1.0_f32, -1.0] {
666 let ray_origin = back_center;
667 let ray_end = ray_origin + street_dir * sign * ray_extent;
668 let mut closest = f32::MAX;
669 for j in 0..n {
670 let ea = poly[j];
671 let eb = poly[(j + 1) % n];
672 if let Some(hit) = segment_intersection(ray_origin, ray_end, ea, eb) {
673 let d = (hit - ray_origin).dot(street_dir * sign);
674 if d > RAY_HIT_EPS && d < closest {
675 closest = d;
676 }
677 }
678 }
679 if closest < half_width_limit {
680 half_width_limit = closest;
681 }
682 }
683
684 let width = half_width_limit * 2.0;
685
686 let street_midpoint = (fa + fb) * 0.5;
687 let center = street_midpoint + inward_dir * (min_depth * 0.5);
688
689 Some((center, rotation, width, min_depth))
690}
691
692fn apply_setbacks(
693 center: Vec2,
694 frontage_center: Vec2,
695 rotation: f32,
696 width: f32,
697 depth: f32,
698 config: &LotConfig,
699) -> Option<BuildingLot> {
700 let new_width = width - 2.0 * config.side_setback.max(0.0);
701 let new_depth = depth - config.front_setback.max(0.0) - config.rear_setback.max(0.0);
702
703 if new_width < config.min_width || new_depth < config.min_depth {
704 return None;
705 }
706
707 let street_dir = Vec2::new(rotation.cos(), rotation.sin());
709 let inward_dir = Vec2::new(street_dir.y, -street_dir.x);
710 let depth_shift = (config.front_setback.max(0.0) - config.rear_setback.max(0.0)) * 0.5;
711 let adjusted_center = center + inward_dir * depth_shift;
712
713 Some(BuildingLot {
714 position: adjusted_center,
715 frontage_center,
716 rotation,
717 width: new_width,
718 depth: new_depth,
719 is_shoreline: false,
720 })
721}
722
723fn polygon_to_lot(poly: &[Vec2], perimeter: &[Vec2], config: &LotConfig) -> Option<BuildingLot> {
724 if poly.len() < 3 {
725 return None;
726 }
727 let area = polygon_area(poly);
728 if area < config.min_lot_area {
729 return None;
730 }
731
732 let (frontage_idx, _) = find_frontage(poly, perimeter);
733 let n = poly.len();
734 let frontage_center = (poly[frontage_idx] + poly[(frontage_idx + 1) % n]) * 0.5;
735 let (center, rotation, width, depth) = inscribed_box(poly, frontage_idx)?;
736 apply_setbacks(center, frontage_center, rotation, width, depth, config)
737}
738
739#[cfg(test)]
744mod tests {
745 use super::*;
746 use crate::graph::RoadGraph;
747
748 fn square(x: f32, y: f32, size: f32) -> Vec<Vec2> {
749 vec![
750 Vec2::new(x, y),
751 Vec2::new(x + size, y),
752 Vec2::new(x + size, y + size),
753 Vec2::new(x, y + size),
754 ]
755 }
756
757 #[test]
758 fn polygon_area_square() {
759 let sq = square(0.0, 0.0, 10.0);
760 let area = polygon_area(&sq);
761 assert!((area - 100.0).abs() < 1e-3);
762 }
763
764 #[test]
765 fn polygon_centroid_unit_square() {
766 let sq = square(0.0, 0.0, 1.0);
767 let c = polygon_centroid(&sq);
768 assert!((c.x - 0.5).abs() < 1e-3);
769 assert!((c.y - 0.5).abs() < 1e-3);
770 }
771
772 #[test]
773 fn split_rectangle_into_halves() {
774 let rect = vec![
776 Vec2::new(0.0, 0.0),
777 Vec2::new(20.0, 0.0),
778 Vec2::new(20.0, 10.0),
779 Vec2::new(0.0, 10.0),
780 ];
781 let longest = longest_edge_index(&rect);
782 let edge_a = rect[longest];
783 let edge_b = rect[(longest + 1) % rect.len()];
784 let edge_dir = (edge_b - edge_a).normalize();
785 let perp = Vec2::new(-edge_dir.y, edge_dir.x);
786 let centroid = polygon_centroid(&rect);
787
788 let result = split_polygon_by_line(&rect, centroid, perp);
789 assert!(result.is_some());
790 let (a, b) = result.unwrap();
791 let area_a = polygon_area(&a);
792 let area_b = polygon_area(&b);
793 assert!(
794 (area_a - 100.0).abs() < 1.0,
795 "half A should be ~100 sqm, got {area_a}"
796 );
797 assert!(
798 (area_b - 100.0).abs() < 1.0,
799 "half B should be ~100 sqm, got {area_b}"
800 );
801 }
802
803 #[test]
804 fn subdivide_large_block() {
805 let big = square(0.0, 0.0, 40.0); let sub = subdivide_polygon(&big, 400.0, 50.0, 10);
807 assert!(
808 sub.len() >= 4,
809 "1600 sqm block should yield at least 4 lots, got {}",
810 sub.len()
811 );
812 for poly in &sub {
813 let area = polygon_area(poly);
814 assert!(area <= 400.0 + 1.0, "sub-polygon area {area} exceeds max");
815 }
816 }
817
818 #[test]
819 fn inscribed_box_rectangle() {
820 let rect = vec![
822 Vec2::new(0.0, 0.0),
823 Vec2::new(0.0, 5.0),
824 Vec2::new(10.0, 5.0),
825 Vec2::new(10.0, 0.0),
826 ];
827 let (frontage_idx, _) = find_frontage(&rect, &rect);
828 let result = inscribed_box(&rect, frontage_idx);
829 assert!(result.is_some());
830 let (center, _rotation, width, depth) = result.unwrap();
831 assert!(
832 (width - 10.0).abs() < 0.5,
833 "width should be ~10, got {width}"
834 );
835 assert!((depth - 5.0).abs() < 0.5, "depth should be ~5, got {depth}");
836 assert!((center.x - 5.0).abs() < 0.5);
837 assert!((center.y - 2.5).abs() < 0.5);
838 }
839
840 #[test]
841 fn setbacks_filter_tiny_lots() {
842 let config = LotConfig {
843 min_width: 6.0,
844 min_depth: 6.0,
845 side_setback: 1.5,
846 front_setback: 3.0,
847 rear_setback: 2.0,
848 ..Default::default()
849 };
850 let result = apply_setbacks(Vec2::ZERO, Vec2::ZERO, 0.0, 5.0, 20.0, &config);
852 assert!(result.is_none());
853 }
854
855 #[test]
856 fn inscribed_box_notched_polygon() {
857 let poly = vec![
864 Vec2::new(0.0, 0.0), Vec2::new(0.0, 5.0), Vec2::new(5.0, 2.0), Vec2::new(10.0, 5.0), Vec2::new(10.0, 0.0), ];
870 let frontage_idx = 4; let result = inscribed_box(&poly, frontage_idx);
872 assert!(result.is_some());
873 let (_center, _rotation, _width, depth) = result.unwrap();
874 assert!(
876 depth <= 2.1,
877 "depth should be clamped by notch vertex at y=2, got {depth}"
878 );
879 }
880
881 #[test]
882 fn extract_lots_empty_graph() {
883 let mut hm = symbios_ground::HeightMap::new(8, 8, 1.0);
884 let graph = RoadGraph::default();
885 let lots = extract_lots(&graph, &mut hm, &LotConfig::default());
886 assert!(lots.is_empty());
887 }
888
889 fn rect_block_graph() -> (RoadGraph, crate::graph::CityBlock) {
890 use crate::graph::{CityBlock, RoadType};
891 let mut graph = RoadGraph::default();
892 let n0 = graph.add_node(Vec2::new(0.0, 0.0));
893 let n1 = graph.add_node(Vec2::new(30.0, 0.0));
894 let n2 = graph.add_node(Vec2::new(30.0, 20.0));
895 let n3 = graph.add_node(Vec2::new(0.0, 20.0));
896 graph.add_edge(n0, n1, RoadType::Minor);
897 graph.add_edge(n1, n2, RoadType::Minor);
898 graph.add_edge(n2, n3, RoadType::Minor);
899 graph.add_edge(n3, n0, RoadType::Minor);
900 let block = CityBlock {
901 perimeter: vec![n0, n3, n2, n1],
902 };
903 graph.blocks.push(block.clone());
904 (graph, block)
905 }
906
907 #[test]
908 fn skip_policy_drops_submerged_lots() {
909 let mut hm = symbios_ground::HeightMap::new(32, 16, 2.0);
911 for z in 0..16 {
912 for x in 0..32 {
913 let h = if x < 8 { 1.0 } else { -1.0 };
914 hm.set(x, z, h);
915 }
916 }
917 let (graph, _) = rect_block_graph();
918
919 let cfg = LotConfig {
920 water_level: 0.0,
921 water_policy: WaterPolicy::Skip,
922 ..Default::default()
923 };
924 let lots = extract_lots(&graph, &mut hm, &cfg);
925
926 for lot in &lots {
927 assert!(
928 !lot.is_shoreline,
929 "Skip policy must never produce shoreline-tagged lots"
930 );
931 for c in lot_corners(lot) {
932 assert!(
933 hm.get_height_at(c.x, c.y) > 0.0,
934 "Skip policy left a lot with corner under water at {c:?}"
935 );
936 }
937 }
938 }
939
940 #[test]
941 fn tag_shoreline_keeps_lots_and_marks_them() {
942 let mut hm = symbios_ground::HeightMap::new(32, 16, 2.0);
943 for z in 0..16 {
944 for x in 0..32 {
945 let h = if x < 8 { 1.0 } else { -1.0 };
946 hm.set(x, z, h);
947 }
948 }
949 let (graph, _) = rect_block_graph();
950
951 let cfg = LotConfig {
952 water_level: 0.0,
953 water_policy: WaterPolicy::TagShoreline,
954 ..Default::default()
955 };
956 let lots = extract_lots(&graph, &mut hm, &cfg);
957
958 assert!(
960 lots.iter().any(|l| l.is_shoreline),
961 "TagShoreline produced no shoreline-tagged lots"
962 );
963 assert!(
965 hm.data().iter().any(|&h| h < 0.0),
966 "TagShoreline must not modify the heightmap"
967 );
968 }
969
970 #[test]
971 fn carve_flush_lifts_heightmap_and_tags() {
972 let mut hm = symbios_ground::HeightMap::new(32, 16, 2.0);
973 for z in 0..16 {
974 for x in 0..32 {
975 let h = if x < 8 { 1.0 } else { -1.0 };
976 hm.set(x, z, h);
977 }
978 }
979 let (graph, _) = rect_block_graph();
980
981 let cfg = LotConfig {
982 water_level: 0.0,
983 water_policy: WaterPolicy::CarveFlush { offset: 0.5 },
984 ..Default::default()
985 };
986 let lots = extract_lots(&graph, &mut hm, &cfg);
987
988 let shoreline_lots: Vec<_> = lots.iter().filter(|l| l.is_shoreline).collect();
989 assert!(
990 !shoreline_lots.is_empty(),
991 "CarveFlush produced no shoreline-tagged lots"
992 );
993 for lot in shoreline_lots {
994 for c in lot_corners(lot) {
995 assert!(
996 hm.get_height_at(c.x, c.y) >= 0.5 - 1e-3,
997 "CarveFlush failed to lift corner {c:?} above water_level+offset"
998 );
999 }
1000 }
1001 }
1002}