use glam::DVec3;
use pantometry::prelude::*;
use pantometry::shape::{Mesh, Triangle, Voxels};
use pantometry_core::{Domain, Exchange, Schedule};
fn box_mesh(low: DVec3, high: DVec3) -> Mesh {
let (l, h) = (low, high);
let corner = [
DVec3::new(l.x, l.y, l.z),
DVec3::new(h.x, l.y, l.z),
DVec3::new(h.x, h.y, l.z),
DVec3::new(l.x, h.y, l.z),
DVec3::new(l.x, l.y, h.z),
DVec3::new(h.x, l.y, h.z),
DVec3::new(h.x, h.y, h.z),
DVec3::new(l.x, h.y, h.z),
];
let face = [
[0, 3, 2, 1],
[4, 5, 6, 7],
[0, 1, 5, 4],
[3, 7, 6, 2],
[0, 4, 7, 3],
[1, 2, 6, 5],
];
let mut triangles = Vec::with_capacity(12);
for f in face {
triangles.push(Triangle {
a: corner[f[0]],
b: corner[f[1]],
c: corner[f[2]],
});
triangles.push(Triangle {
a: corner[f[0]],
b: corner[f[2]],
c: corner[f[3]],
});
}
Mesh::new(triangles)
}
fn named(name: &str) -> Substance {
Substance::from_name(name).expect("in the catalogue")
}
fn block_from(voxels: &Voxels, part: &Substance, around: &Substance) -> Solid3D {
let mut block = Solid3D::new(
"part",
around.clone(),
voxels.counts(),
voxels.cell(),
Temperature::celsius(20.0),
);
block.fill(part.clone(), |i, j, k| voxels.contains(i, j, k));
block
}
#[test]
fn a_rasterised_part_carries_its_own_heat_capacity() {
let mesh = box_mesh(DVec3::ZERO, DVec3::new(0.024, 0.016, 0.008));
let voxels = Voxels::of(&mesh, Length::mm(2.0)).expect("a box is closed");
assert!(
voxels.loss().volume_error.abs() < 1e-12,
"an aligned box loses nothing: {:?}",
voxels.loss()
);
assert_eq!(voxels.filled(), 12 * 8 * 4, "and fills the cells it should");
let (aluminium, glass) = (named("aluminium"), named("borosilicate"));
let block = block_from(&voxels, &aluminium, &glass);
let meshed = mesh.volume();
let around = Volume::from_si(block.volume().to_si() - meshed.to_si());
let want = aluminium
.heat_capacity(meshed)
.expect("has a specific heat")
.to_si()
+ glass
.heat_capacity(around)
.expect("has a specific heat")
.to_si();
let got = block.heat_capacity().to_si();
println!(
" {} cells of aluminium in {} of glass: {got:.6} J/K against {want:.6} J/K",
voxels.filled(),
block.volume().to_si() / voxels.cell().to_si().powi(3),
);
assert!(
(got / want - 1.0).abs() < 1e-12,
"the cells hold what the mesh's own volume says: {got} against {want}"
);
let (nx, ny, nz) = voxels.counts();
for (i, j, k, want_part) in [
(nx / 2, ny / 2, nz / 2, true),
(0, 0, 0, false),
(nx - 1, ny - 1, nz - 1, false),
] {
let is_part = *block.substance_at(i, j, k) == aluminium;
assert_eq!(
is_part,
want_part,
"cell ({i}, {j}, {k}) should {} be part of the object",
if want_part { "" } else { "not" }
);
}
}
#[test]
fn a_hole_through_the_part_removes_exactly_its_own_volume() {
let (w, h, d) = (0.024, 0.024, 0.008);
let outer = box_mesh(DVec3::ZERO, DVec3::new(w, h, d));
let (lo, hi) = (0.008, 0.016);
let shaft: Vec<Triangle> = box_mesh(DVec3::new(lo, lo, 0.0), DVec3::new(hi, hi, d))
.triangles()
.iter()
.map(|t| Triangle {
a: t.a,
b: t.c,
c: t.b,
})
.collect();
let mut both = outer.triangles().to_vec();
both.extend(shaft);
let holed = Mesh::new(both);
let shaft_volume = (hi - lo) * (hi - lo) * d;
let want_volume = w * h * d - shaft_volume;
println!(
" the mesh encloses {:.4} mm3; the box less the shaft is {:.4} mm3",
holed.volume().to_si() * 1e9,
want_volume * 1e9
);
assert!(
(holed.volume().to_si() / want_volume - 1.0).abs() < 1e-12,
"an inward-wound inner shell subtracts: {:e} against {want_volume:e}",
holed.volume().to_si()
);
let pierced = Voxels::of(&holed, Length::mm(2.0)).expect("both shells are closed");
let solid = Voxels::of(&outer, Length::mm(2.0)).expect("closed");
println!(
" {} cells with the shaft against {} without, volume error {:.3e}, ambiguous rows {}",
pierced.filled(),
solid.filled(),
pierced.loss().volume_error,
pierced.loss().ambiguous_rows
);
assert_eq!(
pierced.counts(),
solid.counts(),
"the same bounding box either way, so the two blocks are comparable"
);
assert!(
pierced.loss().volume_error.abs() < 1e-12,
"cell-aligned inside and out, so the hole costs nothing extra: {:?}",
pierced.loss()
);
let (nx, ny, nz) = pierced.counts();
assert!(
!pierced.contains(nx / 2, ny / 2, nz / 2),
"the middle of the shaft is a hole, not metal"
);
assert!(
solid.contains(nx / 2, ny / 2, nz / 2),
"and it is metal when the shaft is not there, which is what makes the pair a measurement"
);
let (aluminium, glass) = (named("aluminium"), named("borosilicate"));
let removed = block_from(&solid, &aluminium, &glass)
.heat_capacity()
.to_si()
- block_from(&pierced, &aluminium, &glass)
.heat_capacity()
.to_si();
let shaft_volume = Volume::from_si(shaft_volume);
let want = aluminium
.heat_capacity(shaft_volume)
.expect("has one")
.to_si()
- glass.heat_capacity(shaft_volume).expect("has one").to_si();
println!(" the shaft removed {removed:.6} J/K; the closed form says {want:.6} J/K");
assert!(
(removed / want - 1.0).abs() < 1e-12,
"the hole costs exactly its own volume: {removed} against {want}"
);
}
#[test]
fn a_part_from_a_mesh_runs_under_the_same_audit() {
let mesh = box_mesh(DVec3::ZERO, DVec3::new(0.008, 0.008, 0.008));
let voxels = Voxels::of(&mesh, Length::mm(1.0)).expect("closed");
assert_eq!(voxels.filled(), 8 * 8 * 8);
let (copper, aluminium) = (named("copper"), named("aluminium"));
let (hot, cold) = (Temperature::celsius(120.0), Temperature::celsius(20.0));
let mut block = Solid3D::new(
"cube",
aluminium.clone(),
voxels.counts(),
voxels.cell(),
cold,
);
block.fill(copper.clone(), |i, j, k| voxels.contains(i, j, k));
let (nx, ny, nz) = voxels.counts();
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
if voxels.contains(i, j, k) {
block.set_temperature(i, j, k, hot);
}
}
}
}
let meshed = mesh.volume();
let around = Volume::from_si(block.volume().to_si() - meshed.to_si());
let c_part = copper.heat_capacity(meshed).expect("has one").to_si();
let c_around = aluminium.heat_capacity(around).expect("has one").to_si();
let want = (c_part * hot.to_si() + c_around * cold.to_si()) / (c_part + c_around);
const SETTLED_TOLERANCE_K: f64 = 1e-3;
let dt = Time::s(1e-4);
assert!(
block.stability_ratio(dt) < 1.0,
"the step has to be inside the limit before any of this means anything: {}",
block.stability_ratio(dt)
);
let mut world = Simulation::new(Schedule::Multirate).with(block);
for n in 0..20_000 {
world.advance(dt).unwrap_or_else(|v| {
panic!("step {n}: a part read from a mesh audits like any other: {v}")
});
}
let block = world
.domain_as::<Solid3D>("cube")
.expect("still there, and still a Solid3D");
let spread = block.peak_temperature().to_si() - block.coldest_temperature().to_si();
let settled = block.mean_temperature().to_si();
println!(
" settled at {settled:.5} K against a weighted mean of {want:.5} K, spread {spread:.2e} K"
);
assert!(
spread < 1e-3,
"two seconds is long enough for an 11 mm block of metal to become uniform: {spread:.3e} K"
);
assert!(
(settled - want).abs() < SETTLED_TOLERANCE_K,
"an insulated block ends at its capacity-weighted mean: {settled} against {want}"
);
let crossed = c_part * (hot.to_si() - want);
println!(
" {crossed:.2} J crossed inside the block; it took in {:.3e} J",
block.absorbed_energy().to_si()
);
assert!(
block.absorbed_energy().to_si().abs() < 1e-9,
"and it took nothing in, being insulated: {} J against {crossed:.2} J that moved inside it",
block.absorbed_energy().to_si()
);
let one_cell = Volume::from_si(voxels.cell().to_si().powi(3));
let lost = copper.heat_capacity(one_cell).expect("has one").to_si();
let gained = aluminium.heat_capacity(one_cell).expect("has one").to_si();
let shifted = ((c_part - lost) * hot.to_si() + (c_around + gained) * cold.to_si())
/ (c_part - lost + c_around + gained);
println!(
" one cell of {} moves the answer {:.4} K, against a tolerance of 0.001 K",
voxels.filled(),
(shifted - want).abs()
);
assert!(
(shifted - want).abs() > 50.0 * SETTLED_TOLERANCE_K,
"the tolerance has to be small compared to the smallest error it must catch, and one cell \
moves the answer only {:.4} K",
(shifted - want).abs()
);
}
#[test]
fn a_rasterised_fill_and_a_handwritten_one_are_the_same_call() {
let mesh = box_mesh(
DVec3::new(0.002, 0.002, 0.002),
DVec3::new(0.014, 0.010, 0.008),
);
let voxels = Voxels::of(&mesh, Length::mm(1.0)).expect("closed");
let (nx, ny, nz) = voxels.counts();
let (steel, glass) = (named("stainless_304"), named("borosilicate"));
let from_file = block_from(&voxels, &steel, &glass);
let origin = voxels.origin().to_si();
let cell = voxels.cell().to_si();
let by_hand_start = |low: f64, axis: f64| ((low - axis) / cell).round() as usize;
let (i0, j0, k0) = (
by_hand_start(0.002, origin.x),
by_hand_start(0.002, origin.y),
by_hand_start(0.002, origin.z),
);
let mut by_hand = Solid3D::new(
"part",
glass.clone(),
voxels.counts(),
voxels.cell(),
Temperature::celsius(20.0),
);
by_hand.fill(steel.clone(), |i, j, k| {
(i0..i0 + 12).contains(&i) && (j0..j0 + 8).contains(&j) && (k0..k0 + 6).contains(&k)
});
let mut same = 0;
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
assert_eq!(
from_file.substance_at(i, j, k),
by_hand.substance_at(i, j, k),
"cell ({i}, {j}, {k}) differs between the file and the hand-written fill"
);
same += 1;
}
}
}
println!(
" {same} cells agree, and {} of them are the part",
voxels.filled()
);
assert_eq!(
voxels.filled(),
12 * 8 * 6,
"the part is what the mesh says"
);
let dt = Time::s(1e-4);
let (mut a, mut b) = (from_file, by_hand);
let mut bus = Exchange::new();
a.set_temperature(nx / 2, ny / 2, nz / 2, Temperature::celsius(200.0));
b.set_temperature(nx / 2, ny / 2, nz / 2, Temperature::celsius(200.0));
let mut t = Time::from_si(0.0);
for _ in 0..500 {
a.step(t, dt, &mut bus).expect("stable");
b.step(t, dt, &mut bus).expect("stable");
t += dt;
}
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
assert_eq!(
a.temperature_at(i, j, k).to_si().to_bits(),
b.temperature_at(i, j, k).to_si().to_bits(),
"after 500 steps, cell ({i}, {j}, {k}) differs bit for bit"
);
}
}
}
}