indicatrix-cut 0.7.2

Desktop faceting-design editor: library browsing, spectral 3D rendering, material retargeting, and a solid inspection view.
use super::*;
use indicatrix::geometry::stone_metrics::build_solid_mesh;

/// The six planes of an axis-aligned box `[-hx, hx] x [-hy, hy] x [-hz, hz]`.
fn box_planes(half: DVec3) -> Vec<(DVec3, f64)> {
    vec![
        (DVec3::X, half.x),
        (DVec3::NEG_X, half.x),
        (DVec3::Y, half.y),
        (DVec3::NEG_Y, half.y),
        (DVec3::Z, half.z),
        (DVec3::NEG_Z, half.z),
    ]
}

/// The same box turned by `degrees` about y and moved by `shift`.
fn turned_box(half: DVec3, degrees: f64, shift: DVec3) -> Vec<(DVec3, f64)> {
    let (sin, cos) = degrees.to_radians().sin_cos();
    let turn = |v: DVec3| {
        DVec3::new(
            v.x.mul_add(cos, v.z * sin),
            v.y,
            (-v.x).mul_add(sin, v.z * cos),
        )
    };
    box_planes(half)
        .into_iter()
        .map(|(n, m)| {
            let turned = turn(n);
            (turned, m + turned.dot(shift))
        })
        .collect()
}

fn bounds(mesh: &DesignMesh) -> (DVec3, DVec3) {
    let (mut low, mut high) = (DVec3::splat(f64::INFINITY), DVec3::splat(f64::NEG_INFINITY));
    for facet in &mesh.facets {
        for &p in &facet.ring {
            low = low.min(p);
            high = high.max(p);
        }
    }
    (low, high)
}

#[test]
fn a_turned_box_gets_its_width_on_x_and_its_length_on_z_and_is_centred() {
    // 2 wide, 3 tall, 6 long, turned 30 degrees and moved off the origin.
    let planes = turned_box(DVec3::new(1.0, 1.5, 3.0), 30.0, DVec3::new(0.7, 0.2, -0.4));
    let mesh = design_mesh_from_planes(&planes, &[]).expect("a box closes");
    let (low, high) = bounds(&mesh);
    assert!((low + high).length() < 1e-9, "bounding box is centred");
    // The frame comes from f32-rounded corners (like the planner's cached hull), so the
    // box is axis-aligned to about 1e-7 rad.
    assert!((high - low - DVec3::new(2.0, 3.0, 6.0)).length() < 1e-5);
    assert_eq!(mesh.facets.len(), 6);
}

#[test]
fn the_caliper_width_is_the_extent_along_x() {
    // The box is 2 wide (x), 3 tall, 6 long, so the width is its 2 units.
    let planes = turned_box(DVec3::new(1.0, 1.5, 3.0), 30.0, DVec3::ZERO);
    let mesh = design_mesh_from_planes(&planes, &[]).expect("a box closes");
    assert!((mesh.caliper_width() - 2.0).abs() < 1e-5);
    assert!(DesignMesh { facets: Vec::new() }.caliper_width().abs() < f64::EPSILON);
}

#[test]
fn facet_normals_stay_unit_and_point_away_from_the_centre() {
    let planes = turned_box(DVec3::new(1.0, 1.5, 3.0), 30.0, DVec3::ZERO);
    let mesh = design_mesh_from_planes(&planes, &[]).expect("a box closes");
    for facet in &mesh.facets {
        assert!((facet.normal.length() - 1.0).abs() < 1e-9);
        let mid = facet.ring.iter().copied().sum::<DVec3>() / facet.ring.len() as f64;
        assert!(facet.normal.dot(mid) > 0.0, "outward normal");
    }
}

