indicatrix-cut 0.7.1

Desktop faceting-design editor: library browsing, spectral 3D rendering, material retargeting, and a solid inspection view.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
//! The words of the locate windows: what the alignment, the solution and the calibration
//! tell the user, as lines the windows show.
//!
//! A line is a [`Row`]: its text and whether it is a warning (shown in amber).

use std::fmt::Write as _;

use indicatrix_cut_core::rough_plan::locate::{
    AlignOptions, AlignResult, Calibrated, InsideState, Located, LocatedPolyline, ViewPose,
    ViewStatus,
};

use super::pipeline::Found;

/// One line of a report.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
    /// The text.
    pub text: String,
    /// Whether the line is a warning.
    pub warn: bool,
}

impl Row {
    const fn info(text: String) -> Self {
        Self { text, warn: false }
    }

    const fn warning(text: String) -> Self {
        Self { text, warn: true }
    }
}

/// The caveat of the calibration's scale check, in plain words. It is shown next to the result
/// and written in the manual.
pub const SCALE_CAVEAT: &str = "The scale check only means something when the camera distances and the focal lengths are entered precisely. The image scale is the focal length times the cube's edge divided by the distance, so with loose values the fit can hide a wrong cube size in them. Measure the distances, take the focal lengths from a trusted calibration, and then read the ratio.";

/// The tooltip of the disabled Accept button for a line or polygon.
pub const LINES_NOT_ADDED: &str =
    "Feathers and silk can be located but not yet added as inclusions";

/// What a view's status means for its mark, as a clause.
#[must_use]
pub const fn status_words(status: ViewStatus) -> &'static str {
    match status {
        ViewStatus::Used => "used",
        ViewStatus::NoSurfaceHit => "the ray misses the stone",
        ViewStatus::FromInside => {
            "the ray starts inside the stone or meets its surface from behind"
        }
        ViewStatus::TotalInternalReflection => {
            "beyond the critical angle at the surface, so light from there cannot leave the stone in this direction and the mark cannot be a direct image"
        }
        ViewStatus::NoExit => "the ray never leaves the stone again (a damaged mesh)",
        ViewStatus::NoSuchView => "the rig has no such view",
    }
}

fn view_name(views: &[ViewPose], view: usize) -> String {
    views
        .get(view)
        .map_or_else(|| format!("View {}", view + 1), |pose| pose.name.clone())
}

/// A length in mm to two decimals.
fn mm(value: f64) -> String {
    format!("{value:.2} mm")
}

/// A distance in pixels to one decimal.
fn px(value: f64) -> String {
    format!("{value:.1} px")
}

/// The per-view misfit lines of an alignment, in the order the outlines were given.
#[must_use]
pub fn alignment_rows(result: &AlignResult, views: &[ViewPose]) -> Vec<Row> {
    let limit = AlignOptions::default().max_misfit_px;
    result
        .misfit
        .iter()
        .map(|misfit| {
            let text = format!(
                "{}: {} RMS, worst {}",
                view_name(views, misfit.view),
                px(misfit.rms_px),
                px(misfit.max_px)
            );
            if misfit.rms_px > limit {
                Row::warning(text)
            } else {
                Row::info(text)
            }
        })
        .collect()
}

/// The headline of an alignment: accepted with its worst misfit, or refused with the reason.
#[must_use]
pub fn alignment_summary(result: &AlignResult) -> String {
    if result.accepted {
        format!(
            "Aligned. The worst view misses the outline by {} RMS.",
            px(result.worst_rms_px)
        )
    } else {
        let note = result
            .note
            .as_deref()
            .unwrap_or("The mesh does not match the photos' outlines well enough.");
        format!(
            "Not accepted: the worst view misses the outline by {} RMS. {note}",
            px(result.worst_rms_px)
        )
    }
}

/// The headline of a located point.
fn point_summary(located: &Located) -> String {
    let [x, y, z] = located.point;
    let mut text = format!(
        "Located at ({x:.2}, {y:.2}, {z:.2}) mm in the rough's own coordinates. Uncertainty {} RMS over {} views; suggested margin {}.",
        mm(located.rms_mm),
        located.used_views,
        mm(located.suggested_margin_mm())
    );
    match located.inside {
        InsideState::Inside => {}
        InsideState::MovedInward => text.push_str(
            " The rays did not meet inside the stone, so the point was moved inward. Check the marks.",
        ),
        InsideState::Outside => text.push_str(
            " The rays do not meet inside the stone and the point could not be moved in. The marks do not agree.",
        ),
    }
    text
}

/// The headline of a located line or polygon.
fn line_summary(line: &LocatedPolyline) -> String {
    let kind = if line.closed { "polygon" } else { "line" };
    format!(
        "Located {kind} of {} vertices. Worst uncertainty {} RMS; suggested margin {}.",
        line.vertices.len(),
        mm(line.worst_rms_mm()),
        mm(line.suggested_margin_mm())
    )
}

