1#![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
32fn check(model: &Model, manifold: bool) -> Result<(), Vec<GeopError>> {
38 let params = ValidationParameters::default();
39 match manifold {
41 true => validate_manifold(¶ms, model),
42 false => validate(¶ms, model),
43 }
44}
45
46fn 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
53fn 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#[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#[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
86pub fn check_valid(part: &NamedModel) -> Result<(), String> {
90 check(part.topology(), true).map_err(|errors| named_report(part, &errors))
91}
92
93fn 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#[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#[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
123fn 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#[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
162fn point(p: [f64; 3]) -> Vector3 {
166 Vector3::from_array(p.map(Scalar::from_f64))
167}
168
169pub 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#[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
185pub fn middle(edge: &Edge) -> Vector3 {
187 (edge.curve.evaluate(edge.curve.mid_parameter().unwrap())).unwrap()
188}
189
190pub 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
200pub 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#[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#[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
235pub 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#[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
256pub 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#[track_caller]
275pub fn solid_volume(part: &NamedModel, solid: &str) -> f64 {
276 volume(part, part.solid_id(solid).unwrap(), 64)
277}
278
279#[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
290pub 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
319pub fn model_shown(editor: &Editor) -> NamedModel {
324 read_model(&editor.part()).unwrap().into_owned()
325}
326
327fn given(_: &Reader, model: &NamedModel) -> GeopResult<NamedModel> {
331 Ok(model.clone())
332}
333
334pub 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
345pub const GIVEN: &str = "given";
347
348pub 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
356pub 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
366pub fn runner_of(registry: OperationRegistry) -> Runner {
371 let mut runner = Runner::new(registry);
372 runner.location.file = "part.geop".into();
373 runner
374}
375
376pub trait Built {
379 fn built(&self, registry: fn() -> GeopResult<OperationRegistry>) -> GeopResult<Part>;
381
382 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
400pub fn model_of(part: &Part) -> NamedModel {
402 (read_model(&part.current()).map(|model| model.into_owned())).unwrap_or_default()
403}
404
405pub 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#[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
425pub enum Outcome {
429 Built,
431 Refused,
433 Wrong(String),
436}
437
438pub 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#[derive(Default)]
471pub struct Cases {
472 built: usize,
473 refused: usize,
474 wrong: Vec<String>,
475}
476
477impl Cases {
478 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 #[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}