#[test]
fn the_caliper_frame_agrees_with_the_hull_pipeline_on_a_turned_box() {
    // The planner's own hull rotation (`run::rotate_into_caliper_frame`) applied to
    // the box's vertices as the vault stores them (f32) must give the same bounding
    // box, and the same centre to subtract, as this mesh (to the f32 rounding).
    let planes = turned_box(DVec3::new(1.0, 1.5, 3.0), 30.0, DVec3::new(0.7, 0.2, -0.4));
    let mesh = design_mesh_from_planes(&planes, &[]).expect("a box closes");
    let SolidStatus::Closed(raw) = build_solid_mesh(&planes) else {
        panic!("a box closes");
    };
    let corners: Vec<[f64; 3]> = raw
        .rings
        .iter()
        .flat_map(|(_, r)| r.iter().map(|p| p.to_array().map(|c| f64::from(c as f32))))
        .collect();
    let hull = rotate_into_caliper_frame(&corners).expect("outline");
    let hull_size = hull.size();
    let (mesh_low, mesh_high) = bounds(&mesh);
    let mesh_size = (mesh_high - mesh_low).to_array();
    for axis in 0..3 {
        assert!(
            (hull_size[axis] - mesh_size[axis]).abs() < 1e-5,
            "axis {axis}: hull {} vs mesh {}",
            hull_size[axis],
            mesh_size[axis]
        );
    }
    // Every mesh corner is a hull corner minus the hull's centre.
    let centre = DVec3::from_array(hull.centre());
    let hull_corners: Vec<DVec3> = hull
        .vertices
        .iter()
        .map(|&v| DVec3::from_array(v) - centre)
        .collect();
    for facet in &mesh.facets {
        for &corner in &facet.ring {
            assert!(
                hull_corners.iter().any(|&h| (h - corner).length() < 1e-5),
                "mesh corner {corner:?} is not a centred hull corner"
            );
        }
    }
    // And it is the design's own shape: 2 wide (x), 3 tall (y), 6 long (z).
    assert!((mesh_size[0] - 2.0).abs() < 1e-5 && (mesh_size[1] - 3.0).abs() < 1e-5);
    assert!((mesh_size[2] - 6.0).abs() < 1e-5);
}

#[test]
fn a_tied_outline_is_turned_the_way_the_stored_f32_hull_is() {
    // A square outline has four equal caliper widths, so which edge the frame follows
    // is decided by rounding noise. The mesh must follow the edge the f32 hull picks.
    let planes = turned_box(DVec3::new(1.5, 1.0, 1.5), 17.0, DVec3::new(9.3, 0.1, -4.7));
    let mesh = design_mesh_from_planes(&planes, &[]).expect("a box closes");
    let SolidStatus::Closed(raw) = build_solid_mesh(&planes) else {
        panic!("a box closes");
    };
    let stored: Vec<[f64; 3]> = raw
        .rings
        .iter()
        .flat_map(|(_, r)| r.iter().map(|p| p.to_array().map(|c| f64::from(c as f32))))
        .collect();
    let hull = rotate_into_caliper_frame(&stored).expect("outline");
    // The facet whose outward normal points along the caliper width direction in the
    // design frame is the +x facet of the mesh.
    let [wx, wz] = hull.width_dir;
    let along_width = mesh
        .facets
        .iter()
        .filter(|facet| facet.normal.x > 0.999)
        .count();
    assert_eq!(along_width, 1, "one facet faces +x of the caliper frame");
    // Turning the +x mesh normal back into the design frame gives the hull's width
    // direction (a proper rotation about y: x -> (wx, 0, wz)).
    let facet = mesh
        .facets
        .iter()
        .find(|facet| facet.normal.x > 0.999)
        .expect("a +x facet");
    let back = DVec3::new(
        facet.normal.x.mul_add(wx, -facet.normal.z * wz),
        facet.normal.y,
        facet.normal.x.mul_add(wz, facet.normal.z * wx),
    );
    assert!(
        (back - DVec3::new(wx, 0.0, wz)).length() < 1e-5,
        "the mesh's width axis {back:?} is the hull's {wx}, {wz}"
    );
}

