use nalgebra::Vector3;
use serde::Serialize;
use thiserror::Error;
use crate::geometry::{LocalBasis, Segment, SegmentError};
mod grading;
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Filament {
start: Vector3<f64>,
end: Vector3<f64>,
length: f64,
basis: LocalBasis,
width: f64,
height: f64,
sigma: f64,
}
impl Filament {
pub fn new(segment: &Segment) -> Result<Self, SegmentError> {
let basis = segment.basis()?;
Ok(Self::from_parts(
segment.a.position(),
segment.b.position(),
segment.length(),
basis,
segment.width,
segment.height,
segment.sigma,
))
}
fn from_parts(
start: Vector3<f64>,
end: Vector3<f64>,
length: f64,
basis: LocalBasis,
width: f64,
height: f64,
sigma: f64,
) -> Self {
Self {
start,
end,
length,
basis,
width,
height,
sigma,
}
}
pub fn start(&self) -> Vector3<f64> {
self.start
}
pub fn end(&self) -> Vector3<f64> {
self.end
}
pub fn center(&self) -> Vector3<f64> {
(self.start + self.end) * 0.5
}
pub fn length(&self) -> f64 {
self.length
}
pub fn basis(&self) -> &LocalBasis {
&self.basis
}
pub fn direction(&self) -> Vector3<f64> {
self.basis.length
}
pub fn width_dir(&self) -> Vector3<f64> {
self.basis.width
}
pub fn height_dir(&self) -> Vector3<f64> {
self.basis.height
}
pub fn width(&self) -> f64 {
self.width
}
pub fn height(&self) -> f64 {
self.height
}
pub fn area(&self) -> f64 {
self.width * self.height
}
pub fn sigma(&self) -> f64 {
self.sigma
}
}
#[derive(Clone, Debug, PartialEq, Error)]
#[non_exhaustive]
pub enum DiscretizeError {
#[error(transparent)]
Segment(#[from] SegmentError),
#[error("filament counts must be at least 1, got nw = {nw}, nh = {nh}")]
ZeroSubdivision {
nw: usize,
nh: usize,
},
#[error("filament grid nw * nh overflows usize, got nw = {nw}, nh = {nh}")]
Overflow {
nw: usize,
nh: usize,
},
#[error("the grading ratio must be finite and at least 1, got {ratio}")]
InvalidRatio {
ratio: f64,
},
#[error("grading ratio {ratio} over {count} filaments overflows to infinity")]
GradingOverflow {
ratio: f64,
count: usize,
},
#[error("unusable graded grid {nw} x {nh} at ratio {ratio}: {axis} cell extent {extent:e} m requires at least {minimum:e} m for kernel conditioning and coordinate resolution")]
UnusableGrading {
axis: &'static str,
nw: usize,
nh: usize,
ratio: f64,
extent: f64,
minimum: f64,
},
}
pub fn graded_extents(total: f64, count: usize, ratio: f64) -> Result<Vec<f64>, DiscretizeError> {
let weights = graded_weights(count, ratio)?;
let sum: f64 = weights.iter().sum();
Ok(weights.into_iter().map(|w| total * w / sum).collect())
}
pub fn graded_surface_extent(total: f64, count: usize, ratio: f64) -> Result<f64, DiscretizeError> {
let weights = graded_weights(count, ratio)?;
let sum: f64 = weights.iter().sum();
Ok(total / sum)
}
fn graded_weights(count: usize, ratio: f64) -> Result<Vec<f64>, DiscretizeError> {
if !(ratio.is_finite() && ratio >= 1.0) {
return Err(DiscretizeError::InvalidRatio { ratio });
}
if count == 0 {
return Err(DiscretizeError::ZeroSubdivision { nw: 0, nh: 0 });
}
let weights: Vec<f64> = (0..count)
.map(|i| ratio.powi(i.min(count - 1 - i) as i32))
.collect();
if weights.iter().all(|w| w.is_finite()) {
Ok(weights)
} else {
Err(DiscretizeError::GradingOverflow { ratio, count })
}
}
pub fn discretize(
segment: &Segment,
nw: usize,
nh: usize,
) -> Result<Vec<Filament>, DiscretizeError> {
if nw == 0 || nh == 0 {
return Err(DiscretizeError::ZeroSubdivision { nw, nh });
}
let filament_count = nw
.checked_mul(nh)
.ok_or(DiscretizeError::Overflow { nw, nh })?;
let basis = segment.basis()?;
let (a, b) = (segment.a.position(), segment.b.position());
let length = segment.length();
let filament_width = segment.width / nw as f64;
let filament_height = segment.height / nh as f64;
let fraction = |index: usize, count: usize| (index as f64 + 0.5) / count as f64 - 0.5;
let mut filaments = Vec::with_capacity(filament_count);
for j in 0..nh {
let along_height = basis.height * (fraction(j, nh) * segment.height);
for i in 0..nw {
let offset = basis.width * (fraction(i, nw) * segment.width) + along_height;
filaments.push(Filament::from_parts(
a + offset,
b + offset,
length,
basis,
filament_width,
filament_height,
segment.sigma,
));
}
}
Ok(filaments)
}
pub fn discretize_graded(
segment: &Segment,
nw: usize,
nh: usize,
ratio: f64,
) -> Result<Vec<Filament>, DiscretizeError> {
if !(ratio.is_finite() && ratio >= 1.0) {
return Err(DiscretizeError::InvalidRatio { ratio });
}
if nw == 0 || nh == 0 {
return Err(DiscretizeError::ZeroSubdivision { nw, nh });
}
let filament_count = nw
.checked_mul(nh)
.ok_or(DiscretizeError::Overflow { nw, nh })?;
let basis = segment.basis()?;
let (a, b) = (segment.a.position(), segment.b.position());
let length = segment.length();
let across_width = graded_cells(segment.width, nw, ratio)?;
let across_height = graded_cells(segment.height, nh, ratio)?;
grading::validate(
segment,
&basis,
&across_width,
&across_height,
(nw, nh, ratio),
)?;
if ratio == 1.0 {
return discretize(segment, nw, nh);
}
let mut filaments = Vec::with_capacity(filament_count);
for &(height_offset, filament_height) in &across_height {
let along_height = basis.height * height_offset;
for &(width_offset, filament_width) in &across_width {
let offset = basis.width * width_offset + along_height;
filaments.push(Filament::from_parts(
a + offset,
b + offset,
length,
basis,
filament_width,
filament_height,
segment.sigma,
));
}
}
Ok(filaments)
}
fn graded_cells(total: f64, count: usize, ratio: f64) -> Result<Vec<(f64, f64)>, DiscretizeError> {
let weights = graded_weights(count, ratio)?;
let sum: f64 = weights.iter().sum();
let mut lower = 0.0;
Ok(weights
.into_iter()
.map(|weight| {
let centre = total * ((lower + 0.5 * weight) / sum - 0.5);
lower += weight;
(centre, total * (weight / sum))
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::Node;
use nalgebra::{Rotation3, Unit};
const COPPER: f64 = 5.8e7;
const TOL: f64 = 1e-12;
fn assert_vec_close(actual: Vector3<f64>, expected: [f64; 3]) {
let expected = Vector3::from(expected);
assert!(
(actual - expected).norm() < TOL,
"expected {expected:?}, got {actual:?}"
);
}
fn skew_segment() -> Segment {
Segment::new(
Node::new(0.3, -1.2, 2.0),
Node::new(1.7, 0.4, -0.5),
0.6,
0.25,
COPPER,
)
.with_width_dir([0.2, 1.0, 0.1])
}
#[test]
fn overflowing_filament_grid_is_rejected_not_panicked() {
let err = discretize(&skew_segment(), usize::MAX, 2).unwrap_err();
assert_eq!(
err,
DiscretizeError::Overflow {
nw: usize::MAX,
nh: 2
}
);
let err = discretize(&skew_segment(), 0, usize::MAX).unwrap_err();
assert_eq!(
err,
DiscretizeError::ZeroSubdivision {
nw: 0,
nh: usize::MAX
}
);
}
#[test]
fn filament_count_is_nw_times_nh() {
for (nw, nh) in [(1, 1), (1, 4), (3, 1), (3, 2), (5, 7)] {
let filaments = discretize(&skew_segment(), nw, nh).unwrap();
assert_eq!(filaments.len(), nw * nh, "nw = {nw}, nh = {nh}");
}
}
#[test]
fn mean_of_filament_centroids_is_the_segment_centreline() {
let segment = skew_segment();
for (nw, nh) in [(1, 1), (2, 2), (3, 2), (5, 7)] {
let filaments = discretize(&segment, nw, nh).unwrap();
let n = filaments.len() as f64;
let mean = |f: fn(&Filament) -> Vector3<f64>| {
filaments.iter().map(f).sum::<Vector3<f64>>() / n
};
assert!((mean(Filament::start) - segment.a.position()).norm() < TOL);
assert!((mean(Filament::end) - segment.b.position()).norm() < TOL);
assert!((mean(Filament::center) - segment.center()).norm() < TOL);
}
}
#[test]
fn total_cross_section_area_is_preserved() {
let segment = skew_segment();
for (nw, nh) in [(1, 1), (2, 2), (3, 2), (5, 7)] {
let filaments = discretize(&segment, nw, nh).unwrap();
let total: f64 = filaments.iter().map(Filament::area).sum();
assert!(
(total - segment.area()).abs() < TOL * segment.area(),
"nw = {nw}, nh = {nh}: {total} vs {}",
segment.area()
);
for f in &filaments {
assert!((f.width() - segment.width / nw as f64).abs() < TOL);
assert!((f.height() - segment.height / nh as f64).abs() < TOL);
}
}
}
#[test]
fn filaments_inherit_length_basis_and_conductivity() {
let segment = skew_segment();
let basis = segment.basis().unwrap();
for f in discretize(&segment, 3, 2).unwrap() {
assert!((f.length() - segment.length()).abs() < TOL);
assert!(((f.end() - f.start()) / f.length() - f.direction()).norm() < TOL);
assert_eq!(f.basis(), &basis);
assert_eq!(f.direction(), basis.length);
assert_eq!(f.width_dir(), basis.width);
assert_eq!(f.height_dir(), basis.height);
assert_eq!(f.sigma(), COPPER);
}
}
#[test]
fn filaments_tile_the_cross_section_in_documented_order() {
let segment = Segment::new(
Node::new(0.0, 0.0, 0.0),
Node::new(2.0, 0.0, 0.0),
0.6,
0.4,
COPPER,
);
let filaments = discretize(&segment, 3, 2).unwrap();
let expected_yz = [
[-0.2, -0.1],
[0.0, -0.1],
[0.2, -0.1],
[-0.2, 0.1],
[0.0, 0.1],
[0.2, 0.1],
];
assert_eq!(filaments.len(), expected_yz.len());
for (f, [y, z]) in filaments.iter().zip(expected_yz) {
assert_vec_close(f.start(), [0.0, y, z]);
assert_vec_close(f.end(), [2.0, y, z]);
}
}
#[test]
fn single_filament_is_the_segment_itself() {
let segment = skew_segment();
let filaments = discretize(&segment, 1, 1).unwrap();
assert_eq!(filaments, vec![Filament::new(&segment).unwrap()]);
assert_vec_close(filaments[0].start(), [0.3, -1.2, 2.0]);
assert_vec_close(filaments[0].end(), [1.7, 0.4, -0.5]);
assert!((filaments[0].area() - 0.15).abs() < TOL);
}
#[test]
fn quarter_turn_about_z_matches_hand_rotated_fixture() {
let rotated = Segment::new(
Node::new(0.0, 0.0, 0.0),
Node::new(0.0, 2.0, 0.0),
0.4,
0.2,
COPPER,
);
let filaments = discretize(&rotated, 2, 1).unwrap();
assert_eq!(filaments.len(), 2);
assert_vec_close(filaments[0].start(), [0.1, 0.0, 0.0]);
assert_vec_close(filaments[0].end(), [0.1, 2.0, 0.0]);
assert_vec_close(filaments[1].start(), [-0.1, 0.0, 0.0]);
assert_vec_close(filaments[1].end(), [-0.1, 2.0, 0.0]);
for f in &filaments {
assert_vec_close(f.direction(), [0.0, 1.0, 0.0]);
assert_vec_close(f.width_dir(), [-1.0, 0.0, 0.0]);
assert_vec_close(f.height_dir(), [0.0, 0.0, 1.0]);
assert!((f.width() - 0.2).abs() < TOL);
assert!((f.height() - 0.2).abs() < TOL);
}
}
#[test]
fn diagonal_segment_matches_hand_rotated_fixture() {
let rotated = Segment::new(
Node::new(0.0, 0.0, 0.0),
Node::new(1.0, 1.0, 0.0),
0.4 * std::f64::consts::SQRT_2,
0.2,
COPPER,
);
let filaments = discretize(&rotated, 2, 1).unwrap();
assert_eq!(filaments.len(), 2);
assert_vec_close(filaments[0].start(), [0.1, -0.1, 0.0]);
assert_vec_close(filaments[0].end(), [1.1, 0.9, 0.0]);
assert_vec_close(filaments[1].start(), [-0.1, 0.1, 0.0]);
assert_vec_close(filaments[1].end(), [0.9, 1.1, 0.0]);
let s = std::f64::consts::FRAC_1_SQRT_2;
for f in &filaments {
assert_vec_close(f.direction(), [s, s, 0.0]);
assert_vec_close(f.width_dir(), [-s, s, 0.0]);
assert_vec_close(f.height_dir(), [0.0, 0.0, 1.0]);
}
}
#[test]
fn cyclic_axis_rotation_matches_hand_rotated_fixture() {
let rotated = Segment::new(
Node::new(3.0, 1.0, 2.0),
Node::new(3.0, 4.0, 2.0),
0.6,
0.3,
COPPER,
)
.with_width_dir([0.0, 0.0, 1.0]);
let filaments = discretize(&rotated, 3, 2).unwrap();
let expected_xz = [
[2.925, 1.8],
[2.925, 2.0],
[2.925, 2.2],
[3.075, 1.8],
[3.075, 2.0],
[3.075, 2.2],
];
assert_eq!(filaments.len(), expected_xz.len());
for (f, [x, z]) in filaments.iter().zip(expected_xz) {
assert_vec_close(f.start(), [x, 1.0, z]);
assert_vec_close(f.end(), [x, 4.0, z]);
assert_vec_close(f.direction(), [0.0, 1.0, 0.0]);
assert_vec_close(f.width_dir(), [0.0, 0.0, 1.0]);
assert_vec_close(f.height_dir(), [1.0, 0.0, 0.0]);
assert!((f.width() - 0.2).abs() < TOL);
assert!((f.height() - 0.15).abs() < TOL);
}
}
#[test]
fn discretization_commutes_with_arbitrary_rigid_motion() {
let base = skew_segment();
let rotation =
Rotation3::from_axis_angle(&Unit::new_normalize(Vector3::new(0.4, -1.0, 0.7)), 1.234);
let shift = Vector3::new(-2.0, 0.5, 3.0);
let moved = Segment {
a: Node::from(rotation * base.a.position() + shift),
b: Node::from(rotation * base.b.position() + shift),
width_dir: base
.width_dir
.map(|dir| (rotation * Vector3::from(dir)).into()),
..base
};
let before = discretize(&base, 4, 3).unwrap();
let after = discretize(&moved, 4, 3).unwrap();
assert_eq!(after.len(), 12);
for (f, g) in before.iter().zip(&after) {
assert!((rotation * f.start() + shift - g.start()).norm() < TOL);
assert!((rotation * f.end() + shift - g.end()).norm() < TOL);
assert!((rotation * f.direction() - g.direction()).norm() < TOL);
assert!((rotation * f.width_dir() - g.width_dir()).norm() < TOL);
assert!((rotation * f.height_dir() - g.height_dir()).norm() < TOL);
assert!((f.width() - g.width()).abs() < TOL);
assert!((f.height() - g.height()).abs() < TOL);
}
}
#[test]
fn zero_subdivision_is_rejected() {
for (nw, nh) in [(0, 1), (1, 0), (0, 0)] {
assert_eq!(
discretize(&skew_segment(), nw, nh),
Err(DiscretizeError::ZeroSubdivision { nw, nh })
);
}
}
#[test]
fn invalid_segment_is_rejected() {
let segment = skew_segment();
let zero_length = Segment {
b: segment.a,
..segment
};
assert_eq!(
discretize(&zero_length, 2, 2),
Err(DiscretizeError::Segment(SegmentError::ZeroLength))
);
assert_eq!(Filament::new(&zero_length), Err(SegmentError::ZeroLength));
let flat = Segment {
height: 0.0,
..segment
};
assert!(matches!(
discretize(&flat, 2, 2),
Err(DiscretizeError::Segment(
SegmentError::NonPositiveDimension {
dimension: "height",
..
}
))
));
}
#[test]
fn unit_ratio_is_bit_identical_to_the_uniform_grid() {
let segment = skew_segment();
for (nw, nh) in [(1, 1), (1, 4), (3, 1), (3, 2), (5, 7)] {
assert_eq!(
discretize_graded(&segment, nw, nh, 1.0).unwrap(),
discretize(&segment, nw, nh).unwrap(),
"nw = {nw}, nh = {nh}"
);
}
}
#[test]
fn graded_filaments_tile_the_cross_section() {
let segment = skew_segment();
for (nw, nh, ratio) in [(4, 4, 10.0), (3, 5, 2.0), (1, 8, 1.5), (7, 1, 3.0)] {
let filaments = discretize_graded(&segment, nw, nh, ratio).unwrap();
assert_eq!(filaments.len(), nw * nh);
let total: f64 = filaments.iter().map(Filament::area).sum();
assert!(
(total - segment.area()).abs() < TOL * segment.area(),
"nw = {nw}, nh = {nh}, ratio = {ratio}"
);
for (index, f) in filaments.iter().enumerate() {
assert!((f.width() - filaments[index % nw].width()).abs() < TOL);
assert!((f.height() - filaments[(index / nw) * nw].height()).abs() < TOL);
}
}
}
#[test]
fn graded_extents_follow_the_geometric_progression() {
let segment = skew_segment();
let (nw, nh, ratio) = (6, 5, 2.5);
let filaments = discretize_graded(&segment, nw, nh, ratio).unwrap();
let widths: Vec<f64> = filaments[..nw].iter().map(Filament::width).collect();
let heights: Vec<f64> = filaments.iter().step_by(nw).map(Filament::height).collect();
for (extents, total) in [(&widths, segment.width), (&heights, segment.height)] {
let count = extents.len();
let surface = graded_surface_extent(total, count, ratio).unwrap();
assert!((extents[0] - surface).abs() < TOL * surface);
assert!((extents[count - 1] - surface).abs() < TOL * surface);
for i in 0..count / 2 {
assert!((extents[i] - extents[count - 1 - i]).abs() < TOL * extents[i]);
if i + 1 < count / 2 {
let grown = ratio * extents[i];
assert!((extents[i + 1] - grown).abs() < TOL * grown);
}
}
}
}
#[test]
fn graded_bundle_is_symmetric_and_fills_the_surfaces() {
let segment = Segment::new(
Node::new(0.0, 0.0, 0.0),
Node::new(2.0, 0.0, 0.0),
0.6,
0.4,
COPPER,
);
for (nw, nh, ratio) in [(4, 4, 10.0), (5, 3, 2.0), (2, 6, 1.25)] {
let filaments = discretize_graded(&segment, nw, nh, ratio).unwrap();
let n = filaments.len() as f64;
let mean: Vector3<f64> =
filaments.iter().map(Filament::center).sum::<Vector3<f64>>() / n;
assert!(
(mean - segment.center()).norm() < TOL,
"{nw}×{nh} @ {ratio}"
);
let (mut min_y, mut max_y, mut min_z, mut max_z) =
(f64::MAX, f64::MIN, f64::MAX, f64::MIN);
for f in &filaments {
let c = f.center();
min_y = min_y.min(c.y - 0.5 * f.width());
max_y = max_y.max(c.y + 0.5 * f.width());
min_z = min_z.min(c.z - 0.5 * f.height());
max_z = max_z.max(c.z + 0.5 * f.height());
}
assert!((min_y + 0.3).abs() < TOL && (max_y - 0.3).abs() < TOL);
assert!((min_z + 0.2).abs() < TOL && (max_z - 0.2).abs() < TOL);
}
}
#[test]
fn graded_grid_matches_hand_computed_fixture() {
let segment = Segment::new(
Node::new(0.0, 0.0, 0.0),
Node::new(1.0, 0.0, 0.0),
0.8,
0.2,
COPPER,
);
let filaments = discretize_graded(&segment, 4, 1, 3.0).unwrap();
let expected = [(-0.35, 0.1), (-0.15, 0.3), (0.15, 0.3), (0.35, 0.1)];
assert_eq!(filaments.len(), expected.len());
for (f, (y, width)) in filaments.iter().zip(expected) {
assert_vec_close(f.start(), [0.0, y, 0.0]);
assert_vec_close(f.end(), [1.0, y, 0.0]);
assert!((f.width() - width).abs() < TOL, "{} vs {width}", f.width());
assert!((f.height() - 0.2).abs() < TOL);
}
}
#[test]
fn grading_commutes_with_rigid_motion() {
let base = skew_segment();
let rotation =
Rotation3::from_axis_angle(&Unit::new_normalize(Vector3::new(0.4, -1.0, 0.7)), 1.234);
let shift = Vector3::new(-2.0, 0.5, 3.0);
let moved = Segment {
a: Node::from(rotation * base.a.position() + shift),
b: Node::from(rotation * base.b.position() + shift),
width_dir: base
.width_dir
.map(|dir| (rotation * Vector3::from(dir)).into()),
..base
};
let before = discretize_graded(&base, 4, 3, 2.0).unwrap();
let after = discretize_graded(&moved, 4, 3, 2.0).unwrap();
assert_eq!(after.len(), 12);
for (f, g) in before.iter().zip(&after) {
assert!((rotation * f.start() + shift - g.start()).norm() < TOL);
assert!((rotation * f.end() + shift - g.end()).norm() < TOL);
assert!((f.width() - g.width()).abs() < TOL);
assert!((f.height() - g.height()).abs() < TOL);
}
}
#[test]
fn graded_surface_extent_beats_the_uniform_cell() {
assert!((graded_surface_extent(1e-3, 16, 1.0).unwrap() - 62.5e-6).abs() < 1e-18);
let graded = graded_surface_extent(1e-3, 6, 4.0).unwrap();
assert!((graded - 1e-3 / (1.0 + 4.0 + 16.0 + 16.0 + 4.0 + 1.0)).abs() < 1e-18);
assert!(graded < 0.4 * 62.5e-6);
}
#[test]
fn invalid_grading_is_rejected() {
let segment = skew_segment();
for ratio in [0.5, 0.0, -2.0, f64::INFINITY] {
assert_eq!(
discretize_graded(&segment, 2, 2, ratio),
Err(DiscretizeError::InvalidRatio { ratio })
);
assert_eq!(
graded_extents(1.0, 4, ratio),
Err(DiscretizeError::InvalidRatio { ratio })
);
}
assert!(matches!(
discretize_graded(&segment, 2, 2, f64::NAN),
Err(DiscretizeError::InvalidRatio { ratio }) if ratio.is_nan()
));
for (nw, nh) in [(0, 1), (1, 0), (0, 0)] {
assert_eq!(
discretize_graded(&segment, nw, nh, 2.0),
Err(DiscretizeError::ZeroSubdivision { nw, nh })
);
}
assert_eq!(
discretize_graded(&segment, 2, 4096, 10.0),
Err(DiscretizeError::GradingOverflow {
ratio: 10.0,
count: 4096
})
);
assert_eq!(
graded_extents(1.0, 0, 2.0),
Err(DiscretizeError::ZeroSubdivision { nw: 0, nh: 0 })
);
assert_eq!(
discretize_graded(&segment, usize::MAX, 2, 2.0),
Err(DiscretizeError::Overflow {
nw: usize::MAX,
nh: 2
})
);
}
#[test]
fn filament_serializes_to_json() {
let segment = Segment::new(
Node::new(0.0, 0.0, 0.0),
Node::new(2.0, 0.0, 0.0),
0.6,
0.4,
COPPER,
);
let value = serde_json::to_value(Filament::new(&segment).unwrap()).unwrap();
assert_eq!(value["start"], serde_json::json!([0.0, 0.0, 0.0]));
assert_eq!(value["end"], serde_json::json!([2.0, 0.0, 0.0]));
assert_eq!(value["basis"]["width"], serde_json::json!([0.0, 1.0, 0.0]));
assert_eq!(value["length"], serde_json::json!(2.0));
}
}