use flatland_protocol::{BuildingView, ResourceNodeState, ResourceNodeView};
const GROUND_CLIP_SLACK_M: f32 = 0.25;
const MAX_VISIBLE_ELEV_DELTA_M: f32 = 1.0;
const SAMPLES: usize = 12;
const LOS_XY_STEP_M: f32 = 0.5;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BlockingRect {
pub x0: f32,
pub y0: f32,
pub x1: f32,
pub y1: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BlockingCircle {
pub x: f32,
pub y: f32,
pub radius_m: f32,
}
#[derive(Debug, Clone, Default)]
pub struct LosObstacles {
pub rects: Vec<BlockingRect>,
pub circles: Vec<BlockingCircle>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LosBlockKind {
ElevationGap,
ElevationTaper,
Rect,
Circle,
Terrain,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LosBlock {
pub x: f32,
pub y: f32,
pub z: f32,
pub kind: LosBlockKind,
}
impl LosObstacles {
pub fn from_outdoor_views(
buildings: &[BuildingView],
resource_nodes: &[ResourceNodeView],
inside_building: Option<&str>,
) -> Self {
let mut rects = Vec::new();
for b in buildings {
if inside_building == Some(b.id.as_str()) {
continue;
}
let hw = b.width_m / 2.0;
let hd = b.depth_m / 2.0;
rects.push(BlockingRect {
x0: b.x - hw,
y0: b.y - hd,
x1: b.x + hw,
y1: b.y + hd,
});
}
let mut circles = Vec::new();
for n in resource_nodes {
if !n.blocking {
continue;
}
if !matches!(
n.state,
ResourceNodeState::Available | ResourceNodeState::Harvesting { .. }
) {
continue;
}
circles.push(BlockingCircle {
x: n.x,
y: n.y,
radius_m: n.blocking_radius_m.max(0.1),
});
}
Self { rects, circles }
}
}
pub fn first_los_block(
from_x: f32,
from_y: f32,
from_z: f32,
to_x: f32,
to_y: f32,
to_z: f32,
obstacles: &LosObstacles,
ground_z: impl Fn(f32, f32) -> f32,
) -> Option<LosBlock> {
let elev_delta = (from_z - to_z).abs();
if elev_delta > MAX_VISIBLE_ELEV_DELTA_M + GROUND_CLIP_SLACK_M {
let t = 0.5;
return Some(LosBlock {
x: from_x + (to_x - from_x) * t,
y: from_y + (to_y - from_y) * t,
z: from_z + (to_z - from_z) * t,
kind: LosBlockKind::ElevationGap,
});
}
if elev_delta > GROUND_CLIP_SLACK_M {
let t = (elev_delta / MAX_VISIBLE_ELEV_DELTA_M).clamp(0.0, 1.0);
let max_xy = 24.0 + (10.0 - 24.0) * t;
let dist_xy = (to_x - from_x).hypot(to_y - from_y);
if dist_xy > max_xy {
let t = (max_xy / dist_xy.max(0.001)).clamp(0.0, 1.0);
return Some(LosBlock {
x: from_x + (to_x - from_x) * t,
y: from_y + (to_y - from_y) * t,
z: from_z + (to_z - from_z) * t,
kind: LosBlockKind::ElevationTaper,
});
}
}
let high_z = from_z.max(to_z);
let adjacent_shelf = elev_delta > GROUND_CLIP_SLACK_M;
let dist_xy = ((to_x - from_x).hypot(to_y - from_y)).max(0.001);
let sample_count = SAMPLES
.max((dist_xy / LOS_XY_STEP_M).ceil() as usize)
.max(2);
for i in 1..sample_count {
let t = i as f32 / sample_count as f32;
let x = from_x + (to_x - from_x) * t;
let y = from_y + (to_y - from_y) * t;
let z = from_z + (to_z - from_z) * t;
if point_in_rects(x, y, &obstacles.rects) {
return Some(LosBlock {
x,
y,
z,
kind: LosBlockKind::Rect,
});
}
if point_in_circles(x, y, &obstacles.circles) {
return Some(LosBlock {
x,
y,
z,
kind: LosBlockKind::Circle,
});
}
let ground = ground_z(x, y);
if terrain_undercut_blocks(ground, z, high_z, elev_delta, adjacent_shelf) {
return Some(LosBlock {
x,
y,
z,
kind: LosBlockKind::Terrain,
});
}
}
None
}
pub fn has_los(
from_x: f32,
from_y: f32,
from_z: f32,
to_x: f32,
to_y: f32,
to_z: f32,
obstacles: &LosObstacles,
ground_z: impl Fn(f32, f32) -> f32,
) -> bool {
first_los_block(
from_x, from_y, from_z, to_x, to_y, to_z, obstacles, ground_z,
)
.is_none()
}
fn point_in_circles(x: f32, y: f32, circles: &[BlockingCircle]) -> bool {
circles.iter().any(|o| {
let dx = x - o.x;
let dy = y - o.y;
dx * dx + dy * dy < o.radius_m * o.radius_m
})
}
fn point_in_rects(x: f32, y: f32, rects: &[BlockingRect]) -> bool {
rects
.iter()
.any(|r| x >= r.x0 && x <= r.x1 && y >= r.y0 && y <= r.y1)
}
fn terrain_undercut_blocks(
ground: f32,
sample_z: f32,
high_z: f32,
elev_delta: f32,
adjacent_shelf: bool,
) -> bool {
let under = ground - sample_z;
if under <= GROUND_CLIP_SLACK_M {
return false;
}
if !adjacent_shelf {
return true;
}
if ground > high_z + GROUND_CLIP_SLACK_M {
return true;
}
under > elev_delta + GROUND_CLIP_SLACK_M + 0.15
}
#[cfg(test)]
mod tests {
use super::*;
use flatland_protocol::BuildingView;
#[test]
fn intervening_peak_blocks() {
let ground = |x: f32, _y: f32| {
if (10.0..11.0).contains(&x) {
3.0
} else if x < 10.0 {
1.0
} else {
0.0
}
};
let obs = LosObstacles::default();
assert_eq!(
first_los_block(9.5, 20.0, 1.0, 11.5, 20.0, 0.0, &obs, ground).map(|b| b.kind),
Some(LosBlockKind::Terrain)
);
assert!(has_los(9.5, 20.0, 1.0, 9.8, 20.0, 1.0, &obs, ground));
}
#[test]
fn building_footprint_blocks_ray() {
let buildings = vec![BuildingView {
id: "shop".into(),
label: "Shop".into(),
x: 10.0,
y: 10.0,
width_m: 4.0,
depth_m: 4.0,
interior_blueprint: None,
tags: vec![],
market_boundary_zone_ids: vec![],
market_max_volume: None,
wall_set: None,
roof_set: None,
}];
let obs = LosObstacles::from_outdoor_views(&buildings, &[], None);
let ground = |_x: f32, _y: f32| 0.0;
let block = first_los_block(5.0, 10.0, 0.0, 15.0, 10.0, 0.0, &obs, ground);
assert_eq!(block.map(|b| b.kind), Some(LosBlockKind::Rect));
assert!((block.unwrap().x - 8.0).abs() < 0.5);
}
}