#[test]
fn a_concave_design_is_drawn_with_its_tool_cuts_in_the_flat_stones_frame() {
    // A ball dimple (radius 0.5) in the top face of a box turned 30 degrees. The dimple
    // sits inside the outline, so the carved hull is the flat box's and so are the frame
    // and the centre.
    let planes = turned_box(DVec3::new(1.0, 1.5, 3.0), 30.0, DVec3::new(0.7, 0.2, -0.4));
    let top = planes[2];
    let on_top = top.0 * top.1;
    let tool = ToolPrimitive {
        kind: 0,
        sweep_kind: 0,
        _pad: [0; 2],
        origin: [on_top.x as f32, on_top.y as f32, on_top.z as f32, 0.5],
        axis: [0.0, 1.0, 0.0, 0.0],
        profile: [0.0; 4],
        sweep_dir: [0.0; 4],
    };
    let flat = design_mesh_from_planes(&planes, &[]).expect("a box closes");
    let carved = design_mesh_from_planes(&planes, &[tool]).expect("a carved box closes");
    assert!(
        carved.facets.len() > flat.facets.len(),
        "the dimple adds pieces: {} vs {}",
        carved.facets.len(),
        flat.facets.len()
    );
    let ((flat_low, flat_high), (low, high)) = (bounds(&flat), bounds(&carved));
    // The carved mesh snaps clipped pieces to a 1e-9·W grid and the tool is stored
    // in f32, so the boxes agree to rounding, not bit for bit.
    assert!(
        (flat_low - low).length() < 1e-6 && (flat_high - high).length() < 1e-6,
        "carved bounds {low:?}..{high:?} differ from flat {flat_low:?}..{flat_high:?}"
    );
    for facet in &carved.facets {
        assert!((facet.normal.length() - 1.0).abs() < 1e-9);
        let drawn = facet
            .edge_drawn
            .as_ref()
            .expect("a concave mesh has edge flags");
        assert_eq!(drawn.len(), facet.ring.len());
    }
    assert!(
        flat.facets.iter().all(|f| f.edge_drawn.is_none()),
        "a flat design draws every edge"
    );
}

#[test]
fn a_ring_keeps_a_boundary_edge_when_its_collinear_seam_points_are_dropped() {
    let ring = [
        DVec3::new(0.0, 0.0, 0.0),
        DVec3::new(0.5, 0.0, 0.0),
        DVec3::new(1.0, 0.0, 0.0),
        DVec3::new(1.0, 0.0, 1.0),
        DVec3::new(0.0, 0.0, 1.0),
    ];
    let corners = simplify_ring(&ring);
    assert_eq!(corners.len(), 4);
    // Segment 0 -> 1 is a seam, 1 -> 2 a boundary; the merged edge is drawn.
    let flags = simplified_flags(&ring, &corners, &[false, true, false, false, false]);
    assert_eq!(flags, vec![true, false, false, false]);
}

#[test]
fn a_cached_mesh_is_loaded_once_and_dropped_by_remove() {
    let cache = MeshCache::default();
    let loads = std::sync::atomic::AtomicUsize::new(0);
    let load = || -> Result<Option<DesignMesh>, ()> {
        loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        Ok(Some(DesignMesh { facets: Vec::new() }))
    };
    let first = cache.get_or_load(5, load).expect("loaded");
    let second = cache.get_or_load(5, load).expect("cached");
    assert!(Arc::ptr_eq(&first, &second));
    assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
    // Another design is another slot.
    cache.get_or_load(6, load).expect("loaded");
    assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 2);
    // After a remove the design is read again, and the old mesh is not handed out.
    cache.remove(5);
    let third = cache.get_or_load(5, load).expect("reloaded");
    assert!(!Arc::ptr_eq(&first, &third));
    assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 3);
    // The other design was not touched, and both have a slot.
    let mut ids = cache.ids();
    ids.sort_unstable();
    assert_eq!(ids, vec![5, 6]);
    cache.get_or_load(6, load).expect("still cached");
    assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 3);
}

