use std::fmt::Write as _;
use indicatrix_cut_core::rough_plan::{
locate::{
AlignOptions, AlignResult, Ghost, LocateError, LocateOptions, Located, LocatedPolyline,
MAX_GHOST_BOUNCES, OutlineView, Reprojection, RigProfile, Rigid, Scene, ViewStatus,
align_mesh_to_rig, locate_point, locate_polyline, predict_ghosts, reproject,
},
shape::RoughMesh,
};
use super::{
marks::{MarkKind, MarkSet},
report::status_words,
};
const GHOST_MISS_FLOOR_MM: f64 = 0.25;
#[derive(Debug, Clone, PartialEq)]
pub enum Found {
Point(Located),
Line(LocatedPolyline),
}
impl Found {
#[must_use]
pub fn margin_mm(&self) -> f64 {
match self {
Self::Point(located) => located.suggested_margin_mm(),
Self::Line(line) => line.suggested_margin_mm(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VertexReprojection {
pub vertex: usize,
pub reprojection: Reprojection,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ViewOverlay {
pub reprojections: Vec<VertexReprojection>,
pub ghosts: Vec<Ghost>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Solution {
pub found: Found,
pub overlays: Vec<ViewOverlay>,
}
pub fn align(
mesh: &RoughMesh,
rig: &RigProfile,
outlines: &[OutlineView],
start: Rigid,
) -> Result<AlignResult, String> {
align_mesh_to_rig(mesh, rig, outlines, start, &AlignOptions::default())
.map_err(|error| error.to_string())
}
pub fn solve(
mesh: &RoughMesh,
rig: &RigProfile,
alignment: Rigid,
marks: &MarkSet,
) -> Result<Solution, String> {
rig.validate().map_err(|error| error.to_string())?;
let names: Vec<String> = rig.views.iter().map(|view| view.name.clone()).collect();
let scene = Scene::new(mesh, rig, alignment);
let options = LocateOptions::default();
let views = rig.views.len();
if marks.kind == MarkKind::Point {
let point_marks = marks.point_marks();
if point_marks.len() < 2 {
return Err("Mark the inclusion in at least two views.".to_owned());
}
let located = locate_point(&scene, &point_marks, &options)
.map_err(|error| locate_error_text(&error, &names))?;
let target = located.point_vec();
let miss_mm = (3.0 * located.rms_mm).max(GHOST_MISS_FLOOR_MM);
let overlays = (0..views)
.map(|view| {
let marked = point_marks
.iter()
.find(|mark| mark.view == view)
.map(|mark| mark.pixel);
ViewOverlay {
reprojections: reproject(&scene, view, target, marked)
.map(|reprojection| VertexReprojection {
vertex: 0,
reprojection,
})
.into_iter()
.collect(),
ghosts: predict_ghosts(&scene, view, target, MAX_GHOST_BOUNCES, miss_mm),
}
})
.collect();
return Ok(Solution {
found: Found::Point(located),
overlays,
});
}
if let Some(problem) = marks.vertex_count_problem(&names) {
return Err(problem);
}
let lines = marks.polylines();
if lines.len() < 2 {
return Err(format!(
"Mark the {} in at least two views.",
marks.kind.word()
));
}
let located = locate_polyline(&scene, &lines, marks.kind.closed(), &options)
.map_err(|error| locate_error_text(&error, &names))?;
let overlays = (0..views)
.map(|view| {
let drawn = lines.iter().find(|line| line.view == view);
let reprojections = located
.vertices
.iter()
.enumerate()
.filter_map(|(vertex, found)| {
let marked = drawn.and_then(|line| line.pixels.get(vertex)).copied();
reproject(&scene, view, found.point_vec(), marked).map(|reprojection| {
VertexReprojection {
vertex,
reprojection,
}
})
})
.collect();
ViewOverlay {
reprojections,
ghosts: Vec::new(),
}
})
.collect();
Ok(Solution {
found: Found::Line(located),
overlays,
})
}
fn locate_error_text(error: &LocateError, names: &[String]) -> String {
let name = |view: usize| names.get(view).map_or("a view", String::as_str);
match error {
LocateError::TooFewViews { usable, views } => {
let mut text = format!(
"Only {usable} of the marks reach the stone's interior; at least 2 are needed."
);
for report in views {
if report.status != ViewStatus::Used {
let _ = write!(
text,
" {}: {}.",
name(report.view),
status_words(report.status)
);
}
}
text
}
LocateError::AtVertex { index, reason } => {
format!("Vertex {}: {}", index + 1, locate_error_text(reason, names))
}
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::locate_io::marks::ClickMode;
use glam::DVec3;
use indicatrix_cut_core::rough_plan::locate::{Projection, box_mesh};
fn fixture() -> (RoughMesh, RigProfile) {
let mesh = box_mesh(DVec3::splat(5.0)).expect("a box");
let views = RigProfile::side_layout(
150.0,
30.0,
Projection::Pinhole { focal_px: 4000.0 },
[4000, 3000],
);
let mut rig = RigProfile::new("test", views, 1.5);
rig.surround_n = 1.5;
(mesh, rig)
}
fn mark_point(rig: &RigProfile, point: DVec3, views: &[usize]) -> MarkSet {
let mut marks = MarkSet::new(rig.views.len());
for &view in views {
let pixel = rig.views[view].project(point).expect("in front");
marks.click(view, ClickMode::Mark(MarkKind::Point), pixel.to_array());
}
marks
}
#[test]
fn a_point_marked_in_every_view_is_found_where_it_is() {
let (mesh, rig) = fixture();
let point = DVec3::new(0.8, -0.5, 0.3);
let marks = mark_point(&rig, point, &[0, 1, 2, 3, 4, 5, 6, 7]);
let solution = solve(&mesh, &rig, Rigid::IDENTITY, &marks).expect("solved");
let Found::Point(located) = &solution.found else {
panic!("a point");
};
assert!((located.point_vec() - point).length() < 1e-3, "{located:?}");
assert_eq!(located.used_views, 8);
assert_eq!(solution.overlays.len(), 8);
assert!(
solution
.overlays
.iter()
.all(|overlay| overlay.reprojections.len() == 1),
"every photo shows the point"
);
assert!(solution.found.margin_mm() >= 0.3);
}
#[test]
fn one_mark_is_not_enough() {
let (mesh, rig) = fixture();
let marks = mark_point(&rig, DVec3::ZERO, &[0]);
let message = solve(&mesh, &rig, Rigid::IDENTITY, &marks).unwrap_err();
assert!(message.contains("at least two views"), "{message}");
}
#[test]
fn a_line_needs_the_same_vertices_in_every_view() {
let (mesh, rig) = fixture();
let mut marks = MarkSet::new(8);
let line = ClickMode::Mark(MarkKind::Line);
for point in [DVec3::new(-1.0, 0.0, 0.0), DVec3::new(1.0, 0.5, 0.0)] {
for view in [0, 2] {
let pixel = rig.views[view].project(point).expect("in front");
marks.click(view, line, pixel.to_array());
}
}
let extra = rig.views[2]
.project(DVec3::new(2.0, 0.5, 0.0))
.expect("in front");
marks.click(2, line, extra.to_array());
let message = solve(&mesh, &rig, Rigid::IDENTITY, &marks).unwrap_err();
assert!(message.contains("same line vertices"), "{message}");
}
#[test]
fn a_line_is_located_vertex_by_vertex() {
let (mesh, rig) = fixture();
let mut marks = MarkSet::new(8);
let line = ClickMode::Mark(MarkKind::Line);
let ends = [DVec3::new(-1.0, 0.0, 0.0), DVec3::new(1.0, 0.5, 0.2)];
for point in ends {
for view in [0, 2, 4, 6] {
let pixel = rig.views[view].project(point).expect("in front");
marks.click(view, line, pixel.to_array());
}
}
let solution = solve(&mesh, &rig, Rigid::IDENTITY, &marks).expect("solved");
let Found::Line(found) = &solution.found else {
panic!("a line");
};
assert_eq!(found.vertices.len(), 2);
for (vertex, end) in found.vertices.iter().zip(ends) {
assert!((vertex.point_vec() - end).length() < 1e-3);
}
assert_eq!(solution.overlays[0].ghosts.len(), 0);
}
#[test]
fn a_mark_that_misses_the_stone_is_explained_by_view_name() {
let (mesh, rig) = fixture();
let mut marks = MarkSet::new(8);
let point = ClickMode::Mark(MarkKind::Point);
marks.click(0, point, [10.0, 10.0]);
marks.click(1, point, [10.0, 10.0]);
let message = solve(&mesh, &rig, Rigid::IDENTITY, &marks).unwrap_err();
assert!(message.contains("Only 0 of the marks"), "{message}");
assert!(message.contains("+X upper"), "{message}");
assert!(message.contains("misses the stone"), "{message}");
}
#[test]
fn alignment_without_outlines_is_refused_in_words() {
let (mesh, rig) = fixture();
let message = align(&mesh, &rig, &[], Rigid::IDENTITY).unwrap_err();
assert!(message.contains("outline"), "{message}");
}
}