use bevy::asset::RenderAssetUsages;
use bevy::math::{DVec2, Vec2, Vec3};
use bevy::mesh::{Indices, Mesh, PrimitiveTopology, VertexAttributeValues};
use symbios_shape::FaceProfile;
pub fn build_tapered_cuboid(taper: f32, size: Vec3, stretch_uvs: bool) -> Mesh {
let taper = taper.clamp(0.0, 1.0);
let shrink = taper * 0.5;
let b0 = Vec3::new(-0.5, -0.5, -0.5); let b1 = Vec3::new(0.5, -0.5, -0.5); let b2 = Vec3::new(0.5, -0.5, 0.5); let b3 = Vec3::new(-0.5, -0.5, 0.5);
let t0 = Vec3::new(-0.5 + shrink, 0.5, -0.5 + shrink); let t1 = Vec3::new(0.5 - shrink, 0.5, -0.5 + shrink); let t2 = Vec3::new(0.5 - shrink, 0.5, 0.5 - shrink); let t3 = Vec3::new(-0.5 + shrink, 0.5, 0.5 - shrink);
let normal_bottom = Vec3::NEG_Y;
let normal_top = Vec3::Y;
let front_n = face_normal(b1, b0, t0, t1); let back_n = face_normal(b3, b2, t2, t3); let left_n = face_normal(b0, b3, t3, t0); let right_n = face_normal(b2, b1, t1, t2);
#[rustfmt::skip]
let positions: Vec<[f32; 3]> = vec![
b0.to_array(), b1.to_array(), b2.to_array(), b3.to_array(),
t0.to_array(), t3.to_array(), t2.to_array(), t1.to_array(),
b1.to_array(), b0.to_array(), t0.to_array(), t1.to_array(),
b3.to_array(), b2.to_array(), t2.to_array(), t3.to_array(),
b0.to_array(), b3.to_array(), t3.to_array(), t0.to_array(),
b2.to_array(), b1.to_array(), t1.to_array(), t2.to_array(),
];
#[rustfmt::skip]
let normals: Vec<[f32; 3]> = vec![
normal_bottom.to_array(), normal_bottom.to_array(),
normal_bottom.to_array(), normal_bottom.to_array(),
normal_top.to_array(), normal_top.to_array(),
normal_top.to_array(), normal_top.to_array(),
front_n.to_array(), front_n.to_array(), front_n.to_array(), front_n.to_array(),
back_n.to_array(), back_n.to_array(), back_n.to_array(), back_n.to_array(),
left_n.to_array(), left_n.to_array(), left_n.to_array(), left_n.to_array(),
right_n.to_array(), right_n.to_array(), right_n.to_array(), right_n.to_array(),
];
let (w, h, d) = if stretch_uvs {
(1.0, 1.0, 1.0)
} else {
(size.x, size.y, size.z)
};
let s = shrink;
#[rustfmt::skip]
let uvs: Vec<[f32; 2]> = vec![
[0.0, 0.0], [w, 0.0], [w, d ], [0.0, d ],
[0.0, 0.0], [0.0, d ], [w, d ], [w, 0.0],
[0.0, h ], [w, h ], [(1.0-s)*w, 0.0], [s*w, 0.0],
[0.0, h ], [w, h ], [(1.0-s)*w, 0.0], [s*w, 0.0],
[0.0, h ], [d, h ], [(1.0-s)*d, 0.0], [s*d, 0.0],
[0.0, h ], [d, h ], [(1.0-s)*d, 0.0], [s*d, 0.0],
];
let is_pyramid = taper >= 1.0 - 1e-5;
let mut indices: Vec<u32> = Vec::with_capacity(if is_pyramid { 18 } else { 36 });
for face in 0u32..6 {
let b = face * 4;
match (is_pyramid, face) {
(true, 1) => {} (true, 2..=5) => indices.extend([b, b + 1, b + 2]), _ => indices.extend([b, b + 1, b + 2, b, b + 2, b + 3]), }
}
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_indices(Indices::U32(indices));
let tangents_ok = mesh.generate_tangents().is_ok_and(|_| {
!matches!(
mesh.attribute(Mesh::ATTRIBUTE_TANGENT),
Some(VertexAttributeValues::Float32x4(t))
if t.iter().any(|v| v.iter().any(|f| f.is_nan()))
)
});
if !tangents_ok {
mesh.insert_attribute(Mesh::ATTRIBUTE_TANGENT, vec![[1.0_f32, 0.0, 0.0, 1.0]; 24]);
}
mesh
}
fn face_normal(a: Vec3, b: Vec3, c: Vec3, d: Vec3) -> Vec3 {
let _ = d; (b - a).cross(c - a).normalize()
}
pub fn build_profiled_mesh(profile: &FaceProfile, size: Vec3, stretch_uvs: bool) -> Mesh {
build_profiled_mesh_with(profile, size, stretch_uvs, 0)
}
pub fn build_profiled_mesh_with(
profile: &FaceProfile,
size: Vec3,
stretch_uvs: bool,
round_segments: u32,
) -> Mesh {
if round_segments >= 3 {
match profile {
FaceProfile::Rectangle => {
return build_round_prism(0.0, size, stretch_uvs, round_segments);
}
FaceProfile::Taper(t) => {
return build_round_prism(*t as f32, size, stretch_uvs, round_segments);
}
_ => {}
}
}
match profile {
FaceProfile::Rectangle => build_tapered_cuboid(0.0, size, stretch_uvs),
FaceProfile::Taper(t) => build_tapered_cuboid(*t as f32, size, stretch_uvs),
FaceProfile::Triangle { peak_offset } => {
build_triangle_face(*peak_offset as f32, size, stretch_uvs)
}
FaceProfile::Trapezoid {
top_width,
offset_x,
} => build_trapezoid_face(*top_width as f32, *offset_x as f32, size, stretch_uvs),
FaceProfile::Polygon(pts) => build_polygon_face(pts, size, stretch_uvs),
}
}
fn build_round_prism(taper: f32, size: Vec3, stretch_uvs: bool, segments: u32) -> Mesh {
use std::f32::consts::TAU;
let taper = taper.clamp(0.0, 1.0);
let segments = segments.clamp(3, 256);
let top_scale = 1.0 - taper;
let apex = top_scale <= 1e-6;
let r = 0.5_f32;
let (rx_world, rz_world) = (size.x.abs() * 0.5, size.z.abs() * 0.5);
let perimeter = {
let (a, b) = (rx_world, rz_world);
if a <= 0.0 && b <= 0.0 {
0.0
} else {
let h = ((a - b) * (a - b)) / ((a + b) * (a + b)).max(1e-12);
std::f32::consts::PI * (a + b) * (1.0 + (3.0 * h) / (10.0 + (4.0 - 3.0 * h).sqrt()))
}
};
let (u_span, v_span) = if stretch_uvs {
(1.0, 1.0)
} else {
(perimeter, size.y.abs())
};
let mut positions: Vec<[f32; 3]> = Vec::new();
let mut normals: Vec<[f32; 3]> = Vec::new();
let mut uvs: Vec<[f32; 2]> = Vec::new();
let mut tangents: Vec<[f32; 4]> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
let slope_y = r * taper; let side_base = 0;
for i in 0..=segments {
let f = i as f32 / segments as f32;
let ang = f * TAU;
let (sin_a, cos_a) = ang.sin_cos();
let bx = r * cos_a;
let bz = r * sin_a;
let tx = bx * top_scale;
let tz = bz * top_scale;
let n = Vec3::new(cos_a, slope_y, sin_a).normalize_or_zero();
let n = if n.length_squared() < 1e-12 {
Vec3::new(cos_a, 0.0, sin_a)
} else {
n
};
let t = Vec3::new(-sin_a, 0.0, cos_a);
positions.push([bx, -0.5, bz]);
normals.push(n.into());
uvs.push([f * u_span, v_span]);
tangents.push([t.x, t.y, t.z, 1.0]);
positions.push([tx, 0.5, tz]);
normals.push(n.into());
uvs.push([f * u_span, 0.0]);
tangents.push([t.x, t.y, t.z, 1.0]);
}
for i in 0..segments {
let b0 = side_base + i * 2;
let t0 = b0 + 1;
let b1 = b0 + 2;
let t1 = b0 + 3;
if apex {
indices.extend_from_slice(&[b0, t0, b1]);
} else {
indices.extend_from_slice(&[b0, t1, b1, b0, t0, t1]);
}
}
let mut push_cap = |y: f32, scale: f32, up: bool| {
if scale <= 1e-6 {
return;
}
let base = positions.len() as u32;
let n = if up { 1.0_f32 } else { -1.0 };
positions.push([0.0, y, 0.0]);
normals.push([0.0, n, 0.0]);
uvs.push([0.0, 0.0]);
tangents.push([1.0, 0.0, 0.0, 1.0]);
for i in 0..=segments {
let ang = (i as f32 / segments as f32) * TAU;
let (sin_a, cos_a) = ang.sin_cos();
let (x, z) = (r * cos_a * scale, r * sin_a * scale);
positions.push([x, y, z]);
normals.push([0.0, n, 0.0]);
uvs.push(if stretch_uvs {
[0.5 + cos_a * 0.5 * scale, 0.5 + sin_a * 0.5 * scale]
} else {
[cos_a * rx_world * scale, sin_a * rz_world * scale]
});
tangents.push([1.0, 0.0, 0.0, 1.0]);
}
for i in 0..segments {
let a = base + 1 + i;
let b = base + 2 + i;
if up {
indices.extend_from_slice(&[base, b, a]);
} else {
indices.extend_from_slice(&[base, a, b]);
}
}
};
push_cap(-0.5, 1.0, false);
push_cap(0.5, top_scale, true);
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_attribute(Mesh::ATTRIBUTE_TANGENT, tangents);
mesh.insert_indices(Indices::U32(indices));
mesh
}
fn build_triangle_face(peak_offset: f32, size: Vec3, stretch_uvs: bool) -> Mesh {
let peak_offset = peak_offset.clamp(0.0, 1.0);
let (w, h) = if stretch_uvs {
(1.0, 1.0)
} else {
(size.x, size.y)
};
let v0 = [-0.5_f32, -0.5, 0.0];
let v1 = [0.5_f32, -0.5, 0.0];
let v2 = [peak_offset - 0.5, 0.5, 0.0];
let positions: Vec<[f32; 3]> = vec![v0, v1, v2, v0, v1, v2];
let nf = [0.0_f32, 0.0, 1.0];
let nb = [0.0_f32, 0.0, -1.0];
let normals: Vec<[f32; 3]> = vec![nf, nf, nf, nb, nb, nb];
let u0 = [0.0, h];
let u1 = [w, h];
let u2 = [peak_offset * w, 0.0_f32];
let uvs: Vec<[f32; 2]> = vec![u0, u1, u2, u0, u1, u2];
let indices: Vec<u32> = vec![0, 1, 2, 3, 5, 4];
let tangents: Vec<[f32; 4]> = vec![
[1.0, 0.0, 0.0, -1.0],
[1.0, 0.0, 0.0, -1.0],
[1.0, 0.0, 0.0, -1.0],
[1.0, 0.0, 0.0, 1.0],
[1.0, 0.0, 0.0, 1.0],
[1.0, 0.0, 0.0, 1.0],
];
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_attribute(Mesh::ATTRIBUTE_TANGENT, tangents);
mesh.insert_indices(Indices::U32(indices));
mesh
}
fn build_trapezoid_face(top_width: f32, offset_x: f32, size: Vec3, stretch_uvs: bool) -> Mesh {
let top_width = top_width.clamp(0.0, 1.0);
let offset_x = offset_x.clamp(0.0, (1.0 - top_width).max(0.0));
let (w, h) = if stretch_uvs {
(1.0, 1.0)
} else {
(size.x, size.y)
};
let v0 = [-0.5_f32, -0.5, 0.0]; let v1 = [0.5_f32, -0.5, 0.0]; let v2 = [offset_x + top_width - 0.5, 0.5, 0.0]; let v3 = [offset_x - 0.5, 0.5, 0.0];
let positions: Vec<[f32; 3]> = vec![v0, v1, v2, v3, v0, v1, v2, v3];
let nf = [0.0_f32, 0.0, 1.0];
let nb = [0.0_f32, 0.0, -1.0];
let normals: Vec<[f32; 3]> = vec![nf, nf, nf, nf, nb, nb, nb, nb];
let uv0 = [0.0, h];
let uv1 = [w, h];
let uv2 = [(offset_x + top_width) * w, 0.0_f32];
let uv3 = [offset_x * w, 0.0_f32];
let uvs: Vec<[f32; 2]> = vec![uv0, uv1, uv2, uv3, uv0, uv1, uv2, uv3];
let indices: Vec<u32> = vec![0, 1, 2, 0, 2, 3, 4, 6, 5, 4, 7, 6];
let tangents: Vec<[f32; 4]> = vec![[1.0, 0.0, 0.0, -1.0]; 4]
.into_iter()
.chain(vec![[1.0_f32, 0.0, 0.0, 1.0]; 4])
.collect();
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_attribute(Mesh::ATTRIBUTE_TANGENT, tangents);
mesh.insert_indices(Indices::U32(indices));
mesh
}
fn build_polygon_face(pts: &[DVec2], size: Vec3, stretch_uvs: bool) -> Mesh {
if pts.len() < 3 {
return build_tapered_cuboid(0.0, size, stretch_uvs);
}
let (w, h) = if stretch_uvs {
(1.0, 1.0)
} else {
(size.x, size.y)
};
let scope_pts: Vec<Vec2> = pts
.iter()
.map(|p| Vec2::new(p.x as f32, p.y as f32))
.collect();
let area: f32 = (0..scope_pts.len())
.map(|i| {
let j = (i + 1) % scope_pts.len();
scope_pts[i].x * scope_pts[j].y - scope_pts[j].x * scope_pts[i].y
})
.sum::<f32>()
* 0.5;
let pts_ccw: Vec<Vec2> = if area < 0.0 {
scope_pts.iter().rev().cloned().collect()
} else {
scope_pts.clone()
};
let tris = ear_clip(&pts_ccw);
if tris.is_empty() {
return build_tapered_cuboid(0.0, size, stretch_uvs);
}
let n = pts_ccw.len() as u32;
let positions: Vec<[f32; 3]> = pts_ccw
.iter()
.chain(pts_ccw.iter())
.map(|p| [p.x - 0.5, p.y - 0.5, 0.0])
.collect();
let nf = [0.0_f32, 0.0, 1.0];
let nb = [0.0_f32, 0.0, -1.0];
let normals: Vec<[f32; 3]> = (0..n as usize)
.map(|_| nf)
.chain((0..n as usize).map(|_| nb))
.collect();
let uvs: Vec<[f32; 2]> = pts_ccw
.iter()
.chain(pts_ccw.iter())
.map(|p| [p.x * w, (1.0 - p.y) * h])
.collect();
let tangents: Vec<[f32; 4]> = (0..n as usize)
.map(|_| [1.0_f32, 0.0, 0.0, -1.0])
.chain((0..n as usize).map(|_| [1.0_f32, 0.0, 0.0, 1.0]))
.collect();
let indices: Vec<u32> = tris
.iter()
.flat_map(|&[a, b, c]| [a, b, c])
.chain(tris.iter().flat_map(|&[a, b, c]| [a + n, c + n, b + n]))
.collect();
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_attribute(Mesh::ATTRIBUTE_TANGENT, tangents);
mesh.insert_indices(Indices::U32(indices));
mesh
}
fn ear_clip(verts: &[Vec2]) -> Vec<[u32; 3]> {
let n = verts.len();
if n < 3 {
return vec![];
}
if n == 3 {
return vec![[0, 1, 2]];
}
let mut active: Vec<usize> = (0..n).collect();
let mut tris: Vec<[u32; 3]> = Vec::with_capacity(n - 2);
let mut safety = n * n + n;
while active.len() > 3 && safety > 0 {
safety -= 1;
let m = active.len();
let mut clipped = false;
for i in 0..m {
let (ia, ib, ic) = (active[(i + m - 1) % m], active[i], active[(i + 1) % m]);
let (a, b, c) = (verts[ia], verts[ib], verts[ic]);
if cross2(b - a, c - b) <= 0.0 {
continue;
}
let is_ear = !active
.iter()
.any(|&j| j != ia && j != ib && j != ic && point_in_tri(verts[j], a, b, c));
if !is_ear {
continue;
}
tris.push([ia as u32, ib as u32, ic as u32]);
active.remove(i);
clipped = true;
break;
}
if !clipped {
break; }
}
if active.len() == 3 {
tris.push([active[0] as u32, active[1] as u32, active[2] as u32]);
}
tris
}
#[inline]
fn cross2(a: Vec2, b: Vec2) -> f32 {
a.x * b.y - a.y * b.x
}
#[inline]
fn point_in_tri(p: Vec2, a: Vec2, b: Vec2, c: Vec2) -> bool {
cross2(b - a, p - a) > 0.0 && cross2(c - b, p - b) > 0.0 && cross2(a - c, p - c) > 0.0
}
#[cfg(test)]
mod tests {
use super::*;
fn positions_of(mesh: &Mesh) -> Vec<[f32; 3]> {
match mesh.attribute(Mesh::ATTRIBUTE_POSITION).unwrap() {
VertexAttributeValues::Float32x3(v) => v.clone(),
_ => panic!("expected Float32x3 positions"),
}
}
fn uvs_of(mesh: &Mesh) -> Vec<[f32; 2]> {
match mesh.attribute(Mesh::ATTRIBUTE_UV_0).unwrap() {
VertexAttributeValues::Float32x2(v) => v.clone(),
_ => panic!("expected Float32x2 uvs"),
}
}
fn assert_winding_matches_normals(mesh: &Mesh, what: &str) {
let pos = positions_of(mesh);
let normals = match mesh.attribute(Mesh::ATTRIBUTE_NORMAL).unwrap() {
VertexAttributeValues::Float32x3(v) => v.clone(),
_ => panic!("expected Float32x3 normals"),
};
let Some(Indices::U32(idx)) = mesh.indices() else {
panic!("expected U32 indices");
};
for tri in idx.chunks_exact(3) {
let (a, b, c) = (
Vec3::from(pos[tri[0] as usize]),
Vec3::from(pos[tri[1] as usize]),
Vec3::from(pos[tri[2] as usize]),
);
let geometric = (b - a).cross(c - a);
if geometric.length_squared() < 1e-12 {
continue; }
let shaded = (Vec3::from(normals[tri[0] as usize])
+ Vec3::from(normals[tri[1] as usize])
+ Vec3::from(normals[tri[2] as usize]))
/ 3.0;
assert!(
geometric.normalize().dot(shaded.normalize()) > 0.0,
"{what}: triangle {tri:?} is wound against its normal \
(geometric {geometric:?}, shaded {shaded:?}) — the solid \
will render inside-out",
);
}
}
#[test]
fn round_prism_winding_faces_outward() {
for (taper, what) in [(0.0_f32, "cylinder"), (0.35, "frustum"), (1.0, "cone")] {
let mesh = build_round_prism(taper, Vec3::new(2.0, 5.0, 1.2), false, 16);
assert_winding_matches_normals(&mesh, what);
}
}
#[test]
fn cuboid_winding_faces_outward() {
for taper in [0.0_f32, 0.5, 1.0] {
let mesh = build_tapered_cuboid(taper, Vec3::new(2.0, 3.0, 1.0), false);
assert_winding_matches_normals(&mesh, "cuboid");
}
}
#[test]
fn round_prism_fits_the_unit_footprint() {
let mesh = build_round_prism(0.0, Vec3::new(2.0, 5.0, 2.0), false, 24);
for p in positions_of(&mesh) {
assert!(p[0].abs() <= 0.5 + 1e-5 && p[2].abs() <= 0.5 + 1e-5);
assert!(p[1].abs() <= 0.5 + 1e-5);
let r = (p[0] * p[0] + p[2] * p[2]).sqrt();
assert!(r <= 0.5 + 1e-5, "vertex outside the inscribed circle: {r}");
}
}
#[test]
fn round_prism_segment_count_drives_tessellation() {
let coarse = positions_of(&build_round_prism(0.0, Vec3::ONE, false, 8));
let fine = positions_of(&build_round_prism(0.0, Vec3::ONE, false, 64));
assert!(
fine.len() > coarse.len() * 4,
"more segments must mean more vertices ({} vs {})",
fine.len(),
coarse.len()
);
let seg = 8_usize;
assert_eq!(coarse.len(), (seg + 1) * 2 + 2 * (seg + 2));
}
#[test]
fn cone_collapses_the_top_ring_and_drops_the_top_cap() {
let cyl = positions_of(&build_round_prism(0.0, Vec3::ONE, false, 16));
let cone = positions_of(&build_round_prism(1.0, Vec3::ONE, false, 16));
assert!(cone.len() < cyl.len());
for p in cone.iter().filter(|p| p[1] > 0.49) {
let r = (p[0] * p[0] + p[2] * p[2]).sqrt();
assert!(r < 1e-5, "cone apex is not on the axis: r={r}");
}
}
#[test]
fn frustum_top_radius_follows_taper() {
let mesh = build_round_prism(0.5, Vec3::ONE, false, 16);
let top_r = positions_of(&mesh)
.iter()
.filter(|p| p[1] > 0.49)
.map(|p| (p[0] * p[0] + p[2] * p[2]).sqrt())
.fold(0.0_f32, f32::max);
assert!((top_r - 0.25).abs() < 1e-5, "top radius {top_r} != 0.25");
}
#[test]
fn round_uvs_tile_in_world_space_and_stretch_on_demand() {
let tiled = uvs_of(&build_round_prism(0.0, Vec3::new(2.0, 5.0, 2.0), false, 32));
let max_u = tiled.iter().map(|uv| uv[0]).fold(0.0_f32, f32::max);
let max_v = tiled.iter().map(|uv| uv[1]).fold(0.0_f32, f32::max);
assert!(
(max_u - std::f32::consts::TAU).abs() < 0.05,
"U should span the circumference in metres, got {max_u}"
);
assert!(
(max_v - 5.0).abs() < 1e-4,
"V should span the height in metres, got {max_v}"
);
let stretched = uvs_of(&build_round_prism(0.0, Vec3::new(2.0, 5.0, 2.0), true, 32));
let s_max_u = stretched.iter().map(|uv| uv[0]).fold(0.0_f32, f32::max);
let s_max_v = stretched.iter().map(|uv| uv[1]).fold(0.0_f32, f32::max);
assert!((s_max_u - 1.0).abs() < 1e-5, "stretched U spans 0..1");
assert!((s_max_v - 1.0).abs() < 1e-5, "stretched V spans 0..1");
}
#[test]
fn round_prism_indices_reference_real_vertices() {
for taper in [0.0_f32, 0.5, 1.0] {
let mesh = build_round_prism(taper, Vec3::ONE, false, 12);
let n = positions_of(&mesh).len() as u32;
let Some(Indices::U32(idx)) = mesh.indices() else {
panic!("expected U32 indices");
};
assert!(!idx.is_empty());
assert_eq!(idx.len() % 3, 0, "index count must be a multiple of 3");
assert!(
idx.iter().all(|i| *i < n),
"taper {taper}: index out of range (n={n})"
);
}
}
#[test]
fn build_profiled_mesh_with_routes_round_only_for_volume_profiles() {
let boxed = positions_of(&build_profiled_mesh_with(
&FaceProfile::Rectangle,
Vec3::ONE,
false,
0,
));
let round = positions_of(&build_profiled_mesh_with(
&FaceProfile::Rectangle,
Vec3::ONE,
false,
24,
));
assert_eq!(boxed.len(), 24, "0 segments keeps the cuboid");
assert!(round.len() > 24, "round path must tessellate");
let flat_boxed = positions_of(&build_profiled_mesh_with(
&FaceProfile::Triangle { peak_offset: 0.5 },
Vec3::ONE,
false,
0,
));
let flat_round = positions_of(&build_profiled_mesh_with(
&FaceProfile::Triangle { peak_offset: 0.5 },
Vec3::ONE,
false,
24,
));
assert_eq!(flat_boxed, flat_round);
let degenerate = positions_of(&build_profiled_mesh_with(
&FaceProfile::Rectangle,
Vec3::ONE,
false,
2,
));
assert_eq!(degenerate.len(), 24);
}
#[test]
fn box_has_correct_vertex_count() {
let mesh = build_tapered_cuboid(0.0, Vec3::ONE, false);
let positions = mesh.attribute(Mesh::ATTRIBUTE_POSITION).unwrap();
assert_eq!(positions.len(), 24);
}
#[test]
fn box_has_correct_index_count() {
let mesh = build_tapered_cuboid(0.0, Vec3::ONE, false);
if let Some(Indices::U32(idx)) = mesh.indices() {
assert_eq!(idx.len(), 36);
} else {
panic!("expected U32 indices");
}
}
#[test]
fn pyramid_has_same_structure() {
let mesh = build_tapered_cuboid(1.0, Vec3::ONE, false);
let positions = mesh.attribute(Mesh::ATTRIBUTE_POSITION).unwrap();
assert_eq!(positions.len(), 24);
}
#[test]
fn winding_is_ccw_from_outside() {
for taper in [0.0f32, 0.5, 1.0] {
let mesh = build_tapered_cuboid(taper, Vec3::ONE, false);
let positions = match mesh.attribute(Mesh::ATTRIBUTE_POSITION) {
Some(bevy::mesh::VertexAttributeValues::Float32x3(p)) => p,
_ => panic!("expected Float32x3 positions"),
};
let normals = match mesh.attribute(Mesh::ATTRIBUTE_NORMAL) {
Some(bevy::mesh::VertexAttributeValues::Float32x3(n)) => n,
_ => panic!("expected Float32x3 normals"),
};
let indices = match mesh.indices() {
Some(Indices::U32(idx)) => idx.clone(),
_ => panic!("expected U32 indices"),
};
for tri in indices.chunks(3) {
let (i0, i1, i2) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
let p0 = Vec3::from(positions[i0]);
let p1 = Vec3::from(positions[i1]);
let p2 = Vec3::from(positions[i2]);
let face_n = Vec3::from(normals[i0]); let computed = (p1 - p0).cross(p2 - p0);
if computed.length_squared() < 1e-10 {
continue;
}
assert!(
computed.dot(face_n) > 0.0,
"CW winding at taper={taper}: tri ({i0},{i1},{i2}), \
computed={computed:?}, stored={face_n:?}"
);
}
}
}
#[test]
fn uvs_have_correct_orientation_and_scale() {
let size = Vec3::new(4.0, 3.0, 2.0); let mesh = build_tapered_cuboid(0.0, size, false);
let uvs = match mesh.attribute(Mesh::ATTRIBUTE_UV_0) {
Some(bevy::mesh::VertexAttributeValues::Float32x2(u)) => u.clone(),
_ => panic!("expected Float32x2 UVs"),
};
assert_eq!(uvs[0], [0.0, 0.0], "bottom b0");
assert_eq!(uvs[1], [4.0, 0.0], "bottom b1");
assert_eq!(uvs[2], [4.0, 2.0], "bottom b2");
assert_eq!(uvs[3], [0.0, 2.0], "bottom b3");
assert_eq!(uvs[4], [0.0, 0.0], "top t0 front-left");
assert_eq!(uvs[5], [0.0, 2.0], "top t3 back-left");
assert_eq!(uvs[6], [4.0, 2.0], "top t2 back-right");
assert_eq!(uvs[7], [4.0, 0.0], "top t1 front-right");
assert_eq!(uvs[8], [0.0, 3.0], "front b1 viewer-left-bottom");
assert_eq!(uvs[9], [4.0, 3.0], "front b0 viewer-right-bottom");
assert_eq!(uvs[10], [4.0, 0.0], "front t0 viewer-right-top (taper=0)");
assert_eq!(uvs[11], [0.0, 0.0], "front t1 viewer-left-top (taper=0)");
assert_eq!(uvs[16], [0.0, 3.0], "left b0 front-bottom");
assert_eq!(uvs[17], [2.0, 3.0], "left b3 back-bottom");
assert_eq!(uvs[18], [2.0, 0.0], "left t3 back-top (taper=0)");
assert_eq!(uvs[19], [0.0, 0.0], "left t0 front-top (taper=0)");
}
#[test]
fn tapered_side_face_uvs_narrow_at_top() {
let size = Vec3::new(4.0, 3.0, 2.0);
let mesh = build_tapered_cuboid(1.0, size, false);
let uvs = match mesh.attribute(Mesh::ATTRIBUTE_UV_0) {
Some(bevy::mesh::VertexAttributeValues::Float32x2(u)) => u.clone(),
_ => panic!("expected Float32x2 UVs"),
};
let front_t0_u = uvs[10][0];
let front_t1_u = uvs[11][0];
assert!(
(front_t0_u - 2.0).abs() < 1e-5,
"front top-left U should be 2.0, got {front_t0_u}"
);
assert!(
(front_t1_u - 2.0).abs() < 1e-5,
"front top-right U should be 2.0, got {front_t1_u}"
);
}
#[test]
fn normals_are_unit_length() {
let mesh = build_tapered_cuboid(0.5, Vec3::ONE, false);
if let Some(bevy::mesh::VertexAttributeValues::Float32x3(normals)) =
mesh.attribute(Mesh::ATTRIBUTE_NORMAL)
{
for n in normals {
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
assert!((len - 1.0).abs() < 1e-5, "non-unit normal: {:?}", n);
}
} else {
panic!("expected Float32x3 normals");
}
}
#[test]
fn triangle_face_vertex_count() {
let mesh = build_triangle_face(0.5, Vec3::new(4.0, 3.0, 0.0), false);
let pos = mesh.attribute(Mesh::ATTRIBUTE_POSITION).unwrap();
assert_eq!(pos.len(), 6, "expected 3 front + 3 back verts");
}
#[test]
fn triangle_face_index_count() {
let mesh = build_triangle_face(0.5, Vec3::new(4.0, 3.0, 0.0), false);
if let Some(Indices::U32(idx)) = mesh.indices() {
assert_eq!(idx.len(), 6, "expected 2 triangles × 3 indices");
} else {
panic!("expected U32 indices");
}
}
#[test]
fn triangle_face_front_winding_is_ccw() {
let mesh = build_triangle_face(0.5, Vec3::ONE, false);
let positions = match mesh.attribute(Mesh::ATTRIBUTE_POSITION) {
Some(VertexAttributeValues::Float32x3(p)) => p,
_ => panic!("expected Float32x3"),
};
let p0 = Vec3::from(positions[0]);
let p1 = Vec3::from(positions[1]);
let p2 = Vec3::from(positions[2]);
let normal = (p1 - p0).cross(p2 - p0);
assert!(
normal.z > 0.0,
"front face should have +Z normal, got {normal:?}"
);
}
#[test]
fn triangle_face_uvs_correct() {
let size = Vec3::new(4.0, 3.0, 0.0);
let mesh = build_triangle_face(0.5, size, false);
let uvs = match mesh.attribute(Mesh::ATTRIBUTE_UV_0) {
Some(VertexAttributeValues::Float32x2(u)) => u.clone(),
_ => panic!("expected Float32x2 UVs"),
};
assert!((uvs[0][0]).abs() < 1e-5, "V0 U should be 0");
assert!((uvs[0][1] - 3.0).abs() < 1e-5, "V0 V should be size.y=3");
assert!((uvs[1][0] - 4.0).abs() < 1e-5, "V1 U should be size.x=4");
assert!(
(uvs[2][0] - 2.0).abs() < 1e-5,
"V2 U should be 2.0 for peak=0.5"
);
assert!((uvs[2][1]).abs() < 1e-5, "V2 V should be 0 at ridge");
}
#[test]
fn triangle_face_normals_are_unit() {
let mesh = build_triangle_face(0.3, Vec3::new(5.0, 4.0, 0.0), false);
if let Some(VertexAttributeValues::Float32x3(normals)) =
mesh.attribute(Mesh::ATTRIBUTE_NORMAL)
{
for n in normals {
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
assert!((len - 1.0).abs() < 1e-5, "non-unit normal: {n:?}");
}
} else {
panic!("expected Float32x3 normals");
}
}
#[test]
fn trapezoid_face_vertex_count() {
let mesh = build_trapezoid_face(0.5, 0.25, Vec3::new(10.0, 5.0, 0.0), false);
let pos = mesh.attribute(Mesh::ATTRIBUTE_POSITION).unwrap();
assert_eq!(pos.len(), 8, "expected 4 front + 4 back verts");
}
#[test]
fn trapezoid_face_index_count() {
let mesh = build_trapezoid_face(0.5, 0.25, Vec3::new(10.0, 5.0, 0.0), false);
if let Some(Indices::U32(idx)) = mesh.indices() {
assert_eq!(idx.len(), 12, "expected 4 triangles × 3 indices");
} else {
panic!("expected U32 indices");
}
}
#[test]
fn trapezoid_face_front_winding_is_ccw() {
let mesh = build_trapezoid_face(0.5, 0.25, Vec3::ONE, false);
let positions = match mesh.attribute(Mesh::ATTRIBUTE_POSITION) {
Some(VertexAttributeValues::Float32x3(p)) => p,
_ => panic!("expected Float32x3"),
};
let p0 = Vec3::from(positions[0]);
let p1 = Vec3::from(positions[1]);
let p2 = Vec3::from(positions[2]);
let n = (p1 - p0).cross(p2 - p0);
assert!(
n.z > 0.0,
"front face first tri should have +Z normal, got {n:?}"
);
}
#[test]
fn trapezoid_face_uvs_correct() {
let size = Vec3::new(10.0, 5.0, 0.0);
let mesh = build_trapezoid_face(0.5, 0.25, size, false);
let uvs = match mesh.attribute(Mesh::ATTRIBUTE_UV_0) {
Some(VertexAttributeValues::Float32x2(u)) => u.clone(),
_ => panic!("expected Float32x2 UVs"),
};
assert!((uvs[0][0]).abs() < 1e-5);
assert!((uvs[0][1] - 5.0).abs() < 1e-5);
assert!((uvs[1][0] - 10.0).abs() < 1e-5);
assert!((uvs[2][0] - 7.5).abs() < 1e-5);
assert!((uvs[2][1]).abs() < 1e-5);
assert!((uvs[3][0] - 2.5).abs() < 1e-5);
assert!((uvs[3][1]).abs() < 1e-5);
}
#[test]
fn ear_clip_triangle() {
let verts = vec![
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 0.0),
Vec2::new(0.5, 1.0),
];
let tris = ear_clip(&verts);
assert_eq!(tris, vec![[0, 1, 2]]);
}
#[test]
fn ear_clip_square_gives_two_triangles() {
let verts = vec![
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 0.0),
Vec2::new(1.0, 1.0),
Vec2::new(0.0, 1.0),
];
let tris = ear_clip(&verts);
assert_eq!(tris.len(), 2, "square → 2 triangles");
for tri in &tris {
for &i in tri {
assert!(i < 4);
}
}
}
#[test]
fn ear_clip_pentagon_gives_three_triangles() {
use std::f32::consts::TAU;
let verts: Vec<Vec2> = (0..5)
.map(|i| {
let t = TAU * i as f32 / 5.0;
Vec2::new(t.cos(), t.sin())
})
.collect();
let tris = ear_clip(&verts);
assert_eq!(tris.len(), 3, "pentagon → 3 triangles");
}
#[test]
fn profiled_mesh_rectangle_matches_cuboid() {
let size = Vec3::new(2.0, 3.0, 1.0);
let a = build_profiled_mesh(&FaceProfile::Rectangle, size, false);
let b = build_tapered_cuboid(0.0, size, false);
assert_eq!(
a.attribute(Mesh::ATTRIBUTE_POSITION).unwrap().len(),
b.attribute(Mesh::ATTRIBUTE_POSITION).unwrap().len()
);
}
#[test]
fn profiled_mesh_triangle_has_six_verts() {
let mesh = build_profiled_mesh(
&FaceProfile::Triangle { peak_offset: 0.5 },
Vec3::new(4.0, 3.0, 0.0),
false,
);
assert_eq!(mesh.attribute(Mesh::ATTRIBUTE_POSITION).unwrap().len(), 6);
}
#[test]
fn profiled_mesh_trapezoid_has_eight_verts() {
let mesh = build_profiled_mesh(
&FaceProfile::Trapezoid {
top_width: 0.5,
offset_x: 0.25,
},
Vec3::new(10.0, 5.0, 0.0),
false,
);
assert_eq!(mesh.attribute(Mesh::ATTRIBUTE_POSITION).unwrap().len(), 8);
}
#[test]
fn profiled_mesh_polygon_square_has_eight_verts() {
use bevy::math::DVec2;
let pts = vec![
DVec2::new(0.0, 0.0),
DVec2::new(1.0, 0.0),
DVec2::new(1.0, 1.0),
DVec2::new(0.0, 1.0),
];
let mesh = build_profiled_mesh(&FaceProfile::Polygon(pts), Vec3::new(4.0, 4.0, 0.0), false);
assert_eq!(mesh.attribute(Mesh::ATTRIBUTE_POSITION).unwrap().len(), 8);
}
}