#[test]
fn an_absent_design_is_remembered_as_gone_but_a_failed_read_is_not_and_reads_unreadable() {
    let cache = MeshCache::default();
    let calls = std::sync::atomic::AtomicUsize::new(0);
    let absent = || -> Result<Option<DesignMesh>, ()> {
        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        Ok(None)
    };
    assert_eq!(cache.get_or_load(1, absent).err(), Some(MeshMiss::Gone));
    assert_eq!(cache.get_or_load(1, absent).err(), Some(MeshMiss::Gone));
    assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);

    let failing = || -> Result<Option<DesignMesh>, ()> {
        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        Err(())
    };
    assert_eq!(
        cache.get_or_load(2, failing).err(),
        Some(MeshMiss::Unreadable)
    );
    assert_eq!(
        cache.get_or_load(2, failing).err(),
        Some(MeshMiss::Unreadable)
    );
    assert_eq!(
        calls.load(std::sync::atomic::Ordering::SeqCst),
        3,
        "the failed read was tried twice"
    );
    // A later successful read fills the slot.
    let ok = || -> Result<Option<DesignMesh>, ()> { Ok(Some(DesignMesh { facets: Vec::new() })) };
    assert!(cache.get_or_load(2, ok).is_ok());
}

#[test]
fn two_threads_asking_for_one_design_load_it_once() {
    let cache = MeshCache::default();
    let loads = std::sync::atomic::AtomicUsize::new(0);
    let meshes: Vec<Arc<DesignMesh>> = std::thread::scope(|scope| {
        let handles: Vec<_> = (0..4)
            .map(|_| {
                scope.spawn(|| {
                    cache
                        .get_or_load(9, || -> Result<Option<DesignMesh>, ()> {
                            loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
                            std::thread::sleep(std::time::Duration::from_millis(20));
                            Ok(Some(DesignMesh { facets: Vec::new() }))
                        })
                        .expect("loaded")
                })
            })
            .collect();
        handles
            .into_iter()
            .map(|h| h.join().expect("no panic"))
            .collect()
    });
    assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
    assert!(meshes.iter().all(|m| Arc::ptr_eq(m, &meshes[0])));
}

#[test]
fn a_load_under_way_during_a_remove_cannot_refill_the_cache() {
    let cache = MeshCache::default();
    let stale = cache
        .get_or_load(3, || -> Result<Option<DesignMesh>, ()> {
            // The design is edited (and its slot dropped) while it loads.
            cache.remove(3);
            Ok(Some(DesignMesh { facets: Vec::new() }))
        })
        .expect("the old asker still gets its mesh");
    let fresh = cache
        .get_or_load(3, || -> Result<Option<DesignMesh>, ()> {
            Ok(Some(DesignMesh { facets: Vec::new() }))
        })
        .expect("reloaded");
    assert!(!Arc::ptr_eq(&stale, &fresh), "the stale mesh was not kept");
}

#[test]
fn open_planes_give_no_mesh() {
    assert!(design_mesh_from_planes(&[(DVec3::X, 1.0), (DVec3::NEG_X, 1.0)], &[]).is_none());
    assert!(design_mesh_from_planes(&[], &[]).is_none());
}

#[test]
fn simplifying_a_ring_drops_repeats_and_midpoints() {
    let ring = [
        DVec3::new(0.0, 0.0, 0.0),
        DVec3::new(0.5, 0.0, 0.0),
        DVec3::new(0.5, 0.0, 0.0),
        DVec3::new(1.0, 0.0, 0.0),
        DVec3::new(1.0, 0.0, 1.0),
        DVec3::new(0.0, 0.0, 1.0),
    ];
    let corners = simplify_ring(&ring);
    assert_eq!(corners.len(), 4);
    assert_eq!(simplify_ring(&[]), Vec::<DVec3>::new());
}