mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
An empty mesh passed the unit-normal, closed and outward checks without testing a wall. The test now asserts that each revolve makes triangles.
319 lines
13 KiB
Rust
319 lines
13 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
|
|
|
|
use super::*;
|
|
use crate::{Bevel, Edge};
|
|
|
|
fn square(b: &mut BezPath, x: f64, y: f64, s: f64, clockwise: bool) {
|
|
b.move_to((x, y));
|
|
if clockwise {
|
|
b.line_to((x, y + s));
|
|
b.line_to((x + s, y + s));
|
|
b.line_to((x + s, y));
|
|
} else {
|
|
b.line_to((x + s, y));
|
|
b.line_to((x + s, y + s));
|
|
b.line_to((x, y + s));
|
|
}
|
|
b.close_path();
|
|
}
|
|
|
|
fn shape(b: &BezPath) -> (PathData, Rect) {
|
|
let p = PathData::from_bezpath(b);
|
|
let r = p.bounds().unwrap();
|
|
(p, r)
|
|
}
|
|
|
|
/// Straight on, without perspective.
|
|
fn front() -> View {
|
|
View { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, perspective: 0.0, ..View::default() }
|
|
}
|
|
|
|
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
|
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
|
}
|
|
|
|
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
|
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
|
}
|
|
|
|
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
|
|
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
}
|
|
|
|
fn corners(m: &Mesh, t: [u32; 3]) -> [[f64; 3]; 3] {
|
|
t.map(|i| m.positions[i as usize])
|
|
}
|
|
|
|
/// The area of every triangle together.
|
|
fn area(m: &Mesh) -> f64 {
|
|
m.triangles
|
|
.iter()
|
|
.map(|t| {
|
|
let [a, b, c] = corners(m, *t);
|
|
dot(cross(sub(b, a), sub(c, a)), cross(sub(b, a), sub(c, a))).sqrt() / 2.0
|
|
})
|
|
.sum()
|
|
}
|
|
|
|
/// Every edge of every triangle is met by an edge running the other way, as often: the surface is
|
|
/// closed and its triangles turn one way.
|
|
fn closed(m: &Mesh) -> bool {
|
|
let key = |p: [f64; 3]| p.map(|v| (v * 1e6).round() as i64);
|
|
let mut edges: HashMap<([i64; 3], [i64; 3]), i64> = HashMap::new();
|
|
for t in &m.triangles {
|
|
let c = corners(m, *t).map(key);
|
|
for (a, b) in [(c[0], c[1]), (c[1], c[2]), (c[2], c[0])] {
|
|
*edges.entry((a, b)).or_default() += 1;
|
|
*edges.entry((b, a)).or_default() -= 1;
|
|
}
|
|
}
|
|
edges.values().all(|n| *n == 0)
|
|
}
|
|
|
|
/// Every triangle of a solid convex about the origin turns its front away from the origin, and
|
|
/// agrees with its corners' normals.
|
|
fn outward(m: &Mesh) -> bool {
|
|
m.triangles.iter().all(|t| {
|
|
let [a, b, c] = corners(m, *t);
|
|
let n = cross(sub(b, a), sub(c, a));
|
|
let centre = [(a[0] + b[0] + c[0]) / 3.0, (a[1] + b[1] + c[1]) / 3.0, (a[2] + b[2] + c[2]) / 3.0];
|
|
dot(n, centre) > 0.0 && dot(n, m.normals[t[0] as usize]) > 0.0
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn an_extruded_square_is_a_closed_box_about_its_centre() {
|
|
let mut b = BezPath::new();
|
|
square(&mut b, 200.0, 300.0, 100.0, false);
|
|
let (p, r) = shape(&b);
|
|
let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 50.0, ..Extrude::default() }, front()).unwrap();
|
|
assert_eq!(m.triangles.len(), 12, "two triangles for each side of the box");
|
|
assert_eq!(m.positions.len(), m.normals.len());
|
|
assert_eq!(m.bounds(), Some(([-50.0, -50.0, -25.0], [50.0, 50.0, 25.0])));
|
|
assert!((area(&m) - (2.0 * 100.0 * 100.0 + 4.0 * 100.0 * 50.0)).abs() < 1e-6, "{}", area(&m));
|
|
assert!(closed(&m));
|
|
assert!(outward(&m));
|
|
assert!(m.normals.iter().all(|n| (dot(*n, *n) - 1.0).abs() < 1e-9));
|
|
}
|
|
|
|
#[test]
|
|
fn caps_with_holes_are_cut_around_them() {
|
|
// Two holes in one outline: each cap keeps the outline less both holes.
|
|
let mut b = BezPath::new();
|
|
square(&mut b, 0.0, 0.0, 100.0, false);
|
|
square(&mut b, 10.0, 10.0, 30.0, true);
|
|
square(&mut b, 60.0, 50.0, 30.0, true);
|
|
let (p, r) = shape(&b);
|
|
let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 20.0, ..Extrude::default() }, front()).unwrap();
|
|
let caps = 2.0 * (100.0 * 100.0 - 2.0 * 30.0 * 30.0);
|
|
let walls = 4.0 * 100.0 * 20.0 + 2.0 * 4.0 * 30.0 * 20.0;
|
|
assert!((area(&m) - (caps + walls)).abs() < 1e-6, "{} != {}", area(&m), caps + walls);
|
|
assert!(closed(&m));
|
|
}
|
|
|
|
#[test]
|
|
fn a_bridge_from_a_hole_never_runs_through_the_hole() {
|
|
// The hole's rightmost corner (60, 50) is nearest the outline's spike tip (10, 50), and the
|
|
// straight line to it runs through the hole and out at its corner (20, 50).
|
|
let poly = |b: &mut BezPath, pts: &[(f64, f64)]| {
|
|
for (k, p) in pts.iter().enumerate() {
|
|
if k == 0 { b.move_to(*p) } else { b.line_to(*p) }
|
|
}
|
|
b.close_path();
|
|
};
|
|
let mut b = BezPath::new();
|
|
poly(&mut b, &[(0.0, 0.0), (200.0, 0.0), (200.0, 100.0), (0.0, 100.0), (0.0, 55.0), (10.0, 50.0), (0.0, 45.0)]);
|
|
let hole = [(60.0, 50.0), (30.0, 40.0), (20.0, 50.0), (30.0, 60.0)];
|
|
poly(&mut b, &hole);
|
|
let (p, r) = shape(&b);
|
|
let m = plane_mesh(&p, FillRule::EvenOdd, r, front()).unwrap();
|
|
let face = 200.0 * 100.0 - 50.0 - 400.0;
|
|
assert!((area(&m) - face).abs() < 1e-6, "{} != {face}", area(&m));
|
|
// No triangle's centre is in the hole (page coordinates: the mesh is y up about (100, 50)).
|
|
let in_hole = |x: f64, y: f64| {
|
|
let mut odd = false;
|
|
for (k, a) in hole.iter().enumerate() {
|
|
let c = hole[(k + 1) % hole.len()];
|
|
if (a.1 > y) != (c.1 > y) && x < a.0 + (y - a.1) / (c.1 - a.1) * (c.0 - a.0) {
|
|
odd = !odd;
|
|
}
|
|
}
|
|
odd
|
|
};
|
|
for t in &m.triangles {
|
|
let [a, b, c] = corners(&m, *t);
|
|
let (x, y) = ((a[0] + b[0] + c[0]) / 3.0 + 100.0, 50.0 - (a[1] + b[1] + c[1]) / 3.0);
|
|
assert!(!in_hole(x, y), "a triangle at ({x}, {y})");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_hole_that_crosses_the_outline_is_refused_rather_than_filled() {
|
|
// The hole starts inside the square, but its rightmost corner (15, 5) lies outside it, so no
|
|
// bridge from there reaches the square through the face.
|
|
let ring = |pts: &[(f64, f64)]| pts.iter().map(|(x, y)| V3(*x, *y, 0.0)).collect::<Vec<_>>();
|
|
let outline = ring(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]);
|
|
let hole = ring(&[(5.0, 4.0), (5.0, 6.0), (15.0, 5.0)]);
|
|
let mut work = 0;
|
|
assert!(super::triangulate(std::slice::from_ref(&outline), V3(0.0, 0.0, 1.0), &mut work).is_ok());
|
|
let r = super::triangulate(&[outline.clone(), hole], V3(0.0, 0.0, 1.0), &mut work);
|
|
assert!(r.as_ref().is_err_and(|e| e.contains("hole")), "{:?}", r.map(|t| t.len()));
|
|
// The same hole starting outside the square belongs to no outline, and is refused too.
|
|
let hole = ring(&[(15.0, 5.0), (5.0, 4.0), (5.0, 6.0)]);
|
|
let r = super::triangulate(&[outline, hole], V3(0.0, 0.0, 1.0), &mut work);
|
|
assert!(r.as_ref().is_err_and(|e| e.contains("hole")), "{:?}", r.map(|t| t.len()));
|
|
}
|
|
|
|
#[test]
|
|
fn a_bridge_through_another_corner_touches_it() {
|
|
assert!(super::touches((5.0, 0.0), (0.0, 0.0), (10.0, 0.0)));
|
|
assert!(!super::touches((0.0, 0.0), (0.0, 0.0), (10.0, 0.0)), "an end is not a touch");
|
|
assert!(!super::touches((5.0, 1.0), (0.0, 0.0), (10.0, 0.0)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_beveled_extrusion_stays_closed() {
|
|
let mut b = BezPath::new();
|
|
square(&mut b, 0.0, 0.0, 100.0, false);
|
|
let (p, r) = shape(&b);
|
|
let bevel = Bevel { width: 6.0, height: 6.0, both_sides: true, ..Bevel::default() };
|
|
let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 40.0, bevel: Some(bevel), ..Extrude::default() }, front()).unwrap();
|
|
assert!(closed(&m));
|
|
assert!(outward(&m));
|
|
}
|
|
|
|
#[test]
|
|
fn the_mesh_keeps_the_turn_of_the_view() {
|
|
let mut b = BezPath::new();
|
|
square(&mut b, 0.0, 0.0, 100.0, false);
|
|
let (p, r) = shape(&b);
|
|
// Turned 90° about the vertical axis, the extrusion's depth lies along x.
|
|
let view = View { rotation_y: 90.0, ..front() };
|
|
let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 20.0, ..Extrude::default() }, view).unwrap();
|
|
let (lo, hi) = m.bounds().unwrap();
|
|
assert!((hi[0] - lo[0] - 20.0).abs() < 1e-9 && (hi[2] - lo[2] - 100.0).abs() < 1e-9, "{lo:?} {hi:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_turned_plane_keeps_its_area_less_its_hole() {
|
|
let mut b = BezPath::new();
|
|
square(&mut b, 0.0, 0.0, 100.0, false);
|
|
square(&mut b, 25.0, 25.0, 50.0, true);
|
|
let (p, r) = shape(&b);
|
|
let view = View { rotation_x: 30.0, rotation_y: -40.0, ..front() };
|
|
let m = plane_mesh(&p, FillRule::NonZero, r, view).unwrap();
|
|
assert!((area(&m) - (100.0 * 100.0 - 50.0 * 50.0)).abs() < 1e-6, "{}", area(&m));
|
|
}
|
|
|
|
#[test]
|
|
fn y_points_up() {
|
|
// The art's top edge (y 0 on the page) is the mesh's highest.
|
|
let mut b = BezPath::new();
|
|
b.move_to((0.0, 0.0));
|
|
b.line_to((100.0, 0.0));
|
|
b.line_to((50.0, 100.0));
|
|
b.close_path();
|
|
let (p, r) = shape(&b);
|
|
let m = plane_mesh(&p, FillRule::NonZero, r, front()).unwrap();
|
|
let top = m.positions.iter().filter(|q| (q[1] - 50.0).abs() < 1e-9).count();
|
|
assert!(top >= 2, "{:?}", m.positions);
|
|
assert!(m.normals.iter().all(|n| n[2] > 0.99), "a plane facing the viewer has its normal toward +z");
|
|
}
|
|
|
|
#[test]
|
|
fn a_revolved_profile_is_a_closed_cylinder() {
|
|
// A vertical line 10 pt from the axis, swept round with its ends capped: a closed cylinder.
|
|
let mut b = BezPath::new();
|
|
b.move_to((20.0, 0.0));
|
|
b.line_to((20.0, 100.0));
|
|
let p = PathData::from_bezpath(&b);
|
|
let r = p.bounds().unwrap();
|
|
let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() };
|
|
let m = revolve_mesh(&p, r, o).unwrap();
|
|
assert!(closed(&m));
|
|
let exact = 2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0;
|
|
assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m));
|
|
assert!(outward(&m));
|
|
}
|
|
|
|
#[test]
|
|
fn a_partial_revolve_caps_its_ends_with_sectors() {
|
|
let mut b = BezPath::new();
|
|
b.move_to((20.0, 0.0));
|
|
b.line_to((20.0, 100.0));
|
|
let p = PathData::from_bezpath(&b);
|
|
let r = p.bounds().unwrap();
|
|
let o = Revolve { angle: 90.0, offset: 10.0, segments: 64, ..Revolve::default() };
|
|
let m = revolve_mesh(&p, r, o).unwrap();
|
|
let exact = 0.25 * (2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0);
|
|
assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m));
|
|
}
|
|
|
|
#[test]
|
|
fn a_revolved_surface_has_unit_normals_facing_out_whatever_its_length_and_direction() {
|
|
// A wall 100 pt tall and one 0.5 pt tall, each drawn up and down the page.
|
|
for (from, to) in [(0.0, 100.0), (100.0, 0.0), (0.0, 0.5), (0.5, 0.0)] {
|
|
let mut b = BezPath::new();
|
|
b.move_to((20.0, from));
|
|
b.line_to((20.0, to));
|
|
let p = PathData::from_bezpath(&b);
|
|
let r = p.bounds().unwrap();
|
|
let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() };
|
|
let m = revolve_mesh(&p, r, o).unwrap();
|
|
assert!(!m.is_empty(), "{from} to {to}: the revolve made no triangles");
|
|
assert!(m.normals.iter().all(|n| (dot(*n, *n) - 1.0).abs() < 1e-9), "{from} to {to}: a normal is not unit length");
|
|
assert!(closed(&m), "{from} to {to}");
|
|
assert!(outward(&m), "{from} to {to}: a face turns inward");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn inflate_puffs_the_front_toward_the_viewer() {
|
|
let mut b = BezPath::new();
|
|
square(&mut b, 0.0, 0.0, 100.0, false);
|
|
let (p, r) = shape(&b);
|
|
let m = inflate_meshes(&[(&p, FillRule::NonZero)], r, Inflate::default(), front()).last().unwrap().unwrap();
|
|
assert!(!m.is_empty());
|
|
let (lo, hi) = m.bounds().unwrap();
|
|
assert!(hi[2] > 0.0 && lo[2] < hi[2], "the front puffs toward the viewer: {lo:?} {hi:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn inflate_builds_each_solid_as_it_is_reached() {
|
|
let (mut a, mut b) = (BezPath::new(), BezPath::new());
|
|
square(&mut a, 0.0, 0.0, 100.0, false);
|
|
square(&mut b, 120.0, 0.0, 100.0, false);
|
|
let ((pa, _), (pb, _)) = (shape(&a), shape(&b));
|
|
let built = || crate::solid::INFLATED.with(|n| n.get());
|
|
let start = built();
|
|
let mut meshes =
|
|
inflate_meshes(&[(&pa, FillRule::NonZero), (&pb, FillRule::NonZero)], Rect::new(0.0, 0.0, 220.0, 100.0), Inflate::default(), front());
|
|
assert_eq!(built(), start, "no solid is built before the first mesh is asked for");
|
|
assert!(meshes.next().is_some_and(|m| m.is_ok_and(|m| !m.is_empty())));
|
|
assert_eq!(built(), start + 1, "the second solid waits until it is reached");
|
|
assert!(meshes.next().is_some_and(|m| m.is_ok_and(|m| !m.is_empty())));
|
|
assert_eq!(built(), start + 2);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_shape_makes_an_empty_mesh() {
|
|
let p = PathData::new(vec![]);
|
|
let m = plane_mesh(&p, FillRule::NonZero, Rect::new(0.0, 0.0, 10.0, 10.0), front()).unwrap();
|
|
assert!(m.is_empty());
|
|
assert_eq!(m.bounds(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn join_numbers_each_parts_corners_after_the_last() {
|
|
let mut b = BezPath::new();
|
|
square(&mut b, 0.0, 0.0, 10.0, false);
|
|
let (p, r) = shape(&b);
|
|
let one = plane_mesh(&p, FillRule::NonZero, r, front()).unwrap();
|
|
let both = Mesh::join([one.clone(), one.clone()]);
|
|
assert_eq!(both.triangles.len(), 2 * one.triangles.len());
|
|
assert!(both.triangles.iter().all(|t| t.iter().all(|i| (*i as usize) < both.positions.len())));
|
|
assert_eq!(both.triangles[one.triangles.len()][0], one.triangles[0][0] + one.positions.len() as u32);
|
|
}
|