/// The headline of a solution.
#[must_use]
pub fn found_summary(found: &Found) -> String {
    match found {
        Found::Point(located) => point_summary(located),
        Found::Line(line) => line_summary(line),
    }
}

/// The per-view lines of a located point: the residual, and the leave-one-out warning.
#[must_use]
pub fn point_rows(located: &Located, views: &[ViewPose]) -> Vec<Row> {
    located
        .views
        .iter()
        .map(|report| {
            let name = view_name(views, report.view);
            if report.status != ViewStatus::Used {
                return Row::warning(format!("{name}: not used, {}.", status_words(report.status)));
            }
            let mut text = format!(
                "{name}: {} from the solution",
                report.distance_mm.map_or_else(|| "?".to_owned(), mm)
            );
            let mut warn = false;
            if report.likely_outlier {
                warn = true;
                let _ = write!(
                    text,
                    " - this view's mark may be a reflected copy: it is {} from where the other views agree, which they do to {}",
                    report.leave_one_out_mm.map_or_else(|| "?".to_owned(), mm),
                    report.rms_without_mm.map_or_else(|| "?".to_owned(), mm)
                );
            }
            if report.beyond_segment {
                warn = true;
                text.push_str(" - the point lies beyond the stretch of this ray inside the stone");
            }
            if warn {
                Row::warning(text)
            } else {
                Row::info(text)
            }
        })
        .collect()
}

/// The per-vertex lines of a located line or polygon.
#[must_use]
pub fn line_rows(line: &LocatedPolyline) -> Vec<Row> {
    line.vertices
        .iter()
        .enumerate()
        .map(|(index, vertex)| {
            let [x, y, z] = vertex.point;
            let text = format!(
                "Vertex {}: ({x:.2}, {y:.2}, {z:.2}) mm, uncertainty {} RMS",
                index + 1,
                mm(vertex.rms_mm)
            );
            if vertex.outlier_views().is_empty() {
                Row::info(text)
            } else {
                Row::warning(format!(
                    "{text} - a view of this vertex may be a reflected copy"
                ))
            }
        })
        .collect()
}

/// The per-view lines of a solution.
#[must_use]
pub fn found_rows(found: &Found, views: &[ViewPose]) -> Vec<Row> {
    match found {
        Found::Point(located) => point_rows(located, views),
        Found::Line(line) => line_rows(line),
    }
}

/// The label drawn beside a reprojected point: the error against the click.
#[must_use]
pub fn reprojection_label(error_px: Option<f64>) -> String {
    error_px.map_or_else(String::new, px)
}

/// The label of a predicted ghost: how many reflections it takes.
#[must_use]
pub fn ghost_label(bounces: u8) -> String {
    if bounces == 1 {
        "ghost, 1 reflection".to_owned()
    } else {
        format!("ghost, {bounces} reflections")
    }
}

/// The lines of a calibration: the edge fit, the scale against the datasheet, the diagonal check
/// and the warnings, with the per-view misfit; the caveat is [`SCALE_CAVEAT`].
#[must_use]
pub fn calibration_rows(calibrated: &Calibrated, views: &[ViewPose]) -> Vec<Row> {
    let result = &calibrated.result;
    let mut rows = vec![Row::info(format!(
        "Edge fit: {} RMS over {} lines.",
        px(result.edge_rms_px),
        result.lines_used
    ))];
    for (view, rms) in calibrated.view_rms_px.iter().enumerate() {
        let name = view_name(views, view);
        let dot = calibrated
            .mark_error_px
            .get(view)
            .copied()
            .flatten()
            .map_or_else(String::new, |error| {
                format!(", dot {} from its corner", px(error))
            });
        rows.push(Row::info(rms.map_or_else(
            || format!("{name}: no edge lines{dot}"),
            |rms| format!("{name}: {} RMS{dot}", px(rms)),
        )));
    }
    let percent = (result.scale_ratio - 1.0) * 100.0;
    let scale = format!(
        "Scale: the fitted edge is {:.3} mm against the datasheet's {:.3} +/- {:.3} mm ({percent:+.2} %), {}.",
        result.fitted_edge_mm,
        result.datasheet_edge_mm,
        result.tolerance_mm,
        if result.scale_within_tolerance {
            "within the tolerance"
        } else {
            "outside the tolerance"
        }
    );
    rows.push(if result.scale_within_tolerance {
        Row::info(scale)
    } else {
        Row::warning(scale)
    });
    match (result.diagonal_rms_mm, result.diagonal_plane_rms_mm) {
        (Some(edges), Some(plane)) => rows.push(Row::info(format!(
            "Diagonal check: the triangulated edges are {} RMS from the true ones and {} RMS from the true plane. This is the rig's measured accuracy, and it is saved with the rig.",
            mm(edges),
            mm(plane)
        ))),
        _ => rows.push(Row::info(
            "No diagonal check was run: mark the coated diagonal's edges to measure the rig's accuracy.".to_owned(),
        )),
    }
    rows.extend(calibrated.warnings.iter().cloned().map(Row::warning));
    rows
}

