Skip to main content

geop_ops_model/
testing.rs

1//! What the tests of the operations share about the model: checking it
2//! valid and drawn as its trims say, finding its entities by where they
3//! are, the model an editor shows, building a part of a model given and
4//! steps run on it one by one, and the model a program builds. Only built
5//! for tests: with `testing`, or in this crate's own.
6
7// Helpers of tests fail as tests do: by panicking, naming what is wrong.
8#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
9
10use geop_core_editor::editor::Editor;
11use geop_core_math::{
12    geop_error::{GeopError, GeopResult},
13    scalars::Scalar,
14    vector::{Vector2, Vector3},
15};
16use geop_core_program::{
17    operation::Operation, operation_registry::OperationRegistry, part::Part, program::Program,
18    reader::Reader, runner::Runner,
19};
20use geop_core_rasterize::{
21    face_triangles_uv, rasterize,
22    stl::{enclosed_volume, stl_triangles},
23};
24use geop_core_topology::{
25    Body, Edge, FaceId, Model, SolidId,
26    contains::shell::{PointClassification, shell_contains},
27    validation::{ValidationParameters, validate, validate_manifold},
28};
29
30use crate::{NamedModel, read_model};
31
32// ── Validity ────────────────────────────────────────────────────────────────
33
34/// Whether `model` is valid — a manifold of closed shells too, if
35/// `manifold` — and if not, every validation error's root message, one per
36/// line.
37fn check(model: &Model, manifold: bool) -> Result<(), Vec<GeopError>> {
38    let params = ValidationParameters::default();
39    // `validate_manifold` runs `validate` first.
40    match manifold {
41        true => validate_manifold(&params, model),
42        false => validate(&params, model),
43    }
44}
45
46/// The report of `errors`: how many, and each one's root message, a line
47/// each.
48fn report(errors: &[GeopError]) -> String {
49    let all: Vec<String> = errors.iter().map(line).collect();
50    format!("{} validation error(s):\n{}", all.len(), all.join("\n"))
51}
52
53/// One validation error on a line: its root message, and the context it
54/// was reported through, outermost first — which entities it is about.
55fn line(error: &GeopError) -> String {
56    let mut contexts = Vec::new();
57    let mut at = error;
58    while let GeopError::Context { inner, message } = at {
59        contexts.push(message.as_str());
60        at = inner;
61    }
62    match contexts.is_empty() {
63        true => error.root_message().to_string(),
64        false => format!("{} ({})", error.root_message(), contexts.join("; ")),
65    }
66}
67
68/// Checks `model` is a valid manifold model, failing with every validation
69/// error's root message.
70#[track_caller]
71pub fn assert_model_valid(model: &Model) {
72    if let Err(errors) = check(model, true) {
73        panic!("{}", report(&errors));
74    }
75}
76
77/// Checks `model`, which may hold open bodies — sheets, wires — is valid,
78/// failing with every validation error's root message.
79#[track_caller]
80pub fn assert_model_valid_sheets(model: &Model) {
81    if let Err(errors) = check(model, false) {
82        panic!("{}", report(&errors));
83    }
84}
85
86/// Whether `part` is a valid manifold model — if not, every validation
87/// error's root message, one per line, and the names of the entities they
88/// mention.
89pub fn check_valid(part: &NamedModel) -> Result<(), String> {
90    check(part.topology(), true).map_err(|errors| named_report(part, &errors))
91}
92
93/// [`report`] of `errors` in `part`, with the names of the entities they
94/// mention.
95fn named_report(part: &NamedModel, errors: &[GeopError]) -> String {
96    let report = report(errors);
97    format!(
98        "{report}\nwhere {}",
99        part.names_mentioned(&report).join(", ")
100    )
101}
102
103/// Checks `part` is a valid manifold model with consistent names, failing
104/// with the report of [`check_valid`].
105#[track_caller]
106pub fn assert_valid(part: &NamedModel) {
107    if let Err(report) = check_valid(part) {
108        panic!("{report}");
109    }
110    part.check_names().unwrap();
111}
112
113/// Checks `part`, which may hold open bodies — sheets, wires — is valid with
114/// consistent names, failing as [`assert_valid`] does.
115#[track_caller]
116pub fn assert_valid_sheets(part: &NamedModel) {
117    if let Err(errors) = check(part.topology(), false) {
118        panic!("{}", named_report(part, &errors));
119    }
120    part.check_names().unwrap();
121}
122
123/// Twice the signed area of a closed polygon.
124fn area2(polygon: &[[f64; 2]]) -> f64 {
125    (0..polygon.len())
126        .map(|i| {
127            let (a, b) = (polygon[i], polygon[(i + 1) % polygon.len()]);
128            a[0] * b[1] - b[0] * a[1]
129        })
130        .sum()
131}
132
133/// Checks every face of `part` is drawn exactly where its trims say: the
134/// triangles the rasterizer draws it with cover, in `(u, v)`, the area its
135/// outer boundary encloses less its holes' — as the rasterizer samples
136/// them. A face drawn over a hole it goes around, or leaving a gap, covers
137/// more or less. Plain `f64`, like the rasterizer.
138#[track_caller]
139pub fn assert_draws_its_trims(part: &NamedModel) {
140    const N: usize = 24;
141    let model = part.topology();
142    for (&id, face) in &model.faces {
143        let (triangles, trims) = face_triangles_uv(model, face, N).unwrap();
144        let plain = |p: &Vector2| [p[0].to_f64(), p[1].to_f64()];
145        let enclosed: f64 = trims
146            .iter()
147            .map(|polygon| area2(&polygon.iter().map(plain).collect::<Vec<_>>()) / 2.0)
148            .sum::<f64>()
149            .abs();
150        let covered: f64 = triangles
151            .iter()
152            .map(|(a, b, c)| area2(&[a, b, c].map(plain)).abs() / 2.0)
153            .sum();
154        assert!(
155            (covered - enclosed).abs() <= 1e-9 * enclosed.max(1e-9),
156            "face {:?} is drawn covering {covered} of its (u, v), where its trims enclose {enclosed}",
157            part.name_of(id),
158        );
159    }
160}
161
162// ── Entities by where they are ──────────────────────────────────────────────
163
164/// The point `p`, as sharp scalars.
165fn point(p: [f64; 3]) -> Vector3 {
166    Vector3::from_array(p.map(Scalar::from_f64))
167}
168
169/// Whether `part` has a vertex that could be at `p`.
170pub fn has_vertex(part: &NamedModel, p: [f64; 3]) -> bool {
171    let p = point(p);
172    (part.topology().vertices.values()).any(|vertex| vertex.point.could_be_equal(&p))
173}
174
175/// The name of the vertex of `part` that could be at `p`.
176#[track_caller]
177pub fn vertex_at(part: &NamedModel, p: [f64; 3]) -> String {
178    let at = point(p);
179    let (&id, _) = (part.topology().vertices.iter())
180        .find(|(_, vertex)| vertex.point.could_be_equal(&at))
181        .unwrap_or_else(|| panic!("no vertex at {p:?}"));
182    part.name_of(id).unwrap().to_string()
183}
184
185/// The middle of `edge`, halfway along its curve's domain.
186pub fn middle(edge: &Edge) -> Vector3 {
187    (edge.curve.evaluate(edge.curve.mid_parameter().unwrap())).unwrap()
188}
189
190/// The names of the edges of `part` that `keep` accepts, sorted.
191pub fn edges_where(part: &NamedModel, keep: impl Fn(&Edge) -> bool) -> Vec<String> {
192    let mut names: Vec<String> = (part.topology().edges.iter())
193        .filter(|(_, edge)| keep(edge))
194        .map(|(&id, _)| part.name_of(id).unwrap().to_string())
195        .collect();
196    names.sort();
197    names
198}
199
200/// The names of the edges of `part` whose [`middle`] `keep` accepts, as
201/// plain numbers, sorted.
202pub fn edges_with_middle_where(part: &NamedModel, keep: impl Fn([f64; 3]) -> bool) -> Vec<String> {
203    edges_where(part, |edge| {
204        keep(middle(edge).to_array().map(|x| x.to_f64()))
205    })
206}
207
208/// The name of the edge of `part` whose [`middle`] could be at `p`.
209#[track_caller]
210pub fn edge_at(part: &NamedModel, p: [f64; 3]) -> String {
211    let at = point(p);
212    let names = edges_where(part, |edge| middle(edge).could_be_equal(&at));
213    match names.as_slice() {
214        [name] => name.clone(),
215        _ => panic!("not one edge with its middle at {p:?}: {names:?}"),
216    }
217}
218
219/// The planar face of `solid` whose outward normal is `normal`, lying
220/// furthest that way.
221#[track_caller]
222pub fn face_towards(part: &NamedModel, solid: SolidId, normal: [f64; 3]) -> FaceId {
223    let n = point(normal);
224    let model = part.topology();
225    (model.body_faces(Body::Solid(solid)).unwrap().into_iter())
226        .filter_map(|f| {
227            let plane = model.get_face(f).unwrap().surface.as_plane().unwrap()?;
228            (plane.normal.could_be_equal(&n)).then(|| (f, plane.point.prod_dot(&n).to_f64()))
229        })
230        .max_by(|a, b| a.1.total_cmp(&b.1))
231        .map(|(f, _)| f)
232        .unwrap_or_else(|| panic!("no face towards {normal:?}"))
233}
234
235// ── Measures ────────────────────────────────────────────────────────────────
236
237/// The volume `solid` of `part` encloses, from its mesh as drawn at
238/// `resolution` (see [`rasterize`]): negative if its faces point in. Its
239/// triangles' corners are `f32`, as STL stores them: a test's measuring
240/// stick, not a kernel comparison.
241pub fn volume(part: &NamedModel, solid: SolidId, resolution: usize) -> f64 {
242    let faces = part.topology().body_faces(Body::Solid(solid)).unwrap();
243    let raster = rasterize(part.topology(), resolution).unwrap();
244    enclosed_volume(&stl_triangles(&raster, &faces))
245}
246
247/// Checks `a` is within `tol` of `b` in every coordinate.
248#[track_caller]
249pub fn assert_close(a: [f64; 3], b: [f64; 3], tol: f64) {
250    assert!(
251        (0..3).all(|k| (a[k] - b[k]).abs() < tol),
252        "{a:?} is not within {tol} of {b:?}"
253    );
254}
255
256/// Where the point `p` lies relative to the solid `solid` of `part`:
257/// inside it, outside it, or on its boundary — inside, if inside any of
258/// its shells.
259pub fn classify(part: &NamedModel, solid: &str, p: [f64; 3]) -> PointClassification {
260    let model = part.topology();
261    let solid = part.solid_id(solid).unwrap();
262    let mut result = PointClassification::Outside;
263    for &shell in &model.get_solid(solid).unwrap().shells {
264        match shell_contains(model, shell, point(p), 2000, Scalar::from_f64(1e-6), 7).unwrap() {
265            PointClassification::Outside => {}
266            other => result = other,
267        }
268    }
269    result
270}
271
272/// The volume the solid named `solid` of `part` encloses, from its mesh as
273/// drawn (see [`volume`]).
274#[track_caller]
275pub fn solid_volume(part: &NamedModel, solid: &str) -> f64 {
276    volume(part, part.solid_id(solid).unwrap(), 64)
277}
278
279/// Checks the solid named `solid` of `part` encloses `expected`, to within
280/// `tolerance` (see [`solid_volume`]).
281#[track_caller]
282pub fn assert_volume(part: &NamedModel, solid: &str, expected: f64, tolerance: f64) {
283    let got = solid_volume(part, solid);
284    assert!(
285        (got - expected).abs() <= tolerance,
286        "{solid} encloses {got}, expected {expected} (± {tolerance})"
287    );
288}
289
290/// A unit cube, every entity named by its kind and its position among
291/// those: `v0` … `v7`, `e0` … `e11`, `f0` … `f5`, `s0`.
292pub fn unit_cube() -> NamedModel {
293    let topology = geop_core_topology::test_fixtures::test_cube_model();
294    let mut names = crate::NameRegistry::default();
295    let mut name = |id: crate::RefId, kind: &str, k: usize| {
296        names
297            .insert(id, format!("{kind}{k}"))
298            .map_err(|e| e.with_context("unit_cube"))
299    };
300    for (k, &id) in topology.vertices.keys().enumerate() {
301        name(id.into(), "v", k).expect("names are fresh");
302    }
303    for (k, &id) in topology.edges.keys().enumerate() {
304        name(id.into(), "e", k).expect("names are fresh");
305    }
306    for (k, &id) in topology.faces.keys().enumerate() {
307        name(id.into(), "f", k).expect("names are fresh");
308    }
309    for (k, &id) in topology.solids.keys().enumerate() {
310        name(id.into(), "s", k).expect("names are fresh");
311    }
312    NamedModel {
313        topology,
314        names,
315        features: Vec::new(),
316    }
317}
318
319// ── What an editor shows ────────────────────────────────────────────────────
320
321/// The model the editor `editor` shows: what the program it edits builds,
322/// as far as it runs it.
323pub fn model_shown(editor: &Editor) -> NamedModel {
324    read_model(&editor.part()).unwrap().into_owned()
325}
326
327// ── Parts of a model given and one step ─────────────────────────────────────
328
329/// The generator of a model given: `model` itself.
330fn given(_: &Reader, model: &NamedModel) -> GeopResult<NamedModel> {
331    Ok(model.clone())
332}
333
334/// A part of one step that defines its model as `model`: what the tests of
335/// an operation, an exporter or an inspector build on.
336pub fn given_part(model: &NamedModel) -> GeopResult<Part> {
337    let mut part = Part::default();
338    let runner = Runner::new(OperationRegistry::default());
339    runner.run_step(&mut part, GIVEN, |ctx| {
340        ctx.produce(&crate::model(), model, given)
341    })?;
342    Ok(part)
343}
344
345/// The id of the step that gives a part its model, in [`given_part`].
346pub const GIVEN: &str = "given";
347
348/// Runs `O`'s step `step` with `args` on `part`, after the steps it ran:
349/// the next step of a program built one step at a time, on a model given
350/// (see [`given_part`]).
351pub fn run_step<O: Operation>(part: &mut Part, step: &str, args: &O::Args) -> GeopResult<()> {
352    let runner = Runner::new(OperationRegistry::default());
353    runner.run_step(part, step, |ctx| O::run(ctx, args))
354}
355
356/// What `O`'s step `step` with `args` makes of `model`, as a program of two
357/// steps would build it — the model given, then the step — together with
358/// what else the step made: the part, after its one run. For an
359/// operation's tests, which build the model they act on directly.
360pub fn apply<O: Operation>(model: NamedModel, step: &str, args: &O::Args) -> GeopResult<Part> {
361    let mut part = given_part(&model)?;
362    run_step::<O>(&mut part, step, args)?;
363    Ok(part)
364}
365
366// ── Parts built by programs ─────────────────────────────────────────────────
367
368/// A runner of programs written in the operations `registry` makes, as
369/// the file `part.geop` — of a workspace of no other files.
370pub fn runner_of(registry: OperationRegistry) -> Runner {
371    let mut runner = Runner::new(registry);
372    runner.location.file = "part.geop".into();
373    runner
374}
375
376/// Building a program on its own, written in the operations `registry`
377/// makes.
378pub trait Built {
379    /// The part the program builds.
380    fn built(&self, registry: fn() -> GeopResult<OperationRegistry>) -> GeopResult<Part>;
381
382    /// The model the program builds, or why a step fails.
383    fn build_model(
384        &self,
385        registry: fn() -> GeopResult<OperationRegistry>,
386    ) -> GeopResult<NamedModel> {
387        Ok(model_of(&self.built(registry)?))
388    }
389}
390
391impl Built for Program {
392    fn built(&self, registry: fn() -> GeopResult<OperationRegistry>) -> GeopResult<Part> {
393        let runner = runner_of(registry()?);
394        let mut part = Part::default();
395        self.run(&runner, &mut part)?;
396        Ok(part)
397    }
398}
399
400/// The model `part` holds: empty if it built no topology.
401pub fn model_of(part: &Part) -> NamedModel {
402    (read_model(&part.current()).map(|model| model.into_owned())).unwrap_or_default()
403}
404
405/// The model `O`'s step `step` with `args` leaves of `model` (see
406/// [`apply`]): for the tests of an operation that builds on the model.
407pub fn apply_to_model<O: Operation>(
408    model: NamedModel,
409    step: &str,
410    args: &O::Args,
411) -> GeopResult<NamedModel> {
412    let part = apply::<O>(model, step, args)?;
413    Ok(read_model(&part.current())?.into_owned())
414}
415
416/// The error `result` holds, for a test that expects one.
417#[track_caller]
418pub fn error<T>(result: GeopResult<T>) -> GeopError {
419    match result {
420        Ok(_) => panic!("expected an error"),
421        Err(e) => e,
422    }
423}
424
425// ── Sweeps ──────────────────────────────────────────────────────────────────
426
427/// What one case of a sweep came to.
428pub enum Outcome {
429    /// A valid solid.
430    Built,
431    /// Refused as unsupported, saying why.
432    Refused,
433    /// Anything else: an error that is not a refusal, an invalid result, a
434    /// panic — what the case is listed for.
435    Wrong(String),
436}
437
438/// Applies a step of `O` with `args` to a copy of `part` and says how it
439/// went: refused, where the error's root message is one `is_refusal`
440/// accepts — the operation refusing what it does not support, rather than
441/// failing at what it does.
442pub fn outcome<O: Operation>(
443    part: &NamedModel,
444    args: &O::Args,
445    is_refusal: fn(&str) -> bool,
446) -> Outcome {
447    let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
448        apply_to_model::<O>(part.clone(), "op", args)
449    }));
450    match run {
451        Err(panic) => Outcome::Wrong(format!(
452            "panicked: {}",
453            panic
454                .downcast_ref::<String>()
455                .cloned()
456                .or_else(|| panic.downcast_ref::<&str>().map(|s| s.to_string()))
457                .unwrap_or_default()
458        )),
459        Ok(Err(e)) if is_refusal(e.root_message()) => Outcome::Refused,
460        Ok(Err(e)) => Outcome::Wrong(format!("failed: {e:?}")),
461        Ok(Ok(built)) => match check_valid(&built) {
462            Ok(()) => Outcome::Built,
463            Err(e) => Outcome::Wrong(format!("invalid: {e}")),
464        },
465    }
466}
467
468/// The cases of a sweep, as they come: how many built, how many were
469/// refused, and every one that went wrong.
470#[derive(Default)]
471pub struct Cases {
472    built: usize,
473    refused: usize,
474    wrong: Vec<String>,
475}
476
477impl Cases {
478    /// Records that the case `case` came to `outcome`.
479    pub fn record(&mut self, case: String, outcome: Outcome) {
480        match outcome {
481            Outcome::Built => self.built += 1,
482            Outcome::Refused => self.refused += 1,
483            Outcome::Wrong(why) => self.wrong.push(format!("{case}: {why}")),
484        }
485    }
486
487    /// Checks no case went wrong, listing every one that did.
488    #[track_caller]
489    pub fn assert_none_wrong(&self) {
490        let Cases {
491            built,
492            refused,
493            wrong,
494        } = self;
495        assert!(
496            wrong.is_empty(),
497            "{} of {} cases went wrong ({built} built, {refused} refused):\n\n{}",
498            wrong.len(),
499            wrong.len() + built + refused,
500            wrong.join("\n\n")
501        );
502    }
503}