Skip to main content

symbios_tensor/
lots.rs

1//! Building lot extraction from city blocks.
2//!
3//! Each [`CityBlock`](crate::graph::CityBlock) polygon is recursively split
4//! perpendicular to its longest edge (through the centroid) until sub-polygons
5//! fall below a configurable area threshold. A street-aligned inscribed
6//! rectangle is then computed for each
7//! piece, with front/side/rear setbacks applied to produce the final
8//! [`BuildingLot`] footprint.
9
10use glam::Vec2;
11use serde::{Deserialize, Serialize};
12use symbios_ground::HeightMap;
13
14use crate::geometry::segment_intersection;
15use crate::graph::RoadGraph;
16
17/// Minimum distance between consecutive polygon vertices after splitting.
18const DEDUP_TOLERANCE: f32 = 1e-4;
19
20/// Epsilon for detecting degenerate zero-area centroids.
21const CENTROID_AREA_EPS: f32 = 1e-8;
22
23/// Epsilon for degenerate zero-length segments in point-on-segment tests.
24const DEGENERATE_SEG_LEN_SQ: f32 = 1e-10;
25
26/// Tolerance for point-on-segment proximity checks (world units).
27const POINT_ON_SEG_TOLERANCE: f32 = 1e-3;
28
29/// Minimum positive ray hit distance to avoid self-intersection artifacts.
30const RAY_HIT_EPS: f32 = 1e-4;
31
32/// Maximum OBB aspect ratio for polygon subdivision. Polygons whose
33/// oriented bounding box (aligned to the longest edge) is more skewed
34/// than this are treated as degenerate slivers and skipped.
35const MAX_SUBDIVISION_ASPECT_RATIO: f32 = 20.0;
36
37/// How to handle lots whose footprint touches water.
38///
39/// A lot "touches water" when its centroid or any of its four corners has
40/// terrain elevation at or below [`LotConfig::water_level`].
41#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
42pub enum WaterPolicy {
43    /// Discard any lot that touches water. Default behaviour.
44    #[default]
45    Skip,
46    /// Keep the lot but mark it as a shoreline lot via
47    /// [`BuildingLot::is_shoreline`]. The heightmap is not modified.
48    TagShoreline,
49    /// Keep the lot, mark it as shoreline, and lift heightmap cells under
50    /// the lot footprint up to `water_level + offset` so the building sits
51    /// above water. Mutates the heightmap.
52    CarveFlush {
53        /// World-space offset above `water_level` to which submerged cells
54        /// are raised. Must be non-negative.
55        offset: f32,
56    },
57}
58
59/// Configuration for lot subdivision and building footprint extraction.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct LotConfig {
62    /// Maximum lot area before recursive subdivision (sqm).
63    pub max_lot_area: f32,
64    /// Minimum lot area — polygons below this are discarded.
65    pub min_lot_area: f32,
66    /// Distance from the street edge to the building front.
67    pub front_setback: f32,
68    /// Distance from side edges to the building sides.
69    pub side_setback: f32,
70    /// Distance from the back edge to the building rear.
71    pub rear_setback: f32,
72    /// Minimum building width (along street).
73    pub min_width: f32,
74    /// Minimum building depth (perpendicular to street).
75    pub min_depth: f32,
76    /// World-space Y height of the water plane. Lots whose footprint reaches
77    /// at or below this elevation are handled per [`Self::water_policy`].
78    /// Defaults to [`f32::NEG_INFINITY`] (no water filtering).
79    pub water_level: f32,
80    /// Strategy for handling lots whose footprint touches water.
81    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/// A rectangular building footprint aligned to the nearest street edge.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct BuildingLot {
103    /// World-space center of the footprint.
104    pub position: Vec2,
105    /// Midpoint of the street frontage edge (pre-setback), used for road
106    /// access queries so that pruning doesn't misidentify the access road.
107    pub frontage_center: Vec2,
108    /// Rotation angle in radians (around the Y axis in 3D; around Z in 2D top-down).
109    pub rotation: f32,
110    /// Extent along the street frontage.
111    pub width: f32,
112    /// Extent perpendicular to the street.
113    pub depth: f32,
114    /// True when the lot's footprint touches water (under
115    /// [`WaterPolicy::TagShoreline`] or [`WaterPolicy::CarveFlush`]).
116    /// Always false under [`WaterPolicy::Skip`] since touching lots are
117    /// discarded.
118    pub is_shoreline: bool,
119}
120
121/// Extracts building lots from city blocks in the road graph.
122///
123/// Each block polygon is recursively subdivided until pieces are below
124/// `config.max_lot_area`, then a street-aligned inscribed rectangle is
125/// computed with setbacks applied. Lots whose footprint touches water are
126/// handled per [`LotConfig::water_policy`]; under [`WaterPolicy::CarveFlush`]
127/// the heightmap is mutated to lift submerged cells.
128pub 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
175/// Computes the four world-space corners of a lot's oriented footprint.
176fn 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
190/// Returns `true` if the lot's centroid or any corner sits at or below
191/// `water_level` in `heightmap`.
192fn 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
204/// Lifts every heightmap cell whose center lies inside the lot's oriented
205/// footprint to at least `target_height`.
206fn 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    // Expand the OBB by one cell on each side so bilinear samples taken at
215    // any point inside the lot read only lifted cells. `HeightMap` anchors
216    // cell value (i, j) at world (i*scale, j*scale); a cell contributes to
217    // bilinear samples within `scale` of its anchor in each axis.
218    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            // Cell anchor in world space (matches HeightMap's bilinear convention).
254            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            // Rotate world delta into lot-local frame (inverse rotation).
259            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
271// ---------------------------------------------------------------------------
272// Geometry helpers
273// ---------------------------------------------------------------------------
274
275fn polygon_area(vertices: &[Vec2]) -> f32 {
276    let n = vertices.len();
277    if n < 3 {
278        return 0.0;
279    }
280    // Translate to local origin to avoid f32 cancellation at large coordinates.
281    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    // Translate to local origin to avoid f32 cancellation at large coordinates.
300    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
333// ---------------------------------------------------------------------------
334// Polygon splitting
335// ---------------------------------------------------------------------------
336
337/// Removes consecutive vertices that are closer than [`DEDUP_TOLERANCE`] apart.
338fn dedup_consecutive(poly: &mut Vec<Vec2>) {
339    poly.dedup_by(|a, b| a.distance(*b) < DEDUP_TOLERANCE);
340    // Also check wrap-around (last vs first).
341    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    // Compute half-extent from the polygon's bounding box diagonal so the
353    // splitting line is always long enough to cross the polygon, without
354    // relying on a hardcoded constant that could cause floating-point issues
355    // on very large or very small maps.
356    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    // For concave polygons the splitting line may intersect >2 edges.
383    // Pick the pair of intersections whose midpoint is closest to the
384    // line origin (centroid), which splits through the polygon's core
385    // rather than clipping an outer lobe.
386    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        // `best_pair` can remain None only if every distance was NaN — in
400        // that case the polygon is too degenerate to split cleanly.
401        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    // Poly A: pt_a -> vertices (idx_a+1)..=idx_b -> pt_b
410    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    // Poly B: pt_b -> vertices after idx_b wrapping to idx_a -> pt_a
417    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    // Remove consecutive duplicate vertices (from split points coinciding
429    // with existing polygon vertices), which would produce zero-length
430    // edges and NaN in subsequent normalizations.
431    dedup_consecutive(&mut poly_a);
432    dedup_consecutive(&mut poly_b);
433
434    // Filter degenerate results
435    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    // Early-exit for degenerate slivers: if the polygon's OBB (oriented
455    // along the longest edge) is excessively skewed, further subdivision
456    // will only waste cycles producing sub-threshold fragments. Using the
457    // OBB instead of the AABB ensures diagonal slivers are caught too.
458    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    // Perpendicular to longest edge, through centroid
491    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
515// ---------------------------------------------------------------------------
516// Frontage + inscribed box + setbacks
517// ---------------------------------------------------------------------------
518
519/// Finds the frontage edge: the longest edge that lies on the original block
520/// perimeter (a street edge). Falls back to the longest edge overall if no
521/// boundary edge is found (e.g. heavily subdivided interiors).
522fn 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
551/// Returns true if both endpoints of edge (a, b) lie on some edge of `perimeter`.
552fn 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    // Blocks from extract_blocks() have guaranteed CW winding, so the
590    // interior is always to the right of each edge direction.
591    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    // Compute ray extent from polygon bounding box so rays always reach
597    // the far side without relying on a hardcoded constant.
598    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    // Cast rays inward from points along the frontage edge to find the
606    // minimum depth before hitting the opposite polygon boundary.
607    //
608    // We use uniform samples PLUS projections of every polygon vertex onto
609    // the frontage line so that vertex-induced notches are never missed.
610    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    // Project each non-frontage vertex onto the frontage line and add its
616    // parametric position if it falls within the edge span.
617    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    // Verify side edges: cast rays along ±street_dir from the back corners
661    // to clamp width so the rectangle stays inside the polygon.
662    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    // Shift center inward by (front - rear) / 2 to account for asymmetric setbacks
708    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// ---------------------------------------------------------------------------
740// Tests
741// ---------------------------------------------------------------------------
742
743#[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        // 20x10 rectangle, longest edge is along X (20 units)
775        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); // 1600 sqm
806        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        // CW winding — matches extract_blocks() output
821        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        // Width 5 - 2*1.5 = 2 < 6 → filtered
851        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        // A polygon with an inward notch between uniform sample points.
858        // The notch vertex at (5, 2) should limit depth to 2, not the
859        // far edge at y=5. Without vertex-projection sampling, uniform
860        // rays could miss this notch entirely.
861        //
862        // CW winding: frontage along bottom edge (y=0), interior upward.
863        let poly = vec![
864            Vec2::new(0.0, 0.0),  // 0 — frontage start
865            Vec2::new(0.0, 5.0),  // 1
866            Vec2::new(5.0, 2.0),  // 2 — notch vertex
867            Vec2::new(10.0, 5.0), // 3
868            Vec2::new(10.0, 0.0), // 4 — frontage end
869        ];
870        let frontage_idx = 4; // edge 4→0: (10,0)→(0,0)
871        let result = inscribed_box(&poly, frontage_idx);
872        assert!(result.is_some());
873        let (_center, _rotation, _width, depth) = result.unwrap();
874        // Depth must be ≤ 2.0 (the notch), not ~5.0 (the far edges).
875        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        // Heightmap: half land (x < 16), half lake (x >= 16) at -1.0.
910        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        // At least one lot must be tagged shoreline (the lake side).
959        assert!(
960            lots.iter().any(|l| l.is_shoreline),
961            "TagShoreline produced no shoreline-tagged lots"
962        );
963        // Heightmap unchanged: still has cells below water.
964        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}