use glam::{Vec2, Vec3};
use crate::mesh::{MeshData, Vertex};
use crate::render3d::Transform;
use crate::ui::Color;
pub trait Primitive {
fn name(&self) -> String;
fn build(&self) -> MeshData;
fn placement(&self) -> (Transform, Option<Color>);
}
#[derive(Clone, Copy, Debug)]
pub struct Box {
pub size: Vec3,
pub transform: Transform,
pub color: Option<Color>,
}
impl Box {
pub fn new(width: f32, height: f32, depth: f32) -> Self {
Self {
size: Vec3::new(width, height, depth),
transform: Transform::default(),
color: None,
}
}
pub fn cube(size: f32) -> Self {
Self::new(size, size, size)
}
pub fn at(mut self, x: f32, y: f32, z: f32) -> Self {
self.transform.translation = Vec3::new(x, y, z);
self
}
pub fn yaw(mut self, radians: f32) -> Self {
self.transform.set_yaw(radians);
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
}
impl Primitive for Box {
fn name(&self) -> String {
format!(
"primitive:box:{:.3}x{:.3}x{:.3}",
self.size.x, self.size.y, self.size.z
)
}
fn build(&self) -> MeshData {
let half = self.size.abs() * 0.5;
let mut vertices = Vec::with_capacity(24);
let mut indices = Vec::with_capacity(36);
let faces = [
(Vec3::X, Vec3::NEG_Z, Vec3::Y),
(Vec3::NEG_X, Vec3::Z, Vec3::Y),
(Vec3::Y, Vec3::X, Vec3::NEG_Z),
(Vec3::NEG_Y, Vec3::X, Vec3::Z),
(Vec3::Z, Vec3::X, Vec3::Y),
(Vec3::NEG_Z, Vec3::NEG_X, Vec3::Y),
];
for (normal, u, v) in faces {
let base = vertices.len() as u32;
let (center, du, dv) = (normal * half, u * half, v * half);
for corner in [
center - du - dv,
center + du - dv,
center + du + dv,
center - du + dv,
] {
vertices.push(Vertex {
position: corner.to_array(),
normal: normal.to_array(),
});
}
indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]);
}
MeshData {
name: self.name(),
vertices,
indices,
base_color: self.color,
min: -half,
max: half,
}
}
fn placement(&self) -> (Transform, Option<Color>) {
(self.transform, self.color)
}
}
const SPHERE_SEGMENTS: u32 = 16;
const SPHERE_RINGS: u32 = 10;
#[derive(Clone, Copy, Debug)]
pub struct Sphere {
pub radius: f32,
pub transform: Transform,
pub color: Option<Color>,
}
impl Sphere {
pub fn new(radius: f32) -> Self {
Self {
radius,
transform: Transform::default(),
color: None,
}
}
pub fn at(mut self, x: f32, y: f32, z: f32) -> Self {
self.transform.translation = Vec3::new(x, y, z);
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
}
impl Primitive for Sphere {
fn name(&self) -> String {
format!("primitive:sphere:{:.3}", self.radius)
}
fn build(&self) -> MeshData {
use std::f32::consts::{PI, TAU};
let radius = self.radius.abs();
let (segments, rings) = (SPHERE_SEGMENTS, SPHERE_RINGS);
let mut vertices = Vec::with_capacity((2 + (rings - 1) * segments) as usize);
let mut indices = Vec::with_capacity(((rings - 1) * segments * 6) as usize);
let mut push = |direction: Vec3| {
vertices.push(Vertex {
position: (direction * radius).to_array(),
normal: direction.to_array(),
});
};
push(Vec3::Y);
for ring in 1..rings {
let (sin_theta, cos_theta) = (PI * ring as f32 / rings as f32).sin_cos();
for segment in 0..segments {
let (sin_phi, cos_phi) = (TAU * segment as f32 / segments as f32).sin_cos();
push(Vec3::new(
sin_theta * cos_phi,
cos_theta,
sin_theta * sin_phi,
));
}
}
push(Vec3::NEG_Y);
let north = 0;
let south = 1 + (rings - 1) * segments;
let at = |ring: u32, segment: u32| 1 + (ring - 1) * segments + segment % segments;
for segment in 0..segments {
indices.extend([at(1, segment), north, at(1, segment + 1)]);
}
for ring in 1..rings - 1 {
for segment in 0..segments {
let (a, b) = (at(ring, segment), at(ring + 1, segment));
let (c, d) = (at(ring + 1, segment + 1), at(ring, segment + 1));
indices.extend([a, d, b, b, d, c]);
}
}
for segment in 0..segments {
indices.extend([at(rings - 1, segment), at(rings - 1, segment + 1), south]);
}
MeshData {
name: self.name(),
vertices,
indices,
base_color: self.color,
min: Vec3::splat(-radius),
max: Vec3::splat(radius),
}
}
fn placement(&self) -> (Transform, Option<Color>) {
(self.transform, self.color)
}
}
#[derive(Clone, Debug)]
pub struct Extrusion {
pub profile: Vec<Vec2>,
pub width: f32,
pub transform: Transform,
pub color: Option<Color>,
}
impl Extrusion {
pub fn new(profile: impl Into<Vec<Vec2>>, width: f32) -> Self {
let mut profile = profile.into();
assert!(
profile.len() >= 3,
"an outline needs three corners at the least, and this has {}",
profile.len(),
);
if signed_area(&profile) < 0.0 {
profile.reverse();
}
let sides = profile.len();
for (i, &from) in profile.iter().enumerate() {
let (to, next) = (profile[(i + 1) % sides], profile[(i + 2) % sides]);
assert!(
(to - from).perp_dot(next - to) > 0.0,
"an outline has to be convex, and this one does not turn left at {to}: {profile:?}",
);
}
Self {
profile,
width,
transform: Transform::default(),
color: None,
}
}
pub fn at(mut self, x: f32, y: f32, z: f32) -> Self {
self.transform.translation = Vec3::new(x, y, z);
self
}
pub fn yaw(mut self, radians: f32) -> Self {
self.transform.set_yaw(radians);
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn corners(&self) -> Vec<Vec3> {
let half = self.width.abs() * 0.5;
self.profile
.iter()
.flat_map(|&point| [corner(-half, point), corner(half, point)])
.collect()
}
}
fn corner(x: f32, point: Vec2) -> Vec3 {
Vec3::new(x, point.y, point.x)
}
fn signed_area(profile: &[Vec2]) -> f32 {
profile
.iter()
.zip(profile.iter().cycle().skip(1))
.map(|(from, to)| from.x * to.y - to.x * from.y)
.sum()
}
impl Primitive for Extrusion {
fn name(&self) -> String {
let sides = self.profile.len();
let start = (0..sides)
.min_by(|&a, &b| {
let (a, b) = (self.profile[a], self.profile[b]);
a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))
})
.unwrap_or(0);
let outline: String = (0..sides)
.map(|i| self.profile[(start + i) % sides])
.map(|point| format!(":{:.3},{:.3}", point.x, point.y))
.collect();
format!("primitive:extrusion:{:.3}{outline}", self.width)
}
fn build(&self) -> MeshData {
let half = self.width.abs() * 0.5;
let sides = self.profile.len();
let mut vertices = Vec::with_capacity(sides * 6);
let mut indices = Vec::with_capacity(sides * 6 + (sides - 2) * 6);
for (i, &from) in self.profile.iter().enumerate() {
let to = self.profile[(i + 1) % sides];
let edge = to - from;
let normal = Vec3::new(0.0, -edge.x, edge.y).normalize_or_zero();
let base = vertices.len() as u32;
for position in [
corner(-half, from),
corner(half, from),
corner(half, to),
corner(-half, to),
] {
vertices.push(Vertex {
position: position.to_array(),
normal: normal.to_array(),
});
}
indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]);
}
for (x, normal) in [(-half, Vec3::NEG_X), (half, Vec3::X)] {
let base = vertices.len() as u32;
for &point in &self.profile {
vertices.push(Vertex {
position: corner(x, point).to_array(),
normal: normal.to_array(),
});
}
for i in 1..sides as u32 - 1 {
let (a, b) = (base + i, base + i + 1);
match x < 0.0 {
true => indices.extend([base, a, b]),
false => indices.extend([base, b, a]),
}
}
}
let (mut min, mut max) = (Vec3::splat(f32::MAX), Vec3::splat(f32::MIN));
for position in self.corners() {
min = min.min(position);
max = max.max(position);
}
MeshData {
name: self.name(),
vertices,
indices,
base_color: self.color,
min,
max,
}
}
fn placement(&self) -> (Transform, Option<Color>) {
(self.transform, self.color)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cube_is_six_square_faces_that_do_not_share_corners() {
let mesh = Box::cube(1.0).build();
assert_eq!(mesh.vertices.len(), 24, "four vertices per face");
assert_eq!(mesh.indices.len(), 36, "two triangles per face");
assert_eq!(mesh.min, Vec3::splat(-0.5));
assert_eq!(mesh.max, Vec3::splat(0.5));
for vertex in &mesh.vertices {
let position = Vec3::from(vertex.position);
assert_eq!(position.abs(), Vec3::splat(0.5), "{position}");
assert_eq!(Vec3::from(vertex.normal).length(), 1.0);
}
}
#[test]
fn every_face_winds_outward() {
let mesh = Box::new(2.0, 1.0, 3.0).build();
for triangle in mesh.indices.chunks(3) {
let corner = |i: usize| Vec3::from(mesh.vertices[triangle[i] as usize].position);
let (a, b, c) = (corner(0), corner(1), corner(2));
let facing = (b - a).cross(c - a).normalize();
let outward = Vec3::from(mesh.vertices[triangle[0] as usize].normal);
assert!((facing - outward).length() < 1e-5, "{facing} vs {outward}");
}
}
#[test]
fn size_is_baked_in_and_named_so_two_sizes_do_not_share_a_mesh() {
let mesh = Box::new(2.0, 4.0, 6.0).build();
assert_eq!(mesh.max, Vec3::new(1.0, 2.0, 3.0));
assert_ne!(Box::cube(1.0).name(), Box::cube(2.0).name());
assert_eq!(Box::cube(1.0).name(), Box::new(1.0, 1.0, 1.0).name());
}
#[test]
fn a_sphere_is_one_vertex_per_pole_and_a_ring_between_every_band() {
let mesh = Sphere::new(2.0).build();
assert_eq!(
mesh.vertices.len() as u32,
2 + (SPHERE_RINGS - 1) * SPHERE_SEGMENTS,
"the poles, and a row of vertices at every latitude between them",
);
assert_eq!(
mesh.indices.len() as u32,
(SPHERE_RINGS * 2 - 2) * SPHERE_SEGMENTS * 3
);
assert_eq!(mesh.indices.len() % 3, 0);
assert_eq!(mesh.min, Vec3::splat(-2.0));
assert_eq!(mesh.max, Vec3::splat(2.0));
}
#[test]
fn every_sphere_vertex_is_on_the_surface_with_its_normal_pointing_away() {
let mesh = Sphere::new(2.0).build();
for vertex in &mesh.vertices {
let position = Vec3::from(vertex.position);
let normal = Vec3::from(vertex.normal);
assert!((position.length() - 2.0).abs() < 1e-5, "{position}");
assert!((normal.length() - 1.0).abs() < 1e-5, "{normal}");
assert!(
normal.dot(position.normalize()) > 0.999,
"{normal} is not the direction of {position}",
);
}
}
#[test]
fn every_sphere_triangle_winds_outward() {
let mesh = Sphere::new(1.0).build();
for triangle in mesh.indices.chunks(3) {
let corner = |i: usize| Vec3::from(mesh.vertices[triangle[i] as usize].position);
let (a, b, c) = (corner(0), corner(1), corner(2));
let facing = (b - a).cross(c - a);
assert!(
facing.length() > 1e-6,
"a triangle with no area: {a} {b} {c}"
);
let outward = (a + b + c) / 3.0;
assert!(facing.dot(outward) > 0.0, "{facing} faces in at {outward}");
}
}
#[test]
fn a_sphere_is_named_by_its_radius() {
assert_ne!(Sphere::new(1.0).name(), Sphere::new(2.0).name());
assert_eq!(Sphere::new(1.0).name(), Sphere::new(1.0001).name());
}
fn ramp() -> Extrusion {
Extrusion::new(
[
Vec2::new(0.0, 0.0),
Vec2::new(3.0, 0.0),
Vec2::new(3.0, 1.0),
Vec2::new(2.0, 1.0),
],
2.0,
)
}
#[test]
fn an_extrusion_is_a_face_per_edge_and_a_cap_at_each_end() {
let mesh = ramp().build();
assert_eq!(
mesh.vertices.len(),
4 * 4 + 2 * 4,
"four vertices a side, and the outline again at each end",
);
assert_eq!(
mesh.indices.len(),
4 * 6 + 2 * 6,
"two triangles a side, and a fan of two at each end",
);
assert_eq!(mesh.min, Vec3::new(-1.0, 0.0, 0.0));
assert_eq!(mesh.max, Vec3::new(1.0, 1.0, 3.0));
}
#[test]
fn every_extrusion_face_winds_outward_along_its_own_normal() {
let mesh = ramp().build();
for triangle in mesh.indices.chunks(3) {
let corner = |i: usize| Vec3::from(mesh.vertices[triangle[i] as usize].position);
let (a, b, c) = (corner(0), corner(1), corner(2));
let facing = (b - a).cross(c - a);
assert!(
facing.length() > 1e-6,
"a triangle with no area: {a} {b} {c}"
);
let facing = facing.normalize();
for i in 0..3 {
let normal = Vec3::from(mesh.vertices[triangle[i] as usize].normal);
assert!((normal.length() - 1.0).abs() < 1e-5, "{normal}");
assert!((facing - normal).length() < 1e-5, "{facing} vs {normal}");
}
}
let slope = Vec3::new(0.0, 2.0, -1.0).normalize();
assert!(
mesh.vertices
.iter()
.any(|vertex| (Vec3::from(vertex.normal) - slope).length() < 1e-5),
"and one of the faces is the slope, facing up and forward",
);
}
#[test]
fn an_outline_of_a_rectangle_is_a_box() {
let rectangle = [
Vec2::new(-1.5, -0.5),
Vec2::new(1.5, -0.5),
Vec2::new(1.5, 0.5),
Vec2::new(-1.5, 0.5),
];
let mesh = Extrusion::new(rectangle, 2.0).build();
let cube = Box::new(2.0, 1.0, 3.0).build();
assert_eq!(mesh.vertices.len(), cube.vertices.len());
assert_eq!(mesh.indices.len(), cube.indices.len());
assert_eq!(mesh.min, cube.min);
assert_eq!(mesh.max, cube.max);
for vertex in &mesh.vertices {
let position = Vec3::from(vertex.position);
assert_eq!(position.abs(), Vec3::new(1.0, 0.5, 1.5), "{position}");
}
}
#[test]
fn an_outline_given_clockwise_is_turned_round_rather_than_drawn_inside_out() {
let mut backwards = ramp().profile.clone();
backwards.reverse();
let turned = Extrusion::new(backwards, 2.0);
assert_eq!(turned.profile, ramp().profile);
assert_eq!(turned.name(), ramp().name(), "and it is the same mesh");
}
#[test]
fn the_corners_are_what_is_drawn_and_nothing_else_is() {
let shape = ramp();
let corners = shape.corners();
let mesh = shape.build();
assert_eq!(corners.len(), 8, "the outline at either end of the width");
for vertex in &mesh.vertices {
let position = Vec3::from(vertex.position);
assert!(
corners
.iter()
.any(|corner| (*corner - position).length() < 1e-6),
"{position} is drawn and is not a corner",
);
}
for corner in &corners {
assert!(
mesh.vertices
.iter()
.any(|vertex| (Vec3::from(vertex.position) - *corner).length() < 1e-6),
"{corner} is a corner and is not drawn",
);
}
}
#[test]
fn an_extrusion_is_named_by_its_width_and_its_outline() {
assert_eq!(Extrusion::new(ramp().profile, 2.0).name(), ramp().name());
assert_eq!(Extrusion::new(ramp().profile, 2.0001).name(), ramp().name());
assert_ne!(Extrusion::new(ramp().profile, 3.0).name(), ramp().name());
let mut taller = ramp().profile;
taller[3].y = 1.5;
assert_ne!(Extrusion::new(taller, 2.0).name(), ramp().name());
}
#[test]
fn the_same_outline_begun_at_another_corner_is_the_same_mesh() {
let mut elsewhere = ramp().profile;
elsewhere.rotate_left(2);
assert_ne!(elsewhere, ramp().profile, "begun two corners on");
assert_eq!(Extrusion::new(elsewhere, 2.0).name(), ramp().name());
}
#[test]
#[should_panic(expected = "convex")]
fn an_outline_with_a_corner_turning_in_is_refused() {
let notch = [
Vec2::new(0.0, 0.0),
Vec2::new(2.0, 0.0),
Vec2::new(2.0, 2.0),
Vec2::new(1.0, 0.5),
Vec2::new(0.0, 2.0),
];
Extrusion::new(notch, 1.0);
}
#[test]
#[should_panic(expected = "convex")]
fn an_outline_with_a_corner_given_twice_is_refused() {
let mut stutter = ramp().profile;
stutter.insert(1, stutter[1]);
Extrusion::new(stutter, 1.0);
}
#[test]
#[should_panic(expected = "convex")]
fn an_outline_with_a_corner_on_a_straight_is_refused() {
let mut flat = ramp().profile;
flat.insert(1, Vec2::new(1.5, 0.0));
Extrusion::new(flat, 1.0);
}
}