Skip to main content

flatland_client_lib/
los_probe.rs

1//! Client-side LoS probe (mirrors `flatland_sim::los` elevation + obstacle rules).
2//!
3//! Used by gfx F7 debug overlay. Outdoor AOI: terrain zones, building footprints,
4//! blocking resource nodes. Interior wall segments are not modeled yet.
5
6use flatland_protocol::{BuildingView, ResourceNodeState, ResourceNodeView};
7
8/// Slack when comparing ray height to terrain (meters).
9const GROUND_CLIP_SLACK_M: f32 = 0.25;
10/// Max elevation difference for character LoS across a shelf.
11const MAX_VISIBLE_ELEV_DELTA_M: f32 = 1.0;
12const SAMPLES: usize = 12;
13const LOS_XY_STEP_M: f32 = 0.5;
14
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct BlockingRect {
17    pub x0: f32,
18    pub y0: f32,
19    pub x1: f32,
20    pub y1: f32,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub struct BlockingCircle {
25    pub x: f32,
26    pub y: f32,
27    pub radius_m: f32,
28}
29
30/// Outdoor LoS blockers derivable from AOI snapshot data.
31#[derive(Debug, Clone, Default)]
32pub struct LosObstacles {
33    pub rects: Vec<BlockingRect>,
34    pub circles: Vec<BlockingCircle>,
35}
36
37/// Why a ray stopped (for debug tinting).
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum LosBlockKind {
40    ElevationGap,
41    ElevationTaper,
42    Rect,
43    Circle,
44    Terrain,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq)]
48pub struct LosBlock {
49    pub x: f32,
50    pub y: f32,
51    pub z: f32,
52    pub kind: LosBlockKind,
53}
54
55impl LosObstacles {
56    /// Build outdoor obstacle set from streamed views (matches sim `movement_blocking_rects` +
57    /// outdoor `blocking_obstacles` for buildings + resource nodes).
58    pub fn from_outdoor_views(
59        buildings: &[BuildingView],
60        resource_nodes: &[ResourceNodeView],
61        inside_building: Option<&str>,
62    ) -> Self {
63        let mut rects = Vec::new();
64        for b in buildings {
65            if inside_building == Some(b.id.as_str()) {
66                continue;
67            }
68            let hw = b.width_m / 2.0;
69            let hd = b.depth_m / 2.0;
70            rects.push(BlockingRect {
71                x0: b.x - hw,
72                y0: b.y - hd,
73                x1: b.x + hw,
74                y1: b.y + hd,
75            });
76        }
77        let mut circles = Vec::new();
78        for n in resource_nodes {
79            if !n.blocking {
80                continue;
81            }
82            if !matches!(
83                n.state,
84                ResourceNodeState::Available | ResourceNodeState::Harvesting { .. }
85            ) {
86                continue;
87            }
88            circles.push(BlockingCircle {
89                x: n.x,
90                y: n.y,
91                radius_m: n.blocking_radius_m.max(0.1),
92            });
93        }
94        Self { rects, circles }
95    }
96}
97
98/// First occlusion along the 3D ray, if any.
99pub fn first_los_block(
100    from_x: f32,
101    from_y: f32,
102    from_z: f32,
103    to_x: f32,
104    to_y: f32,
105    to_z: f32,
106    obstacles: &LosObstacles,
107    ground_z: impl Fn(f32, f32) -> f32,
108) -> Option<LosBlock> {
109    let elev_delta = (from_z - to_z).abs();
110    if elev_delta > MAX_VISIBLE_ELEV_DELTA_M + GROUND_CLIP_SLACK_M {
111        let t = 0.5;
112        return Some(LosBlock {
113            x: from_x + (to_x - from_x) * t,
114            y: from_y + (to_y - from_y) * t,
115            z: from_z + (to_z - from_z) * t,
116            kind: LosBlockKind::ElevationGap,
117        });
118    }
119    if elev_delta > GROUND_CLIP_SLACK_M {
120        let t = (elev_delta / MAX_VISIBLE_ELEV_DELTA_M).clamp(0.0, 1.0);
121        let max_xy = 24.0 + (10.0 - 24.0) * t;
122        let dist_xy = (to_x - from_x).hypot(to_y - from_y);
123        if dist_xy > max_xy {
124            let t = (max_xy / dist_xy.max(0.001)).clamp(0.0, 1.0);
125            return Some(LosBlock {
126                x: from_x + (to_x - from_x) * t,
127                y: from_y + (to_y - from_y) * t,
128                z: from_z + (to_z - from_z) * t,
129                kind: LosBlockKind::ElevationTaper,
130            });
131        }
132    }
133    let high_z = from_z.max(to_z);
134    let adjacent_shelf = elev_delta > GROUND_CLIP_SLACK_M;
135    let dist_xy = ((to_x - from_x).hypot(to_y - from_y)).max(0.001);
136    let sample_count = SAMPLES
137        .max((dist_xy / LOS_XY_STEP_M).ceil() as usize)
138        .max(2);
139
140    for i in 1..sample_count {
141        let t = i as f32 / sample_count as f32;
142        let x = from_x + (to_x - from_x) * t;
143        let y = from_y + (to_y - from_y) * t;
144        let z = from_z + (to_z - from_z) * t;
145        if point_in_rects(x, y, &obstacles.rects) {
146            return Some(LosBlock {
147                x,
148                y,
149                z,
150                kind: LosBlockKind::Rect,
151            });
152        }
153        if point_in_circles(x, y, &obstacles.circles) {
154            return Some(LosBlock {
155                x,
156                y,
157                z,
158                kind: LosBlockKind::Circle,
159            });
160        }
161        let ground = ground_z(x, y);
162        if terrain_undercut_blocks(ground, z, high_z, elev_delta, adjacent_shelf) {
163            return Some(LosBlock {
164                x,
165                y,
166                z,
167                kind: LosBlockKind::Terrain,
168            });
169        }
170    }
171    None
172}
173
174pub fn has_los(
175    from_x: f32,
176    from_y: f32,
177    from_z: f32,
178    to_x: f32,
179    to_y: f32,
180    to_z: f32,
181    obstacles: &LosObstacles,
182    ground_z: impl Fn(f32, f32) -> f32,
183) -> bool {
184    first_los_block(
185        from_x, from_y, from_z, to_x, to_y, to_z, obstacles, ground_z,
186    )
187    .is_none()
188}
189
190fn point_in_circles(x: f32, y: f32, circles: &[BlockingCircle]) -> bool {
191    circles.iter().any(|o| {
192        let dx = x - o.x;
193        let dy = y - o.y;
194        dx * dx + dy * dy < o.radius_m * o.radius_m
195    })
196}
197
198fn point_in_rects(x: f32, y: f32, rects: &[BlockingRect]) -> bool {
199    rects
200        .iter()
201        .any(|r| x >= r.x0 && x <= r.x1 && y >= r.y0 && y <= r.y1)
202}
203
204fn terrain_undercut_blocks(
205    ground: f32,
206    sample_z: f32,
207    high_z: f32,
208    elev_delta: f32,
209    adjacent_shelf: bool,
210) -> bool {
211    let under = ground - sample_z;
212    if under <= GROUND_CLIP_SLACK_M {
213        return false;
214    }
215    if !adjacent_shelf {
216        return true;
217    }
218    if ground > high_z + GROUND_CLIP_SLACK_M {
219        return true;
220    }
221    under > elev_delta + GROUND_CLIP_SLACK_M + 0.15
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use flatland_protocol::BuildingView;
228
229    #[test]
230    fn intervening_peak_blocks() {
231        let ground = |x: f32, _y: f32| {
232            if (10.0..11.0).contains(&x) {
233                3.0
234            } else if x < 10.0 {
235                1.0
236            } else {
237                0.0
238            }
239        };
240        let obs = LosObstacles::default();
241        assert_eq!(
242            first_los_block(9.5, 20.0, 1.0, 11.5, 20.0, 0.0, &obs, ground).map(|b| b.kind),
243            Some(LosBlockKind::Terrain)
244        );
245        assert!(has_los(9.5, 20.0, 1.0, 9.8, 20.0, 1.0, &obs, ground));
246    }
247
248    #[test]
249    fn building_footprint_blocks_ray() {
250        let buildings = vec![BuildingView {
251            id: "shop".into(),
252            label: "Shop".into(),
253            x: 10.0,
254            y: 10.0,
255            width_m: 4.0,
256            depth_m: 4.0,
257            interior_blueprint: None,
258            tags: vec![],
259            market_boundary_zone_ids: vec![],
260            market_max_volume: None,
261            wall_set: None,
262            roof_set: None,
263        }];
264        let obs = LosObstacles::from_outdoor_views(&buildings, &[], None);
265        let ground = |_x: f32, _y: f32| 0.0;
266        let block = first_los_block(5.0, 10.0, 0.0, 15.0, 10.0, 0.0, &obs, ground);
267        assert_eq!(block.map(|b| b.kind), Some(LosBlockKind::Rect));
268        assert!((block.unwrap().x - 8.0).abs() < 0.5);
269    }
270}