use std::borrow::Cow;
use crate::heightfield::Heightfield;
use crate::descriptor::{placed_footprint, placed_height, Descriptor, Mount, DecalHost};
use crate::library::Library;
use crate::map::{Map, Placed};
use crate::volume::Volume;
pub fn descriptor_of<'a>(library: &'a Library, p: &Placed) -> Option<Cow<'a, Descriptor>> {
descriptor_with(library, &p.descriptor, p.patch.as_ref())
}
pub fn descriptor_with<'a>(
library: &'a Library,
id: &str,
patch: Option<&Descriptor>,
) -> Option<Cow<'a, Descriptor>> {
let base = library.get(id)?;
Some(match patch {
Some(patch) => Cow::Owned(base.patched_with(patch)),
None => Cow::Borrowed(base),
})
}
pub fn needs_surface(d: &Descriptor) -> Option<&str> {
match &d.mount {
Some(Mount::OnSurface { class }) => Some(class.as_str()),
_ => None,
}
}
pub fn may_take_surface(d: &Descriptor) -> Option<&str> {
match &d.mount {
Some(Mount::OnSurface { class } | Mount::OnSurfaceOrFloor { class }) => Some(class.as_str()),
_ => None,
}
}
pub fn needs_face(d: &Descriptor) -> Option<(&str, f32)> {
match &d.mount {
Some(Mount::OnFace { class, height }) => Some((class.as_str(), *height)),
_ => None,
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum HostNeed<'a> {
Surface(&'a str),
Face(&'a str, f32),
}
pub fn host_need<'a>(d: &'a Descriptor, on: Option<&str>) -> Option<HostNeed<'a>> {
match &d.mount {
Some(Mount::OnSurface { class }) => Some(HostNeed::Surface(class)),
Some(Mount::OnSurfaceOrFloor { class }) => {
on.map(|_| HostNeed::Surface(class))
}
Some(Mount::OnFace { class, height }) => Some(HostNeed::Face(class, *height)),
_ => None,
}
}
pub fn offers_for(host: &Descriptor, guest: &Descriptor) -> bool {
if let Some(class) = may_take_surface(guest) {
return host.offers.surfaces.iter().any(|s| s == class);
}
if let Some((class, _)) = needs_face(guest) {
return host.offers.faces.iter().any(|s| s == class);
}
false
}
pub fn covers(d: &Descriptor, placed_at: (f32, f32), yaw: f32, probe: (f32, f32)) -> bool {
let Some((w, depth)) = placed_footprint(d) else {
return false;
};
let (dx, dz) = (probe.0 - placed_at.0, probe.1 - placed_at.1);
let (s, c) = (-yaw).to_radians().sin_cos();
let local_x = dx * c + dz * s;
let local_z = -dx * s + dz * c;
local_x.abs() <= w * 0.5 && local_z.abs() <= depth * 0.5
}
pub fn host_under<'a>(
map: &'a Map,
library: &Library,
y: &[f32],
guest: &Descriptor,
probe: (f32, f32),
) -> Option<(&'a Placed, f32)> {
may_take_surface(guest)?;
let mut best: Option<(&Placed, f32)> = None;
for (i, p) in map.placements.iter().enumerate() {
let (Some(d), Some(&py)) = (descriptor_of(library, p), y.get(i)) else {
continue;
};
let d: &Descriptor = &d;
if p.tip != (0, 0) || !offers_for(d, guest) || !covers(d, p.at, p.yaw, probe) {
continue;
}
let Some(top) = placed_height(d).map(|h| py + h) else {
continue;
};
let better = match best {
None => true,
Some((bp, by)) => (top, p.id.as_str()) > (by, bp.id.as_str()),
};
if better {
best = Some((p, top));
}
}
best
}
pub fn resolve_y(map: &Map, library: &Library) -> Result<Vec<f32>, String> {
resolve_y_on(map, library, None)
}
pub fn resolve_y_on(map: &Map, library: &Library, ground: Option<&Heightfield>) -> Result<Vec<f32>, String> {
resolve_y_in(map, library, ground, None)
}
pub fn resolve_y_in(map: &Map, library: &Library, ground: Option<&Heightfield>, volume: Option<&Volume>) -> Result<Vec<f32>, String> {
let mut out = vec![f32::NAN; map.placements.len()];
let mut done = vec![false; map.placements.len()];
for i in 0..map.placements.len() {
resolve_one(map, library, (ground, volume), i, &mut out, &mut done, &mut Vec::new())?;
}
Ok(out)
}
fn resolve_one(
map: &Map,
library: &Library,
(ground, volume): (Option<&Heightfield>, Option<&Volume>),
i: usize,
out: &mut [f32],
done: &mut [bool],
path: &mut Vec<usize>,
) -> Result<f32, String> {
if done[i] {
return Ok(out[i]);
}
if path.contains(&i) {
return Err(format!(
"map: placement `{}` rests on itself through a loop — nothing in the chain reaches the \
floor",
map.placements[i].id
));
}
let p = &map.placements[i];
let d_owned = descriptor_of(library, p).ok_or_else(|| {
format!(
"map: placement `{}` names descriptor `{}`, which this library does not define",
p.id, p.descriptor
)
})?;
let d: &Descriptor = &d_owned;
let host_top = match host_need(d, p.on.as_deref()) {
Some(need) => {
let (class, what, fix) = match need {
HostNeed::Surface(class) => (
class,
"mounts on a",
"Placing it at floor level is how a lamp ends up inside the floor — put it on a \
piece that offers `{class}`, or change the descriptor's layer",
),
HostNeed::Face(class, _) => (
class,
"is fixed to a",
"put it on a piece that offers the `{class}` face, or change the descriptor's \
layer",
),
};
let host_id = p.on.as_ref().ok_or_else(|| {
format!(
"map: placement `{}` {what} `{class}` and records nothing under it. {}",
p.id,
fix.replace("{class}", class)
)
})?;
let hi = map
.placements
.iter()
.position(|q| &q.id == host_id)
.ok_or_else(|| {
format!(
"map: placement `{}` rests on `{host_id}`, which does not exist",
p.id
)
})?;
let host = &map.placements[hi];
let host_d_owned = descriptor_of(library, host).ok_or_else(|| {
format!(
"map: host `{}` names descriptor `{}`, which this library does not define",
host.id, host.descriptor
)
})?;
let host_d: &Descriptor = &host_d_owned;
let lift = match need {
HostNeed::Face(_, height) => {
if !offers_for(host_d, d) {
return Err(face_refusal(host_d, d, &host.id, &p.id));
}
height
}
HostNeed::Surface(_) => surface_of(host_d, d, &host.id, &p.id)?,
};
path.push(i);
let host_y = resolve_one(map, library, (ground, volume), hi, out, done, path)?;
path.pop();
Some(host_y + lift)
}
None => None,
};
let base_y = datum(map, d, host_top, &p.id)?;
let mut y = base_y + p.lift;
let on_ground = matches!(&d.mount, None | Some(Mount::OnFloor) | Some(Mount::Tiled) | Some(Mount::OnSurfaceOrFloor { .. }));
if let (Some(g), None, true) = (ground, host_top, on_ground) {
let surface = base_y + g.height_at(p.at.0, p.at.1);
let cave = volume
.filter(|_| y < surface)
.and_then(|v| v.floor_below(p.at.0, p.at.1, y, surface - y, &|q| q[1] - (base_y + g.height_at(q[0], q[2]))));
match cave {
Some(floor) => y = floor,
None => y += rest_on(g, d, p.at, p.yaw),
}
}
out[i] = y;
done[i] = true;
Ok(out[i])
}
fn rest_on(g: &Heightfield, d: &Descriptor, at: (f32, f32), yaw: f32) -> f32 {
let Some((w, depth)) = placed_footprint(d) else {
return g.height_at(at.0, at.1);
};
let (s, c) = (-yaw).to_radians().sin_cos();
let corners: Vec<(f32, f32)> = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
.iter()
.map(|&(sx, sz)| {
let (lx, lz) = (sx * w * 0.5, sz * depth * 0.5);
(at.0 + c * lx - s * lz, at.1 + s * lx + c * lz)
})
.collect();
g.rest_height(&corners)
}
fn face_refusal(host_d: &Descriptor, guest: &Descriptor, host_id: &str, guest_id: &str) -> String {
let class = needs_face(guest).map(|(c, _)| c).unwrap_or("");
format!(
"map: `{guest_id}` is fixed to a `{class}` face and rests on `{host_id}`, which presents {}. \
A piece that does not present the face cannot carry it.",
if host_d.offers.faces.is_empty() {
"none".to_owned()
} else {
host_d.offers.faces.join(", ")
}
)
}
fn surface_of(host_d: &Descriptor, guest: &Descriptor, host_id: &str, guest_id: &str) -> Result<f32, String> {
let class = needs_surface(guest).unwrap_or("");
if !offers_for(host_d, guest) {
return Err(format!(
"map: `{guest_id}` needs a `{class}` surface and rests on `{host_id}`, which offers {}. \
A piece that does not offer the class cannot hold it up.",
if host_d.offers.surfaces.is_empty() {
"none".to_owned()
} else {
host_d.offers.surfaces.join(", ")
}
));
}
surface_lift(host_d).ok_or_else(|| {
format!(
"map: `{guest_id}` rests on `{host_id}`, whose descriptor records no height. An \
unmeasured piece cannot say where its surface is — measure `{}` in the tiles tab.",
host_d.id
)
})
}
pub fn surface_lift(host_d: &Descriptor) -> Option<f32> {
placed_height(host_d).map(|h| h - host_d.align.y_offset.unwrap_or(0.0))
}
pub fn datum(
map: &Map,
d: &Descriptor,
host_top: Option<f32>,
who: &str,
) -> Result<f32, String> {
let base = match &d.mount {
None | Some(Mount::OnFloor) | Some(Mount::Tiled) | Some(Mount::InOpening { .. }) => {
map.origin.1
}
Some(Mount::OnWall { height }) => map.origin.1 + height,
Some(Mount::OnCeiling) => map.origin.1 + map.bounds.1,
Some(Mount::OnSurface { class }) => host_top.ok_or_else(|| {
format!("`{who}` needs a `{class}` surface and there is none under it")
})?,
Some(Mount::OnSurfaceOrFloor { .. }) => host_top.unwrap_or(map.origin.1),
Some(Mount::OnFace { class, .. }) => host_top.ok_or_else(|| {
format!("`{who}` is fixed to a `{class}` face and there is nothing presenting one")
})?,
Some(Mount::Decal { on }) => match on {
DecalHost::Floor => map.origin.1,
DecalHost::Ceiling => map.origin.1 + map.bounds.1,
DecalHost::Wall { height } => map.origin.1 + height,
},
};
Ok(base + d.align.y_offset.unwrap_or(0.0))
}
pub fn placement_at<'a>(
map: &'a Map,
library: &Library,
y: &[f32],
d: &Descriptor,
probe: (f32, f32),
) -> Result<(f32, Option<&'a Placed>), String> {
let host = host_under(map, library, y, d, probe);
if needs_surface(d).is_some() && host.is_none() {
let class = needs_surface(d).unwrap_or("");
return Err(no_surface_sentence(d, class));
}
let at = datum(map, d, host.map(|(_, top)| top), &d.id)?;
Ok((at, host.map(|(p, _)| p)))
}
pub fn no_surface_sentence(d: &Descriptor, class: &str) -> String {
format!(
"`{}` goes on a `{class}` surface — there is none here. Put it on something that offers \
one.",
d.id
)
}
pub const OVERLAP_EPS: f32 = 0.01;
type PlanRect = ((f32, f32), [(f32, f32); 2], (f32, f32));
fn plan_box(d: &Descriptor, at: (f32, f32), yaw: f32, tip: (u8, u8)) -> Option<PlanRect> {
let (w, _h, depth) = crate::descriptor::tipped_extents(d, tip)?;
let (s, c) = yaw.to_radians().sin_cos();
Some((at, [(c, -s), (s, c)], (w * 0.5, depth * 0.5)))
}
fn plans_overlap(
a: PlanRect,
b: PlanRect,
) -> bool {
let (ac, aa, ah) = a;
let (bc, ba, bh) = b;
let d = (bc.0 - ac.0, bc.1 - ac.1);
let dot = |u: (f32, f32), v: (f32, f32)| u.0 * v.0 + u.1 * v.1;
for n in [aa[0], aa[1], ba[0], ba[1]] {
let dist = dot(d, n).abs();
let ra = ah.0 * dot(aa[0], n).abs() + ah.1 * dot(aa[1], n).abs();
let rb = bh.0 * dot(ba[0], n).abs() + bh.1 * dot(ba[1], n).abs();
if dist >= ra + rb - OVERLAP_EPS {
return false;
}
}
true
}
fn same_layer(a: &Descriptor, a_on: Option<&str>, b: &Descriptor, b_on: Option<&str>) -> bool {
use Mount::*;
match (a.mount.as_ref(), b.mount.as_ref()) {
(None | Some(OnFloor), None | Some(OnFloor)) => true,
(Some(Tiled), Some(Tiled)) => true,
(Some(OnCeiling), Some(OnCeiling)) => true,
(Some(InOpening { .. }), Some(InOpening { .. })) => true,
(Some(OnWall { height: h1 }), Some(OnWall { height: h2 })) => (h1 - h2).abs() < 1e-3,
(
Some(OnSurface { .. } | OnSurfaceOrFloor { .. }),
Some(OnSurface { .. } | OnSurfaceOrFloor { .. }),
) => match (a_on, b_on) {
(Some(x), Some(y)) => x == y,
(None, None) => {
matches!(a.mount, Some(OnSurfaceOrFloor { .. }))
&& matches!(b.mount, Some(OnSurfaceOrFloor { .. }))
}
_ => false,
},
(Some(OnFloor) | None, Some(OnSurfaceOrFloor { .. }))
| (Some(OnSurfaceOrFloor { .. }), Some(OnFloor) | None) => a_on.is_none() && b_on.is_none(),
(
Some(OnFace { class: c1, height: h1 }),
Some(OnFace { class: c2, height: h2 }),
) => c1 == c2 && (h1 - h2).abs() < 1e-3 && matches!((a_on, b_on), (Some(x), Some(y)) if x == y),
_ => false,
}
}
pub fn blocking<'a>(
map: &'a Map,
library: &Library,
d: &Descriptor,
at: (f32, f32),
yaw: f32,
tip: (u8, u8),
on: Option<&str>,
) -> Option<&'a Placed> {
blocker_indices(map, library, d, at, yaw, tip, on)
.next()
.and_then(|i| map.placements.get(i))
}
pub fn blockers(
map: &Map,
library: &Library,
d: &Descriptor,
at: (f32, f32),
yaw: f32,
tip: (u8, u8),
on: Option<&str>,
) -> Vec<usize> {
blocker_indices(map, library, d, at, yaw, tip, on).collect()
}
pub fn overlaps(map: &Map, library: &Library) -> Vec<(usize, usize)> {
let resolved: Vec<Option<(std::borrow::Cow<'_, Descriptor>, PlanRect)>> = map
.placements
.iter()
.map(|p| {
let d = descriptor_of(library, p)?;
let plan = plan_box(&d, p.at, p.yaw, p.tip)?;
Some((d, plan))
})
.collect();
let mut out = Vec::new();
for (i, left) in resolved.iter().enumerate() {
let Some((di, bi)) = left else { continue };
for (j, right) in resolved.iter().enumerate().skip(i + 1) {
let Some((dj, bj)) = right else { continue };
let (pi, pj) = (&map.placements[i], &map.placements[j]);
if same_layer(di, pi.on.as_deref(), dj, pj.on.as_deref()) && plans_overlap(*bi, *bj) {
out.push((i, j));
}
}
}
out
}
fn blocker_indices<'a>(
map: &'a Map,
library: &'a Library,
d: &'a Descriptor,
at: (f32, f32),
yaw: f32,
tip: (u8, u8),
on: Option<&'a str>,
) -> impl Iterator<Item = usize> + 'a {
let guest = plan_box(d, at, yaw, tip);
map.placements
.iter()
.enumerate()
.filter(move |(_, p)| {
let Some(guest) = guest else {
return false;
};
let Some(pd) = descriptor_of(library, p) else {
return false;
};
let pd: &Descriptor = &pd;
if !same_layer(d, on, pd, p.on.as_deref()) {
return false;
}
match plan_box(pd, p.at, p.yaw, p.tip) {
Some(b) => plans_overlap(guest, b),
None => false,
}
})
.map(|(i, _)| i)
}
struct Body<'a> {
descriptor: Cow<'a, Descriptor>,
plan: PlanRect,
span: (f32, f32),
front: f32,
}
fn bodies<'a>(map: &'a Map, library: &'a Library) -> Option<Vec<Option<Body<'a>>>> {
let bases = resolve_y(map, library).ok()?;
Some(
map.placements
.iter()
.enumerate()
.map(|(i, p)| {
let d = descriptor_of(library, p)?;
let plan = plan_box(&d, p.at, p.yaw, p.tip)?;
let (_, h, _) = crate::descriptor::tipped_extents(&d, p.tip)?;
let base = bases[i];
let front = p.yaw + d.align.front.map(|f| f.yaw_degrees()).unwrap_or(0.0);
Some(Body { descriptor: d, plan, span: (base, base + h), front })
})
.collect(),
)
}
pub fn blocked_openings(map: &Map, library: &Library) -> Vec<(usize, usize)> {
let Some(bodies) = bodies(map, library) else {
return Vec::new();
};
let mut out = Vec::new();
for (i, opening) in bodies.iter().enumerate() {
let Some(Body { descriptor, plan, span, .. }) = opening else { continue };
let Some((width, height)) = opening_hole(descriptor) else { continue };
let hole = hole_rect(*plan, width);
let hole_span = (span.0, span.0 + height);
for (j, other) in bodies.iter().enumerate() {
if i == j {
continue;
}
let Some(Body { descriptor: od, plan: other_plan, span: other_span, .. }) = other else {
continue;
};
if matches!(od.mount, Some(Mount::InOpening { .. })) {
continue;
}
if map.placements[j].on.as_deref() == Some(map.placements[i].id.as_str()) {
continue;
}
if !plans_overlap(hole, *other_plan) {
continue;
}
if other_span.1 <= hole_span.0 + OVERLAP_EPS
|| other_span.0 >= hole_span.1 - OVERLAP_EPS
{
continue;
}
out.push((i, j));
}
}
out
}
pub fn blocked_clearances(map: &Map, library: &Library) -> Vec<(usize, usize)> {
use crate::descriptor::Dir;
let Some(bodies) = bodies(map, library) else {
return Vec::new();
};
let reserves = |d: &Descriptor| !d.clearance.is_empty();
let mut out: Vec<(usize, usize)> = Vec::new();
for (i, keeper) in bodies.iter().enumerate() {
let Some(Body { descriptor, plan, span, front }) = keeper else { continue };
if descriptor.clearance.is_empty() {
continue;
}
for clear in &descriptor.clearance {
if !(clear.dist.is_finite() && clear.dist > 0.0) {
continue;
}
let sides: &[(f32, f32, f32)] = match clear.dir {
Dir::Front => &[(0.0, clear.dist, 0.0)],
Dir::Back => &[(180.0, clear.dist, 0.0)],
Dir::Left => &[(90.0, clear.dist, 0.0)],
Dir::Right => &[(270.0, clear.dist, 0.0)],
Dir::Around => &[
(0.0, clear.dist, clear.dist),
(90.0, clear.dist, clear.dist),
(180.0, clear.dist, clear.dist),
(270.0, clear.dist, clear.dist),
],
};
for &(offset, dist, widen) in sides {
let band = clearance_band(*plan, *front, offset, dist, widen);
for (j, other) in bodies.iter().enumerate() {
if i == j {
continue;
}
let Some(Body { descriptor: od, plan: other_plan, span: other_span, .. }) = other
else {
continue;
};
if reserves(od)
|| matches!(od.mount, Some(Mount::Tiled) | Some(Mount::Decal { .. }))
{
continue;
}
if map.placements[j].on.as_deref() == Some(map.placements[i].id.as_str()) {
continue;
}
if other_span.1 <= span.0 + OVERLAP_EPS
|| other_span.0 >= span.1 - OVERLAP_EPS
{
continue;
}
if plans_overlap(band, *other_plan) {
out.push((i, j));
}
}
}
}
}
out.sort_unstable();
out.dedup();
out
}
fn clearance_band(plan: PlanRect, front: f32, offset: f32, dist: f32, widen: f32) -> PlanRect {
let (centre, axes, half) = plan;
let rad = (front + offset).to_radians();
let (s, c) = rad.sin_cos();
let u = (s, c);
let across = (c, -s);
let dot = |a: (f32, f32), b: (f32, f32)| a.0 * b.0 + a.1 * b.1;
let reach = |n: (f32, f32)| half.0 * dot(axes[0], n).abs() + half.1 * dot(axes[1], n).abs();
let out = reach(u) + dist * 0.5;
let centre = (centre.0 + u.0 * out, centre.1 + u.1 * out);
(centre, [across, u], (reach(across) + widen, dist * 0.5))
}
fn opening_hole(d: &Descriptor) -> Option<(f32, f32)> {
match &d.mount {
Some(Mount::InOpening { clear: Some((w, h)) }) if w.is_finite() && h.is_finite() => {
Some((*w, *h))
}
_ => None,
}
}
fn hole_rect(door: PlanRect, width: f32) -> PlanRect {
let (centre, axes, half) = door;
(centre, axes, (half.0, width * 0.5))
}
#[derive(Clone, Debug, PartialEq)]
pub struct Moved {
pub was: Vec<(usize, (f32, f32), Option<String>)>,
}
pub fn move_placement(
map: &mut Map,
library: &Library,
index: usize,
to: (f32, f32),
) -> Result<Moved, String> {
let anchor = map.placements.get(index).ok_or_else(|| {
format!(
"move: no placement at index {index}; the map has {}",
map.placements.len()
)
})?;
let from = anchor.at;
let delta = (to.0 - from.0, to.1 - from.1);
let group = group_of(map, index);
let mut next = map.clone();
for &i in &group {
let p = &mut next.placements[i];
p.at = (p.at.0 + delta.0, p.at.1 + delta.1);
}
let anchor_d_owned = descriptor_of(library, &next.placements[index]).ok_or_else(|| {
format!(
"move: `{}` names descriptor `{}`, which the library does not have",
next.placements[index].id, next.placements[index].descriptor
)
})?;
let anchor_d: &Descriptor = &anchor_d_owned;
let mut probe = next.clone();
let mut drop_order = group.clone();
drop_order.sort_unstable();
for &i in drop_order.iter().rev() {
probe.placements.remove(i);
}
let probe_ys = resolve_y(&probe, library)?;
let (_, host) = placement_at(
&probe,
library,
&probe_ys,
anchor_d,
next.placements[index].at,
)?;
let anchor_on = host.map(|h| h.id.clone());
let mut was = Vec::with_capacity(group.len());
for &i in &group {
was.push((i, map.placements[i].at, map.placements[i].on.clone()));
map.placements[i].at = next.placements[i].at;
if i == index {
map.placements[i].on = anchor_on.clone();
}
}
Ok(Moved { was })
}
pub fn group_of(map: &Map, index: usize) -> Vec<usize> {
if index >= map.placements.len() {
return Vec::new();
}
let mut group = vec![index];
let mut seen: Vec<usize> = vec![index];
let mut frontier = vec![index];
while let Some(host) = frontier.pop() {
let host_id = map.placements[host].id.clone();
for (i, p) in map.placements.iter().enumerate() {
if seen.contains(&i) || p.on.as_deref() != Some(host_id.as_str()) {
continue;
}
seen.push(i);
group.push(i);
frontier.push(i);
}
}
group.sort_by(|a, b| map.placements[*a].id.cmp(&map.placements[*b].id));
group
}
pub fn restore_moved(map: &mut Map, moved: &Moved) {
for (i, at, on) in &moved.was {
if let Some(p) = map.placements.get_mut(*i) {
p.at = *at;
p.on = on.clone();
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn every_mount_variant_contests_itself_or_says_why_not() {
use crate::descriptor::{Extent, Mount, DecalHost};
let every = [
Mount::OnFloor,
Mount::OnWall { height: 1.8 },
Mount::OnCeiling,
Mount::InOpening { clear: None },
Mount::OnSurface { class: "worktop".to_owned() },
Mount::OnSurfaceOrFloor { class: "worktop".to_owned() },
Mount::Decal { on: DecalHost::Floor },
Mount::Tiled,
Mount::OnFace { class: "wall-inner".to_owned(), height: 1.8 },
];
for mount in every {
let (exempt, why) = match &mount {
Mount::Decal { .. } => (
true,
"claims no volume — two decals may share a wall, which is the point of them",
),
Mount::OnFloor
| Mount::OnWall { .. }
| Mount::OnCeiling
| Mount::InOpening { .. }
| Mount::OnSurface { .. }
| Mount::OnSurfaceOrFloor { .. }
| Mount::OnFace { .. }
| Mount::Tiled => (false, ""),
};
let d = Descriptor {
id: "probe".to_owned(),
extent: Extent { footprint: Some((1.0, 1.0)), height: Some(1.0) },
mount: Some(mount.clone()),
..Descriptor::default()
};
let host = matches!(mount, Mount::OnSurface { .. } | Mount::OnFace { .. })
.then_some("table@1");
let contests = same_layer(&d, host, &d, host);
if exempt {
assert!(
!contests,
"`{mount:?}` is exempt because it {why}, but it contests itself"
);
} else {
assert!(
contests,
"`{mount:?}` does not contest another of itself, so two of them may occupy one \
space and nothing will refuse it. Add an arm to `same_layer`, or exempt it here \
with a reason."
);
}
}
}
use super::*;
use crate::descriptor::{Align, Extent, Offers};
use crate::library::LIBRARY_VERSION;
use crate::map::Placed;
fn table() -> Descriptor {
Descriptor {
id: "table".into(),
extent: Extent {
footprint: Some((1.6, 0.8)),
height: Some(0.8),
},
offers: Offers {
surfaces: vec!["worktop".into()],
faces: Vec::new(),
sockets: Vec::new(),
},
mount: Some(Mount::OnFloor),
..Descriptor::default()
}
}
fn lamp() -> Descriptor {
Descriptor {
id: "lamp".into(),
extent: Extent {
footprint: Some((0.3, 0.3)),
height: Some(0.5),
},
mount: Some(Mount::OnSurface {
class: "worktop".into(),
}),
..Descriptor::default()
}
}
fn lib(descriptors: Vec<Descriptor>) -> Library {
Library {
version: LIBRARY_VERSION,
note: None,
descriptors,
}
}
fn at(id: &str, descriptor: &str, on: Option<&str>) -> Placed {
Placed {
id: id.into(),
descriptor: descriptor.into(),
at: (0.0, 0.0),
on: on.map(str::to_owned),
..Placed::default()
}
}
fn map(placements: Vec<Placed>) -> Map {
Map {
name: "test_map".into(),
placements,
..Map::default()
}
}
fn stacked() -> (Map, Library) {
let mut m = map(vec![
at("t1", "table", None),
at("l1", "lamp", Some("t1")),
at("m1", "lamp", Some("t1")),
at("t2", "table", None),
]);
m.placements[3].at = (10.0, 10.0);
m.placements[1].at = (0.4, 0.2);
m.placements[2].at = (-0.4, -0.2);
(m, lib(vec![table(), lamp()]))
}
#[test]
fn moving_a_host_carries_everything_resting_on_it() {
let (mut m, l) = stacked();
let moved = move_placement(&mut m, &l, 0, (3.0, 0.0)).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(m.placements[0].at, (3.0, 0.0), "the table went where it was sent");
assert_eq!(m.placements[1].at, (3.4, 0.2));
assert_eq!(m.placements[2].at, (2.6, -0.2));
assert_eq!(m.placements[1].on.as_deref(), Some("t1"));
assert_eq!(m.placements[2].on.as_deref(), Some("t1"));
let y = resolve_y(&m, &l).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(y[1], 0.8, "the lamp is still on the tabletop");
assert_eq!(m.placements[3].at, (10.0, 10.0));
assert_eq!(moved.was.len(), 3, "table plus two riders: {:?}", moved.was);
}
#[test]
fn a_move_that_cannot_be_seated_changes_nothing() {
let (mut m, l) = stacked();
let before = m.clone();
let err = move_placement(&mut m, &l, 1, (40.0, 40.0))
.expect_err("a piece with nowhere to rest must refuse the move");
assert!(err.contains("worktop"), "the refusal names the surface: {err}");
assert_eq!(m, before, "a refused move must leave the map byte-identical");
}
#[test]
fn a_moved_host_does_not_land_on_the_things_it_is_carrying() {
let (mut m, l) = stacked();
move_placement(&mut m, &l, 0, (3.0, 0.0)).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(
m.placements[0].on, None,
"the table stands on the floor, not on its own lamp"
);
resolve_y(&m, &l).unwrap_or_else(|e| panic!("{e}"));
}
#[test]
fn moving_a_guest_onto_another_host_repoints_it() {
let (mut m, l) = stacked();
move_placement(&mut m, &l, 1, (10.0, 10.0)).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(
m.placements[1].on.as_deref(),
Some("t2"),
"the lamp now rests on the table it was dropped on"
);
let y = resolve_y(&m, &l).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(y[1], 0.8, "and stands at that table's height");
}
#[test]
fn restoring_a_move_puts_the_whole_group_back() {
let (mut m, l) = stacked();
let before = m.clone();
let moved = move_placement(&mut m, &l, 0, (3.0, 0.0)).unwrap_or_else(|e| panic!("{e}"));
assert_ne!(m, before, "the move did something");
restore_moved(&mut m, &moved);
assert_eq!(m, before, "restoring must be exact, not approximate");
}
#[test]
fn riders_are_collected_through_the_whole_chain() {
let mut tray = table();
tray.id = "tray".into();
tray.extent.footprint = Some((0.6, 0.6));
tray.extent.height = Some(0.05);
tray.mount = Some(Mount::OnSurface {
class: "worktop".into(),
});
let mut m = map(vec![
at("t1", "table", None),
at("tray1", "tray", Some("t1")),
at("mug1", "lamp", Some("tray1")),
]);
let l = lib(vec![table(), lamp(), tray]);
let moved = move_placement(&mut m, &l, 0, (5.0, 0.0)).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(moved.was.len(), 3, "the mug two levels up must come too");
assert_eq!(m.placements[1].at, (5.0, 0.0));
assert_eq!(m.placements[2].at, (5.0, 0.0));
assert_eq!(m.placements[2].on.as_deref(), Some("tray1"));
}
#[test]
fn moving_a_placement_that_is_not_there_is_refused() {
let (mut m, l) = stacked();
let err = move_placement(&mut m, &l, 99, (1.0, 1.0)).expect_err("no such placement");
assert!(err.contains("99"), "{err}");
}
#[test]
fn a_lamp_on_a_table_stands_at_the_tables_height() {
let m = map(vec![
at("t1", "table", None),
at("l1", "lamp", Some("t1")),
]);
let y = resolve_y(&m, &lib(vec![table(), lamp()])).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(y[0], 0.0);
assert_eq!(y[1], 0.8);
}
fn wall() -> Descriptor {
Descriptor {
id: "wall".into(),
extent: Extent { footprint: Some((0.1, 1.0)), height: Some(2.4) },
offers: Offers {
surfaces: Vec::new(),
faces: vec!["wall-inner".into()],
sockets: Vec::new(),
},
mount: Some(Mount::OnFloor),
..Descriptor::default()
}
}
fn sconce() -> Descriptor {
Descriptor {
id: "sconce".into(),
extent: Extent { footprint: Some((0.2, 0.2)), height: Some(0.3) },
mount: Some(Mount::OnFace { class: "wall-inner".into(), height: 1.8 }),
..Descriptor::default()
}
}
#[test]
fn a_fixture_rides_the_face_it_is_fixed_to() {
let l = lib(vec![wall(), sconce()]);
let m = map(vec![at("w1", "wall", None), at("s1", "sconce", Some("w1"))]);
let y = resolve_y(&m, &l).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(y[0], 0.0, "the wall stands on the floor");
assert_eq!(y[1], 1.8, "the sconce is 1.8 m up the wall");
let mut raised = m.clone();
raised.placements[0].lift = 0.5;
let y = resolve_y(&raised, &l).unwrap_or_else(|e| panic!("{e}"));
assert_eq!((y[0], y[1]), (0.5, 2.3));
}
#[test]
fn a_fixture_whose_host_is_gone_is_an_error_naming_both() {
let l = lib(vec![wall(), sconce()]);
let m = map(vec![at("s1", "sconce", Some("w1"))]);
let e = resolve_y(&m, &l).expect_err("a missing host must be reported, not floored");
assert!(e.contains("s1") && e.contains("w1"), "{e}");
let m = map(vec![at("t1", "table", None), at("s1", "sconce", Some("t1"))]);
let e = resolve_y(&m, &lib(vec![table(), sconce()]))
.expect_err("a table presents no wall face");
assert!(e.contains("wall-inner"), "{e}");
assert!(e.contains("presents none"), "it must say what the host does present: {e}");
}
#[test]
fn a_fixture_with_no_host_recorded_is_refused() {
let m = map(vec![at("s1", "sconce", None)]);
let e = resolve_y(&m, &lib(vec![wall(), sconce()])).expect_err("no host, no height");
assert!(e.contains("wall-inner"), "{e}");
}
#[test]
fn the_host_may_appear_after_the_piece_that_rests_on_it() {
let m = map(vec![
at("l1", "lamp", Some("t1")),
at("t1", "table", None),
]);
let y = resolve_y(&m, &lib(vec![table(), lamp()])).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(y[0], 0.8);
assert_eq!(y[1], 0.0);
}
#[test]
fn stacks_compose_and_ride_the_maps_floor() {
let mut shelf = table();
shelf.id = "shelf".into();
shelf.mount = Some(Mount::OnSurface {
class: "worktop".into(),
});
shelf.extent.height = Some(0.4);
let m = Map {
name: "test_map".into(),
origin: (0.0, 10.0, 0.0),
placements: vec![
at("t1", "table", None),
at("s1", "shelf", Some("t1")),
at("l1", "lamp", Some("s1")),
],
..Map::default()
};
let y = resolve_y(&m, &lib(vec![table(), shelf, lamp()])).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(y, vec![10.0, 10.8, 11.2]);
}
#[test]
fn the_surface_plane_is_the_extent_and_scale_does_not_move_it() {
let mut big = table();
big.align = Align {
scale: Some(2.0),
..Align::default()
};
let m = map(vec![at("t1", "table", None), at("l1", "lamp", Some("t1"))]);
let y = resolve_y(&m, &lib(vec![big, lamp()])).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(y[1], 0.8);
let mut stretched = table();
stretched.align = Align {
scale: Some(2.0),
stretch_y: Some(1.5),
..Align::default()
};
let y = resolve_y(&m, &lib(vec![stretched, lamp()])).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(y[1], 1.2);
}
#[test]
fn a_surface_mount_with_no_host_is_refused() {
let m = map(vec![at("l1", "lamp", None)]);
let err = resolve_y(&m, &lib(vec![table(), lamp()]))
.err()
.unwrap_or_default();
assert!(err.contains("records nothing under it"), "{err}");
}
#[test]
fn a_host_that_does_not_offer_the_class_is_refused() {
let mut crate_ = table();
crate_.id = "crate".into();
crate_.offers.surfaces.clear();
let m = map(vec![at("c1", "crate", None), at("l1", "lamp", Some("c1"))]);
let err = resolve_y(&m, &lib(vec![crate_, lamp()]))
.err()
.unwrap_or_default();
assert!(err.contains("cannot hold it up"), "{err}");
}
#[test]
fn an_unmeasured_host_is_refused() {
let mut vague = table();
vague.extent.height = None;
let m = map(vec![at("t1", "table", None), at("l1", "lamp", Some("t1"))]);
let err = resolve_y(&m, &lib(vec![vague, lamp()]))
.err()
.unwrap_or_default();
assert!(err.contains("records no height"), "{err}");
}
#[test]
fn a_ceiling_mount_hangs_from_the_maps_own_ceiling() {
let mut light = lamp();
light.mount = Some(Mount::OnCeiling);
let m = Map {
name: "test_map".into(),
bounds: (10.0, 3.5, 10.0),
placements: vec![at("l1", "lamp", None)],
..Map::default()
};
let y = resolve_y(&m, &lib(vec![light])).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(y[0], 3.5);
}
#[test]
fn a_decal_lies_on_the_plane_it_names() {
let m = Map {
name: "test_map".into(),
origin: (0.0, 10.0, 0.0),
bounds: (8.0, 3.0, 8.0),
placements: vec![at("d", "decal", None)],
..Map::default()
};
let decal = |on| {
let mut d = lamp();
d.id = "decal".into();
d.mount = Some(Mount::Decal { on });
d
};
let floor = resolve_y(&m, &lib(vec![decal(DecalHost::Floor)]))
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(floor[0], 10.0);
let ceiling = resolve_y(&m, &lib(vec![decal(DecalHost::Ceiling)]))
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(ceiling[0], 13.0);
let wall = resolve_y(&m, &lib(vec![decal(DecalHost::Wall { height: 1.6 })]))
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(wall[0], 11.6);
}
#[test]
fn a_loop_is_an_error_rather_than_a_hang() {
let mut shelf = lamp();
shelf.id = "shelf".into();
shelf.offers.surfaces = vec!["worktop".into()];
let m = map(vec![
at("a", "shelf", Some("b")),
at("b", "shelf", Some("a")),
]);
let err = resolve_y(&m, &lib(vec![shelf]))
.err()
.unwrap_or_default();
assert!(err.contains("loop"), "{err}");
}
#[test]
fn a_turned_piece_covers_the_area_it_now_occupies() {
let t = table(); assert!(covers(&t, (0.0, 0.0), 0.0, (0.7, 0.0)));
assert!(covers(&t, (0.0, 0.0), 0.0, (0.7, 0.3)));
assert!(!covers(&t, (0.0, 0.0), 90.0, (0.7, 0.3)));
assert!(!covers(&t, (0.0, 0.0), 0.0, (0.0, 0.7)));
assert!(covers(&t, (0.0, 0.0), 90.0, (0.0, 0.7)));
let mut vague = table();
vague.extent.footprint = None;
assert!(!covers(&vague, (0.0, 0.0), 0.0, (0.0, 0.0)));
}
#[test]
fn the_topmost_surface_is_the_one_a_piece_lands_on() {
let mut shelf = table();
shelf.id = "shelf".into();
shelf.mount = Some(Mount::OnSurface {
class: "worktop".into(),
});
shelf.extent.height = Some(0.4);
let m = map(vec![at("t1", "table", None), at("s1", "shelf", Some("t1"))]);
let l = lib(vec![table(), shelf, lamp()]);
let ys = resolve_y(&m, &l).unwrap_or_else(|e| panic!("{e}"));
let (host, top) = host_under(&m, &l, &ys, &lamp(), (0.0, 0.0))
.unwrap_or_else(|| panic!("nothing under the cursor"));
assert_eq!(host.id, "s1");
assert_eq!(top, 1.2);
}
#[test]
fn a_surface_piece_over_bare_floor_is_refused_with_a_reason() {
let m = map(vec![at("t1", "table", None)]);
let l = lib(vec![table(), lamp()]);
let ys = resolve_y(&m, &l).unwrap_or_else(|e| panic!("{e}"));
let (y, host) = placement_at(&m, &l, &ys, &lamp(), (0.0, 0.0)).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(y, 0.8);
assert_eq!(host.map(|h| h.id.as_str()), Some("t1"));
let err = placement_at(&m, &l, &ys, &lamp(), (10.0, 10.0))
.err()
.unwrap_or_default();
assert!(err.contains("worktop"), "{err}");
let (y, host) = placement_at(&m, &l, &ys, &table(), (10.0, 10.0)).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(y, 0.0);
assert!(host.is_none());
}
#[test]
fn a_moved_map_keeps_its_own_floor_plan() {
let m = Map {
name: "test_map".into(),
origin: (100.0, 0.0, -50.0),
bounds: (32.0, 4.0, 32.0),
..Map::default()
};
assert_eq!(m.floor_rect(), (-16.0, -16.0, 16.0, 16.0));
assert_eq!(m.to_map_space((100.0, -50.0)), (0.0, 0.0));
assert_eq!(m.to_map_space((104.0, -50.0)), (4.0, 0.0));
let (min_x, min_z, ..) = m.floor_rect();
assert_eq!(
m.to_map_space((m.origin.0 + min_x, m.origin.2 + min_z)),
(min_x, min_z)
);
}
#[test]
fn validate_refuses_a_dangling_host_and_a_loop() {
let dangling = map(vec![at("l1", "lamp", Some("nothing"))]);
let err = dangling.validate().err().unwrap_or_default();
assert!(err.contains("does not exist"), "{err}");
let looped = map(vec![at("a", "lamp", Some("b")), at("b", "lamp", Some("a"))]);
let err = looped.validate().err().unwrap_or_default();
assert!(err.contains("loop"), "{err}");
let itself = map(vec![at("a", "lamp", Some("a"))]);
let err = itself.validate().err().unwrap_or_default();
assert!(err.contains("rests on itself"), "{err}");
}
#[test]
fn flush_passes_and_overlap_blocks() {
let (m, l) = (map(vec![at("t1", "table", None)]), lib(vec![table(), lamp()]));
assert!(
blocking(&m, &l, &table(), (1.6, 0.0), 0.0, (0, 0), None).is_none(),
"flush end-to-end must not read as overlap"
);
let hit = blocking(&m, &l, &table(), (1.0, 0.0), 0.0, (0, 0), None);
assert_eq!(hit.map(|p| p.id.as_str()), Some("t1"), "a lapped table is blocked");
assert!(
blocking(&m, &l, &table(), (1.3, 0.0), 90.0, (0, 0), None).is_none(),
"the turned footprint is the one that counts"
);
}
#[test]
fn blockers_lists_every_overlap_in_map_order_and_blocking_is_the_first() {
let m = map(vec![
at("t1", "table", None),
Placed { at: (5.0, 0.0), ..at("far", "table", None) },
Placed { at: (1.0, 0.0), ..at("t2", "table", None) },
]);
let l = lib(vec![table(), lamp()]);
let hit = blockers(&m, &l, &table(), (0.5, 0.0), 0.0, (0, 0), None);
assert_eq!(hit, vec![0, 2], "both lapped tables, never the far one, ascending");
let first = blocking(&m, &l, &table(), (0.5, 0.0), 0.0, (0, 0), None);
assert_eq!(first.map(|p| p.id.as_str()), Some("t1"));
assert!(blockers(&m, &l, &table(), (3.0, 0.0), 0.0, (0, 0), None).is_empty());
}
#[test]
fn the_overlap_pass_names_a_lapped_pair_once() {
let m = map(vec![
at("t1", "table", None),
Placed { at: (1.0, 0.0), ..at("t2", "table", None) },
]);
let l = lib(vec![table(), lamp()]);
assert_eq!(overlaps(&m, &l), vec![(0, 1)]);
}
#[test]
fn the_overlap_pass_lets_flush_pieces_be() {
let m = map(vec![
at("t1", "table", None),
Placed { at: (1.6, 0.0), ..at("t2", "table", None) },
]);
let l = lib(vec![table(), lamp()]);
assert!(overlaps(&m, &l).is_empty(), "flush end-to-end is not an overlap");
}
#[test]
fn the_overlap_pass_ignores_a_rider_on_its_host() {
let m = map(vec![
at("t1", "table", None),
Placed { at: (0.0, 0.0), ..at("l1", "lamp", Some("t1")) },
]);
let l = lib(vec![table(), lamp()]);
assert!(
overlaps(&m, &l).is_empty(),
"a lamp on a table is not a table inside a table"
);
}
#[test]
fn other_layers_do_not_block() {
let tiled = Descriptor {
id: "floor_tile".into(),
extent: Extent {
footprint: Some((0.5, 0.5)),
height: Some(0.05),
},
mount: Some(Mount::Tiled),
..Descriptor::default()
};
let l = lib(vec![table(), lamp(), tiled.clone()]);
let m = map(vec![at("f1", "floor_tile", None), at("t1", "table", None)]);
assert!(blocking(&m, &l, &table(), (0.0, 0.0), 0.0, (0, 0), None).is_some());
assert!(blocking(&m, &l, &tiled, (1.6, 0.0), 0.0, (0, 0), None).is_none());
let stacked = map(vec![
at("t1", "table", None),
at("l1", "lamp", Some("t1")),
]);
assert!(
blocking(&stacked, &l, &lamp(), (0.0, 0.0), 0.0, (0, 0), Some("t1")).is_some(),
"two lamps on one table at one spot is an overlap"
);
assert!(
blocking(&stacked, &l, &lamp(), (0.0, 0.0), 0.0, (0, 0), Some("t2")).is_none(),
"a different host is a different layer"
);
}
#[test]
fn a_tip_swaps_the_reserved_footprint() {
let l = lib(vec![table(), lamp()]);
let mut m = map(vec![at("t1", "table", None)]);
m.placements[0].tip = (1, 0); m.placements[0].tip = (0, 1); assert!(
blocking(&m, &l, &table(), (1.2, 0.0), 0.0, (0, 0), None).is_none(),
"tipped about Z the table is only 0.8 wide, so x = 1.2 clears it"
);
m.placements[0].tip = (0, 0);
assert!(
blocking(&m, &l, &table(), (1.2, 0.0), 0.0, (0, 0), None).is_some(),
"upright it is 1.6 wide again and x = 1.2 laps it"
);
}
fn strip() -> Descriptor {
Descriptor {
extent: Extent {
footprint: Some((4.0, 20.0)),
height: Some(1.0),
},
..Descriptor::default()
}
}
#[test]
fn the_descriptor_of_a_placement_is_borrowed_unless_patched() {
let l = lib(vec![table()]);
let plain = at("t1", "table", None);
match descriptor_of(&l, &plain) {
Some(Cow::Borrowed(d)) => assert_eq!(d.extent.footprint, Some((1.6, 0.8))),
other => panic!("an unpatched placement must borrow the library's descriptor: {other:?}"),
}
let mut long = at("t2", "table", None);
long.patch = Some(strip());
match descriptor_of(&l, &long) {
Some(Cow::Owned(d)) => {
assert_eq!(d.id, "table", "a patch that names no id keeps the base's");
assert_eq!(d.extent.footprint, Some((4.0, 20.0)));
assert_eq!(d.offers.surfaces, vec!["worktop".to_owned()], "inherited");
}
other => panic!("a patched placement owns its layered descriptor: {other:?}"),
}
assert!(descriptor_of(&l, &at("x", "nope", None)).is_none());
}
#[test]
fn a_patched_footprint_blocks_and_is_blocked_at_its_real_size() {
let l = lib(vec![table()]);
let probe = (0.0, 8.0);
let mut m = map(vec![at("road", "table", None)]);
assert!(blockers(&m, &l, &table(), probe, 0.0, (0, 0), None).is_empty());
m.placements[0].patch = Some(strip());
assert_eq!(blockers(&m, &l, &table(), probe, 0.0, (0, 0), None), vec![0]);
assert!(blocking(&m, &l, &table(), probe, 0.0, (0, 0), None).is_some());
assert!(blockers(&m, &l, &table(), (0.0, 11.0), 0.0, (0, 0), None).is_empty());
let m = map(vec![at("t1", "table", None)]);
let guest = table().patched_with(&strip());
assert!(blockers(&m, &l, &table(), probe, 0.0, (0, 0), None).is_empty());
assert_eq!(blockers(&m, &l, &guest, probe, 0.0, (0, 0), None), vec![0]);
}
#[test]
fn heights_and_hosts_use_the_patched_descriptor() {
let l = lib(vec![table(), lamp()]);
let mut m = map(vec![at("t1", "table", None), at("l1", "lamp", Some("t1"))]);
let plain = resolve_y(&m, &l).unwrap_or_else(|e| panic!("{e}"));
assert!((plain[1] - 0.8).abs() < 1e-5, "unpatched: the table's own 0.8 m");
m.placements[0].patch = Some(strip());
let patched = resolve_y(&m, &l).unwrap_or_else(|e| panic!("{e}"));
assert!((patched[1] - 1.0).abs() < 1e-5, "patched: the strip's 1.0 m, got {}", patched[1]);
let probe = (0.0, 8.0);
let ys = &patched;
let (_, host) = placement_at(&m, &l, ys, &lamp(), probe).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(host.map(|h| h.id.as_str()), Some("t1"), "8 m along the strip is on the strip");
m.placements[0].patch = None;
let ys = resolve_y(&m, &l).unwrap_or_else(|e| panic!("{e}"));
assert!(
placement_at(&m, &l, &ys, &lamp(), probe).is_err(),
"unpatched, 8 m from the table is bare floor"
);
}
}