#[cfg(test)]
mod tests {
    use super::*;
    use indicatrix_cut_core::rough_plan::locate::{
        AlignResult, CalibrationResult, Projection, RigProfile, Rigid, ViewMisfit,
    };

    fn views() -> Vec<ViewPose> {
        RigProfile::side_layout(
            150.0,
            30.0,
            Projection::Pinhole { focal_px: 4000.0 },
            [4000, 3000],
        )
    }

    fn aligned(rms: f64, accepted: bool) -> AlignResult {
        AlignResult {
            transform: Rigid::IDENTITY,
            misfit: vec![
                ViewMisfit {
                    view: 0,
                    rms_px: 1.3,
                    max_px: 3.0,
                },
                ViewMisfit {
                    view: 3,
                    rms_px: rms,
                    max_px: rms * 2.0,
                },
            ],
            worst_rms_px: rms.max(1.3),
            accepted,
            note: (!accepted).then(|| "Check the outlines.".to_owned()),
            iterations: 5,
        }
    }

    #[test]
    fn alignment_lines_name_the_view_and_flag_a_misfit_over_the_limit() {
        let rows = alignment_rows(&aligned(6.0, false), &views());
        assert_eq!(rows[0].text, "+X upper: 1.3 px RMS, worst 3.0 px");
        assert!(!rows[0].warn);
        assert!(rows[1].warn, "6 px is over the 4 px limit");
        assert!(rows[1].text.starts_with("-X lower"), "{}", rows[1].text);
    }

    #[test]
    fn an_alignment_summary_says_accepted_or_why_not() {
        assert!(alignment_summary(&aligned(2.0, true)).starts_with("Aligned."));
        let refused = alignment_summary(&aligned(6.0, false));
        assert!(refused.starts_with("Not accepted"), "{refused}");
        assert!(refused.contains("Check the outlines."), "{refused}");
    }

    #[test]
    fn every_status_has_words() {
        for status in [
            ViewStatus::Used,
            ViewStatus::NoSurfaceHit,
            ViewStatus::FromInside,
            ViewStatus::TotalInternalReflection,
            ViewStatus::NoExit,
            ViewStatus::NoSuchView,
        ] {
            assert_ne!(status_words(status), "");
        }
        assert!(status_words(ViewStatus::NoSurfaceHit).contains("misses the stone"));
    }

    #[test]
    fn ghost_and_reprojection_labels() {
        assert_eq!(ghost_label(1), "ghost, 1 reflection");
        assert_eq!(ghost_label(2), "ghost, 2 reflections");
        assert_eq!(reprojection_label(Some(3.26)), "3.3 px");
        assert_eq!(reprojection_label(None), "");
    }

    #[test]
    fn a_calibration_reports_the_scale_and_the_diagonal_and_keeps_its_warnings() {
        let rig = RigProfile::new("t", views(), 1.5);
        let result = CalibrationResult {
            datasheet_edge_mm: 25.4,
            tolerance_mm: 0.1,
            cube_n_d: 1.5168,
            fitted_edge_mm: 25.55,
            scale_ratio: 25.55 / 25.4,
            scale_within_tolerance: false,
            edge_rms_px: 0.8,
            lines_used: 30,
            diagonal_rms_mm: Some(0.12),
            diagonal_plane_rms_mm: Some(0.05),
        };
        let calibrated = Calibrated {
            rig: rig.clone(),
            result,
            view_rms_px: vec![Some(0.7), None],
            mark_error_px: vec![Some(4.0), None],
            diagonal: None,
            fit_cost: 1.0,
            iterations: 7,
            warnings: vec!["A line was rejected.".to_owned()],
        };
        let rows = calibration_rows(&calibrated, &rig.views);
        let text: Vec<&str> = rows.iter().map(|row| row.text.as_str()).collect();
        assert!(text[0].contains("30 lines"), "{text:?}");
        assert!(
            text[1].starts_with("+X upper: 0.7 px RMS, dot 4.0 px"),
            "{text:?}"
        );
        assert!(text[2].contains("no edge lines"), "{text:?}");
        let scale = rows
            .iter()
            .find(|row| row.text.starts_with("Scale"))
            .unwrap();
        assert!(
            scale.warn && scale.text.contains("outside the tolerance"),
            "{scale:?}"
        );
        assert!(scale.text.contains("+0.59 %"), "{scale:?}");
        assert!(
            text.iter()
                .any(|t| t.contains("0.12 mm RMS from the true ones"))
        );
        assert!(rows.last().unwrap().warn, "the warnings come last");
    }
}