use std::f32::consts::{FRAC_PI_2, TAU};
use rand_chacha::ChaCha8Rng;
use serde::{Deserialize, Serialize};
use crate::placement::ir::{
Candidate, Capabilities, Constraint, Hardness, Locality, Modality, Outcome, Placement,
PlacementProblem, Predicate, Region, Role, Scope, SolveError,
};
use crate::placement::solver::Solver;
use crate::rng::DetRng;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct MetropolisWeights {
pub iterations: u32,
pub temp_start: f64,
pub temp_end: f64,
pub translate_sigma: f32,
pub rotate_prob: f64,
pub w_overlap: f64,
pub w_bounds: f64,
pub w_wall: f64,
pub w_min_distance: f64,
pub w_facing: f64,
pub w_clearance: f64,
pub w_hard: f64,
pub w_wall_angle: f64,
pub w_group: f64,
pub coherence: f64,
}
pub struct MetropolisSolver {
weights: MetropolisWeights,
wall_inset: f32,
}
impl MetropolisSolver {
pub fn new(weights: MetropolisWeights, wall_inset: f32) -> Self {
Self {
weights,
wall_inset,
}
}
}
type Obj = crate::placement::ir::Footprint;
struct Bounds {
xmin: f32,
xmax: f32,
zmin: f32,
zmax: f32,
}
impl Solver for MetropolisSolver {
fn name(&self) -> &str {
"metropolis"
}
fn handles(&self, role: &Role) -> bool {
matches!(role, Role::Freestanding)
}
fn capabilities(&self) -> Capabilities {
Capabilities {
hardness: Hardness::Both,
locality: Locality::Relational,
cardinality: false,
deterministic: true,
needs_training_data: false,
}
}
fn solve(
&self,
problem: &PlacementProblem,
rng: &mut ChaCha8Rng,
) -> Result<Outcome, SolveError> {
let objs_meta: Vec<&Candidate> = problem.candidates.iter().collect();
let n = objs_meta.len();
if n == 0 {
return Ok(Outcome::Assignment(Vec::new()));
}
let r = problem.region;
let (rx0, rx1, rz0, rz1) = room_world_bounds(r, self.wall_inset);
let mut cur: Vec<Obj> = objs_meta
.iter()
.map(|c| {
let hw = c.footprint[0] * 0.5;
let hd = c.footprint[1] * 0.5;
let yaw = (rng.below(4) as f32) * FRAC_PI_2;
let mut o = Obj {
x: 0.0,
z: 0.0,
yaw,
hw,
hd,
};
let b = obj_bounds(&o, rx0, rx1, rz0, rz1);
o.x = rand_range(rng, b.xmin, b.xmax);
o.z = rand_range(rng, b.zmin, b.zmax);
o
})
.collect();
let mut cur_cost = self.cost(&cur, problem, rx0, rx1, rz0, rz1);
let mut best = cur.clone();
let mut best_cost = cur_cost;
let iters = self.weights.iterations.max(1);
let (t0, t1) = (
self.weights.temp_start.max(1e-6),
self.weights.temp_end.max(1e-6),
);
for step in 0..iters {
let frac = step as f64 / iters as f64;
let temp = t0 * (t1 / t0).powf(frac);
let i = rng.below(n);
let saved = cur[i];
if rng.unit() < self.weights.rotate_prob {
cur[i].yaw = (cur[i].yaw + FRAC_PI_2) % TAU;
} else {
let s = self.weights.translate_sigma;
cur[i].x += rand_range(rng, -s, s);
cur[i].z += rand_range(rng, -s, s);
}
let b = obj_bounds(&cur[i], rx0, rx1, rz0, rz1);
cur[i].x = cur[i].x.clamp(b.xmin, b.xmax);
cur[i].z = cur[i].z.clamp(b.zmin, b.zmax);
let cand_cost = self.cost(&cur, problem, rx0, rx1, rz0, rz1);
let accept =
cand_cost <= cur_cost || rng.unit() < (-(cand_cost - cur_cost) / temp).exp();
if accept {
cur_cost = cand_cost;
if cur_cost < best_cost {
best_cost = cur_cost;
best = cur.clone();
}
} else {
cur[i] = saved; }
}
let placed: Vec<Placement> = best
.iter()
.enumerate()
.map(|(i, o)| Placement {
candidate: i,
pos: [o.x, 0.0, o.z],
yaw: o.yaw,
})
.collect();
let unsatisfied: Vec<_> = problem
.constraints
.iter()
.filter(|c| matches!(c.modality, Modality::Hard))
.filter(|c| self.constraint_cost(c, &best, (rx0, rx1, rz0, rz1)) > 1e-3)
.map(|c| c.id)
.collect();
if unsatisfied.is_empty() {
Ok(Outcome::Ranked(vec![(best_cost, placed)]))
} else {
Ok(Outcome::Partial {
placed,
unsatisfied,
})
}
}
}
impl MetropolisSolver {
fn cost(
&self,
objs: &[Obj],
problem: &PlacementProblem,
rx0: f32,
rx1: f32,
rz0: f32,
rz1: f32,
) -> f64 {
let w = &self.weights;
let mut overlap = 0.0;
let mut bounds = 0.0;
for (i, a) in objs.iter().enumerate() {
bounds += crate::placement::ir::escapes_bounds(a, (rx0, rx1, rz0, rz1));
for b in objs.iter().skip(i + 1) {
overlap += aabb_overlap_area(a, b) as f64;
}
}
let mut constraint_cost = 0.0;
for c in &problem.constraints {
let weight = match c.modality {
Modality::Soft(wt) => wt,
Modality::Hard => w.w_hard,
};
constraint_cost += weight * self.constraint_cost(c, objs, (rx0, rx1, rz0, rz1));
}
w.w_overlap * overlap + w.w_bounds * bounds + constraint_cost
}
fn constraint_cost(&self, c: &Constraint, objs: &[Obj], bounds: (f32, f32, f32, f32)) -> f64 {
let w = &self.weights;
match (&c.scope, &c.predicate) {
(Scope::Pair(i, j), Predicate::MinDistance(min)) => {
if let (Some(a), Some(b)) = (objs.get(*i), objs.get(*j)) {
let d = ((a.x - b.x).powi(2) + (a.z - b.z).powi(2)).sqrt();
w.w_min_distance * over(*min - d)
} else {
0.0
}
}
(Scope::Object(i), Predicate::Facing(j)) => {
if let (Some(a), Some(b)) = (objs.get(*i), objs.get(*j)) {
let dot = crate::placement::ir::facing_cosine((a.x, a.z), a.yaw, (b.x, b.z));
w.w_facing * w.coherence * (1.0 - dot as f64)
} else {
0.0
}
}
(Scope::Object(i), Predicate::Clearance(gap)) => {
let Some(a) = objs.get(*i) else { return 0.0 };
let (ahw, ahd) = a.half_extents();
let mut pen = 0.0;
for (k, b) in objs.iter().enumerate() {
if k == *i {
continue;
}
let (bhw, bhd) = b.half_extents();
let dx = (a.x - b.x).abs() - (ahw + bhw);
let dz = (a.z - b.z).abs() - (ahd + bhd);
let sep = dx.max(dz); pen += over(gap - sep);
}
w.w_clearance * pen
}
(Scope::Object(i), Predicate::Aligned(_feature)) => {
let Some(a) = objs.get(*i) else { return 0.0 };
w.w_facing * a.yaw.sin().abs() as f64
}
(Scope::Object(i), Predicate::AgainstWall) => {
let Some(a) = objs.get(*i) else { return 0.0 };
let (ahw, ahd) = a.half_extents();
let (rx0, rx1, rz0, rz1) = bounds;
let dw = ((a.x - ahw) - rx0).max(0.0); let de = (rx1 - (a.x + ahw)).max(0.0); let dn = ((a.z - ahd) - rz0).max(0.0); let ds = (rz1 - (a.z + ahd)).max(0.0); let d = dw.min(de).min(dn).min(ds);
let mut cost = w.w_wall * d as f64;
if matches!(c.modality, Modality::Hard) {
let (nx, nz) = if dw <= de && dw <= dn && dw <= ds {
(1.0, 0.0)
} else if de <= dn && de <= ds {
(-1.0, 0.0)
} else if dn <= ds {
(0.0, 1.0)
} else {
(0.0, -1.0)
};
let (fx, fz) = (a.yaw.sin(), a.yaw.cos());
let align = 1.0 - (fx * nx + fz * nz); cost += w.w_wall_angle * align as f64;
}
cost
}
(Scope::Pair(i, j), Predicate::Near(max)) => {
if let (Some(a), Some(b)) = (objs.get(*i), objs.get(*j)) {
let d = ((a.x - b.x).powi(2) + (a.z - b.z).powi(2)).sqrt();
w.w_group * over(d - max)
} else {
0.0
}
}
_ => 0.0,
}
}
}
#[inline]
fn over(x: f32) -> f64 {
x.max(0.0) as f64
}
#[inline]
fn rand_range(rng: &mut ChaCha8Rng, lo: f32, hi: f32) -> f32 {
if hi <= lo {
return lo;
}
lo + (rng.unit() as f32) * (hi - lo)
}
fn aabb_overlap_area(a: &Obj, b: &Obj) -> f32 {
crate::placement::ir::overlap_area(a, b)
}
fn room_world_bounds(r: &Region, wall_inset: f32) -> (f32, f32, f32, f32) {
let rx0 = r.rect.min[0] as f32 - 0.5 + wall_inset;
let rx1 = (r.rect.max[0] - 1) as f32 + 0.5 - wall_inset;
let rz0 = r.rect.min[1] as f32 - 0.5 + wall_inset;
let rz1 = (r.rect.max[1] - 1) as f32 + 0.5 - wall_inset;
(rx0, rx1, rz0, rz1)
}
fn obj_bounds(o: &Obj, rx0: f32, rx1: f32, rz0: f32, rz1: f32) -> Bounds {
let (hw, hd) = o.half_extents();
Bounds {
xmin: rx0 + hw,
xmax: (rx1 - hw).max(rx0 + hw),
zmin: rz0 + hd,
zmax: (rz1 - hd).max(rz0 + hd),
}
}
#[cfg(test)]
const PINNED_LAYOUT_SEED_11: &[(u32, u32)] =
&[(1081317365, 1074081542), (1074665548, 1063516872)];
#[cfg(test)]
mod tests {
use super::*;
const TEST_WALL_INSET: f32 = 0.14;
use crate::placement::ir::{Dof, PropertyBag, Rect2, Role};
use crate::rng::seeded;
fn weights() -> MetropolisWeights {
MetropolisWeights {
iterations: 3000,
temp_start: 1.0,
temp_end: 0.02,
translate_sigma: 0.6,
rotate_prob: 0.35,
w_overlap: 10.0,
w_bounds: 25.0,
w_wall: 1.2,
w_min_distance: 2.0,
w_facing: 1.5,
w_clearance: 2.0,
w_hard: 12.0,
w_wall_angle: 1.0,
w_group: 1.5,
coherence: 0.3,
}
}
fn region() -> Region {
Region {
id: 0,
rect: Rect2 {
min: [0, 0],
max: [5, 5],
}, openings: Vec::new(),
adjacency: Vec::new(),
props: PropertyBag::default(),
}
}
fn item(w: f32, d: f32) -> Candidate {
Candidate {
asset: "x".into(),
role: Role::Freestanding,
footprint: [w, d],
dof: Dof {
translate: true,
rotate_quarter: true,
rotate_free: false,
},
affordances: Vec::new(),
}
}
#[test]
fn keeps_objects_inside_and_non_overlapping() {
let r = region();
let problem = PlacementProblem {
region: &r,
candidates: vec![item(1.6, 0.7), item(2.0, 0.9), item(0.9, 0.6)].into(),
constraints: Vec::new(),
};
let mut rng = seeded(3);
let solver = MetropolisSolver::new(weights(), TEST_WALL_INSET);
let out = solver.solve(&problem, &mut rng).expect("solve");
let placed = match out {
Outcome::Ranked(mut v) => v.remove(0).1,
Outcome::Assignment(p) => p,
Outcome::Partial { placed, .. } => placed,
};
assert_eq!(placed.len(), 3);
let (rx0, rx1, rz0, rz1) = room_world_bounds(&r, TEST_WALL_INSET);
let objs: Vec<Obj> = placed
.iter()
.map(|p| {
let c = &problem.candidates[p.candidate];
Obj {
x: p.pos[0],
z: p.pos[2],
yaw: p.yaw,
hw: c.footprint[0] * 0.5,
hd: c.footprint[1] * 0.5,
}
})
.collect();
for o in &objs {
let (hw, hd) = o.half_extents();
assert!(
o.x - hw >= rx0 - 0.05 && o.x + hw <= rx1 + 0.05,
"x out of bounds"
);
assert!(
o.z - hd >= rz0 - 0.05 && o.z + hd <= rz1 + 0.05,
"z out of bounds"
);
}
let mut total = 0.0;
for (i, a) in objs.iter().enumerate() {
for b in objs.iter().skip(i + 1) {
total += aabb_overlap_area(a, b);
}
}
assert!(total < 0.25, "residual overlap too high: {total}");
}
#[test]
fn hard_against_wall_seats_a_fixture_flush() {
let r = region(); let problem = PlacementProblem {
region: &r,
candidates: vec![item(0.4, 0.6)].into(), constraints: vec![Constraint {
id: 0,
scope: Scope::Object(0),
predicate: Predicate::AgainstWall,
modality: Modality::Hard,
guard: None,
}],
};
let solver = MetropolisSolver::new(weights(), TEST_WALL_INSET);
for seed in [1u64, 7, 42] {
let mut rng = seeded(seed);
let placed = match solver.solve(&problem, &mut rng).expect("solve") {
Outcome::Ranked(mut v) => v.remove(0).1,
Outcome::Assignment(p) => p,
Outcome::Partial { placed, .. } => placed,
};
let p = placed[0];
let o = Obj {
x: p.pos[0],
z: p.pos[2],
yaw: p.yaw,
hw: 0.2,
hd: 0.3,
};
let (hw, hd) = o.half_extents();
let (rx0, rx1, rz0, rz1) = room_world_bounds(&r, TEST_WALL_INSET);
let d = ((o.x - hw) - rx0)
.min(rx1 - (o.x + hw))
.min((o.z - hd) - rz0)
.min(rz1 - (o.z + hd))
.max(0.0);
assert!(
d < 0.12,
"seed {seed}: fixture should sit flush to a wall, got d={d}"
);
}
}
#[test]
fn deterministic_under_seed() {
let r = region();
let problem = PlacementProblem {
region: &r,
candidates: vec![item(1.6, 0.7), item(0.9, 0.6)].into(),
constraints: Vec::new(),
};
let solver = MetropolisSolver::new(weights(), TEST_WALL_INSET);
let run = || {
let mut rng = seeded(11);
match solver.solve(&problem, &mut rng).expect("solve") {
Outcome::Ranked(v) => v[0]
.1
.iter()
.map(|p| (p.pos[0].to_bits(), p.pos[2].to_bits()))
.collect::<Vec<_>>(),
_ => panic!("expected ranked"),
}
};
assert_eq!(run(), run());
}
#[test]
fn the_solved_layout_is_pinned_for_a_fixed_seed() {
let r = region();
let problem = PlacementProblem {
region: &r,
candidates: vec![item(1.6, 0.7), item(0.9, 0.6)].into(),
constraints: Vec::new(),
};
let solver = MetropolisSolver::new(weights(), TEST_WALL_INSET);
let mut rng = seeded(11);
let got: Vec<(u32, u32)> = match solver.solve(&problem, &mut rng).expect("solve") {
Outcome::Ranked(v) => v[0]
.1
.iter()
.map(|p| (p.pos[0].to_bits(), p.pos[2].to_bits()))
.collect(),
_ => panic!("expected ranked"),
};
assert_eq!(
got, PINNED_LAYOUT_SEED_11,
"the solver no longer lands where it did — the dungeon now furnishes differently for \
EVERY seed. Deliberate? Re-pin and say so. Not deliberate? Something moved in the cost \
function; `escapes_bounds`/`overlap_area` in `placement::ir` are the shared geometry."
);
}
}