makepad/libs/fab_tour/src/route.rs
Admin 9821263b2c fab: a 3D creation shell and the viewer built on it — colour picker, material textures, dials that move the scene while they drag, the glTF loader, the tour, and the probes
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- fab: a 3D creation shell and the viewer built on it
- raytrace: the traced pane starts coarse and doubles to native, with the raster underneath
- fab: a colour picker, a material's textures, and dials that move the scene while they drag
- texcomp: the block codec and the container every texture will travel in
- fab: FAB_PROBE_MAT — per-material triangle counts, texture presence and uv spread in the roof probe
2026-08-26 08:49:47 +02:00

350 lines
12 KiB
Rust

//! Getting from A to B: A\* over a storey's walkable lattice, a string pull to
//! throw away the staircase-shaped detail, and a room-order walk that visits
//! every reachable room.
//!
//! The A\* cost is not just distance. Every step is multiplied by a penalty
//! that grows as clearance falls below `prefer` metres, so given two ways
//! across a room the planner takes the open one. A camera that scrapes the
//! furniture is technically collision-free and still looks wrong.
use crate::analysis::{ClearMode, ClearanceField, SiteAnalysis};
use makepad_math::{vec3, Vec3f};
use std::cmp::Reverse;
use std::collections::{BinaryHeap, VecDeque};
const ORTHO: u32 = 100;
const DIAG: u32 = 141;
/// A\* between two cells of one storey. Returns cell indices, or `None` when
/// the two are not connected.
pub fn route_cells(site: &SiteAnalysis, si: usize, from: u32, to: u32) -> Option<Vec<u32>> {
let st = site.storeys.get(si)?;
let (nx, ny) = (st.nx, st.ny);
let n = nx * ny;
if from as usize >= n || to as usize >= n || !st.walkable[from as usize] || !st.walkable[to as usize] {
return None;
}
let prefer = (site.config.body.radius * 2.5).max(0.6);
let step_cost = |i: usize, base: u32| -> u32 {
let c = st.clearance[i];
let tight = ((prefer - c).max(0.0) / prefer).min(1.0);
base + (base as f32 * 2.0 * tight) as u32
};
let (tx, ty) = ((to as usize) % nx, (to as usize) / nx);
let h = |i: usize| -> u32 {
let (x, y) = (i % nx, i / nx);
let dx = (x as i32 - tx as i32).unsigned_abs();
let dy = (y as i32 - ty as i32).unsigned_abs();
let (lo, hi) = (dx.min(dy), dx.max(dy));
lo * DIAG + (hi - lo) * ORTHO
};
let mut g = vec![u32::MAX; n];
let mut parent = vec![u32::MAX; n];
let mut heap: BinaryHeap<Reverse<(u32, u32)>> = BinaryHeap::new();
g[from as usize] = 0;
heap.push(Reverse((h(from as usize), from)));
while let Some(Reverse((_, cur))) = heap.pop() {
let ci = cur as usize;
if cur == to {
let mut path = vec![to];
let mut c = to;
while parent[c as usize] != u32::MAX {
c = parent[c as usize];
path.push(c);
}
path.reverse();
return Some(path);
}
let (x, y) = (ci % nx, ci / nx);
for (dx, dy) in [
(1i32, 0i32),
(-1, 0),
(0, 1),
(0, -1),
(1, 1),
(1, -1),
(-1, 1),
(-1, -1),
] {
let (jx, jy) = (x as i32 + dx, y as i32 + dy);
if jx < 0 || jy < 0 || jx >= nx as i32 || jy >= ny as i32 {
continue;
}
let j = jy as usize * nx + jx as usize;
if !st.walkable[j] {
continue;
}
let diag = dx != 0 && dy != 0;
if diag {
// No squeezing through the gap between two corners.
let a = y * nx + (x as i32 + dx) as usize;
let b = (y as i32 + dy) as usize * nx + x;
if !st.walkable[a] || !st.walkable[b] {
continue;
}
}
let cost = step_cost(j, if diag { DIAG } else { ORTHO });
let ng = g[ci].saturating_add(cost);
if ng < g[j] {
g[j] = ng;
parent[j] = cur;
heap.push(Reverse((ng.saturating_add(h(j)), j as u32)));
}
}
}
None
}
/// Snap a world point to the nearest walkable cell of a storey.
///
/// Nearest **in a straight clear line**, not merely nearest by distance. A
/// point on a stair tread or inside a wall has walkable ground on both sides
/// of that wall, and the plain nearest cell is regularly the one *outside* the
/// building — which then routes the whole leg around the garden, through the
/// façade, and back in.
pub fn cell_near(site: &SiteAnalysis, si: usize, p: Vec3f) -> Option<u32> {
let st = site.storeys.get(si)?;
let (cx, cy, _) = site.grid.cell_of(vec3(p.x, p.y, st.eye_z))?;
let start = st.at(cx, cy);
if st.walkable[start] {
return Some(start as u32);
}
let field = site.clearance(ClearMode::Walk(si));
let from = vec3(p.x, p.y, st.eye_z);
let radius = site.config.body.radius * 0.7;
let max_r = ((3.0 / site.grid.cell).ceil() as i32).max(4);
let mut fallback: Option<(f32, u32)> = None;
for r in 1..=max_r {
let mut visible: Option<(f32, u32)> = None;
for dy in -r..=r {
for dx in -r..=r {
if dx.abs() != r && dy.abs() != r {
continue;
}
let (x, y) = (cx as i32 + dx, cy as i32 + dy);
if x < 0 || y < 0 || x >= st.nx as i32 || y >= st.ny as i32 {
continue;
}
let i = y as usize * st.nx + x as usize;
if !st.walkable[i] {
continue;
}
let d = (dx * dx + dy * dy) as f32;
if fallback.map_or(true, |(bd, _)| d < bd) {
fallback = Some((d, i as u32));
}
let w = site.grid.world_of(x as usize, y as usize, 0);
if field.segment_clear(from, vec3(w.x, w.y, st.eye_z), radius)
&& visible.map_or(true, |(bd, _)| d < bd)
{
visible = Some((d, i as u32));
}
}
}
if let Some((_, i)) = visible {
return Some(i);
}
}
fallback.map(|(_, i)| i)
}
pub fn cell_point(site: &SiteAnalysis, si: usize, cell: u32) -> Vec3f {
let st = &site.storeys[si];
let (x, y) = ((cell as usize) % st.nx, (cell as usize) / st.nx);
let w = site.grid.world_of(x, y, 0);
vec3(w.x, w.y, st.eye_z)
}
/// Full route between two world points on one storey: A\*, then a string pull
/// that keeps only the corners the geometry actually forces.
///
/// The visibility test is [`ClearanceField::segment_clear`] — the same oracle
/// the planner and the QA use, at the same radius. If this function shortens a
/// path across something, the QA would have caught it; it cannot, because they
/// are asking one function.
pub fn route_points(site: &SiteAnalysis, si: usize, from: Vec3f, to: Vec3f) -> Option<Vec<Vec3f>> {
let a = cell_near(site, si, from)?;
let b = cell_near(site, si, to)?;
let cells = route_cells(site, si, a, b)?;
let pts: Vec<Vec3f> = cells.iter().map(|c| cell_point(site, si, *c)).collect();
let field = site.clearance(ClearMode::Walk(si));
Some(string_pull(&field, &pts, site.config.body.radius))
}
/// Greedy furthest-visible waypoint. Keeps the first and last point.
pub fn string_pull(field: &ClearanceField, pts: &[Vec3f], radius: f32) -> Vec<Vec3f> {
if pts.len() < 3 {
return pts.to_vec();
}
let mut out = vec![pts[0]];
let mut i = 0usize;
while i < pts.len() - 1 {
let mut j = pts.len() - 1;
// Never let one straight leg swallow the whole path: long legs make
// the spline bulge away from the geometry that justified them.
let cap = (i + 64).min(pts.len() - 1);
j = j.min(cap);
while j > i + 1 {
if field.segment_clear(pts[i], pts[j], radius) {
break;
}
j -= 1;
}
out.push(pts[j]);
i = j;
}
out
}
/// The **walk** over the room graph, not the visit list: always step to the
/// best-scoring unvisited neighbour, and when there is none, walk back through
/// the rooms in between to the nearest one that still has an unvisited
/// neighbour.
///
/// Consecutive entries are therefore always adjacent — joined by a portal or a
/// stair — and rooms already seen appear again when the route re-treads them,
/// which is what actually happens when you tour a house. Returning only the
/// first visits would put non-adjacent rooms next to each other, and the
/// generator would then draw a straight line between two rooms with no door
/// between them; when they were on different storeys that line went through
/// the floor slab.
///
/// Every interior room reachable from `start` appears at least once.
///
/// The outdoors is a "room" too — the walkable ground around the building is
/// one enormous connected region — and it is excluded here. Wandering into the
/// garden halfway through a house tour is not a shot, it is a wrong turn.
pub fn room_order(site: &SiteAnalysis, start: usize) -> Vec<usize> {
let n = site.rooms.len();
let inside = |r: usize| site.rooms[r].interior || r == start;
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
for p in &site.portals {
if !inside(p.a) || !inside(p.b) {
continue;
}
adj[p.a].push(p.b);
adj[p.b].push(p.a);
}
for s in &site.stairs {
if !inside(s.lower_room) || !inside(s.upper_room) {
continue;
}
adj[s.lower_room].push(s.upper_room);
adj[s.upper_room].push(s.lower_room);
}
let mut seen = vec![false; n];
let mut order = Vec::new();
let mut cur = start;
seen[cur] = true;
order.push(cur);
loop {
// Best unvisited neighbour.
let next = adj[cur]
.iter()
.copied()
.filter(|r| !seen[*r])
.max_by(|a, b| {
site.room_priority(*a)
.partial_cmp(&site.room_priority(*b))
.unwrap_or(std::cmp::Ordering::Equal)
});
if let Some(nx) = next {
seen[nx] = true;
order.push(nx);
cur = nx;
continue;
}
// Nothing next door: BFS to the nearest room that still has an
// unvisited neighbour, and walk the corridor back through it.
let mut prev = vec![usize::MAX; n];
let mut q = VecDeque::new();
let mut mark = vec![false; n];
q.push_back(cur);
mark[cur] = true;
let mut target = None;
while let Some(r) = q.pop_front() {
if !seen[r] {
target = Some(r);
break;
}
for nb in &adj[r] {
if !mark[*nb] {
mark[*nb] = true;
prev[*nb] = r;
q.push_back(*nb);
}
}
}
let Some(t) = target else { break };
// Re-tread the whole way back, listing every room passed through so
// that consecutive entries stay adjacent.
let mut back = vec![t];
let mut c = t;
while prev[c] != usize::MAX {
c = prev[c];
back.push(c);
}
back.reverse();
for r in back.into_iter().skip(1) {
seen[r] = true;
order.push(r);
}
cur = t;
}
order
}
/// Shortest path over the room graph from `from` to `to`, inclusive of both.
/// Consecutive entries are adjacent, so a generator can walk it directly.
pub fn room_path(site: &SiteAnalysis, from: usize, to: usize) -> Vec<usize> {
let n = site.rooms.len();
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
for p in &site.portals {
adj[p.a].push(p.b);
adj[p.b].push(p.a);
}
for s in &site.stairs {
adj[s.lower_room].push(s.upper_room);
adj[s.upper_room].push(s.lower_room);
}
let mut prev = vec![usize::MAX; n];
let mut seen = vec![false; n];
let mut q = VecDeque::new();
seen[from] = true;
q.push_back(from);
while let Some(r) = q.pop_front() {
if r == to {
let mut path = vec![to];
let mut c = to;
while prev[c] != usize::MAX {
c = prev[c];
path.push(c);
}
path.reverse();
return path;
}
for nb in &adj[r] {
if !seen[*nb] {
seen[*nb] = true;
prev[*nb] = r;
q.push_back(*nb);
}
}
}
Vec::new()
}
/// The portal joining two rooms, if they share one.
pub fn portal_between(site: &SiteAnalysis, a: usize, b: usize) -> Option<&crate::analysis::Portal> {
site.portals
.iter()
.find(|p| (p.a == a && p.b == b) || (p.a == b && p.b == a))
}
/// The stair joining two rooms, if they are on different storeys.
pub fn stair_between(site: &SiteAnalysis, a: usize, b: usize) -> Option<&crate::analysis::StairLink> {
site.stairs
.iter()
.find(|s| (s.lower_room == a && s.upper_room == b) || (s.lower_room == b && s.upper_room == a))
}