use super::{
overlay::{
ADDED_TINT, AFTER_OUTLINE, BEFORE_OUTLINE, FACET_EDGE, REMOVED_TINT, difference_overlay,
},
render::{
TRACED_MAX_EDGE, pixel_size, placeholder_rgba, render_solid_pick, render_solid_rgba,
traced_size,
},
session::{
CompareOrigin, CompareSession, FrameBook, FrameKind, Renderer, SideInput, TracedProgress,
clamp_split_fraction, default_pose, fitted_pose, orbit, resolve_side_material, status_text,
traced_may_start, zoom,
},
};
use crate::gui::{
render::camera_lighting::{fit_distance_for_radius, orbit_distance_bounds},
solid_preview::preview_state::CameraPose,
};
use indicatrix::geometry::meet_solver::MeetConstraint;
use indicatrix_cut_core::{ConstraintTier, Design, MaterialSelection, PreformSpec, ScheduleMeta};
use std::f32::consts::FRAC_PI_2;
fn round_brilliant() -> Design {
let mut design = Design::new(
PreformSpec::block(2.0, 1.0, 2.0),
ScheduleMeta::standard_round_brilliant(),
ConstraintTier::standard_round_brilliant(),
);
design.material = MaterialSelection {
name: Some("Diamond".to_string()),
..MaterialSelection::default()
};
design
}
fn round_brilliant_steeper_crown() -> Design {
let mut design = round_brilliant();
let main = design
.tiers
.iter_mut()
.find(|tier| tier.name == "Crown Main")
.expect("the standard round brilliant has a Crown Main tier");
main.angle_deg = 37.0;
design
}
fn unsolvable_design() -> Design {
let mut design = Design::fresh(PreformSpec::block(2.0, 1.0, 2.0), 96, 4, 1.62);
design.tiers.push(ConstraintTier {
angle_deg: 30.0,
name: "A".to_string(),
indices: vec![0.0, 24.0, 48.0, 72.0],
constraint: MeetConstraint::MeetExisting,
imported_meet: None,
original_notes: None,
detached: Vec::new(),
});
design
}
fn input(design: Design, label: &str) -> SideInput {
let material = resolve_side_material(&design, &[]);
SideInput {
design,
label: label.to_string(),
material,
}
}
const START_POSE: CameraPose = CameraPose {
yaw: 0.6,
pitch: 0.45,
distance: 2.4,
};
#[test]
fn a_session_of_two_solving_designs_keeps_labels_and_can_keep() {
let session = CompareSession::build(
input(round_brilliant(), "Current design"),
input(round_brilliant_steeper_crown(), "Optimize candidate"),
CompareOrigin::Optimize,
START_POSE,
);
assert_eq!(session.before.label, "Current design");
assert_eq!(session.after.label, "Optimize candidate");
assert!(session.before.is_solved() && session.after.is_solved());
assert!(!session.before.planes.is_empty() && !session.after.planes.is_empty());
assert_eq!(
session.before.preform_planes,
round_brilliant().preform.planes().len()
);
assert!(session.before.material.is_ok() && session.after.material.is_ok());
assert!(session.can_keep());
assert_eq!(
status_text(Some(&session), Renderer::Solid, TracedProgress::Done, 48),
"Solid"
);
}
#[test]
fn a_snapshot_session_never_offers_keep() {
let session = CompareSession::build(
input(round_brilliant(), "Snapshot"),
input(round_brilliant(), "Current design"),
CompareOrigin::Snapshot,
START_POSE,
);
assert!(session.before.is_solved() && session.after.is_solved());
assert!(!session.can_keep());
assert!(!CompareOrigin::Snapshot.offers_keep());
assert!(CompareOrigin::Retarget.offers_keep());
}
#[test]
fn a_side_that_does_not_solve_blocks_keep_and_says_so() {
let session = CompareSession::build(
input(round_brilliant(), "Current design"),
input(unsolvable_design(), "Retarget to Ruby (Shift)"),
CompareOrigin::Retarget,
START_POSE,
);
assert!(session.before.is_solved());
assert!(!session.after.is_solved());
assert_eq!(session.after.planes, Vec::<(glam::Vec3, f32)>::new());
assert!(!session.can_keep());
let status = status_text(Some(&session), Renderer::Solid, TracedProgress::Done, 48);
assert!(status.contains("After does not solve"), "{status}");
assert!(!status.contains("Before does not solve"), "{status}");
}
#[test]
fn status_reports_solving_and_traced_progress() {
assert_eq!(
status_text(None, Renderer::Traced, TracedProgress::Waiting, 48),
"Solving both sides…"
);
let session = CompareSession::build(
input(round_brilliant(), "a"),
input(round_brilliant(), "b"),
CompareOrigin::Retarget,
START_POSE,
);
let rendering = TracedProgress::Rendering { side: 2 };
assert_eq!(
status_text(Some(&session), Renderer::Traced, rendering, 48),
"Tracing… 2 of 2"
);
assert_eq!(
status_text(Some(&session), Renderer::Traced, TracedProgress::Done, 48),
"Traced (48 spp)"
);
}
#[test]
fn an_unresolvable_material_is_refused_not_substituted() {
let mut design = round_brilliant();
design.material = MaterialSelection {
name: Some("Unobtainium".to_string()),
..MaterialSelection::default()
};
assert!(resolve_side_material(&design, &[]).is_err());
assert!(resolve_side_material(&round_brilliant(), &[]).is_ok());
}
#[test]
fn orbit_turns_both_axes_and_clamps_pitch_at_the_poles() {
let turned = orbit(START_POSE, 10.0, 10.0);
assert!((turned.yaw - (START_POSE.yaw - 0.08)).abs() < 1e-6);
assert!((turned.pitch - (START_POSE.pitch + 0.08)).abs() < 1e-6);
assert!((turned.distance - START_POSE.distance).abs() < f32::EPSILON);
assert!((orbit(START_POSE, 0.0, 10_000.0).pitch - FRAC_PI_2).abs() < f32::EPSILON);
assert!((orbit(START_POSE, 0.0, -10_000.0).pitch + FRAC_PI_2).abs() < f32::EPSILON);
}
#[test]
fn zoom_clamps_distance_to_the_sessions_own_bounds() {
let radius = 1.5;
let (min, max) = orbit_distance_bounds(radius);
assert!((zoom(START_POSE, 1.0e6, radius).distance - min).abs() < f32::EPSILON);
assert!((zoom(START_POSE, -1.0e6, radius).distance - max).abs() < f32::EPSILON);
let small = zoom(START_POSE, 100.0, radius);
assert!((small.distance - (START_POSE.distance - 0.2)).abs() < 1e-5);
assert!((small.yaw - START_POSE.yaw).abs() < f32::EPSILON);
}
#[test]
fn the_reset_pose_is_the_front_view_inside_the_zoom_bounds() {
let radius = 1.5;
let (min, max) = orbit_distance_bounds(radius);
let pose = default_pose(radius);
assert!(pose.yaw.abs() < f32::EPSILON && pose.pitch.abs() < f32::EPSILON);
assert!(pose.distance >= min && pose.distance <= max);
}
#[test]
fn split_fraction_clamps_and_nan_falls_back_to_the_centre() {
assert!((clamp_split_fraction(-0.3)).abs() < f32::EPSILON);
assert!((clamp_split_fraction(1.7) - 1.0).abs() < f32::EPSILON);
assert!((clamp_split_fraction(0.25) - 0.25).abs() < f32::EPSILON);
assert!((clamp_split_fraction(f32::NAN) - 0.5).abs() < f32::EPSILON);
}
#[test]
fn a_stale_frame_is_dropped_and_solid_never_paints_over_traced() {
let mut book = FrameBook::default();
let first = book.bump();
let second = book.bump();
assert_ne!(first, second);
assert!(!book.accept(FrameKind::Solid, first, Renderer::Solid));
assert!(!book.accept(FrameKind::Traced, first, Renderer::Traced));
assert!(!book.accept(FrameKind::Traced, second, Renderer::Solid));
assert!(book.accept(FrameKind::Solid, second, Renderer::Traced));
assert!(book.accept(FrameKind::Traced, second, Renderer::Traced));
assert!(!book.accept(FrameKind::Solid, second, Renderer::Traced));
let third = book.bump();
assert_eq!(book.generation(), third);
assert!(book.accept(FrameKind::Solid, third, Renderer::Traced));
}
#[test]
fn solid_renders_are_identical_for_identical_inputs_and_see_one_changed_tier() {
let session = CompareSession::build(
input(round_brilliant(), "before"),
input(round_brilliant(), "same"),
CompareOrigin::Retarget,
START_POSE,
);
let changed = CompareSession::build(
input(round_brilliant(), "before"),
input(round_brilliant_steeper_crown(), "after"),
CompareOrigin::Retarget,
START_POSE,
);
let size = (64, 64);
let render = |side: &super::session::CompareSide| {
render_solid_rgba(&side.planes, side.preform_planes, START_POSE, size)
};
let before = render(&session.before);
assert_eq!(before.len(), 64 * 64 * 4);
assert_eq!(
before,
render(&session.after),
"identical inputs, identical pixels"
);
assert_eq!(
before,
render(&changed.before),
"rendering is deterministic"
);
assert_ne!(
before,
render(&changed.after),
"one steeper crown tier must show up in the solid render"
);
}
#[test]
fn traced_size_caps_the_long_edge_and_keeps_the_aspect() {
assert_eq!(traced_size((200, 100)), (200, 100));
assert_eq!(traced_size((0, 0)), (1, 1));
let (width, height) = traced_size((1000, 500));
assert_eq!(width, TRACED_MAX_EDGE);
assert_eq!(height, TRACED_MAX_EDGE / 2);
}
#[test]
fn pixel_size_scales_rounds_and_rejects_nonsense() {
assert_eq!(pixel_size(100.0, 50.0, 1.5), (150, 75));
assert_eq!(pixel_size(-5.0, f32::NAN, 1.0), (0, 0));
assert_eq!(pixel_size(1.0e6, 10.0, 1.0), (4096, 10));
}
#[test]
fn the_placeholder_fills_the_whole_frame() {
assert_eq!(placeholder_rgba((12, 7)).len(), 12 * 7 * 4);
}
fn filled_box(
size: (u32, u32),
(x0, x1): (usize, usize),
(y0, y1): (usize, usize),
id: u32,
) -> Vec<u32> {
let width = size.0 as usize;
let mut pick = vec![0u32; width * size.1 as usize];
for y in y0..=y1 {
for x in x0..=x1 {
pick[y * width + x] = id;
}
}
pick
}
fn overlay_px(overlay: &[u8], width: usize, x: usize, y: usize) -> [u8; 4] {
let o = (y * width + x) * 4;
[overlay[o], overlay[o + 1], overlay[o + 2], overlay[o + 3]]
}
const CLEAR: [u8; 4] = [0, 0, 0, 0];
const GRID: (u32, u32) = (12, 12);
#[test]
fn overlay_classifies_removed_added_shared_and_neither() {
let before = filled_box(GRID, (2, 7), (2, 9), 1);
let after = filled_box(GRID, (5, 10), (2, 9), 1);
let overlay = difference_overlay(&before, &after, None, GRID);
let px = |x, y| overlay_px(&overlay, 12, x, y);
assert_eq!(px(3, 5), REMOVED_TINT, "before only -> removed");
assert_eq!(px(9, 5), ADDED_TINT, "after only -> added");
assert_eq!(px(6, 5), CLEAR, "covered by both -> transparent");
assert_eq!(px(0, 0), CLEAR, "covered by neither -> transparent");
assert_eq!(px(11, 11), CLEAR, "covered by neither -> transparent");
}
#[test]
fn overlay_draws_the_after_outline_two_pixels_wide_and_the_before_outline_one() {
let before = filled_box(GRID, (2, 7), (2, 9), 1);
let after = filled_box(GRID, (5, 10), (2, 9), 1);
let overlay = difference_overlay(&before, &after, None, GRID);
let px = |x, y| overlay_px(&overlay, 12, x, y);
assert_eq!(px(8, 2), AFTER_OUTLINE);
assert_eq!(px(8, 1), AFTER_OUTLINE);
assert_eq!(px(8, 3), ADDED_TINT, "a third row in is back to the tint");
assert_eq!(px(8, 0), CLEAR, "a second row out is clear");
assert_eq!(px(5, 5), AFTER_OUTLINE);
assert_eq!(px(4, 5), AFTER_OUTLINE);
assert_eq!(px(2, 5), BEFORE_OUTLINE);
assert_eq!(px(3, 5), REMOVED_TINT, "one pixel in, the tint again");
assert_eq!(px(1, 5), CLEAR, "no outline outside before's silhouette");
assert_eq!(px(7, 5), BEFORE_OUTLINE);
assert_eq!(px(6, 9), AFTER_OUTLINE);
}
#[test]
fn overlay_draws_facet_edges_only_inside_the_shared_area() {
let mut after = filled_box(GRID, (2, 9), (2, 9), 1);
for y in 2..=9 {
for x in 6..=9 {
after[y * 12 + x] = 2;
}
}
let before = filled_box(GRID, (2, 9), (2, 9), 1);
let derived = difference_overlay(&before, &after, None, GRID);
assert_eq!(overlay_px(&derived, 12, 5, 5), FACET_EDGE, "facet 1 | 2");
assert_eq!(overlay_px(&derived, 12, 4, 5), CLEAR, "not on the seam");
assert_eq!(
overlay_px(&derived, 12, 6, 5),
CLEAR,
"the seam is one pixel"
);
let mut mask = vec![0u8; 144];
mask[5 * 12 + 3] = 255;
let explicit = difference_overlay(&before, &after, Some(&mask), GRID);
assert_eq!(overlay_px(&explicit, 12, 3, 5), FACET_EDGE);
assert_eq!(overlay_px(&explicit, 12, 5, 5), CLEAR);
mask[0] = 255;
let outside = difference_overlay(&before, &after, Some(&mask), GRID);
assert_eq!(overlay_px(&outside, 12, 0, 0), CLEAR);
}
#[test]
fn overlay_has_the_frame_size_and_only_the_defined_alphas() {
let before = filled_box(GRID, (1, 6), (3, 8), 1);
let after = filled_box(GRID, (4, 10), (1, 9), 3);
for edges in [None, Some(vec![255u8; 144])] {
let overlay = difference_overlay(&before, &after, edges.as_deref(), GRID);
assert_eq!(overlay.len(), 12 * 12 * 4);
for pixel in overlay.as_chunks::<4>().0.iter().map(<[u8; 4]>::as_slice) {
match pixel[3] {
0 => assert_eq!(pixel, CLEAR, "a transparent pixel carries no colour"),
a => assert!(
[
REMOVED_TINT,
ADDED_TINT,
AFTER_OUTLINE,
BEFORE_OUTLINE,
FACET_EDGE
]
.iter()
.any(|c| c[3] == a && c[..3] == pixel[..3]),
"unexpected pixel {pixel:?}"
),
}
}
}
}
#[test]
fn identical_masks_leave_no_tint_and_empty_masks_stay_transparent() {
let mask = filled_box(GRID, (2, 9), (2, 9), 1);
let overlay = difference_overlay(&mask, &mask, None, GRID);
assert!(
overlay
.as_chunks::<4>()
.0
.iter()
.map(<[u8; 4]>::as_slice)
.all(|p| p != REMOVED_TINT && p != ADDED_TINT),
"no removed or added pixel when nothing changed"
);
let empty = vec![0u32; 144];
let overlay = difference_overlay(&empty, &empty, None, GRID);
assert_eq!(overlay, vec![0u8; 144 * 4]);
}
#[test]
fn a_mismatched_buffer_gives_a_transparent_layer_not_a_panic() {
let overlay = difference_overlay(&[1, 1], &[1; 144], None, GRID);
assert_eq!(overlay, vec![0u8; 144 * 4]);
let overlay = difference_overlay(&[1; 144], &[1; 144], Some(&[1, 2]), GRID);
assert_eq!(overlay, vec![0u8; 144 * 4]);
assert_eq!(difference_overlay(&[], &[], None, (0, 0)), Vec::<u8>::new());
}
#[test]
fn a_stone_clipped_by_the_frame_still_gets_a_closed_outline() {
let full = filled_box(GRID, (0, 11), (0, 11), 1);
let overlay = difference_overlay(&full, &full, None, GRID);
assert_eq!(overlay_px(&overlay, 12, 0, 6), AFTER_OUTLINE);
assert_eq!(overlay_px(&overlay, 12, 6, 11), AFTER_OUTLINE);
}
#[test]
fn real_solid_renders_give_an_overlay_that_sees_the_change() {
let same = CompareSession::build(
input(round_brilliant(), "a"),
input(round_brilliant(), "b"),
CompareOrigin::Retarget,
START_POSE,
);
let size = (96, 96);
let pick = |side: &super::session::CompareSide| {
render_solid_pick(&side.planes, side.preform_planes, START_POSE, size)
};
let (before, after) = (pick(&same.before), pick(&same.after));
assert!(before.iter().any(|&p| p != 0), "the stone covers pixels");
let overlay = difference_overlay(&before, &after, None, size);
assert_eq!(overlay.len(), 96 * 96 * 4);
assert!(
overlay
.as_chunks::<4>()
.0
.iter()
.map(<[u8; 4]>::as_slice)
.all(|p| p != REMOVED_TINT && p != ADDED_TINT),
"identical stones differ nowhere"
);
assert!(
overlay
.as_chunks::<4>()
.0
.iter()
.map(<[u8; 4]>::as_slice)
.any(|p| p == AFTER_OUTLINE),
"the silhouette is outlined"
);
let nothing = vec![0u32; before.len()];
let added = difference_overlay(¬hing, &after, None, size);
assert!(
added
.as_chunks::<4>()
.0
.iter()
.map(<[u8; 4]>::as_slice)
.any(|p| p == ADDED_TINT)
);
assert!(
added
.as_chunks::<4>()
.0
.iter()
.map(<[u8; 4]>::as_slice)
.all(|p| p != REMOVED_TINT)
);
}
#[test]
fn the_session_radius_is_the_larger_of_the_two_and_symmetric() {
let ab = CompareSession::build(
input(round_brilliant(), "a"),
input(round_brilliant_steeper_crown(), "b"),
CompareOrigin::Retarget,
START_POSE,
);
let ba = CompareSession::build(
input(round_brilliant_steeper_crown(), "b"),
input(round_brilliant(), "a"),
CompareOrigin::Retarget,
START_POSE,
);
let (r_before, r_after) = (
ab.before.bounding_radius.expect("closes"),
ab.after.bounding_radius.expect("closes"),
);
assert!((ab.radius - r_before.max(r_after)).abs() < f64::EPSILON);
assert!((ab.radius - ba.radius).abs() < f64::EPSILON);
let one = CompareSession::build(
input(round_brilliant(), "a"),
input(unsolvable_design(), "b"),
CompareOrigin::Retarget,
START_POSE,
);
assert!((one.radius - r_before).abs() < f64::EPSILON);
}
#[test]
fn the_start_pose_fits_the_larger_radius_and_keeps_a_wider_view() {
let radius = 3.0;
let (min, max) = orbit_distance_bounds(radius);
let fit = fit_distance_for_radius(radius);
let near = fitted_pose(START_POSE, radius);
assert!(
near.distance >= fit.clamp(min, max) - 1e-4,
"pulled out to fit"
);
assert!((near.yaw - START_POSE.yaw).abs() < f32::EPSILON);
let far = CameraPose {
distance: fit * 1.3,
..START_POSE
};
assert!((fitted_pose(far, radius).distance - far.distance).abs() < 1e-4);
let huge = CameraPose {
distance: 1.0e6,
pitch: 9.0,
..START_POSE
};
let clamped = fitted_pose(huge, radius);
assert!((clamped.distance - max).abs() < 1e-3);
assert!((clamped.pitch - FRAC_PI_2).abs() < f32::EPSILON);
let larger = fitted_pose(START_POSE, 5.0).distance;
assert!(
larger > near.distance,
"a wider stone fits from further out"
);
}
#[test]
fn traced_never_starts_while_the_button_is_held() {
assert!(!traced_may_start(true));
assert!(traced_may_start(false));
}