mod decor;
mod mask;
pub use decor::{PlantKind, PodDecor, WallDecor, WaypointKind};
use crate::walkable::WalkableMask;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Bounds {
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Point {
pub x: u16,
pub y: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Waypoint {
pub pos: Point,
pub kind: WaypointKind,
}
#[derive(Debug, Clone)]
pub struct SceneLayout {
pub buf_w: u16,
pub buf_h: u16,
pub cubicle_band: Bounds,
pub walkway: Bounds,
pub home_desks: Vec<Point>,
pub waypoints: Vec<Waypoint>,
pub plants: Vec<(PlantKind, Point)>,
pub wall_decor: Vec<(WallDecor, Point)>,
pub pod_decor: Vec<(PodDecor, Point)>,
pub floor_lamp: Option<Point>,
pub lounge_side_table: Option<Point>,
pub door: Option<Point>,
pub door_threshold: Option<Point>,
pub floor_seats: Vec<Point>,
pub meeting_room: Option<Bounds>,
pub pantry_room: Option<Bounds>,
pub meeting_sofas: Vec<Point>,
pub meeting_table: Option<Point>,
pub room_walls: Vec<(Point, Point)>,
pub top_margin: u16,
pub pantry_table: Option<Point>,
pub pantry_chairs: Vec<Point>,
pub pantry_counter_size: (u16, u16),
pub corridor: Option<Bounds>,
pub walkable: WalkableMask,
}
pub const OBSTACLE_PAD_PX: u16 = 2;
pub const DESK_W: u16 = 12;
pub const DESK_H: u16 = 6;
pub const MAX_VISIBLE_DESKS: usize = 16;
pub const DESK_GAP_X: u16 = 11;
pub const DESK_GAP_Y: u16 = 14;
pub const MIN_TOP_MARGIN: u16 = 20;
pub const POD_SIDE: u16 = 2;
pub const INTRA_POD_GAP_X: u16 = 12;
pub const INTRA_POD_GAP_Y: u16 = 12;
pub const INTER_POD_AISLE_X: u16 = 28;
pub const INTER_POD_AISLE_Y: u16 = 28;
impl SceneLayout {
pub fn compute(buf_w: u16, buf_h: u16, num_agents: usize) -> Option<Self> {
const MIN_W: u16 = DESK_W + DESK_GAP_X * 2;
let min_h: u16 = 40 + MIN_TOP_MARGIN;
if buf_w < MIN_W || buf_h < min_h {
return None;
}
let top_margin = (buf_h / 4).max(MIN_TOP_MARGIN);
let usable_h = buf_h - top_margin;
let mid_x = buf_w * 28 / 100;
let mid_y_split = top_margin + usable_h / 2;
let meeting_room = Some(Bounds {
x: 0,
y: top_margin,
width: mid_x,
height: usable_h / 2,
});
let pantry_room = Some(Bounds {
x: 0,
y: mid_y_split,
width: mid_x,
height: usable_h - usable_h / 2,
});
let right_x = mid_x + 1;
let right_w = buf_w.saturating_sub(right_x);
const BASEBOARD_RESERVE: u16 = 3;
let walkway_h = (usable_h / 10).max(8);
let cubicle_h = usable_h
.saturating_sub(walkway_h)
.saturating_sub(BASEBOARD_RESERVE);
let cubicle_band = Bounds {
x: right_x,
y: top_margin,
width: right_w,
height: cubicle_h,
};
let walkway = Bounds {
x: right_x,
y: top_margin + cubicle_h,
width: right_w,
height: walkway_h,
};
let pod_w = POD_SIDE * DESK_W + (POD_SIDE - 1) * INTRA_POD_GAP_X;
let pod_h = POD_SIDE * DESK_H + (POD_SIDE - 1) * INTRA_POD_GAP_Y;
let pod_stride_x = pod_w + INTER_POD_AISLE_X;
let pod_stride_y = pod_h + INTER_POD_AISLE_Y;
let couch_to_desk_extra = buf_h.saturating_sub(60) / 20;
let pod_cols = ((right_w.saturating_sub(INTER_POD_AISLE_X / 2)) / pod_stride_x).max(1);
let pod_rows = ((cubicle_h.saturating_sub(couch_to_desk_extra) + INTER_POD_AISLE_Y)
/ pod_stride_y)
.max(1);
let max_pods = MAX_VISIBLE_DESKS as u16 / (POD_SIDE * POD_SIDE);
let total_pods = (pod_cols * pod_rows).min(max_pods);
let pod_rows = total_pods.div_ceil(pod_cols).min(pod_rows);
let n = num_agents.min(MAX_VISIBLE_DESKS);
let mut home_desks = Vec::with_capacity(n);
let desk_y_max = cubicle_band.y + cubicle_band.height - DESK_H;
let push_desk = |desks: &mut Vec<Point>, x: u16, y: u16| -> bool {
if desks.len() >= n || y > desk_y_max {
return desks.len() >= n;
}
desks.push(Point { x, y });
false
};
'outer: for pod_r in 0..pod_rows {
for pod_c in 0..pod_cols {
let pod_origin_x = right_x + INTER_POD_AISLE_X / 2 + pod_c * pod_stride_x;
let pod_origin_y = cubicle_band.y
+ INTER_POD_AISLE_Y / 2
+ couch_to_desk_extra
+ pod_r * pod_stride_y;
for r in 0..POD_SIDE {
for c in 0..POD_SIDE {
let full = push_desk(
&mut home_desks,
pod_origin_x + c * (DESK_W + INTRA_POD_GAP_X),
pod_origin_y + r * (DESK_H + INTRA_POD_GAP_Y),
);
if full {
break 'outer;
}
}
}
}
}
let main_pod_used_w = INTER_POD_AISLE_X / 2 + pod_cols * pod_stride_x;
let residual_w = right_w.saturating_sub(main_pod_used_w);
let partial_col_stride = DESK_W + INTER_POD_AISLE_X / 2;
let partial_col_count = (residual_w / partial_col_stride).min(4);
let partial_col_at_right = partial_col_count > 0;
let partial_col_x = |i: u16| -> u16 {
right_x + main_pod_used_w + INTER_POD_AISLE_X / 2 + i * partial_col_stride
};
if partial_col_at_right {
'partial_x: for pod_r in 0..pod_rows {
let pod_origin_y = cubicle_band.y
+ INTER_POD_AISLE_Y / 2
+ couch_to_desk_extra
+ pod_r * pod_stride_y;
for r in 0..POD_SIDE {
for i in 0..partial_col_count {
let full = push_desk(
&mut home_desks,
partial_col_x(i),
pod_origin_y + r * (DESK_H + INTRA_POD_GAP_Y),
);
if full {
break 'partial_x;
}
}
}
}
}
let main_pod_used_h = INTER_POD_AISLE_Y / 2 + couch_to_desk_extra + pod_rows * pod_stride_y;
let residual_h = cubicle_h.saturating_sub(main_pod_used_h);
let partial_row_at_bottom = residual_h >= DESK_H + INTER_POD_AISLE_Y / 2;
if partial_row_at_bottom {
let partial_y = cubicle_band.y + main_pod_used_h + INTER_POD_AISLE_Y / 2;
'partial_y: for pod_c in 0..pod_cols {
let pod_origin_x = right_x + INTER_POD_AISLE_X / 2 + pod_c * pod_stride_x;
for c in 0..POD_SIDE {
let full = push_desk(
&mut home_desks,
pod_origin_x + c * (DESK_W + INTRA_POD_GAP_X),
partial_y,
);
if full {
break 'partial_y;
}
}
}
for i in 0..partial_col_count {
let full = push_desk(&mut home_desks, partial_col_x(i), partial_y);
if full {
break;
}
}
}
let mut pod_decor: Vec<(PodDecor, Point)> = Vec::new();
let mut slot_idx: usize = 0;
let mut push_slot = |pod_decor: &mut Vec<(PodDecor, Point)>, x: u16, y: u16| {
let kind = PodDecor::ALL[slot_idx % PodDecor::ALL.len()];
slot_idx += 1;
pod_decor.push((kind, Point { x, y }));
};
for pod_r in 0..pod_rows {
for pod_c in 0..pod_cols.saturating_sub(1) {
let pod_origin_x = right_x + INTER_POD_AISLE_X / 2 + pod_c * pod_stride_x;
let pod_origin_y = cubicle_band.y
+ INTER_POD_AISLE_Y / 2
+ couch_to_desk_extra
+ pod_r * pod_stride_y;
let aisle_cx = pod_origin_x + pod_w + INTER_POD_AISLE_X / 2;
let aisle_cy = pod_origin_y + pod_h / 2;
push_slot(&mut pod_decor, aisle_cx, aisle_cy);
}
}
for pod_r in 0..pod_rows.saturating_sub(1) {
for pod_c in 0..pod_cols {
let pod_origin_x = right_x + INTER_POD_AISLE_X / 2 + pod_c * pod_stride_x;
let pod_origin_y = cubicle_band.y
+ INTER_POD_AISLE_Y / 2
+ couch_to_desk_extra
+ pod_r * pod_stride_y;
let aisle_cx = pod_origin_x + pod_w / 2;
let aisle_cy = pod_origin_y + pod_h + INTER_POD_AISLE_Y / 2;
push_slot(&mut pod_decor, aisle_cx, aisle_cy);
}
}
const SOFA_H: u16 = 7;
let meeting_sofas = if let Some(mr) = meeting_room {
let cx = mr.x + mr.width / 2;
let south_y =
(mr.y + mr.height * 80 / 100).min(mr.y + mr.height.saturating_sub(SOFA_H));
vec![
Point {
x: cx,
y: mr.y + mr.height * 30 / 100,
},
Point { x: cx, y: south_y },
]
} else {
vec![]
};
let meeting_table = meeting_room.map(|mr| Point {
x: mr.x + mr.width / 2,
y: mr.y + mr.height / 2,
});
const DOOR_GAP_V: u16 = 14;
const DOOR_GAP_H: u16 = 14;
let mut room_walls = Vec::new();
let v_x = mid_x;
let v_top = top_margin;
let v_bot = mid_y_split;
let v_door_center = top_margin + (v_bot - v_top) / 2;
let v_door_top = v_door_center.saturating_sub(DOOR_GAP_V / 2);
let v_door_bot = (v_door_center + DOOR_GAP_V / 2).min(v_bot);
room_walls.push((
Point { x: v_x, y: v_top },
Point {
x: v_x,
y: v_door_top,
},
));
room_walls.push((
Point {
x: v_x,
y: v_door_bot,
},
Point { x: v_x, y: v_bot },
));
let h_y = mid_y_split;
let h_door_center = mid_x * 60 / 100;
let h_door_left = h_door_center.saturating_sub(DOOR_GAP_H / 2);
let h_door_right = (h_door_center + DOOR_GAP_H / 2).min(mid_x);
room_walls.push((
Point { x: 0, y: h_y },
Point {
x: h_door_left,
y: h_y,
},
));
room_walls.push((
Point {
x: h_door_right,
y: h_y,
},
Point { x: mid_x, y: h_y },
));
let couch_y = top_margin + 3;
let couch_x = cubicle_band.x + cubicle_band.width * 35 / 100;
let mut waypoints: Vec<Waypoint> = vec![Waypoint {
pos: Point {
x: couch_x,
y: couch_y,
},
kind: WaypointKind::Couch,
}];
let pantry_counter_size: (u16, u16) = match pantry_room {
Some(pr) if pr.width >= 36 => (32, 10),
_ => (20, 8),
};
if let Some(pr) = pantry_room {
let half_cw = pantry_counter_size.0 / 2;
let max_cx = pr.x + pr.width.saturating_sub(half_cw + 1);
let (wx, wy) = if pantry_counter_size.0 >= 32 {
(
(pr.x + pr.width / 2).min(max_cx),
pr.y + pr.height * 65 / 100,
)
} else {
(
(pr.x + pr.width * 60 / 100).min(max_cx),
pr.y + pr.height * 60 / 100,
)
};
waypoints.push(Waypoint {
pos: Point { x: wx, y: wy },
kind: WaypointKind::Pantry,
});
}
for (kind, pos) in &pod_decor {
let wp_kind = match kind {
PodDecor::PhoneBooth => Some(WaypointKind::PhoneBooth),
PodDecor::StandingDesk => Some(WaypointKind::StandingDesk),
_ => None,
};
if let Some(wp_kind) = wp_kind {
waypoints.push(Waypoint {
pos: *pos,
kind: wp_kind,
});
}
}
let plants: Vec<(PlantKind, Point)> = vec![
(
PlantKind::Flower,
Point {
x: cubicle_band.x + 4,
y: walkway.y.saturating_sub(4),
},
),
(
PlantKind::Succulent,
Point {
x: cubicle_band.x + cubicle_band.width.saturating_sub(4),
y: walkway.y.saturating_sub(4),
},
),
]
.into_iter()
.chain(std::iter::empty::<(PlantKind, Point)>())
.chain(meeting_room.into_iter().flat_map(|mr| {
if mr.width < 30 || mr.height < 30 {
Vec::new()
} else {
vec![
(
PlantKind::Tall,
Point {
x: mr.x + 5,
y: mr.y + 6,
},
),
(
PlantKind::Flower,
Point {
x: mr.x + 5,
y: mr.y + mr.height.saturating_sub(7),
},
),
]
}
}))
.collect();
let floor_lamp = Some(Point {
x: couch_x + 9,
y: couch_y + 2,
});
let lounge_side_table = Some(Point {
x: couch_x.saturating_sub(10),
y: couch_y + 2,
});
const ELEVATOR_W: u16 = 16;
const ELEVATOR_H: u16 = 14;
let top_wall_h = top_margin.saturating_sub(4);
let window_bottom_y = top_wall_h.saturating_sub(3); let door = if buf_w >= ELEVATOR_W + 4 && window_bottom_y + 1 >= ELEVATOR_H {
Some(Point {
x: buf_w.saturating_sub(ELEVATOR_W + 2),
y: window_bottom_y + 1 - ELEVATOR_H + 2,
})
} else {
None
};
let door_threshold = door.map(|d| Point {
x: d.x + ELEVATOR_W / 2,
y: top_margin + 4,
});
let wall_decor = vec![
(
WallDecor::Bookshelf,
Point {
x: buf_w * 18 / 100,
y: top_margin.saturating_sub(12),
},
),
(
WallDecor::ExitSign,
Point {
x: buf_w.saturating_sub(9),
y: top_margin.saturating_sub(13),
},
),
(
WallDecor::Whiteboard,
Point {
x: mid_x + 3,
y: v_door_bot + 2,
},
),
(
WallDecor::MeetingScreen,
Point {
x: meeting_room.map(|mr| mr.x + mr.width / 2 - 7).unwrap_or(0),
y: top_margin.saturating_sub(6),
},
),
];
let used_before_floor = home_desks.len() + meeting_sofas.len();
let overflow_count = num_agents.saturating_sub(used_before_floor).min(8);
let mut floor_seats: Vec<Point> = Vec::with_capacity(overflow_count);
for slot in 0..overflow_count {
let c = (slot as u16) % 6;
let along_x = cubicle_band.x + cubicle_band.width * (8 + c * 16) / 100;
floor_seats.push(Point {
x: along_x,
y: walkway.y + walkway.height / 2,
});
}
let (pantry_table, pantry_chairs) = if let Some(pr) = pantry_room {
let tx = pr.x + pr.width * 25 / 100;
let ty = pr.y + pr.height * 25 / 100;
(
Some(Point { x: tx, y: ty }),
vec![
Point {
x: tx.saturating_sub(4),
y: ty,
},
Point { x: tx + 4, y: ty },
Point {
x: tx,
y: ty.saturating_sub(3),
},
Point { x: tx, y: ty + 3 },
],
)
} else {
(None, vec![])
};
let corridor = Some(Bounds {
x: 0,
y: walkway.y,
width: buf_w,
height: walkway.height,
});
let walkable = mask::build_walkable_mask(
buf_w,
buf_h,
top_margin,
door,
&home_desks,
&meeting_sofas,
meeting_table,
pantry_table,
&pantry_chairs,
&waypoints,
&plants,
floor_lamp,
lounge_side_table,
&wall_decor,
&pod_decor,
&room_walls,
pantry_counter_size,
);
Some(Self {
buf_w,
buf_h,
cubicle_band,
walkway,
home_desks,
waypoints,
plants,
wall_decor,
pod_decor,
floor_lamp,
lounge_side_table,
door,
door_threshold,
floor_seats,
meeting_room,
pantry_room,
meeting_sofas,
meeting_table,
room_walls,
top_margin,
pantry_table,
pantry_chairs,
pantry_counter_size,
corridor,
walkable,
})
}
pub fn is_walkable(&self, x: u16, y: u16) -> bool {
self.walkable.is_walkable(x, y)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compute_returns_none_when_buf_too_small() {
assert!(SceneLayout::compute(20, 20, 4).is_none());
}
#[test]
fn compute_zones_are_ordered_top_to_bottom_and_nonoverlapping() {
let l = SceneLayout::compute(120, 80, 6).expect("fits");
assert!(l.cubicle_band.y < l.walkway.y);
let c_bot = l.cubicle_band.y + l.cubicle_band.height;
assert!(c_bot <= l.walkway.y, "cubicle overlaps walkway");
let w_bot = l.walkway.y + l.walkway.height;
assert!(w_bot <= l.buf_h);
}
#[test]
fn compute_places_one_home_desk_per_agent() {
let l = SceneLayout::compute(160, 80, 5).expect("fits");
assert!(l.home_desks.len() <= 5 && !l.home_desks.is_empty());
for d in &l.home_desks {
assert!(d.y >= l.cubicle_band.y);
assert!(d.y + DESK_H <= l.cubicle_band.y + l.cubicle_band.height);
assert!(d.x >= l.cubicle_band.x);
}
}
#[test]
fn compute_places_all_waypoint_kinds() {
let l = SceneLayout::compute(120, 96, 1).expect("fits");
assert!(l.waypoints.len() >= 2);
let kinds: std::collections::HashSet<_> = l.waypoints.iter().map(|w| w.kind).collect();
assert!(kinds.contains(&WaypointKind::Couch));
assert!(kinds.contains(&WaypointKind::Pantry));
for w in &l.waypoints {
match w.kind {
WaypointKind::Pantry => {
let pr = l.pantry_room.expect("pantry");
assert!(w.pos.y >= pr.y && w.pos.y < pr.y + pr.height);
assert!(w.pos.x >= pr.x && w.pos.x < pr.x + pr.width);
}
WaypointKind::Couch => {
assert!(w.pos.y >= l.top_margin);
assert!(w.pos.y < l.cubicle_band.y + DESK_GAP_Y);
}
WaypointKind::PhoneBooth | WaypointKind::StandingDesk => {
assert!(w.pos.y >= l.top_margin);
}
}
}
}
#[test]
fn compute_places_bookshelf_on_wall_and_whiteboard_in_walkway() {
let l = SceneLayout::compute(120, 96, 1).expect("fits");
let bookshelf = l
.wall_decor
.iter()
.find(|(k, _)| *k == WallDecor::Bookshelf);
let whiteboard = l
.wall_decor
.iter()
.find(|(k, _)| *k == WallDecor::Whiteboard);
assert!(bookshelf.is_some());
assert!(whiteboard.is_some());
assert!(bookshelf.unwrap().1.y < l.cubicle_band.y);
assert!(whiteboard.unwrap().1.y > l.cubicle_band.y);
}
#[test]
fn compute_places_plants_in_lounge_and_walkway() {
let l = SceneLayout::compute(120, 96, 1).expect("fits");
assert!(!l.plants.is_empty());
for (_, p) in &l.plants {
assert!(p.x < l.buf_w);
assert!(p.y < l.buf_h);
}
}
#[test]
fn compute_truncates_home_desks_when_more_agents_than_fit() {
let l = SceneLayout::compute(50, 80, 20).expect("fits");
assert!(l.home_desks.len() < 20);
}
#[test]
fn walkable_mask_is_fully_connected_across_buffer_sizes() {
use std::collections::VecDeque;
let sizes = [
(96u16, 70u16, 7usize),
(128, 80, 10),
(160, 100, 12),
(240, 130, 16),
(320, 180, 16),
];
for (buf_w, buf_h, num_agents) in sizes {
let l = SceneLayout::compute(buf_w, buf_h, num_agents)
.unwrap_or_else(|| panic!("layout fits at {buf_w}x{buf_h}"));
let w = l.buf_w as usize;
let h = l.buf_h as usize;
let start = l
.door_threshold
.unwrap_or_else(|| panic!("door_threshold missing at {buf_w}x{buf_h}"));
assert!(
l.is_walkable(start.x, start.y),
"door_threshold {start:?} not walkable at {buf_w}x{buf_h}"
);
let mut visited = vec![false; w * h];
visited[(start.y as usize) * w + (start.x as usize)] = true;
let mut queue: VecDeque<(usize, usize)> = VecDeque::new();
queue.push_back((start.x as usize, start.y as usize));
let mut reachable = 1usize;
while let Some((x, y)) = queue.pop_front() {
for (dx, dy) in [(1i32, 0i32), (-1, 0), (0, 1), (0, -1)] {
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if nx < 0 || ny < 0 {
continue;
}
let (nx, ny) = (nx as usize, ny as usize);
if nx >= w || ny >= h || visited[ny * w + nx] {
continue;
}
if !l.is_walkable(nx as u16, ny as u16) {
continue;
}
visited[ny * w + nx] = true;
reachable += 1;
queue.push_back((nx, ny));
}
}
let mut walkable_total = 0usize;
for y in 0..h {
for x in 0..w {
if l.is_walkable(x as u16, y as u16) {
walkable_total += 1;
}
}
}
assert_eq!(
reachable,
walkable_total,
"{buf_w}x{buf_h} ({num_agents} agents): {} disconnected pixels — \
some open area is isolated from the door",
walkable_total - reachable
);
}
}
}