1use flatland_protocol::{BuildingView, ResourceNodeState, ResourceNodeView};
7
8const GROUND_CLIP_SLACK_M: f32 = 0.25;
10const 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#[derive(Debug, Clone, Default)]
32pub struct LosObstacles {
33 pub rects: Vec<BlockingRect>,
34 pub circles: Vec<BlockingCircle>,
35}
36
37#[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 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
98pub 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}