flatland-client-ui 0.2.32

Engine-agnostic play client UI state (world grid, input, presentation)
Documentation
//! Interior wall glyph lines and door placement on shared room edges (mirrors `flatland_sim::building_interior`).
//!
//! The client computes *glyph* positions: the 1 m cell the sim's blocking strip
//! (`[fixed ± wall_thickness]`) sits in, i.e. `round(fixed)`, plus the door's
//! along-wall coordinate. The server's `DoorView` carries the exact wall-line
//! position; the client re-snaps to the glyph cell so sprites and gap carving line
//! up with the painted wall cells.

use flatland_protocol::{InteriorDoorView, InteriorMapView, InteriorRoomView};

#[derive(Debug, Clone, Copy)]
struct SharedWallEdge {
    horizontal: bool,
    fixed: f32,
    along_min: f32,
    along_max: f32,
}

fn room_bounds(room: &InteriorRoomView) -> (f32, f32, f32, f32) {
    (
        room.x0.min(room.x1),
        room.x0.max(room.x1),
        room.y0.min(room.y1),
        room.y0.max(room.y1),
    )
}

/// Grid row/column for the wall glyph at `fixed` — `round` so the glyph lands on the
/// cell that actually contains the sim's blocking strip (matches collision).
pub fn interior_wall_glyph_line(fixed: f32) -> i32 {
    fixed.round() as i32
}

fn rect_perimeter_edges(room: &InteriorRoomView) -> [SharedWallEdge; 4] {
    let (w, e, s, n) = room_bounds(room);
    [
        SharedWallEdge {
            horizontal: true,
            fixed: s,
            along_min: w,
            along_max: e,
        },
        SharedWallEdge {
            horizontal: true,
            fixed: n,
            along_min: w,
            along_max: e,
        },
        SharedWallEdge {
            horizontal: false,
            fixed: w,
            along_min: s,
            along_max: n,
        },
        SharedWallEdge {
            horizontal: false,
            fixed: e,
            along_min: s,
            along_max: n,
        },
    ]
}

/// Wall line nearest to `(x, y)` among the given rooms. On a tie (e.g. two rooms
/// sharing the same wall line), prefer the edge whose along-span actually contains
/// the point — otherwise a door on the shared line snaps to the wrong room's span
/// and gets clamped onto the wrong wall segment.
fn nearest_room_edge<'a>(
    rooms: impl Iterator<Item = &'a InteriorRoomView>,
    x: f32,
    y: f32,
) -> Option<SharedWallEdge> {
    let mut best: Option<(SharedWallEdge, f32, bool)> = None;
    for room in rooms {
        for edge in rect_perimeter_edges(room) {
            let dist = if edge.horizontal {
                (y - edge.fixed).abs()
            } else {
                (x - edge.fixed).abs()
            };
            let along = if edge.horizontal { x } else { y };
            let contains = along >= edge.along_min - 0.6 && along <= edge.along_max + 0.6;
            let replace = match best {
                None => true,
                Some((_, bd, b_contains)) => {
                    dist < bd - 0.01
                        || ((dist - bd).abs() <= 0.01 && contains && !b_contains)
                }
            };
            if replace {
                best = Some((edge, dist, contains));
            }
        }
    }
    best.map(|(edge, _, _)| edge)
}

/// Glyph position for a door on a wall edge: `round(fixed)` row/col + clamped along.
fn display_on_wall_edge(edge: &SharedWallEdge, x: f32, y: f32) -> (f32, f32) {
    let line = interior_wall_glyph_line(edge.fixed) as f32;
    if edge.horizontal {
        let along = x.clamp(edge.along_min, edge.along_max);
        (along, line)
    } else {
        let along = y.clamp(edge.along_min, edge.along_max);
        (line, along)
    }
}

fn perimeter_on_floor(interior: &InteriorMapView, floor: i32, x: f32, y: f32) -> (f32, f32) {
    let rooms = interior.rooms.iter().filter(|r| r.floor == floor);
    match nearest_room_edge(rooms, x, y) {
        Some(edge) => display_on_wall_edge(&edge, x, y),
        None => (x, y),
    }
}

/// Door/stair sprite position on `floor`. Stairs use per-room coordinates; doors snap to walls.
pub fn interior_room_door_display_xy(
    door: &InteriorDoorView,
    interior: &InteriorMapView,
    floor: i32,
) -> (f32, f32) {
    if door.kind == "stairs" {
        let room_a = interior.rooms.iter().find(|r| r.id == door.room_a);
        let room_b = interior.rooms.iter().find(|r| r.id == door.room_b);
        if room_a.map(|r| r.floor) == Some(floor) {
            return (door.x_a.unwrap_or(door.x), door.y_a.unwrap_or(door.y));
        }
        if room_b.map(|r| r.floor) == Some(floor) {
            return (door.x_b.unwrap_or(door.x), door.y_b.unwrap_or(door.y));
        }
        return (door.x_a.unwrap_or(door.x), door.y_a.unwrap_or(door.y));
    }
    let rooms = interior
        .rooms
        .iter()
        .filter(|r| r.id == door.room_a || r.id == door.room_b);
    match nearest_room_edge(rooms, door.x, door.y) {
        Some(edge) => display_on_wall_edge(&edge, door.x, door.y),
        None => (door.x, door.y),
    }
}

