use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Descriptor {
pub id: String,
pub mesh: Option<String>,
pub align: Align,
pub extent: Extent,
pub mount: Option<Mount>,
pub clearance: Vec<Clearance>,
pub offers: Offers,
pub kind: Vec<String>,
pub effects: Vec<String>,
pub look: Vec<String>,
pub placement: Placement,
pub subgrid: Option<Subgrid>,
pub note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<LabelOrigin>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LabelOrigin {
#[serde(default)]
pub by: AxisOrigin,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_at: Option<String>,
pub at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence: Option<Confidence>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct AxisOrigin {
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<By>,
#[serde(skip_serializing_if = "Option::is_none")]
pub effects: Option<By>,
#[serde(skip_serializing_if = "Option::is_none")]
pub look: Option<By>,
#[serde(skip_serializing_if = "Option::is_none")]
pub surfaces: Option<By>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mount: Option<By>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<By>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum By {
Model,
Human,
Agent,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Confidence {
High,
Medium,
Low,
}
impl std::fmt::Display for Confidence {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::High => "high",
Self::Medium => "medium",
Self::Low => "low",
})
}
}
impl std::str::FromStr for Confidence {
type Err = String;
fn from_str(word: &str) -> Result<Self, String> {
match word {
"high" => Ok(Self::High),
"medium" => Ok(Self::Medium),
"low" => Ok(Self::Low),
other => Err(format!("confidence is high, medium or low, not \"{other}\"")),
}
}
}
impl LabelOrigin {
pub fn stamped(model: &str, at: &str, confidence: Option<Confidence>) -> LabelOrigin {
LabelOrigin {
by: AxisOrigin::default(),
model: Some(model.to_owned()),
at: at.to_owned(),
model_at: Some(at.to_owned()),
confidence,
}
}
pub fn model_date(&self) -> Option<&str> {
self.model.as_ref().map(|_| self.model_at.as_deref().unwrap_or(&self.at))
}
}
impl AxisOrigin {
pub fn set(&mut self, axis: Axis, by: By) {
*match axis {
Axis::Kind => &mut self.kind,
Axis::Effects => &mut self.effects,
Axis::Look => &mut self.look,
Axis::Surfaces => &mut self.surfaces,
Axis::Mount => &mut self.mount,
Axis::Note => &mut self.note,
} = Some(by);
}
pub fn get(&self, axis: Axis) -> Option<By> {
match axis {
Axis::Kind => self.kind,
Axis::Effects => self.effects,
Axis::Look => self.look,
Axis::Surfaces => self.surfaces,
Axis::Mount => self.mount,
Axis::Note => self.note,
}
}
pub fn any_unconfirmed(&self) -> bool {
[self.kind, self.effects, self.look, self.surfaces, self.mount, self.note]
.iter()
.any(|by| matches!(by, Some(By::Model | By::Agent)))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Axis {
Kind,
Effects,
Look,
Surfaces,
Mount,
Note,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Subgrid {
pub cells: Vec<SubCell>,
}
pub fn divisions(d: &Descriptor, per_tile: u32) -> Result<(u32, u32, u32), String> {
let owner = &d.id;
if per_tile == 0 {
return Err(format!(
"`{owner}`: the project divides each tile 0 ways; an axis with no divisions has no cells"
));
}
let (w, dep) = placed_footprint(d).ok_or_else(|| {
format!("`{owner}`: no `extent.footprint`, so its lattice cannot be derived")
})?;
let h = placed_height(d).unwrap_or(0.0);
let axis = |span: f32| crate::grid::cells(span).0 * per_tile;
Ok((axis(w), axis(h), axis(dep)))
}
pub fn placed_footprint(d: &Descriptor) -> Option<(f32, f32)> {
d.extent.footprint
}
pub fn placed_height(d: &Descriptor) -> Option<f32> {
let h = d.extent.height?;
Some(h * d.align.stretch_y.unwrap_or(1.0))
}
pub fn tipped_extents(d: &Descriptor, tip: (u8, u8)) -> Option<(f32, f32, f32)> {
let (w, depth) = placed_footprint(d)?;
let h = placed_height(d)?;
let (w, h, depth) = if tip.0 % 2 == 1 {
(w, depth, h)
} else {
(w, h, depth)
};
let (w, h, depth) = if tip.1 % 2 == 1 {
(h, w, depth)
} else {
(w, h, depth)
};
Some((w, h, depth))
}
pub fn tipped_footprint(d: &Descriptor, tip: (u8, u8)) -> Option<(f32, f32)> {
if tip == (0, 0) {
return placed_footprint(d);
}
tipped_extents(d, tip).map(|(w, _, depth)| (w, depth))
}
pub fn rotate_div(div: (u32, u32, u32), quarter: u8) -> (u32, u32, u32) {
if quarter.is_multiple_of(2) {
div
} else {
(div.2, div.1, div.0)
}
}
impl Subgrid {
pub fn volume(div: (u32, u32, u32)) -> u32 {
div.0.saturating_mul(div.1).saturating_mul(div.2)
}
pub fn at(&self, at: (u32, u32, u32)) -> Option<&SubCell> {
self.cells.iter().find(|c| c.at == at)
}
pub fn holds(at: (u32, u32, u32), div: (u32, u32, u32)) -> bool {
at.0 < div.0 && at.1 < div.1 && at.2 < div.2
}
fn entry(&mut self, at: (u32, u32, u32), div: (u32, u32, u32)) -> Option<&mut SubCell> {
if !Subgrid::holds(at, div) {
return None;
}
if let Some(i) = self.cells.iter().position(|c| c.at == at) {
return self.cells.get_mut(i);
}
self.cells.push(SubCell {
at,
..SubCell::default()
});
self.cells.last_mut()
}
fn prune(&mut self) {
self.cells.retain(|c| c.solid || c.edge.is_some());
}
pub fn toggle_solid(&mut self, at: (u32, u32, u32), div: (u32, u32, u32)) -> Option<bool> {
let now = {
let cell = self.entry(at, div)?;
cell.solid = !cell.solid;
cell.solid
};
self.prune();
Some(now)
}
pub fn set_solid(&mut self, at: (u32, u32, u32), div: (u32, u32, u32)) -> Option<()> {
self.entry(at, div)?.solid = true;
self.prune();
Some(())
}
pub fn set_edge(
&mut self,
at: (u32, u32, u32),
div: (u32, u32, u32),
token: &str,
) -> Option<()> {
let cell = self.entry(at, div)?;
cell.edge = (!token.trim().is_empty()).then(|| token.trim().to_owned());
self.prune();
Some(())
}
pub fn clear(&mut self, at: (u32, u32, u32)) {
self.cells.retain(|c| c.at != at);
}
pub fn rotated(&self, quarter: u8, div: (u32, u32, u32)) -> Subgrid {
let mut out = self.clone();
let mut at_div = div;
for _ in 0..(quarter % 4) {
let (dx, dy, dz) = at_div;
out = Subgrid {
cells: out
.cells
.iter()
.map(|c| SubCell {
at: (
c.at.2,
c.at.1,
dx.saturating_sub(1) - c.at.0.min(dx.saturating_sub(1)),
),
..c.clone()
})
.collect(),
};
at_div = (dz, dy, dx);
}
out
}
pub fn validate(&self, owner: &str, div: (u32, u32, u32)) -> Result<(), String> {
let (dx, dy, dz) = div;
if dx == 0 || dy == 0 || dz == 0 {
return Err(format!(
"`{owner}`'s subgrid divides {dx}x{dy}x{dz}; an axis with no divisions has no cells"
));
}
let mut seen: Vec<(u32, u32, u32)> = Vec::with_capacity(self.cells.len());
for c in &self.cells {
if c.at.0 >= dx || c.at.1 >= dy || c.at.2 >= dz {
return Err(format!(
"`{owner}`'s subgrid cell {:?} is outside its {dx}x{dy}x{dz} lattice",
c.at
));
}
if seen.contains(&c.at) {
return Err(format!(
"`{owner}`'s subgrid names cell {:?} twice — one cell, one answer",
c.at
));
}
seen.push(c.at);
}
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct SubCell {
pub at: (u32, u32, u32),
pub solid: bool,
pub edge: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Placement {
pub rooms: Vec<String>,
pub group: Option<String>,
}
pub fn mount_options(surfaces: &[String]) -> Vec<Mount> {
let mut out = vec![
Mount::OnFloor,
Mount::OnSurface {
class: String::new(),
},
Mount::OnFace {
class: String::new(),
height: 1.8,
},
Mount::OnWall { height: 1.8 },
Mount::OnCeiling,
Mount::Tiled,
Mount::Decal {
on: DecalHost::Floor,
},
Mount::Decal {
on: DecalHost::Wall { height: 1.8 },
},
Mount::Decal {
on: DecalHost::Ceiling,
},
Mount::InOpening { clear: None },
];
let at = out
.iter()
.position(|m| matches!(m, Mount::OnSurface { class } if class.is_empty()));
if let Some(at) = at {
out.remove(at);
for (i, class) in surfaces.iter().enumerate() {
out.insert(
at + i,
Mount::OnSurface {
class: class.clone(),
},
);
}
let at = at + surfaces.len();
for (i, class) in surfaces.iter().enumerate() {
out.insert(
at + i,
Mount::OnSurfaceOrFloor {
class: class.clone(),
},
);
}
}
let at = out
.iter()
.position(|m| matches!(m, Mount::OnFace { class, .. } if class.is_empty()));
if let Some(at) = at {
out.remove(at);
for (i, class) in surfaces.iter().enumerate() {
out.insert(
at + i,
Mount::OnFace {
class: class.clone(),
height: 1.8,
},
);
}
}
out
}
pub fn mount_height(m: &Mount) -> Option<f32> {
match m {
Mount::OnWall { height } => Some(*height),
Mount::Decal {
on: DecalHost::Wall { height },
} => Some(*height),
_ => None,
}
}
pub fn with_mount_height(m: &Mount, height: f32) -> Option<Mount> {
match m {
Mount::OnWall { .. } => Some(Mount::OnWall { height }),
Mount::Decal {
on: DecalHost::Wall { .. },
} => Some(Mount::Decal {
on: DecalHost::Wall { height },
}),
_ => None,
}
}
pub fn mount_label(mount: Option<&Mount>) -> String {
match mount {
None => "unset".to_owned(),
Some(Mount::OnFloor) => "on floor".to_owned(),
Some(Mount::OnSurface { class }) => format!("on {class}"),
Some(Mount::OnSurfaceOrFloor { class }) => format!("on {class} or floor"),
Some(Mount::OnFace { class, height }) => format!("on {class} face at {height:.1} m"),
Some(Mount::OnWall { height }) => format!("on wall at {height:.1} m"),
Some(Mount::OnCeiling) => "on ceiling".to_owned(),
Some(Mount::Tiled) => "tiled".to_owned(),
Some(Mount::Decal { on }) => match on {
DecalHost::Floor => "decal on floor".to_owned(),
DecalHost::Ceiling => "decal on ceiling".to_owned(),
DecalHost::Wall { height } => format!("decal on wall at {height:.1} m"),
},
Some(Mount::InOpening { clear }) => match clear {
Some((w, h)) => format!("in opening {w:.2} x {h:.2} m"),
None => "in opening".to_owned(),
},
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Face {
North,
East,
South,
West,
}
impl Face {
pub fn yaw_degrees(self) -> f32 {
match self {
Face::South => 0.0,
Face::East => 90.0,
Face::North => 180.0,
Face::West => 270.0,
}
}
pub fn from_yaw(deg: f32) -> Face {
if !deg.is_finite() {
return Face::South;
}
match (deg / 90.0).round().rem_euclid(4.0) as u8 {
1 => Face::East,
2 => Face::North,
3 => Face::West,
_ => Face::South,
}
}
pub fn dir(self) -> crate::placement::ir::Dir {
match self {
Face::North => crate::wfc::N,
Face::East => crate::wfc::E,
Face::South => crate::wfc::S,
Face::West => crate::wfc::W,
}
}
pub fn opposite(self) -> Face {
match self {
Face::North => Face::South,
Face::East => Face::West,
Face::South => Face::North,
Face::West => Face::East,
}
}
pub fn label(self) -> &'static str {
match self {
Face::North => "N",
Face::East => "E",
Face::South => "S",
Face::West => "W",
}
}
}
pub fn quarter_turns_xyz(rotate: (i32, i32, i32), owner: &str) -> Result<(u8, u8, u8), String> {
let axis = |deg: i32, name: &str| -> Result<u8, String> {
if deg % 90 != 0 {
return Err(format!(
"`{owner}`'s rotation is {deg} degrees about {name}; a tile only sits square to the \
world, so a rotation must be a multiple of 90"
));
}
Ok((deg.rem_euclid(360) / 90) as u8)
};
Ok((
axis(rotate.0, "X")?,
axis(rotate.1, "Y")?,
axis(rotate.2, "Z")?,
))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Align {
pub scale: Option<f32>,
pub stretch_y: Option<f32>,
pub y_offset: Option<f32>,
pub pivot: Option<(f32, f32)>,
pub rotate: Option<(i32, i32, i32)>,
pub front: Option<Face>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Extent {
pub footprint: Option<(f32, f32)>,
pub height: Option<f32>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Mount {
OnFloor,
OnWall { height: f32 },
OnCeiling,
InOpening { clear: Option<(f32, f32)> },
OnSurface { class: String },
OnSurfaceOrFloor { class: String },
OnFace { class: String, height: f32 },
Decal { on: DecalHost },
Tiled,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DecalHost {
Floor,
Ceiling,
Wall { height: f32 },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Dir {
Front,
Back,
Left,
Right,
Around,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Clearance {
pub dir: Dir,
pub dist: f32,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Offers {
pub surfaces: Vec<String>,
#[serde(default)]
pub faces: Vec<String>,
pub sockets: Vec<Socket>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Socket {
pub id: String,
pub role: Option<String>,
pub at: (f32, f32, f32),
pub yaw: f32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Resolved {
pub id: String,
pub mesh: String,
pub scale: f32,
pub stretch_y: Option<f32>,
pub y_offset: f32,
pub pivot: (f32, f32),
pub front: Option<Face>,
pub footprint: (f32, f32),
pub height: f32,
pub mount: Mount,
pub clearance: Vec<Clearance>,
pub offers: Offers,
pub kind: Vec<String>,
pub effects: Vec<String>,
pub look: Vec<String>,
}
impl Descriptor {
pub fn patched_with(&self, patch: &Descriptor) -> Descriptor {
Descriptor {
id: if patch.id.is_empty() {
self.id.clone()
} else {
patch.id.clone()
},
mesh: patch.mesh.clone().or_else(|| self.mesh.clone()),
align: Align {
scale: patch.align.scale.or(self.align.scale),
stretch_y: patch.align.stretch_y.or(self.align.stretch_y),
y_offset: patch.align.y_offset.or(self.align.y_offset),
pivot: patch.align.pivot.or(self.align.pivot),
rotate: patch.align.rotate.or(self.align.rotate),
front: patch.align.front.or(self.align.front),
},
extent: Extent {
footprint: patch.extent.footprint.or(self.extent.footprint),
height: patch.extent.height.or(self.extent.height),
},
mount: patch.mount.clone().or_else(|| self.mount.clone()),
clearance: pick(&self.clearance, &patch.clearance),
offers: Offers {
surfaces: pick(&self.offers.surfaces, &patch.offers.surfaces),
faces: Vec::new(),
sockets: pick(&self.offers.sockets, &patch.offers.sockets),
},
kind: pick(&self.kind, &patch.kind),
effects: pick(&self.effects, &patch.effects),
look: pick(&self.look, &patch.look),
subgrid: patch.subgrid.clone().or_else(|| self.subgrid.clone()),
placement: Placement {
rooms: pick(&self.placement.rooms, &patch.placement.rooms),
group: patch
.placement
.group
.clone()
.or_else(|| self.placement.group.clone()),
},
note: patch.note.clone().or_else(|| self.note.clone()),
labels: patch.labels.clone().or_else(|| self.labels.clone()),
}
}
pub fn lattice_mut(&mut self) -> &mut Subgrid {
self.subgrid.get_or_insert_with(Subgrid::default)
}
pub fn settle_lattice(&mut self) {
if self.subgrid.as_ref().is_some_and(|g| g.cells.is_empty()) {
self.subgrid = None;
}
}
pub fn resolve(&self) -> Result<Resolved, String> {
if self.id.is_empty() {
return Err("descriptor: an entry has no `id`".to_owned());
}
let need = |what: &str| format!("descriptor `{}`: missing `{what}`", self.id);
let footprint = self
.extent
.footprint
.ok_or_else(|| need("extent.footprint"))?;
let mount = self.mount.clone().ok_or_else(|| need("mount"))?;
let height = match (self.extent.height, &mount) {
(Some(h), _) => h,
(None, Mount::Decal { .. }) => 0.0,
(None, _) => return Err(need("extent.height")),
};
for c in &self.clearance {
if !c.dist.is_finite() || c.dist < 0.0 {
return Err(format!(
"descriptor `{}`: clearance {:?} is {} m — must be finite and non-negative",
self.id, c.dir, c.dist
));
}
}
let scale = self.align.scale.unwrap_or(1.0);
if !scale.is_finite() || scale <= 0.0 {
return Err(format!(
"descriptor `{}`: align.scale is {scale} — must be finite and positive",
self.id
));
}
Ok(Resolved {
id: self.id.clone(),
mesh: self.mesh.clone().ok_or_else(|| need("mesh"))?,
scale,
stretch_y: self.align.stretch_y,
y_offset: self.align.y_offset.unwrap_or(0.0),
pivot: self.align.pivot.unwrap_or((0.0, 0.0)),
front: self.align.front,
footprint,
height,
mount,
clearance: self.clearance.clone(),
offers: self.offers.clone(),
kind: self.kind.clone(),
effects: self.effects.clone(),
look: self.look.clone(),
})
}
}
impl Resolved {
pub fn occupies_floor(&self) -> bool {
matches!(self.mount, Mount::OnFloor | Mount::Tiled)
}
}
fn pick<T: Clone>(base: &[T], patch: &[T]) -> Vec<T> {
if patch.is_empty() {
base.to_vec()
} else {
patch.to_vec()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn everything_that_asks_how_much_room_reads_the_extent_as_placed() {
let mut d = crate_desc();
d.extent.footprint = Some((0.306, 0.106));
d.extent.height = Some(0.178);
d.align.scale = Some(0.6);
assert_eq!(
placed_footprint(&d),
Some((0.306, 0.106)),
"extent IS the answer"
);
assert_eq!(placed_height(&d), Some(0.178));
assert!(crate::stack::covers(&d, (0.0, 0.0), 0.0, (0.15, 0.0)));
assert!(!crate::stack::covers(&d, (0.0, 0.0), 0.0, (0.16, 0.0)));
let div = divisions(&d, 1).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(
div,
(
crate::grid::cells(0.306).0,
crate::grid::cells(0.178).0,
crate::grid::cells(0.106).0
)
);
for s in [None, Some(1.0), Some(0.6), Some(2.0)] {
let mut v = d.clone();
v.align.scale = s;
assert_eq!(
placed_footprint(&v),
Some((0.306, 0.106)),
"scale {s:?} leaked into space"
);
assert_eq!(divisions(&v, 3), divisions(&d, 3));
}
}
#[test]
fn stretch_reaches_the_placed_height_and_scale_does_not() {
let mut d = crate_desc();
d.extent.footprint = Some((1.0, 1.0));
d.extent.height = Some(2.0);
d.align.scale = Some(0.5);
d.align.stretch_y = Some(1.2);
assert_eq!(
placed_height(&d),
Some(2.4),
"2.0 * 1.2 — the scale is already in the 2.0"
);
assert_eq!(placed_footprint(&d), Some((1.0, 1.0)));
}
#[test]
fn an_unmeasured_piece_yields_nothing_rather_than_zero() {
let mut d = crate_desc();
d.extent.footprint = None;
d.align.scale = Some(0.6);
assert_eq!(placed_footprint(&d), None);
assert!(
divisions(&d, 1).is_err(),
"and the lattice is refused by name"
);
}
#[test]
fn the_two_wall_mounts_are_the_ones_with_a_height() {
assert_eq!(mount_height(&Mount::OnWall { height: 1.6 }), Some(1.6));
assert_eq!(
mount_height(&Mount::Decal {
on: DecalHost::Wall { height: 2.1 }
}),
Some(2.1)
);
for no_height in [
Mount::OnFloor,
Mount::OnCeiling,
Mount::Tiled,
Mount::InOpening { clear: None },
Mount::OnSurface {
class: "worktop".into(),
},
Mount::Decal {
on: DecalHost::Floor,
},
Mount::Decal {
on: DecalHost::Ceiling,
},
] {
assert_eq!(mount_height(&no_height), None, "{no_height:?}");
assert_eq!(
with_mount_height(&no_height, 1.0),
None,
"{no_height:?} has no height to set, and must say so rather than no-op"
);
}
}
#[test]
fn setting_a_height_does_not_change_which_mount_it_is() {
let poster = Mount::Decal {
on: DecalHost::Wall { height: 1.8 },
};
let moved = with_mount_height(&poster, 1.2).unwrap_or_else(|| panic!("has a height"));
assert!(matches!(
moved,
Mount::Decal {
on: DecalHost::Wall { .. }
}
));
assert_eq!(mount_height(&moved), Some(1.2));
let sconce = with_mount_height(&Mount::OnWall { height: 1.8 }, 2.4)
.unwrap_or_else(|| panic!("has a height"));
assert!(matches!(sconce, Mount::OnWall { .. }));
assert_eq!(mount_height(&sconce), Some(2.4));
}
#[test]
fn every_offered_wall_mount_can_be_re_heighted() {
for m in mount_options(&["worktop".to_owned()]) {
if mount_height(&m).is_some() {
assert!(
with_mount_height(&m, 1.0).is_some(),
"{m:?} reports a height but cannot be given one"
);
}
}
}
#[test]
fn mount_options_come_from_the_projects_surface_vocabulary() {
let none = mount_options(&[]);
assert!(
!none.iter().any(|m| matches!(m, Mount::OnSurface { .. })),
"a project with no surface classes must offer no surface mount"
);
let two = mount_options(&["support".to_owned(), "worktop".to_owned()]);
let classes: Vec<&str> = two
.iter()
.filter_map(|m| match m {
Mount::OnSurface { class } => Some(class.as_str()),
_ => None,
})
.collect();
assert_eq!(classes, ["support", "worktop"]);
assert!(
!two.iter()
.any(|m| matches!(m, Mount::OnSurface { class } if class.is_empty())),
"the placeholder must not survive into the offered list"
);
}
#[test]
fn the_first_option_is_the_one_most_pieces_want() {
assert_eq!(mount_options(&[])[0], Mount::OnFloor);
}
#[test]
fn every_mount_has_a_label_short_enough_for_one_line() {
for m in mount_options(&["worktop".to_owned()]) {
let label = mount_label(Some(&m));
assert!(!label.is_empty(), "{m:?} has no label");
assert!(label.len() <= 24, "{m:?} label is too long: {label}");
}
assert_eq!(mount_label(None), "unset");
}
fn crate_desc() -> Descriptor {
Descriptor {
id: "crate".into(),
mesh: Some("ozea/crate.glb".into()),
extent: Extent {
footprint: Some((0.6, 0.6)),
height: Some(0.5),
},
mount: Some(Mount::OnFloor),
kind: vec!["container".into()],
..Default::default()
}
}
#[test]
fn a_tipped_piece_reports_the_floor_it_lies_on() {
let d = crate_desc();
assert_eq!(tipped_footprint(&d, (0, 0)), Some((0.6, 0.6)));
assert_eq!(tipped_footprint(&d, (1, 0)), Some((0.6, 0.5)));
assert_eq!(tipped_footprint(&d, (0, 1)), Some((0.5, 0.6)));
assert_eq!(tipped_footprint(&d, (2, 2)), Some((0.6, 0.6)));
for tip in [(0, 0), (1, 0), (0, 1), (1, 1), (3, 2)] {
let (w, _h, depth) = tipped_extents(&d, tip).unwrap_or_else(|| panic!("{tip:?}"));
assert_eq!(tipped_footprint(&d, tip), Some((w, depth)), "tip {tip:?}");
}
let heightless = Descriptor {
extent: Extent {
footprint: Some((0.6, 0.6)),
height: None,
},
..crate_desc()
};
assert_eq!(
tipped_footprint(&heightless, (0, 0)),
Some((0.6, 0.6)),
"an upright piece covers its footprint whatever its height"
);
assert_eq!(tipped_footprint(&heightless, (1, 0)), None);
}
#[test]
fn a_descriptor_resolves_its_defaults() {
let r = crate_desc().resolve().expect("resolves");
assert_eq!(r.scale, 1.0, "scale defaults to unity");
assert_eq!(r.y_offset, 0.0);
assert_eq!(r.pivot, (0.0, 0.0));
assert_eq!(r.front, None, "no front is a claim, not a missing value");
assert!(r.occupies_floor());
}
#[test]
fn a_missing_footprint_is_refused_by_name() {
let mut d = crate_desc();
d.extent.footprint = None;
let err = d.resolve().expect_err("must refuse");
assert!(err.contains("crate") && err.contains("footprint"), "{err}");
}
#[test]
fn a_missing_height_is_refused_unless_it_is_a_decal() {
let mut d = crate_desc();
d.extent.height = None;
assert!(d.resolve().is_err(), "a solid piece must state its height");
d.mount = Some(Mount::Decal {
on: DecalHost::Wall { height: 1.8 },
});
let r = d.resolve().expect("a decal may omit height");
assert_eq!(r.height, 0.0);
assert!(!r.occupies_floor(), "an decal claims no floor");
}
#[test]
fn a_wall_decal_is_expressible() {
let d = Descriptor {
id: "hazard_sign".into(),
mesh: Some("signage/hazard.glb".into()),
extent: Extent {
footprint: Some((0.4, 0.4)),
height: None,
},
mount: Some(Mount::Decal {
on: DecalHost::Wall { height: 1.8 },
}),
..Default::default()
};
let r = d.resolve().expect("resolves");
assert_eq!(
r.mount,
Mount::Decal {
on: DecalHost::Wall { height: 1.8 }
}
);
assert!(!r.occupies_floor());
}
#[test]
fn a_patch_inherits_what_it_does_not_state() {
let base = crate_desc();
let patch = Descriptor {
id: String::new(),
align: Align {
front: Some(Face::East),
..Default::default()
},
..Default::default()
};
let merged = base.patched_with(&patch);
assert_eq!(merged.id, "crate", "an empty id inherits");
assert_eq!(merged.mesh.as_deref(), Some("ozea/crate.glb"));
assert_eq!(merged.extent.footprint, Some((0.6, 0.6)));
assert_eq!(
merged.align.front,
Some(Face::East),
"the patch wins where it speaks"
);
}
#[test]
fn a_patch_replaces_a_list_rather_than_appending() {
let base = crate_desc();
let patch = Descriptor {
kind: vec!["debris".into()],
..Default::default()
};
assert_eq!(base.patched_with(&patch).kind, vec!["debris".to_string()]);
let empty = Descriptor::default();
assert_eq!(
base.patched_with(&empty).kind,
vec!["container".to_string()],
"an empty list inherits rather than clearing"
);
}
#[test]
fn a_negative_clearance_is_refused() {
let mut d = crate_desc();
d.clearance = vec![Clearance {
dir: Dir::Front,
dist: -0.5,
}];
assert!(d.resolve().is_err());
}
#[test]
fn the_schema_round_trips_through_ron() {
let d = Descriptor {
clearance: vec![Clearance {
dir: Dir::Around,
dist: 0.91,
}],
offers: Offers {
surfaces: vec!["worktop".into()],
faces: Vec::new(),
sockets: vec![Socket {
id: "seat_n".into(),
role: Some("diner".into()),
at: (0.0, 0.75, -0.5),
yaw: 0.0,
}],
},
effects: vec!["uses-electricity".into()],
..crate_desc()
};
let text =
ron::ser::to_string_pretty(&d, ron::ser::PrettyConfig::default()).expect("serializes");
let back: Descriptor = ron::from_str(&text).expect("parses");
assert_eq!(d, back);
}
#[test]
fn a_descriptor_written_before_provenance_keeps_no_record_through_a_round_trip() {
let d: Descriptor = ron::from_str("(id: \"crate\")").expect("parses");
assert_eq!(d.labels, None, "no record means no record");
let text =
ron::ser::to_string_pretty(&d, ron::ser::PrettyConfig::default()).expect("serializes");
assert!(
!text.contains("labels"),
"an absent record must not be written back as an empty one: {text}"
);
let back: Descriptor = ron::from_str(&text).expect("parses");
assert_eq!(back.labels, None, "and it must still be absent on the way in");
}
#[test]
fn a_provenance_record_with_no_date_is_refused_at_parse() {
let parsed = ron::from_str::<Descriptor>(
"(id: \"crate\", labels: Some(( by: ( kind: Some(Model) ), model: Some(\"qwen3-vl\") )))",
);
let err = match parsed {
Ok(d) => panic!(
"a record with no date parsed, and `at` came out {:?}",
d.labels.map(|l| l.at)
),
Err(e) => e.to_string(),
};
assert!(err.contains("at"), "the refusal must name the missing field: {err}");
}
#[test]
fn a_provenance_record_round_trips_whole() {
let d = Descriptor {
labels: Some(LabelOrigin {
by: AxisOrigin {
kind: Some(By::Model),
note: Some(By::Human),
..AxisOrigin::default()
},
model: Some("qwen3-vl".into()),
at: "2026-08-22".into(),
model_at: Some("2026-08-20".into()),
confidence: Some(Confidence::High),
}),
..crate_desc()
};
let text =
ron::ser::to_string_pretty(&d, ron::ser::PrettyConfig::default()).expect("serializes");
let back: Descriptor = ron::from_str(&text).expect("parses");
assert_eq!(d, back);
}
#[test]
fn a_record_written_before_model_at_reads_its_model_date_from_at() {
let old: LabelOrigin = ron::from_str(
r#"(by: (kind: Some(Model)), model: Some("qwen3-vl"), at: "2026-08-22")"#,
)
.expect("parses");
assert_eq!(old.model_date(), Some("2026-08-22"));
let human: LabelOrigin = ron::from_str(r#"(at: "2026-08-22")"#).expect("parses");
assert_eq!(human.model_date(), None);
let stamped = LabelOrigin::stamped("qwen3-vl", "2026-08-20", None);
assert_eq!(stamped.model_date(), Some("2026-08-20"));
}
}
#[cfg(test)]
mod face_tests {
use super::*;
#[test]
fn a_faces_yaw_points_out_of_it() {
for face in [Face::North, Face::East, Face::South, Face::West] {
let yaw = face.yaw_degrees().to_radians();
let (x, z) = (yaw.sin(), yaw.cos());
let want = match face {
Face::North => (0.0, -1.0),
Face::East => (1.0, 0.0),
Face::South => (0.0, 1.0),
Face::West => (-1.0, 0.0),
};
assert!(
(x - want.0).abs() < 1e-5 && (z - want.1).abs() < 1e-5,
"{}: forward is ({x:.3}, {z:.3}), wanted {want:?}",
face.label()
);
}
}
#[test]
fn an_angle_snaps_to_the_face_it_is_nearest() {
for face in [Face::North, Face::East, Face::South, Face::West] {
assert_eq!(Face::from_yaw(face.yaw_degrees()), face);
for drift in [-44.0, -1.0, 1.0, 44.0] {
assert_eq!(Face::from_yaw(face.yaw_degrees() + drift), face, "{drift}");
assert_eq!(Face::from_yaw(face.yaw_degrees() + drift + 360.0), face);
assert_eq!(Face::from_yaw(face.yaw_degrees() + drift - 360.0), face);
}
}
assert_eq!(Face::from_yaw(90.0), Face::East);
}
#[test]
fn a_yaw_that_is_not_an_angle_falls_to_the_identity_face() {
assert_eq!(Face::from_yaw(f32::NAN), Face::South);
assert_eq!(Face::South.yaw_degrees(), 0.0);
}
#[test]
fn opposite_is_its_own_inverse_and_never_itself() {
for face in [Face::North, Face::East, Face::South, Face::West] {
assert_eq!(face.opposite().opposite(), face);
assert_ne!(face.opposite(), face);
}
}
#[test]
fn a_face_is_the_same_direction_the_lattice_uses() {
assert_eq!(Face::North.dir(), crate::wfc::N);
assert_eq!(Face::East.dir(), crate::wfc::E);
assert_eq!(Face::South.dir(), crate::wfc::S);
assert_eq!(Face::West.dir(), crate::wfc::W);
}
#[test]
fn a_front_round_trips_through_ron() {
let before = Descriptor {
id: "chair".into(),
align: Align {
front: Some(Face::East),
..Align::default()
},
..Descriptor::default()
};
let text = ron::ser::to_string_pretty(&before, ron::ser::PrettyConfig::default())
.unwrap_or_else(|e| panic!("{e}"));
assert!(text.contains("front: Some(East)"), "{text}");
let after: Descriptor = ron::from_str(&text).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(before, after);
}
}
#[cfg(test)]
mod subgrid_tests {
use super::*;
fn grid(cells: Vec<SubCell>) -> Subgrid {
Subgrid { cells }
}
fn cell(at: (u32, u32, u32)) -> SubCell {
SubCell {
at,
..SubCell::default()
}
}
const D3: (u32, u32, u32) = (3, 3, 3);
fn sized(w: f32, h: f32, d: f32) -> Descriptor {
Descriptor {
id: "x".into(),
extent: Extent {
footprint: Some((w, d)),
height: Some(h),
},
..Descriptor::default()
}
}
#[test]
fn a_tile_says_nothing_about_its_inside_by_default() {
let g = Subgrid::default();
assert_eq!(Subgrid::volume(D3), 27);
assert!(g.cells.is_empty());
assert!(g.validate("x", D3).is_ok());
}
#[test]
fn one_cell_can_be_solid_and_an_edge() {
let c = SubCell {
at: (2, 0, 1),
solid: true,
edge: Some("wall".into()),
};
let g = grid(vec![c.clone()]);
assert!(g.validate("table", D3).is_ok());
let got = g.at((2, 0, 1)).unwrap_or_else(|| panic!("no cell"));
assert_eq!(got, &c);
assert!(
g.at((0, 0, 0)).is_none(),
"unwritten cells are absent, not default rows"
);
}
#[test]
fn a_cell_outside_the_lattice_is_refused() {
let e = grid(vec![cell((3, 0, 0))])
.validate("desk", D3)
.err()
.unwrap_or_else(|| panic!("accepted"));
assert!(e.contains("outside"), "{e}");
}
#[test]
fn naming_one_cell_twice_is_refused() {
let e = grid(vec![cell((1, 1, 1)), cell((1, 1, 1))])
.validate("desk", D3)
.err()
.unwrap_or_else(|| panic!("accepted"));
assert!(e.contains("twice"), "{e}");
}
#[test]
fn an_axis_with_no_divisions_is_refused() {
let e = grid(vec![])
.validate("desk", (3, 0, 3))
.err()
.unwrap_or_else(|| panic!("accepted"));
assert!(e.contains("no cells"), "{e}");
}
#[test]
fn a_lattice_round_trips_through_ron() {
let before = Descriptor {
id: "desk".into(),
subgrid: Some(grid(vec![SubCell {
at: (0, 0, 2),
solid: true,
edge: Some("wall".into()),
}])),
..Descriptor::default()
};
let text = ron::ser::to_string_pretty(&before, ron::ser::PrettyConfig::default())
.unwrap_or_else(|e| panic!("{e}"));
let after: Descriptor = ron::from_str(&text).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(before, after);
}
#[test]
fn a_descriptor_written_before_the_lattice_still_parses() {
let d: Descriptor = ron::from_str("(id: \"crate\")").unwrap_or_else(|e| panic!("{e}"));
assert_eq!(d.subgrid, None);
}
#[test]
fn a_pieces_divisions_come_from_its_own_size() {
let at = |d: Descriptor| divisions(&d, 1).unwrap_or_else(|err| panic!("{err}"));
assert_eq!(at(sized(0.5, 0.5, 0.5)), (1, 1, 1), "crate");
assert_eq!(at(sized(0.5, 0.9, 0.5)), (1, 2, 1), "chair");
assert_eq!(at(sized(1.0, 0.75, 1.0)), (2, 2, 2), "table");
assert_eq!(at(sized(3.0, 2.4, 0.5)), (6, 5, 1), "wall");
assert_eq!(at(sized(1.0, 2.4, 0.5)), (2, 5, 1), "doorway");
let wall = at(sized(3.0, 2.4, 0.5));
let doorway = at(sized(1.0, 2.4, 0.5));
assert_eq!(wall.1, doorway.1);
assert_ne!(at(sized(0.5, 0.5, 0.5)).1, wall.1);
}
#[test]
fn dividing_a_tile_more_finely_refines_every_piece_equally() {
let wall = sized(3.0, 2.4, 0.5);
let one = divisions(&wall, 1).unwrap_or_else(|e| panic!("{e}"));
let three = divisions(&wall, 3).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(three, (one.0 * 3, one.1 * 3, one.2 * 3));
}
#[test]
fn a_piece_with_no_height_still_has_one_layer() {
let mut d = sized(1.0, 0.0, 1.0);
d.extent.height = None;
assert_eq!(
divisions(&d, 1).unwrap_or_else(|err| panic!("{err}")),
(2, 1, 2)
);
}
#[test]
fn a_piece_with_no_footprint_is_refused_by_name() {
let d = Descriptor {
id: "mystery".into(),
..Descriptor::default()
};
let err = divisions(&d, 1).err().unwrap_or_default();
assert!(
err.contains("mystery") && err.contains("footprint"),
"{err}"
);
}
#[test]
fn a_project_that_divides_a_tile_zero_ways_is_refused() {
let err = divisions(&sized(1.0, 1.0, 1.0), 0)
.err()
.unwrap_or_default();
assert!(err.contains("no cells"), "{err}");
}
#[test]
fn turning_swaps_the_x_and_z_divisions() {
let div = (6, 5, 1);
assert_eq!(rotate_div(div, 0), div);
assert_eq!(rotate_div(div, 1), (1, 5, 6));
assert_eq!(rotate_div(div, 2), div);
assert_eq!(rotate_div(div, 3), (1, 5, 6));
assert_eq!(rotate_div(div, 4), div);
}
}
#[cfg(test)]
mod subgrid_edit_tests {
use super::*;
const D3: (u32, u32, u32) = (3, 3, 3);
#[test]
fn toggling_a_cell_solid_and_back_leaves_no_trace() {
let mut g = Subgrid::default();
assert_eq!(g.toggle_solid((1, 0, 1), D3), Some(true));
assert_eq!(g.cells.len(), 1);
assert_eq!(g.toggle_solid((1, 0, 1), D3), Some(false));
assert!(
g.cells.is_empty(),
"an unmarked cell must leave no row behind"
);
}
#[test]
fn marking_solid_is_idempotent_where_toggling_is_not() {
let mut g = Subgrid::default();
g.set_solid((1, 0, 1), D3)
.unwrap_or_else(|| panic!("in range"));
g.set_solid((1, 0, 1), D3)
.unwrap_or_else(|| panic!("in range"));
assert_eq!(g.cells.len(), 1);
assert!(
g.at((1, 0, 1)).is_some_and(|c| c.solid),
"a second scan must not unmark it"
);
}
#[test]
fn a_cell_outside_the_lattice_cannot_be_written() {
let mut g = Subgrid::default();
assert_eq!(g.toggle_solid((3, 0, 0), D3), None);
assert_eq!(g.set_edge((0, 9, 0), D3, "wall"), None);
assert!(
g.cells.is_empty(),
"an out-of-range write must not append anything"
);
}
#[test]
fn a_token_set_and_cleared_leaves_no_row() {
let mut g = Subgrid::default();
g.set_edge((0, 0, 2), D3, "wall")
.unwrap_or_else(|| panic!("in range"));
let c = g.at((0, 0, 2)).unwrap_or_else(|| panic!("written"));
assert_eq!(c.edge.as_deref(), Some("wall"));
g.set_edge((0, 0, 2), D3, " ")
.unwrap_or_else(|| panic!("in range"));
assert!(g.cells.is_empty());
}
#[test]
fn clearing_one_facet_keeps_the_rest() {
let mut g = Subgrid::default();
g.toggle_solid((1, 1, 1), D3);
g.set_edge((1, 1, 1), D3, "wall")
.unwrap_or_else(|| panic!("in range"));
g.set_edge((1, 1, 1), D3, "")
.unwrap_or_else(|| panic!("in range"));
let c = g.at((1, 1, 1)).unwrap_or_else(|| panic!("still solid"));
assert!(c.solid);
assert!(c.edge.is_none());
}
#[test]
fn clear_forgets_the_whole_cell() {
let mut g = Subgrid::default();
g.toggle_solid((2, 2, 2), D3);
g.set_edge((2, 2, 2), D3, "wall")
.unwrap_or_else(|| panic!("in range"));
g.clear((2, 2, 2));
assert!(g.at((2, 2, 2)).is_none());
assert!(g.validate("x", D3).is_ok());
}
#[test]
fn a_patch_can_state_an_empty_lattice_without_it_meaning_silence() {
let base = Descriptor {
id: "wall".into(),
subgrid: Some(Subgrid {
cells: vec![SubCell {
at: (0, 0, 0),
solid: true,
..SubCell::default()
}],
}),
..Descriptor::default()
};
let silent = base.patched_with(&Descriptor::default());
assert_eq!(silent.subgrid, base.subgrid, "no opinion inherits");
let cleared = base.patched_with(&Descriptor {
subgrid: Some(Subgrid::default()),
..Descriptor::default()
});
assert_eq!(
cleared.subgrid,
Some(Subgrid::default()),
"an explicitly empty lattice clears the cells rather than inheriting them"
);
}
}
#[cfg(test)]
mod rotate_tests {
use super::*;
#[test]
fn a_rotation_is_read_as_quarter_turns() {
assert_eq!(quarter_turns_xyz((0, 0, 0), "x"), Ok((0, 0, 0)));
assert_eq!(quarter_turns_xyz((90, 180, 270), "x"), Ok((1, 2, 3)));
assert_eq!(quarter_turns_xyz((-90, 360, 720), "x"), Ok((3, 0, 0)));
}
#[test]
fn a_rotation_that_is_not_a_quarter_turn_is_refused_by_name() {
let err = quarter_turns_xyz((0, 45, 0), "lamp")
.err()
.unwrap_or_default();
assert!(
err.contains("lamp") && err.contains("45") && err.contains("about Y"),
"{err}"
);
}
#[test]
fn a_rotation_layers_like_every_other_correction() {
let base = Descriptor {
id: "door".into(),
align: Align {
rotate: Some((90, 0, 0)),
..Align::default()
},
..Descriptor::default()
};
assert_eq!(
base.patched_with(&Descriptor::default()).align.rotate,
Some((90, 0, 0)),
"silence inherits"
);
assert_eq!(
base.patched_with(&Descriptor {
align: Align {
rotate: Some((0, 90, 0)),
..Align::default()
},
..Descriptor::default()
})
.align
.rotate,
Some((0, 90, 0)),
"a stated rotation wins"
);
}
#[test]
fn a_rotation_round_trips_through_ron() {
let before = Descriptor {
id: "door".into(),
align: Align {
rotate: Some((90, 0, 180)),
..Align::default()
},
..Descriptor::default()
};
let text = ron::ser::to_string_pretty(&before, ron::ser::PrettyConfig::default())
.unwrap_or_else(|e| panic!("{e}"));
assert!(text.contains("rotate: Some((90, 0, 180))"), "{text}");
assert_eq!(
ron::from_str::<Descriptor>(&text).unwrap_or_else(|e| panic!("{e}")),
before
);
}
}
pub fn pick_cell(
ray_origin: [f32; 3],
dir: [f32; 3],
origin: [f32; 3],
size: [f32; 3],
div: (u32, u32, u32),
) -> Option<((u32, u32, u32), Option<Face>)> {
let n = [div.0, div.1, div.2];
if n.contains(&0) || size.iter().any(|s| *s <= 0.0) {
return None;
}
if ray_origin.iter().chain(dir.iter()).any(|v| !v.is_finite()) {
return None;
}
let mut t_enter = f32::NEG_INFINITY;
let mut t_exit = f32::INFINITY;
let mut entry: Option<(usize, bool)> = None;
for a in 0..3 {
let (lo, hi) = (origin[a], origin[a] + size[a]);
if dir[a].abs() < 1e-9 {
if ray_origin[a] < lo || ray_origin[a] > hi {
return None;
}
continue;
}
let (mut t1, mut t2) = ((lo - ray_origin[a]) / dir[a], (hi - ray_origin[a]) / dir[a]);
let from_low = t1 <= t2;
if !from_low {
std::mem::swap(&mut t1, &mut t2);
}
if t1 > t_enter {
t_enter = t1;
entry = Some((a, from_low));
}
t_exit = t_exit.min(t2);
if t_enter > t_exit {
return None;
}
}
if t_enter <= 0.0 {
return None;
}
let hit = [
ray_origin[0] + dir[0] * t_enter,
ray_origin[1] + dir[1] * t_enter,
ray_origin[2] + dir[2] * t_enter,
];
let mut cell = [0u32; 3];
for a in 0..3 {
let frac = (hit[a] - origin[a]) / size[a];
cell[a] = ((frac * n[a] as f32) as i64).clamp(0, n[a] as i64 - 1) as u32;
}
let face = entry.and_then(|(axis, from_low)| match (axis, from_low) {
(0, true) => Some(Face::West),
(0, false) => Some(Face::East),
(2, true) => Some(Face::North),
(2, false) => Some(Face::South),
_ => None,
});
Some(((cell[0], cell[1], cell[2]), face))
}
#[cfg(test)]
mod pick_tests {
use super::*;
const ORIGIN: [f32; 3] = [0.0, 0.0, 0.0];
const SIZE: [f32; 3] = [3.0, 2.4, 0.5];
const DIV: (u32, u32, u32) = (6, 5, 1);
#[test]
fn a_ray_from_the_east_reports_the_east_face() {
let got = pick_cell([10.0, 1.2, 0.25], [-1.0, 0.0, 0.0], ORIGIN, SIZE, DIV);
let ((x, _, _), face) = got.unwrap_or_else(|| panic!("must hit"));
assert_eq!(x, DIV.0 - 1, "the near column, not the far one");
assert_eq!(face, Some(Face::East));
}
#[test]
fn a_ray_from_the_west_reports_the_west_face() {
let got = pick_cell([-10.0, 1.2, 0.25], [1.0, 0.0, 0.0], ORIGIN, SIZE, DIV);
let ((x, _, _), face) = got.unwrap_or_else(|| panic!("must hit"));
assert_eq!(x, 0);
assert_eq!(face, Some(Face::West));
}
#[test]
fn the_z_faces_follow_the_projects_own_compass() {
let from_north = pick_cell([1.5, 1.2, -10.0], [0.0, 0.0, 1.0], ORIGIN, SIZE, DIV);
assert_eq!(from_north.and_then(|(_, f)| f), Some(Face::North));
let from_south = pick_cell([1.5, 1.2, 10.0], [0.0, 0.0, -1.0], ORIGIN, SIZE, DIV);
assert_eq!(from_south.and_then(|(_, f)| f), Some(Face::South));
}
#[test]
fn looking_down_gives_a_cell_and_no_face() {
let got = pick_cell([1.5, 10.0, 0.25], [0.0, -1.0, 0.0], ORIGIN, SIZE, DIV);
let ((_, y, _), face) = got.unwrap_or_else(|| panic!("must hit"));
assert_eq!(y, DIV.1 - 1, "the top layer");
assert_eq!(face, None, "top and bottom are surfaces, not faces");
}
#[test]
fn the_cell_follows_the_hit_along_the_face() {
let got = pick_cell([1.25, 0.25, 10.0], [0.0, 0.0, -1.0], ORIGIN, SIZE, DIV);
let ((x, y, z), _) = got.unwrap_or_else(|| panic!("must hit"));
assert_eq!((x, y, z), (2, 0, 0), "third column, bottom layer");
}
#[test]
fn a_ray_that_misses_reports_nothing() {
assert!(pick_cell([10.0, 1.2, 9.0], [-1.0, 0.0, 0.0], ORIGIN, SIZE, DIV).is_none());
assert!(pick_cell([10.0, 9.0, 0.25], [-1.0, 0.0, 0.0], ORIGIN, SIZE, DIV).is_none());
assert!(pick_cell([10.0, 1.2, 0.25], [1.0, 0.0, 0.0], ORIGIN, SIZE, DIV).is_none());
assert!(pick_cell([1.5, 1.2, 0.25], [0.0, 0.0, -1.0], ORIGIN, SIZE, DIV).is_none());
}
#[test]
fn a_lattice_or_ray_that_cannot_be_picked_is_refused() {
assert!(pick_cell([0.0, 0.0, 10.0], [0.0, 0.0, -1.0], ORIGIN, SIZE, (0, 1, 1)).is_none());
assert!(pick_cell([0.0, 0.0, 10.0], [0.0, 0.0, -1.0], ORIGIN, [0.0; 3], DIV).is_none());
assert!(pick_cell([f32::NAN, 0.0, 10.0], [0.0, 0.0, -1.0], ORIGIN, SIZE, DIV).is_none());
}
#[test]
fn sweeping_the_face_walks_every_column_in_order() {
let mut seen = Vec::new();
for i in 0..DIV.0 {
let x = (i as f32 + 0.5) * SIZE[0] / DIV.0 as f32;
let got = pick_cell([x, 0.25, 10.0], [0.0, 0.0, -1.0], ORIGIN, SIZE, DIV);
let ((cx, _, _), _) = got.unwrap_or_else(|| panic!("column {i} must hit"));
seen.push(cx);
}
assert_eq!(seen, (0..DIV.0).collect::<Vec<_>>());
}
}