use crate::error::{Error, Result};
use crate::mesh::Mesh;
use crate::profile::{Profile2D, Profile2DWithVoids, Triangulation, VoidInfo};
use nalgebra::{Matrix4, Point2, Point3, Vector3};
#[inline]
pub fn extrude_profile(
profile: &Profile2D,
depth: f64,
transform: Option<Matrix4<f64>>,
) -> Result<Mesh> {
if depth <= 0.0 {
return Err(Error::InvalidExtrusion(
"Depth must be positive".to_string(),
));
}
let should_skip_caps = profile_has_extreme_aspect_ratio(&profile.outer);
let triangulation = if should_skip_caps {
None
} else {
Some(profile.triangulate()?)
};
let cap_vertex_count = triangulation
.as_ref()
.map(|t| t.points.len() * 2)
.unwrap_or(0);
let side_vertex_count = profile.outer.len() * 2;
let total_vertices = cap_vertex_count + side_vertex_count;
let cap_index_count = triangulation
.as_ref()
.map(|t| t.indices.len() * 2)
.unwrap_or(0);
let mut mesh = Mesh::with_capacity(total_vertices, cap_index_count + profile.outer.len() * 6);
if let Some(ref tri) = triangulation {
create_cap_mesh(tri, 0.0, Vector3::new(0.0, 0.0, -1.0), &mut mesh);
create_cap_mesh(tri, depth, Vector3::new(0.0, 0.0, 1.0), &mut mesh);
}
create_side_walls(&profile.outer, depth, &mut mesh);
for hole in &profile.holes {
create_side_walls(hole, depth, &mut mesh);
}
if let Some(mat) = transform {
apply_transform(&mut mesh, &mat);
}
Ok(mesh)
}
#[inline]
fn profile_has_extreme_aspect_ratio(outer: &[Point2<f64>]) -> bool {
if outer.len() < 3 {
return false;
}
let mut min_x = f64::MAX;
let mut max_x = f64::MIN;
let mut min_y = f64::MAX;
let mut max_y = f64::MIN;
for p in outer {
min_x = min_x.min(p.x);
max_x = max_x.max(p.x);
min_y = min_y.min(p.y);
max_y = max_y.max(p.y);
}
let width = max_x - min_x;
let height = max_y - min_y;
if width < 0.001 || height < 0.001 {
return false;
}
let aspect_ratio = (width / height).max(height / width);
aspect_ratio > 10000.0
}
#[inline]
pub fn extrude_profile_with_voids(
profile_with_voids: &Profile2DWithVoids,
depth: f64,
transform: Option<Matrix4<f64>>,
) -> Result<Mesh> {
if depth <= 0.0 {
return Err(Error::InvalidExtrusion(
"Depth must be positive".to_string(),
));
}
let profile_with_holes = profile_with_voids.profile_with_through_holes();
let triangulation = profile_with_holes.triangulate()?;
let partial_void_count = profile_with_voids.partial_voids().count();
let vertex_count = triangulation.points.len() * 2;
let side_vertex_count = profile_with_holes.outer.len() * 2
+ profile_with_holes
.holes
.iter()
.map(|h| h.len() * 2)
.sum::<usize>();
let partial_void_vertices = partial_void_count * 100; let total_vertices = vertex_count + side_vertex_count + partial_void_vertices;
let mut mesh = Mesh::with_capacity(
total_vertices,
triangulation.indices.len() * 2 + profile_with_holes.outer.len() * 6,
);
create_cap_mesh(&triangulation, 0.0, Vector3::new(0.0, 0.0, -1.0), &mut mesh);
create_cap_mesh(
&triangulation,
depth,
Vector3::new(0.0, 0.0, 1.0),
&mut mesh,
);
create_side_walls(&profile_with_holes.outer, depth, &mut mesh);
for hole in &profile_with_holes.holes {
create_side_walls(hole, depth, &mut mesh);
}
for void in profile_with_voids.partial_voids() {
create_partial_void_geometry(void, depth, &mut mesh)?;
}
if let Some(mat) = transform {
apply_transform(&mut mesh, &mat);
}
Ok(mesh)
}
fn create_partial_void_geometry(void: &VoidInfo, total_depth: f64, mesh: &mut Mesh) -> Result<()> {
if void.contour.len() < 3 {
return Ok(());
}
let epsilon = 0.001;
let void_profile = Profile2D::new(void.contour.clone());
let void_triangulation = match void_profile.triangulate() {
Ok(t) => t,
Err(_) => return Ok(()), };
if void.depth_start > epsilon {
create_cap_mesh(
&void_triangulation,
void.depth_start,
Vector3::new(0.0, 0.0, -1.0), mesh,
);
}
if void.depth_end < total_depth - epsilon {
create_cap_mesh(
&void_triangulation,
void.depth_end,
Vector3::new(0.0, 0.0, 1.0), mesh,
);
}
let void_depth = void.depth_end - void.depth_start;
if void_depth > epsilon {
create_void_side_walls(&void.contour, void.depth_start, void.depth_end, mesh);
}
Ok(())
}
fn create_void_side_walls(contour: &[Point2<f64>], z_start: f64, z_end: f64, mesh: &mut Mesh) {
let base_index = mesh.vertex_count() as u32;
let mut quad_count = 0u32;
for i in 0..contour.len() {
let j = (i + 1) % contour.len();
let p0 = &contour[i];
let p1 = &contour[j];
let edge = Vector3::new(p1.x - p0.x, p1.y - p0.y, 0.0);
let normal = match Vector3::new(edge.y, -edge.x, 0.0).try_normalize(1e-10) {
Some(n) => n,
None => continue, };
let v0_bottom = Point3::new(p0.x, p0.y, z_start);
let v1_bottom = Point3::new(p1.x, p1.y, z_start);
let v0_top = Point3::new(p0.x, p0.y, z_end);
let v1_top = Point3::new(p1.x, p1.y, z_end);
let idx = base_index + (quad_count * 4);
mesh.add_vertex(v0_bottom, normal);
mesh.add_vertex(v1_bottom, normal);
mesh.add_vertex(v1_top, normal);
mesh.add_vertex(v0_top, normal);
mesh.add_triangle(idx, idx + 2, idx + 1);
mesh.add_triangle(idx, idx + 3, idx + 2);
quad_count += 1;
}
}
#[inline]
fn create_cap_mesh(triangulation: &Triangulation, z: f64, normal: Vector3<f64>, mesh: &mut Mesh) {
let base_index = mesh.vertex_count() as u32;
for point in &triangulation.points {
mesh.add_vertex(Point3::new(point.x, point.y, z), normal);
}
for i in (0..triangulation.indices.len()).step_by(3) {
if i + 2 >= triangulation.indices.len() {
break;
}
let i0 = base_index + triangulation.indices[i] as u32;
let i1 = base_index + triangulation.indices[i + 1] as u32;
let i2 = base_index + triangulation.indices[i + 2] as u32;
if z == 0.0 {
mesh.add_triangle(i0, i2, i1);
} else {
mesh.add_triangle(i0, i1, i2);
}
}
}
#[inline]
fn create_side_walls(boundary: &[nalgebra::Point2<f64>], depth: f64, mesh: &mut Mesh) {
let n = boundary.len();
if n < 2 {
return;
}
let mut cx = 0.0;
let mut cy = 0.0;
for p in boundary.iter() {
cx += p.x;
cy += p.y;
}
cx /= n as f64;
cy /= n as f64;
let use_smooth_radial_normals = is_approximately_circular_profile(boundary, cx, cy);
let vertex_normals: Vec<Vector3<f64>> = if use_smooth_radial_normals {
boundary
.iter()
.map(|p| {
Vector3::new(p.x - cx, p.y - cy, 0.0)
.try_normalize(1e-10)
.unwrap_or(Vector3::new(0.0, 0.0, 1.0))
})
.collect()
} else {
Vec::new()
};
let signed_area2: f64 = (0..n)
.map(|i| {
let a = &boundary[i];
let b = &boundary[(i + 1) % n];
a.x * b.y - b.x * a.y
})
.sum();
let winding_sign = if signed_area2 < 0.0 { -1.0 } else { 1.0 };
let base_index = mesh.vertex_count() as u32;
let mut quad_count = 0u32;
for i in 0..n {
let j = (i + 1) % n;
let p0 = &boundary[i];
let p1 = &boundary[j];
let edge = Vector3::new(p1.x - p0.x, p1.y - p0.y, 0.0);
if edge.magnitude_squared() < 1e-20 {
continue;
}
let flat_normal = Vector3::new(edge.y, -edge.x, 0.0)
.try_normalize(1e-10)
.map(|v| v * winding_sign)
.unwrap_or(Vector3::new(0.0, 0.0, 1.0));
let n0 = if use_smooth_radial_normals {
vertex_normals[i]
} else {
flat_normal
};
let n1 = if use_smooth_radial_normals {
vertex_normals[j]
} else {
flat_normal
};
let v0_bottom = Point3::new(p0.x, p0.y, 0.0);
let v1_bottom = Point3::new(p1.x, p1.y, 0.0);
let v0_top = Point3::new(p0.x, p0.y, depth);
let v1_top = Point3::new(p1.x, p1.y, depth);
let idx = base_index + (quad_count * 4);
mesh.add_vertex(v0_bottom, n0);
mesh.add_vertex(v1_bottom, n1);
mesh.add_vertex(v1_top, n1);
mesh.add_vertex(v0_top, n0);
mesh.add_triangle(idx, idx + 1, idx + 2);
mesh.add_triangle(idx, idx + 2, idx + 3);
quad_count += 1;
}
}
#[inline]
pub fn extrude_profile_lofted(
start: &Profile2D,
end: &Profile2D,
depth: f64,
transform: Option<Matrix4<f64>>,
) -> Result<Mesh> {
if depth <= 0.0 {
return Err(Error::InvalidExtrusion(
"Depth must be positive".to_string(),
));
}
if start.outer.len() < 3 || end.outer.len() < 3 {
return Err(Error::InvalidProfile(
"Lofted extrusion requires both profiles to have ≥3 vertices".to_string(),
));
}
let (outer_start, outer_end) = match_loop_lengths(&start.outer, &end.outer);
let n = outer_start.len();
debug_assert_eq!(n, outer_end.len());
let lofted_hole_pairs: Vec<(Vec<Point2<f64>>, Vec<Point2<f64>>)> = start
.holes
.iter()
.zip(end.holes.iter())
.filter(|(s, e)| s.len() >= 3 && e.len() >= 3)
.map(|(s, e)| match_loop_lengths(s, e))
.collect();
let mut start_profile = Profile2D::new(outer_start.clone());
let mut end_profile = Profile2D::new(outer_end.clone());
for (sh, eh) in &lofted_hole_pairs {
start_profile.add_hole(sh.clone());
end_profile.add_hole(eh.clone());
}
let start_tri = start_profile.triangulate()?;
let end_tri = end_profile.triangulate()?;
let cap_vertex_count = start_tri.points.len() + end_tri.points.len();
let cap_index_count = start_tri.indices.len() + end_tri.indices.len();
let side_vertex_count =
n * 4 + lofted_hole_pairs.iter().map(|(s, _)| s.len() * 4).sum::<usize>();
let mut mesh = Mesh::with_capacity(
cap_vertex_count + side_vertex_count,
cap_index_count + n * 6,
);
create_cap_mesh(&start_tri, 0.0, Vector3::new(0.0, 0.0, -1.0), &mut mesh);
create_cap_mesh(&end_tri, depth, Vector3::new(0.0, 0.0, 1.0), &mut mesh);
create_lofted_side_walls(&outer_start, &outer_end, depth, false, &mut mesh);
for (sh, eh) in &lofted_hole_pairs {
create_lofted_side_walls(sh, eh, depth, true, &mut mesh);
}
if let Some(mat) = transform {
apply_transform(&mut mesh, &mat);
}
Ok(mesh)
}
fn match_loop_lengths(
a: &[Point2<f64>],
b: &[Point2<f64>],
) -> (Vec<Point2<f64>>, Vec<Point2<f64>>) {
if a.len() == b.len() {
return (a.to_vec(), b.to_vec());
}
if a.len() > b.len() {
(a.to_vec(), resample_loop(b, a.len()))
} else {
(resample_loop(a, b.len()), b.to_vec())
}
}
fn resample_loop(loop_pts: &[Point2<f64>], target: usize) -> Vec<Point2<f64>> {
let n = loop_pts.len();
if n == 0 || target == 0 {
return Vec::new();
}
let mut cum = Vec::with_capacity(n + 1);
cum.push(0.0);
for i in 0..n {
let p0 = &loop_pts[i];
let p1 = &loop_pts[(i + 1) % n];
let d = ((p1.x - p0.x).powi(2) + (p1.y - p0.y).powi(2)).sqrt();
cum.push(cum[i] + d);
}
let total = *cum.last().unwrap();
if total <= 0.0 {
return loop_pts.to_vec();
}
let mut out = Vec::with_capacity(target);
for k in 0..target {
let s = (k as f64 / target as f64) * total;
let mut i = 0;
while i + 1 < cum.len() && cum[i + 1] <= s {
i += 1;
}
if i >= n {
i = n - 1;
}
let seg_len = cum[i + 1] - cum[i];
let t = if seg_len > 0.0 { (s - cum[i]) / seg_len } else { 0.0 };
let p0 = &loop_pts[i];
let p1 = &loop_pts[(i + 1) % n];
out.push(Point2::new(
p0.x + (p1.x - p0.x) * t,
p0.y + (p1.y - p0.y) * t,
));
}
out
}
fn create_lofted_side_walls(
bottom: &[Point2<f64>],
top: &[Point2<f64>],
depth: f64,
is_hole: bool,
mesh: &mut Mesh,
) {
let n = bottom.len();
if n < 2 || top.len() != n {
return;
}
let signed_area2: f64 = (0..n)
.map(|i| {
let a = &bottom[i];
let b = &bottom[(i + 1) % n];
a.x * b.y - b.x * a.y
})
.sum();
let winding_sign = if signed_area2 < 0.0 { -1.0 } else { 1.0 };
let base_index = mesh.vertex_count() as u32;
let mut quad_count = 0u32;
for i in 0..n {
let j = (i + 1) % n;
let p0 = &bottom[i];
let p1 = &bottom[j];
let q0 = &top[i];
let q1 = &top[j];
let v0 = Point3::new(p0.x, p0.y, 0.0);
let v1 = Point3::new(p1.x, p1.y, 0.0);
let v2 = Point3::new(q1.x, q1.y, depth);
let v3 = Point3::new(q0.x, q0.y, depth);
let edge_a = v1 - v0;
let edge_b = v3 - v0;
let mut normal = match edge_a.cross(&edge_b).try_normalize(1e-10) {
Some(n) => n * winding_sign,
None => continue,
};
if is_hole {
normal = -normal;
}
let idx = base_index + (quad_count * 4);
mesh.add_vertex(v0, normal);
mesh.add_vertex(v1, normal);
mesh.add_vertex(v2, normal);
mesh.add_vertex(v3, normal);
if is_hole {
mesh.add_triangle(idx, idx + 2, idx + 1);
mesh.add_triangle(idx, idx + 3, idx + 2);
} else {
mesh.add_triangle(idx, idx + 1, idx + 2);
mesh.add_triangle(idx, idx + 2, idx + 3);
}
quad_count += 1;
}
}
#[inline]
fn is_approximately_circular_profile(boundary: &[Point2<f64>], cx: f64, cy: f64) -> bool {
if boundary.len() < 20 {
return false;
}
let mut radii: Vec<f64> = Vec::with_capacity(boundary.len());
for p in boundary {
let r = ((p.x - cx).powi(2) + (p.y - cy).powi(2)).sqrt();
if !r.is_finite() || r < 1e-9 {
return false;
}
radii.push(r);
}
let mean = radii.iter().sum::<f64>() / radii.len() as f64;
if mean < 1e-9 {
return false;
}
let variance = radii
.iter()
.map(|r| {
let d = r - mean;
d * d
})
.sum::<f64>()
/ radii.len() as f64;
let std_dev = variance.sqrt();
let coeff_var = std_dev / mean;
coeff_var < 0.15
}
#[inline]
pub fn apply_transform(mesh: &mut Mesh, transform: &Matrix4<f64>) {
mesh.positions.chunks_exact_mut(3).for_each(|chunk| {
let point = Point3::new(chunk[0] as f64, chunk[1] as f64, chunk[2] as f64);
let transformed = transform.transform_point(&point);
chunk[0] = transformed.x as f32;
chunk[1] = transformed.y as f32;
chunk[2] = transformed.z as f32;
});
let normal_matrix = transform.try_inverse().unwrap_or(*transform).transpose();
mesh.normals.chunks_exact_mut(3).for_each(|chunk| {
let normal = Vector3::new(chunk[0] as f64, chunk[1] as f64, chunk[2] as f64);
let transformed = (normal_matrix * normal.to_homogeneous()).xyz().normalize();
chunk[0] = transformed.x as f32;
chunk[1] = transformed.y as f32;
chunk[2] = transformed.z as f32;
});
}
#[inline]
pub fn apply_transform_with_rtc(
mesh: &mut Mesh,
transform: &Matrix4<f64>,
rtc_offset: (f64, f64, f64),
) {
mesh.positions.chunks_exact_mut(3).for_each(|chunk| {
let point = Point3::new(chunk[0] as f64, chunk[1] as f64, chunk[2] as f64);
let transformed = transform.transform_point(&point);
chunk[0] = (transformed.x - rtc_offset.0) as f32;
chunk[1] = (transformed.y - rtc_offset.1) as f32;
chunk[2] = (transformed.z - rtc_offset.2) as f32;
});
let normal_matrix = transform.try_inverse().unwrap_or(*transform).transpose();
mesh.normals.chunks_exact_mut(3).for_each(|chunk| {
let normal = Vector3::new(chunk[0] as f64, chunk[1] as f64, chunk[2] as f64);
let transformed = (normal_matrix * normal.to_homogeneous()).xyz().normalize();
chunk[0] = transformed.x as f32;
chunk[1] = transformed.y as f32;
chunk[2] = transformed.z as f32;
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::create_rectangle;
#[test]
fn test_extrude_rectangle() {
let profile = create_rectangle(10.0, 5.0);
let mesh = extrude_profile(&profile, 20.0, None).unwrap();
assert!(mesh.vertex_count() > 0);
assert!(mesh.triangle_count() > 0);
let (min, max) = mesh.bounds();
assert!((min.x - -5.0).abs() < 0.01);
assert!((max.x - 5.0).abs() < 0.01);
assert!((min.y - -2.5).abs() < 0.01);
assert!((max.y - 2.5).abs() < 0.01);
assert!((min.z - 0.0).abs() < 0.01);
assert!((max.z - 20.0).abs() < 0.01);
}
#[test]
fn test_extrude_with_transform() {
let profile = create_rectangle(10.0, 5.0);
let transform = Matrix4::new_translation(&Vector3::new(100.0, 200.0, 300.0));
let mesh = extrude_profile(&profile, 20.0, Some(transform)).unwrap();
let (min, max) = mesh.bounds();
assert!((min.x - 95.0).abs() < 0.01); assert!((max.x - 105.0).abs() < 0.01); assert!((min.y - 197.5).abs() < 0.01); assert!((max.y - 202.5).abs() < 0.01); assert!((min.z - 300.0).abs() < 0.01); assert!((max.z - 320.0).abs() < 0.01); }
fn assert_side_walls_outward(mesh: &Mesh) {
let vc = mesh.vertex_count();
let (mut cx, mut cy) = (0.0f32, 0.0f32);
for i in 0..vc {
cx += mesh.positions[i * 3];
cy += mesh.positions[i * 3 + 1];
}
cx /= vc as f32;
cy /= vc as f32;
let mut checked = 0;
for i in 0..vc {
let nz = mesh.normals[i * 3 + 2];
if nz.abs() >= 0.5 {
continue; }
let nx = mesh.normals[i * 3];
let ny = mesh.normals[i * 3 + 1];
let rx = mesh.positions[i * 3] - cx;
let ry = mesh.positions[i * 3 + 1] - cy;
let dot = nx * rx + ny * ry;
assert!(
dot > 0.0,
"side-wall normal points inward at vertex {i}: n=({nx},{ny}) r=({rx},{ry}) dot={dot}"
);
checked += 1;
}
assert!(checked > 0, "expected side-wall vertices to check");
}
#[test]
fn test_side_wall_normals_outward_ccw_profile() {
let profile = create_rectangle(10.0, 6.0);
let mesh = extrude_profile(&profile, 4.0, None).unwrap();
assert_side_walls_outward(&mesh);
}
#[test]
fn test_side_wall_normals_outward_cw_profile() {
let mut pts = create_rectangle(10.0, 6.0).outer;
pts.reverse();
let profile = Profile2D::new(pts);
let mesh = extrude_profile(&profile, 4.0, None).unwrap();
assert_side_walls_outward(&mesh);
}
#[test]
fn test_extrude_circle() {
use crate::profile::create_circle;
let profile = create_circle(5.0, None);
let mesh = extrude_profile(&profile, 10.0, None).unwrap();
assert!(mesh.vertex_count() > 0);
assert!(mesh.triangle_count() > 0);
let (min, max) = mesh.bounds();
assert!((min.x - -5.0).abs() < 0.1);
assert!((max.x - 5.0).abs() < 0.1);
assert!((min.y - -5.0).abs() < 0.1);
assert!((max.y - 5.0).abs() < 0.1);
}
#[test]
fn test_extrude_hollow_circle() {
use crate::profile::create_circle;
let profile = create_circle(10.0, Some(5.0));
let mesh = extrude_profile(&profile, 15.0, None).unwrap();
assert!(mesh.triangle_count() > 20);
}
#[test]
fn test_invalid_depth() {
let profile = create_rectangle(10.0, 5.0);
let result = extrude_profile(&profile, -1.0, None);
assert!(result.is_err());
}
#[test]
fn test_circular_profile_detection() {
use crate::profile::create_circle;
let circle = create_circle(10.0, None);
assert!(
circle.outer.len() >= 20,
"test setup expects ≥20 segments to pass the heuristic threshold; got {}",
circle.outer.len()
);
let mut cx = 0.0;
let mut cy = 0.0;
for p in &circle.outer {
cx += p.x;
cy += p.y;
}
cx /= circle.outer.len() as f64;
cy /= circle.outer.len() as f64;
assert!(is_approximately_circular_profile(&circle.outer, cx, cy));
}
#[test]
fn test_rectangular_profile_not_detected_as_circular() {
let rect = create_rectangle(10.0, 5.0);
let mut cx = 0.0;
let mut cy = 0.0;
for p in &rect.outer {
cx += p.x;
cy += p.y;
}
cx /= rect.outer.len() as f64;
cy /= rect.outer.len() as f64;
assert!(!is_approximately_circular_profile(&rect.outer, cx, cy));
}
#[test]
fn test_lofted_extrusion_rectangle_to_rectangle() {
let start = create_rectangle(200.0, 200.0);
let end = create_rectangle(200.0, 600.0);
let mesh = extrude_profile_lofted(&start, &end, 2000.0, None).unwrap();
assert!(!mesh.is_empty());
let (min, max) = mesh.bounds();
assert!((min.x - -100.0).abs() < 0.01, "min.x = {}", min.x);
assert!((max.x - 100.0).abs() < 0.01, "max.x = {}", max.x);
assert!((min.y - -300.0).abs() < 0.01, "min.y = {}", min.y);
assert!((max.y - 300.0).abs() < 0.01, "max.y = {}", max.y);
assert!((min.z - 0.0).abs() < 0.01);
assert!((max.z - 2000.0).abs() < 0.01);
}
#[test]
fn test_lofted_side_walls_not_vertical_when_profiles_differ() {
let start = create_rectangle(200.0, 200.0);
let end = create_rectangle(200.0, 600.0);
let mesh = extrude_profile_lofted(&start, &end, 2000.0, None).unwrap();
let mut has_sloped_normal = false;
for chunk in mesh.normals.chunks_exact(3) {
if chunk[2].abs() > 1e-3 && chunk[2].abs() < 0.9999 {
has_sloped_normal = true;
break;
}
}
assert!(
has_sloped_normal,
"expected at least one side-wall normal with sloped Z component"
);
}
#[test]
fn test_lofted_outer_normals_match_uniform_extrusion_convention() {
let rect = create_rectangle(200.0, 200.0);
let lofted = extrude_profile_lofted(&rect, &rect, 1000.0, None).unwrap();
let uniform = extrude_profile(&rect, 1000.0, None).unwrap();
fn radial_sign_at(mesh: &Mesh, x: f32, y: f32) -> Option<(f32, f32)> {
for i in 0..mesh.vertex_count() {
let px = mesh.positions[i * 3];
let py = mesh.positions[i * 3 + 1];
let pz = mesh.positions[i * 3 + 2];
if (px - x).abs() < 0.5 && (py - y).abs() < 0.5 && pz < 0.5 {
let nx = mesh.normals[i * 3];
let ny = mesh.normals[i * 3 + 1];
if nx.abs() > 0.1 || ny.abs() > 0.1 {
return Some((nx, ny));
}
}
}
None
}
let lofted_n = radial_sign_at(&lofted, -100.0, -100.0)
.expect("lofted: no side-wall vertex at (-100, -100, 0)");
let uniform_n = radial_sign_at(&uniform, -100.0, -100.0)
.expect("uniform: no side-wall vertex at (-100, -100, 0)");
assert!(
lofted_n.0.signum() == uniform_n.0.signum()
|| lofted_n.1.signum() == uniform_n.1.signum(),
"lofted normal {:?} disagrees with uniform normal {:?}",
lofted_n,
uniform_n,
);
}
#[test]
fn test_lofted_extrusion_invalid_depth() {
let start = create_rectangle(10.0, 10.0);
let end = create_rectangle(10.0, 20.0);
assert!(extrude_profile_lofted(&start, &end, 0.0, None).is_err());
assert!(extrude_profile_lofted(&start, &end, -1.0, None).is_err());
}
#[test]
fn test_resample_loop_preserves_total_length() {
let rect = create_rectangle(10.0, 4.0);
let resampled = resample_loop(&rect.outer, 16);
assert_eq!(resampled.len(), 16);
let total: f64 = (0..resampled.len())
.map(|i| {
let p0 = &resampled[i];
let p1 = &resampled[(i + 1) % resampled.len()];
((p1.x - p0.x).powi(2) + (p1.y - p0.y).powi(2)).sqrt()
})
.sum();
assert!(total > 26.5 && total <= 28.0 + 1e-6, "resampled perimeter = {}", total);
}
}