/// Door/stair sprite position: attach to the shared wall between rooms (or perimeter fallback).
pub fn interior_door_display_xy(
    interior: &InteriorMapView,
    floor: i32,
    door_id: &str,
    x: f32,
    y: f32,
) -> (f32, f32) {
    if let Some(rd) = interior.room_doors.iter().find(|d| d.id == door_id) {
        return interior_room_door_display_xy(rd, interior, floor);
    }
    perimeter_on_floor(interior, floor, x, y)
}

#[cfg(test)]
mod tests {
    use super::*;
    use flatland_protocol::{InteriorDoorView, InteriorMapView, InteriorRoomView};

    fn room(id: &str, x0: f32, y0: f32, x1: f32, y1: f32, floor: i32) -> InteriorRoomView {
        InteriorRoomView {
            id: id.into(),
            label: id.into(),
            floor,
            x0,
            y0,
            x1,
            y1,
            floor_color: None,
            floor_glyph: None,
        }
    }

    fn town_hall() -> InteriorMapView {
        InteriorMapView {
            building_id: "town_hall".into(),
            blueprint_id: "town_hall".into(),
            background_color: "#000".into(),
            default_floor_color: Some("#2a2a2a".into()),
            floor_height_m: 3.0,
            z_platforms: vec![],
            z_transitions: vec![],
            rooms: vec![
                room("main", -3.5, -8.0, 18.5, 6.0, 0),
                room("kitchen", 18.5, -8.0, 23.5, 0.0, 0),
                room("weapon", 18.5, 0.0, 25.5, 12.5, 0),
                room("hall_n", -3.5, 6.0, 12.0, 12.5, 0),
                room("meeting", -3.5, 12.5, 12.0, 23.5, 0),
                room("office", 12.0, 6.0, 18.5, 12.5, 0),
            ],
            room_doors: vec![],
        }
    }

    fn door(id: &str, a: &str, b: &str, x: f32, y: f32, kind: &str) -> InteriorDoorView {
        InteriorDoorView {
            id: id.into(),
            room_a: a.into(),
            room_b: b.into(),
            x,
            y,
            kind: kind.into(),
            x_a: None,
            y_a: None,
            x_b: None,
            y_b: None,
        }
    }

    #[test]
    fn glyph_line_rounds_to_blocking_cell() {
        assert_eq!(interior_wall_glyph_line(-8.0), -8);
        assert_eq!(interior_wall_glyph_line(0.0), 0);
        assert_eq!(interior_wall_glyph_line(6.0), 6);
        assert_eq!(interior_wall_glyph_line(12.5), 13);
        assert_eq!(interior_wall_glyph_line(18.5), 19);
        assert_eq!(interior_wall_glyph_line(-3.5), -4);
        assert_eq!(interior_wall_glyph_line(23.5), 24);
        assert_eq!(interior_wall_glyph_line(25.5), 26);
    }

    #[test]
    fn door_snaps_to_nearest_wall_line() {
        let interior = town_hall();
        // Authored on the x=12 wall (office west) — not the shared y=6 edge with main.
        let d = door("d_office", "main", "office", 12.0, 9.0, "door");
        assert_eq!(interior_room_door_display_xy(&d, &interior, 0), (12.0, 9.0));
        // Authored on the x=18.5 wall shared by weapon+kitchen — stays on it, not y=0.
        let d = door("d_kitchen2", "weapon", "kitchen", 18.5, 8.5, "door");
        assert_eq!(interior_room_door_display_xy(&d, &interior, 0), (19.0, 8.5));
        // Top-wall door projects to the y=12.5 glyph row (13).
        let d = door("d_meeting", "main", "meeting", 5.5, 12.5, "door");
        assert_eq!(interior_room_door_display_xy(&d, &interior, 0), (5.5, 13.0));
        // North-wall door of main hall.
        let d = door("d_hall_n", "main", "hall_n", 6.0, 6.0, "door");
        assert_eq!(interior_room_door_display_xy(&d, &interior, 0), (6.0, 6.0));
    }

    #[test]
    fn stairs_keep_authored_position() {
        let interior = town_hall();
        let d = door("stairs", "main", "upper", -2.0, 4.5, "stairs");
        assert_eq!(interior_room_door_display_xy(&d, &interior, 0), (-2.0, 4.5));
    }

    #[test]
    fn perimeter_fallback_projects_mid_room_portal_to_nearest_wall() {
        let interior = town_hall();
        // Portal authored 2 m inside main hall near the south wall → projects to glyph row -8.
        let (x, y) = interior_door_display_xy(&interior, 0, "front", 7.5, -6.0);
        assert_eq!((x, y), (7.5, -8.0));
    }
}