use std::path::{Path, PathBuf};
use crate::descriptor::{Align, Descriptor, Extent};
use crate::glb::{Glb, Measured, OriginAlignment};
use crate::library::Library;
use crate::grid;
use crate::naming;
pub const BUSY_TRIANGLES: usize = 20_000;
pub const IMPLAUSIBLE_METRES: f32 = 30.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Note,
Warn,
Blocking,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Finding {
pub severity: Severity,
pub message: String,
pub fix: Option<String>,
}
impl Finding {
fn note(message: impl Into<String>) -> Finding {
Finding {
severity: Severity::Note,
message: message.into(),
fix: None,
}
}
fn warn(message: impl Into<String>, fix: impl Into<String>) -> Finding {
Finding {
severity: Severity::Warn,
message: message.into(),
fix: Some(fix.into()),
}
}
fn blocking(message: impl Into<String>, fix: impl Into<String>) -> Finding {
Finding {
severity: Severity::Blocking,
message: message.into(),
fix: Some(fix.into()),
}
}
}
#[derive(Clone, Debug)]
pub struct Candidate {
pub mesh: String,
pub proposed: Descriptor,
pub measured: Option<Measured>,
pub front_detail: Option<(f32, f32)>,
pub triangles: usize,
pub findings: Vec<Finding>,
}
impl Candidate {
pub fn blocked(&self) -> bool {
self.findings.iter().any(|f| f.severity == Severity::Blocking)
}
pub fn worst(&self) -> Option<Severity> {
self.findings.iter().map(|f| f.severity).max()
}
}
pub fn scan(asset_root: &Path, dir: &Path, library: &Library) -> Result<Vec<Candidate>, String> {
let mut paths = Vec::new();
collect_glb(dir, &mut paths)?;
paths.sort();
let known: Vec<&str> = library
.descriptors
.iter()
.filter_map(|d| d.mesh.as_deref())
.collect();
let mut out = Vec::new();
for path in paths {
let rel = path
.strip_prefix(asset_root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
if known.contains(&rel.as_str()) {
continue;
}
out.push(measure(&path, &rel, library));
}
Ok(out)
}
fn collect_glb(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
let entries =
std::fs::read_dir(dir).map_err(|e| format!("import: {}: {e}", dir.display()))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_glb(&path, out)?;
} else if path.extension().is_some_and(|e| e == "glb") {
out.push(path);
}
}
Ok(())
}
pub fn proposed_id(stem: &str) -> String {
naming::to_snake_case(stem)
}
pub fn measure(path: &Path, rel: &str, library: &Library) -> Candidate {
let mut findings = Vec::new();
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let id = proposed_id(&stem);
if naming::to_snake_case(&stem).is_empty() {
findings.push(Finding::warn(
format!("`{stem}` leaves nothing usable as an id"),
"type one before committing",
));
}
let mut proposed = Descriptor {
id,
mesh: Some(rel.to_owned()),
..Descriptor::default()
};
let glb = match Glb::open(path) {
Ok(g) => g,
Err(e) => {
findings.push(Finding::blocking(
format!("this file cannot be read: {e}"),
"re-export it, or check it is a binary glTF rather than a .gltf + .bin pair",
));
return Candidate {
mesh: rel.to_owned(),
proposed,
measured: None,
front_detail: None,
triangles: 0,
findings,
};
}
};
let assembled = glb.has_node_transform();
let measured = match glb.measure() {
Ok(m) => m,
Err(e) => {
findings.push(Finding::blocking(
format!("this file has no measurable geometry: {e}"),
"check the export included a mesh",
));
return Candidate {
mesh: rel.to_owned(),
proposed,
measured: None,
front_detail: None,
triangles: 0,
findings,
};
}
};
proposed.extent = Extent {
footprint: Some(measured.footprint),
height: Some(measured.height),
};
proposed.align = Align {
y_offset: Some(-measured.base_y),
pivot: Some(measured.pivot),
..Align::default()
};
let front_detail = if assembled { None } else { glb.front_detail().ok() };
proposed.align.front = if assembled {
findings.push(Finding::note(
"this model is assembled from parts placed by node transforms, so no facing is derived. \
The size is still correct — it is measured from the assembled scene — but a front has to \
come from a centroid, and a centroid over raw vertices would ignore those transforms and \
point the wrong way. Set the front by hand if this piece has one.",
));
None
} else {
glb.derive_front().ok().flatten()
};
let tris = triangles(&glb);
findings.extend(inspect(&measured, front_detail, tris, rel, library));
Candidate {
mesh: rel.to_owned(),
proposed,
measured: Some(measured),
front_detail,
triangles: tris,
findings,
}
}
fn inspect(
m: &Measured,
front: Option<(f32, f32)>,
triangles: usize,
rel: &str,
library: &Library,
) -> Vec<Finding> {
let mut out = Vec::new();
let (w, d) = m.footprint;
if m.suspect_centimetres {
out.push(Finding::warn(
format!(
"this measures {w:.1} x {d:.1} x {:.1} — that is centimetre data read as metres",
m.height
),
"set scale to 0.01, or re-export with the unit baked in",
));
} else if w.max(d).max(m.height) > IMPLAUSIBLE_METRES {
out.push(Finding::warn(
format!(
"this is {:.1} m at its largest — architecture, or a unit mistake",
w.max(d).max(m.height)
),
"check the export scale before placing hundreds of it",
));
}
match origin_verdict(m) {
OriginAlignment::BaseAtOriginCentred => {}
OriginAlignment::Centred => out.push(Finding::warn(
format!(
"the origin is at the centre of the mesh, so it would sink {:.2} m into the floor",
-m.base_y
),
"the proposed y_offset already corrects this; re-export base-at-origin to make it true \
of the file",
)),
OriginAlignment::Offset => out.push(Finding::warn(
format!(
"the origin is off-centre by ({:.2}, {:.2}) and {:.2} m off the base",
m.pivot.0, m.pivot.1, m.base_y
),
"y_offset corrects the sink, but pivot is only honoured by furniture placement — in a \
map or a tile this piece will sit off its own footprint. Re-export centred with its \
base at zero to fix it for good",
)),
}
let (cells_x, slack_x) = grid::cells(w);
let (cells_z, slack_z) = grid::cells(d);
let tiling = if slack_x.abs() < 1e-3 && slack_z.abs() < 1e-3 {
"tiles exactly".to_owned()
} else {
let phrase = |slack: f32| {
if slack >= 0.0 {
format!("{:.0} mm gap", slack * 1000.0)
} else {
format!("{:.0} mm overlap", -slack * 1000.0)
}
};
format!("tiled it leaves {} x {}", phrase(slack_x), phrase(slack_z))
};
out.push(Finding::note(format!(
"{w:.2} x {d:.2} m occupies {cells_x} x {cells_z} cells on the {} m grid; {tiling}",
grid::SNAP
)));
if let Some((asymmetry, yaw)) = front {
let limit = crate::glb::FRONT_MIN_OFFSET;
out.push(Finding::note(if asymmetry >= limit {
format!(
"the upper mass sits {:.0} mm off centre, so this has a front at {yaw:.0} deg \
(threshold {:.0} mm)",
asymmetry * 1000.0,
limit * 1000.0
)
} else {
format!(
"the upper mass is within {:.0} mm of centre, so no front is asserted (threshold \
{:.0} mm)",
asymmetry * 1000.0,
limit * 1000.0
)
}));
}
if triangles > BUSY_TRIANGLES {
out.push(Finding::warn(
format!(
"{triangles} triangles — dense for a piece a map may hold hundreds of copies of"
),
"check the exporter decimated this mesh; a re-export that forgets to decimate can \
silently return 15x the geometry",
));
}
let spans = |w: f32, h: f32, d: f32| {
let mut v = [w, h, d];
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
v
};
let mine = spans(m.footprint.0, m.height, m.footprint.1);
if let Some(twin) = library.descriptors.iter().find(|other| {
other.mesh.as_deref() != Some(rel)
&& match (other.extent.footprint, other.extent.height) {
(Some((w, d)), Some(h)) => spans(w, h, d)
.iter()
.zip(mine.iter())
.all(|(a, b)| (a - b).abs() < 1e-3),
_ => false,
}
}) {
out.push(Finding::note(format!(
"`{}` already has these exact measurements — this may be the same asset re-exported",
twin.id
)));
}
out
}
fn origin_verdict(m: &Measured) -> OriginAlignment {
let centred_xz = m.pivot.0.abs() <= crate::glb::ORIGIN_TOL
&& m.pivot.1.abs() <= crate::glb::ORIGIN_TOL;
let on_base = m.base_y.abs() <= crate::glb::ORIGIN_TOL;
let centred_y = (m.base_y + m.height * 0.5).abs() <= crate::glb::ORIGIN_TOL;
match (centred_xz, on_base, centred_y) {
(true, true, _) => OriginAlignment::BaseAtOriginCentred,
(true, false, true) => OriginAlignment::Centred,
_ => OriginAlignment::Offset,
}
}
pub fn remeasure_rotated(
d: &mut Descriptor,
glb: &Glb,
was: Option<(i32, i32, i32)>,
) -> Result<(), String> {
let quarters = match d.align.rotate {
Some(rotate) => crate::descriptor::quarter_turns_xyz(rotate, &d.id)?,
None => (0, 0, 0),
};
let before_quarters = match was {
Some(rotate) => crate::descriptor::quarter_turns_xyz(rotate, &d.id)?,
None => (0, 0, 0),
};
let raw = glb.measure()?;
let m = raw.rotated(quarters);
let before = raw.rotated(before_quarters);
let authored_lift = d.align.y_offset.unwrap_or(-before.base_y) + before.base_y;
d.extent.footprint = Some(m.footprint);
d.extent.height = Some(m.height);
d.align.pivot = Some(m.pivot);
d.align.y_offset = Some(-m.base_y + authored_lift);
Ok(())
}
pub fn triangles(glb: &Glb) -> usize {
let Some(meshes) = glb.json["meshes"].as_array() else {
return 0;
};
let count = |accessor: Option<&serde_json::Value>| -> usize {
accessor
.and_then(|i| i.as_u64())
.and_then(|i| glb.json["accessors"].get(i as usize))
.and_then(|a| a["count"].as_u64())
.unwrap_or(0) as usize
};
meshes
.iter()
.filter_map(|m| m["primitives"].as_array())
.flatten()
.map(|p| {
let n = match p.get("indices") {
Some(_) => count(p.get("indices")),
None => count(p["attributes"].get("POSITION")),
};
n / 3
})
.sum()
}
fn triangle_hits_box(tri: [[f32; 3]; 3], half: f32) -> bool {
let [a, b, c] = tri;
for i in 0..3 {
let (mn, mx) = (a[i].min(b[i]).min(c[i]), a[i].max(b[i]).max(c[i]));
if mn > half || mx < -half {
return false;
}
}
let sub = |p: [f32; 3], q: [f32; 3]| [p[0] - q[0], p[1] - q[1], p[2] - q[2]];
let cross = |p: [f32; 3], q: [f32; 3]| {
[
p[1] * q[2] - p[2] * q[1],
p[2] * q[0] - p[0] * q[2],
p[0] * q[1] - p[1] * q[0],
]
};
let dot = |p: [f32; 3], q: [f32; 3]| p[0] * q[0] + p[1] * q[1] + p[2] * q[2];
let n = cross(sub(b, a), sub(c, a));
let d = dot(n, a);
let r = half * (n[0].abs() + n[1].abs() + n[2].abs());
if d.abs() > r {
return false;
}
let edges = [sub(b, a), sub(c, b), sub(a, c)];
let verts = [a, b, c];
for e in edges {
for axis in 0..3 {
let mut unit = [0.0f32; 3];
unit[axis] = 1.0;
let ax = cross(unit, e);
if ax[0].abs() + ax[1].abs() + ax[2].abs() < 1e-12 {
continue;
}
let p: Vec<f32> = verts.iter().map(|v| dot(ax, *v)).collect();
let (mn, mx) = p.iter().fold((f32::MAX, f32::MIN), |(l, h), v| (l.min(*v), h.max(*v)));
let r = half * (ax[0].abs() + ax[1].abs() + ax[2].abs());
if mn > r || mx < -r {
return false;
}
}
}
true
}
pub fn occupancy(
glb: &Glb,
div: (u32, u32, u32),
rotate: (u8, u8, u8),
) -> Result<Vec<(u32, u32, u32)>, String> {
let (dx, dy, dz) = div;
if dx == 0 || dy == 0 || dz == 0 {
return Err(format!(
"a {dx}x{dy}x{dz} lattice has no cells for a mesh to occupy"
));
}
let tris: Vec<[[f32; 3]; 3]> = glb
.triangle_vertices()?
.into_iter()
.map(|t| t.map(|v| crate::glb::spin(v, rotate)))
.collect();
let mut lo = [f32::INFINITY; 3];
let mut hi = [f32::NEG_INFINITY; 3];
for t in &tris {
for v in t {
for a in 0..3 {
if !v[a].is_finite() {
return Err("glb: a vertex position is not a finite number".to_owned());
}
lo[a] = lo[a].min(v[a]);
hi[a] = hi[a].max(v[a]);
}
}
}
let n = [dx, dy, dz];
let to_cell = |v: [f32; 3]| {
let mut out = [0.0f32; 3];
for a in 0..3 {
let span = hi[a] - lo[a];
out[a] = if span <= 0.0 {
0.5
} else {
(v[a] - lo[a]) / span * n[a] as f32
};
}
out
};
let mut marked = vec![false; (dx as usize) * (dy as usize) * (dz as usize)];
for t in &tris {
let p = [to_cell(t[0]), to_cell(t[1]), to_cell(t[2])];
let mut min = [0u32; 3];
let mut max = [0u32; 3];
for a in 0..3 {
let l = p[0][a].min(p[1][a]).min(p[2][a]).floor().max(0.0);
let h = p[0][a].max(p[1][a]).max(p[2][a]).ceil();
min[a] = (l as u32).min(n[a] - 1);
max[a] = (h.max(0.0) as u32).min(n[a] - 1);
}
for x in min[0]..=max[0] {
for y in min[1]..=max[1] {
for z in min[2]..=max[2] {
let at = ((x as usize) * dy as usize + y as usize) * dz as usize + z as usize;
if marked[at] {
continue;
}
let centre = [x as f32 + 0.5, y as f32 + 0.5, z as f32 + 0.5];
let rel = p.map(|v| {
[v[0] - centre[0], v[1] - centre[1], v[2] - centre[2]]
});
if triangle_hits_box(rel, 0.5) {
marked[at] = true;
}
}
}
}
}
let mut out = Vec::new();
for x in 0..dx {
for y in 0..dy {
for z in 0..dz {
let at = ((x as usize) * dy as usize + y as usize) * dz as usize + z as usize;
if marked[at] {
out.push((x, y, z));
}
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library::LIBRARY_VERSION;
fn empty_library() -> Library {
Library {
version: LIBRARY_VERSION,
note: None,
descriptors: Vec::new(),
}
}
fn measured(footprint: (f32, f32), height: f32, base_y: f32, pivot: (f32, f32)) -> Measured {
Measured {
lo: [0.0, base_y, 0.0],
hi: [footprint.0, base_y + height, footprint.1],
footprint,
height,
pivot,
base_y,
suspect_centimetres: false,
}
}
fn messages(f: &[Finding]) -> String {
f.iter().map(|f| f.message.clone()).collect::<Vec<_>>().join(" | ")
}
#[test]
fn a_proposal_is_the_stem_and_not_its_pack() {
assert_eq!(proposed_id("Chair"), "chair");
assert_eq!(
proposed_id("Wall Corner"),
"wall_corner",
"the project's one spelling, whatever the file was called"
);
assert_eq!(
proposed_id("Bed Single"),
"bed_single",
"the id the refusal message told the author to type, proposed for them instead"
);
}
#[test]
fn a_clean_mesh_only_reports_notes() {
let m = measured((1.0, 1.0), 0.5, 0.0, (0.0, 0.0));
let f = inspect(&m, None, 500, "kit/floor.glb", &empty_library());
assert!(
f.iter().all(|f| f.severity == Severity::Note),
"unexpected: {}",
messages(&f)
);
}
#[test]
fn centimetre_data_is_caught_and_the_fix_is_named() {
let mut m = measured((200.3, 20.0), 200.3, 0.0, (0.0, 0.0));
m.suspect_centimetres = true;
let f = inspect(&m, None, 100, "kit/door.glb", &empty_library());
let hit = f
.iter()
.find(|f| f.message.contains("centimetre"))
.unwrap_or_else(|| panic!("{}", messages(&f)));
assert_eq!(hit.severity, Severity::Warn);
assert!(hit.fix.as_deref().unwrap_or_default().contains("0.01"));
}
#[test]
fn a_centred_origin_is_reported_with_how_far_it_would_sink() {
let m = measured((1.0, 1.0), 2.0, -1.0, (0.0, 0.0));
let f = inspect(&m, None, 100, "kit/x.glb", &empty_library());
let hit = f
.iter()
.find(|f| f.message.contains("centre of the mesh"))
.unwrap_or_else(|| panic!("{}", messages(&f)));
assert!(hit.message.contains("1.00 m"), "{}", hit.message);
}
#[test]
fn the_grid_note_gives_cells_and_the_tiling_slack() {
let m = measured((1.45, 0.5), 0.8, 0.0, (0.0, 0.0));
let f = inspect(&m, None, 100, "kit/drawer.glb", &empty_library());
let hit = f
.iter()
.find(|f| f.message.contains("occupies"))
.unwrap_or_else(|| panic!("{}", messages(&f)));
assert!(hit.message.contains("3 x 1 cells"), "{}", hit.message);
assert!(hit.message.contains("50 mm gap"), "{}", hit.message);
}
#[test]
fn an_oversized_piece_reports_an_overlap_not_a_gap() {
let m = measured((0.55, 0.5), 0.8, 0.0, (0.0, 0.0));
let f = inspect(&m, None, 100, "kit/bench.glb", &empty_library());
assert!(messages(&f).contains("50 mm overlap"), "{}", messages(&f));
}
#[test]
fn a_footprint_on_the_grid_says_so_too() {
let m = measured((1.5, 0.5), 0.8, 0.0, (0.0, 0.0));
let f = inspect(&m, None, 100, "kit/x.glb", &empty_library());
assert!(messages(&f).contains("tiles exactly"), "{}", messages(&f));
}
#[test]
fn the_front_finding_carries_the_asymmetry_and_the_angle() {
let m = measured((0.5, 0.5), 1.0, 0.0, (0.0, 0.0));
let backed = inspect(&m, Some((0.166, 90.0)), 100, "a.glb", &empty_library());
assert!(messages(&backed).contains("166 mm"), "{}", messages(&backed));
assert!(messages(&backed).contains("has a front at 90 deg"), "{}", messages(&backed));
let stool = inspect(&m, Some((0.012, 90.0)), 100, "b.glb", &empty_library());
assert!(messages(&stool).contains("no front is asserted"), "{}", messages(&stool));
}
#[test]
fn a_dense_mesh_is_flagged_with_the_decimation_question() {
let m = measured((1.0, 1.0), 1.0, 0.0, (0.0, 0.0));
let f = inspect(&m, None, BUSY_TRIANGLES + 1, "x.glb", &empty_library());
let hit = f
.iter()
.find(|f| f.message.contains("triangles"))
.unwrap_or_else(|| panic!("{}", messages(&f)));
assert!(hit.fix.as_deref().unwrap_or_default().contains("decimate"));
}
#[test]
fn a_mesh_matching_an_existing_entrys_measurements_is_flagged() {
let mut lib = empty_library();
lib.descriptors.push(Descriptor {
id: "crate".into(),
mesh: Some("kit/crate.glb".into()),
extent: Extent {
footprint: Some((0.6, 0.6)),
height: Some(0.6),
},
..Descriptor::default()
});
let m = measured((0.6, 0.6), 0.6, 0.0, (0.0, 0.0));
let f = inspect(&m, None, 100, "kit/crate_v2.glb", &lib);
assert!(messages(&f).contains("`crate` already has these"), "{}", messages(&f));
}
#[test]
fn severity_orders_so_the_worst_finding_can_be_found() {
assert!(Severity::Blocking > Severity::Warn);
assert!(Severity::Warn > Severity::Note);
}
}
#[cfg(test)]
mod occupancy_tests {
use super::*;
fn mesh(tris: &[[[f32; 3]; 3]]) -> Glb {
let count = tris.len() * 3;
let mut lo = [f32::INFINITY; 3];
let mut hi = [f32::NEG_INFINITY; 3];
for t in tris {
for v in t {
for a in 0..3 {
lo[a] = lo[a].min(v[a]);
hi[a] = hi[a].max(v[a]);
}
}
}
let list = |v: [f32; 3]| format!("[{},{},{}]", v[0], v[1], v[2]);
let json = format!(
r#"{{
"accessors":[{{"type":"VEC3","componentType":5126,"count":{count},"bufferView":0,"min":{},"max":{}}}],
"bufferViews":[{{"byteOffset":0,"byteLength":{}}}],
"meshes":[{{"primitives":[{{"attributes":{{"POSITION":0}}}}]}}]
}}"#,
list(lo),
list(hi),
count * 12
);
let mut bin = Vec::new();
for t in tris {
for v in t {
for c in v {
bin.extend_from_slice(&c.to_le_bytes());
}
}
}
let j = json.as_bytes();
let mut jj = j.to_vec();
jj.extend(std::iter::repeat_n(b' ', (4 - j.len() % 4) % 4));
let total = 12 + 8 + jj.len() + 8 + bin.len();
let mut out = Vec::with_capacity(total);
out.extend_from_slice(b"glTF");
out.extend_from_slice(&2u32.to_le_bytes());
out.extend_from_slice(&(total as u32).to_le_bytes());
out.extend_from_slice(&(jj.len() as u32).to_le_bytes());
out.extend_from_slice(b"JSON");
out.extend_from_slice(&jj);
out.extend_from_slice(&(bin.len() as u32).to_le_bytes());
out.extend_from_slice(b"BIN\0");
out.extend_from_slice(&bin);
Glb::parse(&out).unwrap_or_else(|e| panic!("{e}"))
}
fn slab(x: (f32, f32), y: f32, z: (f32, f32)) -> Vec<[[f32; 3]; 3]> {
let (x0, x1) = x;
let (z0, z1) = z;
vec![
[[x0, y, z0], [x1, y, z0], [x1, y, z1]],
[[x0, y, z0], [x1, y, z1], [x0, y, z1]],
]
}
#[test]
fn a_turn_keeps_the_lift_the_author_added() {
let glb = mesh(&box_mesh([0.0, 0.5, 0.0], [1.0, 1.0, 1.0]));
let raw = glb.measure().unwrap_or_else(|e| panic!("{e}"));
assert!((raw.base_y - 0.5).abs() < 1e-6, "base_y {}", raw.base_y);
for (authored, lift) in [(-0.5f32, 0.0f32), (-0.4, 0.1), (-0.56, -0.06)] {
for turn in [(0, 90, 0), (90, 0, 0), (0, 0, 90)] {
let mut d = Descriptor {
id: "plate".to_owned(),
align: Align {
y_offset: Some(authored),
rotate: Some(turn),
..Align::default()
},
..Descriptor::default()
};
remeasure_rotated(&mut d, &glb, None).unwrap_or_else(|e| panic!("{e}"));
let base_now = glb
.measure()
.unwrap_or_else(|e| panic!("{e}"))
.rotated(crate::descriptor::quarter_turns_xyz(turn, "plate").unwrap_or_else(|e| panic!("{e}")))
.base_y;
let got = d.align.y_offset.unwrap_or(f32::NAN);
assert!(
(got - (-base_now + lift)).abs() < 1e-5,
"turn {turn:?}, authored {authored}: y_offset {got}, wanted {} \
(measurement {} plus the author's {lift})",
-base_now + lift,
-base_now
);
}
}
}
#[test]
fn an_identity_turn_leaves_the_offset_exactly_where_it_was() {
let glb = mesh(&box_mesh([0.0, 0.5, 0.0], [1.0, 1.0, 1.0]));
let mut d = Descriptor {
id: "plate".to_owned(),
align: Align {
y_offset: Some(-0.56),
..Align::default()
},
..Descriptor::default()
};
remeasure_rotated(&mut d, &glb, None).unwrap_or_else(|e| panic!("{e}"));
assert!(
(d.align.y_offset.unwrap_or(f32::NAN) + 0.56).abs() < 1e-6,
"{:?}",
d.align.y_offset
);
}
#[test]
fn a_rotation_reads_the_mesh_in_the_frame_its_divisions_came_from() {
let wedge = {
let mut out = box_mesh([0.0, 0.0, 0.0], [1.0, 0.25, 0.5]);
out.extend(box_mesh([0.0, 0.0, 0.0], [0.25, 0.75, 0.5]));
out
};
for rotate in [(1, 0, 0), (3, 0, 0), (0, 1, 0), (0, 0, 1), (1, 1, 0), (2, 1, 3)] {
let turned: Vec<[[f32; 3]; 3]> = wedge
.iter()
.map(|t| t.map(|v| crate::glb::spin(v, rotate)))
.collect();
let div = (3, 2, 4);
assert_eq!(
occupancy(&mesh(&wedge), div, rotate).unwrap_or_else(|e| panic!("{e}")),
occupancy(&mesh(&turned), div, (0, 0, 0)).unwrap_or_else(|e| panic!("{e}")),
"rotate {rotate:?}: reading the mesh turned must equal reading the turned mesh"
);
}
}
fn box_mesh(lo: [f32; 3], hi: [f32; 3]) -> Vec<[[f32; 3]; 3]> {
let c = |x: usize, y: usize, z: usize| {
[
if x == 0 { lo[0] } else { hi[0] },
if y == 0 { lo[1] } else { hi[1] },
if z == 0 { lo[2] } else { hi[2] },
]
};
let quad = |a, b, cc, d| vec![[a, b, cc], [a, cc, d]];
let mut out = Vec::new();
for (a, b, cc, d) in [
(c(0,0,0), c(1,0,0), c(1,1,0), c(0,1,0)),
(c(0,0,1), c(1,0,1), c(1,1,1), c(0,1,1)),
(c(0,0,0), c(0,0,1), c(0,1,1), c(0,1,0)),
(c(1,0,0), c(1,0,1), c(1,1,1), c(1,1,0)),
(c(0,0,0), c(1,0,0), c(1,0,1), c(0,0,1)),
(c(0,1,0), c(1,1,0), c(1,1,1), c(0,1,1)),
] {
out.extend(quad(a, b, cc, d));
}
out
}
#[test]
fn a_wall_slab_is_solid_all_the_way_up() {
let wall = box_mesh([0.0, 0.0, 0.0], [3.0, 2.4, 0.5]);
let div = (6, 5, 1);
let got = occupancy(&mesh(&wall), div, (0, 0, 0)).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(
got.len() as u32,
grimoire_core_volume(div),
"every cell of a solid slab is solid; got {} of {}",
got.len(),
grimoire_core_volume(div)
);
}
fn grimoire_core_volume(div: (u32, u32, u32)) -> u32 {
crate::descriptor::Subgrid::volume(div)
}
#[test]
fn a_box_fills_the_lattice_it_spans() {
let got = occupancy(&mesh(&box_mesh([0.0; 3], [1.0; 3])), (2, 2, 2), (0, 0, 0))
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(got.len(), 8, "{got:?}");
}
#[test]
fn the_far_corner_lands_in_the_last_cell() {
let tri = [[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 1.0]]];
let got = occupancy(&mesh(&tri), (4, 4, 4), (0, 0, 0)).unwrap_or_else(|e| panic!("{e}"));
assert!(got.contains(&(0, 0, 0)), "{got:?}");
assert!(got.contains(&(3, 3, 3)), "the vertex at the maximum bound: {got:?}");
}
#[test]
fn an_l_shape_leaves_its_inner_corner_clear() {
let mut l = slab((0.0, 2.0), 0.0, (0.0, 0.9));
l.extend(slab((0.0, 0.9), 0.0, (0.0, 2.0)));
let got = occupancy(&mesh(&l), (2, 1, 2), (0, 0, 0)).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(got, vec![(0, 0, 0), (0, 0, 1), (1, 0, 0)]);
assert!(!got.contains(&(1, 0, 1)), "the inner corner must stay open");
}
#[test]
fn a_centimetre_mesh_marks_the_same_cells_as_its_metre_twin() {
let m = box_mesh([0.0, 0.0, 0.0], [2.0, 1.0, 2.0]);
let cm: Vec<[[f32; 3]; 3]> = m
.iter()
.map(|t| t.map(|v| [v[0] * 100.0, v[1] * 100.0, v[2] * 100.0]))
.collect();
assert_eq!(
occupancy(&mesh(&m), (4, 2, 4), (0, 0, 0)).unwrap_or_else(|e| panic!("{e}")),
occupancy(&mesh(&cm), (4, 2, 4), (0, 0, 0)).unwrap_or_else(|e| panic!("{e}"))
);
}
#[test]
fn stretching_an_axis_does_not_move_a_single_cell() {
let base = box_mesh([0.0, 0.0, 0.0], [2.0, 1.0, 2.0]);
let tall: Vec<[[f32; 3]; 3]> = base
.iter()
.map(|t| t.map(|v| [v[0], v[1] * 2.4, v[2]]))
.collect();
assert_eq!(
occupancy(&mesh(&base), (3, 3, 3), (0, 0, 0)).unwrap_or_else(|e| panic!("{e}")),
occupancy(&mesh(&tall), (3, 3, 3), (0, 0, 0)).unwrap_or_else(|e| panic!("{e}"))
);
}
#[test]
fn a_flat_mesh_occupies_one_layer_rather_than_dividing_by_zero() {
let got = occupancy(&mesh(&slab((0.0, 1.0), 0.0, (0.0, 1.0))), (2, 1, 2), (0, 0, 0))
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(got.len(), 4);
assert!(got.iter().all(|c| c.1 == 0));
}
#[test]
fn the_answer_is_sorted_and_each_cell_appears_once() {
let got = occupancy(&mesh(&box_mesh([0.0; 3], [1.0; 3])), (3, 3, 3), (0, 0, 0))
.unwrap_or_else(|e| panic!("{e}"));
let mut sorted = got.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(got, sorted);
}
#[test]
fn a_lattice_with_no_cells_is_refused() {
let err = occupancy(&mesh(&slab((0.0, 1.0), 0.0, (0.0, 1.0))), (2, 0, 2), (0, 0, 0))
.err()
.unwrap_or_default();
assert!(err.contains("no cells"), "{err}");
}
}