Another project
1use std::collections::HashSet;
2
3use bone_types::{
4 Aabb3, AngleTolerance, BrepEdgeId, BrepFaceId, BrepLoopId, BrepShellId, BrepVertexId,
5 ChordHeightTolerance, CreaseAngle, EdgeLabel, EdgeRole, FaceFingerprint, FaceLabel, Point3,
6 SideKind, SketchPlaneBasis, StepEntityKind, Tolerance, VertexLabel,
7};
8use slotmap::SlotMap;
9use truck_modeling::ShellCondition;
10
11mod build;
12pub(crate) mod convert;
13mod edges;
14pub mod eval;
15pub mod persist;
16pub mod profile;
17pub mod step;
18pub mod tessellate;
19use crate::curve3::Curve3;
20use build::{Arena, BoundaryIndex, EdgeArenaHandle, edge_length, edge_points};
21use convert::point_from_truck;
22pub use edges::{EdgeCurve3, EdgePolyline, EdgePolylines};
23pub use persist::{BrepReattach, EdgeReattach};
24pub use tessellate::{FaceMesh, MeshError, SolidMesh};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum TruckGap {
28 ReverseNormal,
29 AxisDirection,
30 ReferenceDirection,
31 ThroughAll,
32 UpToNext,
33 UpToVertex,
34 UpToSurface,
35 OffsetFromSurface,
36 UpToBody,
37 Draft,
38 ThinWall,
39}
40
41impl core::fmt::Display for TruckGap {
42 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
43 f.write_str(match self {
44 Self::ReverseNormal => "reverse-normal extrude direction",
45 Self::AxisDirection => "along-axis extrude direction",
46 Self::ReferenceDirection => "between-references extrude direction",
47 Self::ThroughAll => "through-all end condition",
48 Self::UpToNext => "up-to-next end condition",
49 Self::UpToVertex => "up-to-vertex end condition",
50 Self::UpToSurface => "up-to-surface end condition",
51 Self::OffsetFromSurface => "offset-from-surface end condition",
52 Self::UpToBody => "up-to-body end condition",
53 Self::Draft => "draft taper",
54 Self::ThinWall => "thin-wall shelling",
55 })
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum LabelKind {
61 Face,
62 Edge,
63 Vertex,
64}
65
66impl core::fmt::Display for LabelKind {
67 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
68 f.write_str(match self {
69 Self::Face => "face",
70 Self::Edge => "edge",
71 Self::Vertex => "vertex",
72 })
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ProfileDefect {
78 OpenLoop,
79 BranchingVertex,
80 SelfIntersectingLoop,
81 ZeroArea,
82 UncontainedLoop,
83 OverlappingLoops,
84}
85
86impl core::fmt::Display for ProfileDefect {
87 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
88 f.write_str(match self {
89 Self::OpenLoop => "an open loop",
90 Self::BranchingVertex => "a vertex shared by more than two edges",
91 Self::SelfIntersectingLoop => "a self-intersecting loop",
92 Self::ZeroArea => "zero area",
93 Self::UncontainedLoop => "a loop outside the outer boundary",
94 Self::OverlappingLoops => "overlapping inner loops",
95 })
96 }
97}
98
99#[derive(Debug, Clone, thiserror::Error)]
100pub enum BrepError {
101 #[error("extrude profile has {reason}")]
102 InvalidProfile { reason: ProfileDefect },
103 #[error("extrude depth is zero")]
104 EmptyExtrudeDepth,
105 #[error("boundary shell is not a closed manifold")]
106 ShellNotClosed,
107 #[error("edge {edge:?} collapses below the validation tolerance")]
108 DegenerateEdge { edge: BrepEdgeId },
109 #[error("edge {edge:?} belongs to no loop")]
110 DanglingEdge { edge: BrepEdgeId },
111 #[error("vertex {vertex:?} belongs to no loop")]
112 DanglingVertex { vertex: BrepVertexId },
113 #[error("kernel facade does not yet wrap this path: {detail}")]
114 TruckUnsupported { detail: TruckGap },
115 #[error("{kind} carries no extrude label")]
116 MissingLabel { kind: LabelKind },
117 #[error("solid has {found} {kind} entries, reattach payload carries {expected}")]
118 ReattachMismatch {
119 kind: LabelKind,
120 found: usize,
121 expected: usize,
122 },
123 #[error("reattach payload geometry order does not match the solid")]
124 ReattachOrder,
125 #[error("solid geometry blob could not be serialized")]
126 BlobSerialize,
127 #[error("solid geometry blob could not be parsed")]
128 BlobParse,
129 #[error("STEP text is not valid ISO-10303-21")]
130 StepSyntax,
131 #[error("STEP file has no data section")]
132 StepNoData,
133 #[error("STEP shell could not be reconstructed")]
134 StepShellMalformed,
135 #[error("STEP file carries no solid shell")]
136 StepEmpty,
137 #[error("STEP file carries {count} solids; assemblies are not yet imported")]
138 StepMultipleSolids { count: usize },
139 #[error("STEP geometry uses {kind}, which the facade does not yet bridge")]
140 StepUnsupported { kind: StepEntityKind },
141 #[error("STEP parse canceled before it completed")]
142 Canceled,
143}
144
145#[derive(Clone, Debug)]
146pub struct BrepShell {
147 id: BrepShellId,
148 boundary_index: BoundaryIndex,
149 faces: Vec<BrepFaceId>,
150}
151
152impl BrepShell {
153 #[must_use]
154 pub fn id(&self) -> BrepShellId {
155 self.id
156 }
157
158 #[must_use]
159 pub fn faces(&self) -> &[BrepFaceId] {
160 &self.faces
161 }
162}
163
164#[derive(Clone, Debug)]
165pub struct BrepFace {
166 id: BrepFaceId,
167 label: FaceLabel,
168 loops: Vec<BrepLoopId>,
169}
170
171impl BrepFace {
172 #[must_use]
173 pub fn id(&self) -> BrepFaceId {
174 self.id
175 }
176
177 #[must_use]
178 pub fn label(&self) -> FaceLabel {
179 self.label
180 }
181
182 #[must_use]
183 pub fn loops(&self) -> &[BrepLoopId] {
184 &self.loops
185 }
186}
187
188#[derive(Clone, Debug)]
189pub struct BrepLoop {
190 id: BrepLoopId,
191 edges: Vec<BrepEdgeId>,
192}
193
194impl BrepLoop {
195 #[must_use]
196 pub fn id(&self) -> BrepLoopId {
197 self.id
198 }
199
200 #[must_use]
201 pub fn edges(&self) -> &[BrepEdgeId] {
202 &self.edges
203 }
204}
205
206#[derive(Clone, Debug)]
207pub struct BrepEdge {
208 id: BrepEdgeId,
209 label: EdgeLabel,
210 handles: Vec<EdgeArenaHandle>,
211 vertices: [BrepVertexId; 2],
212 curve: EdgeCurve3,
213 crease: CreaseAngle,
214}
215
216impl BrepEdge {
217 #[must_use]
218 pub fn id(&self) -> BrepEdgeId {
219 self.id
220 }
221
222 #[must_use]
223 pub fn label(&self) -> EdgeLabel {
224 self.label
225 }
226
227 #[must_use]
228 pub fn vertices(&self) -> [BrepVertexId; 2] {
229 self.vertices
230 }
231
232 #[must_use]
233 pub fn curve(&self) -> &EdgeCurve3 {
234 &self.curve
235 }
236
237 #[must_use]
238 pub fn crease(&self) -> CreaseAngle {
239 self.crease
240 }
241}
242
243#[derive(Clone, Debug)]
244pub struct BrepVertex {
245 id: BrepVertexId,
246 label: VertexLabel,
247 position: Point3,
248}
249
250impl BrepVertex {
251 #[must_use]
252 pub fn id(&self) -> BrepVertexId {
253 self.id
254 }
255
256 #[must_use]
257 pub fn label(&self) -> VertexLabel {
258 self.label
259 }
260
261 #[must_use]
262 pub fn position(&self) -> Point3 {
263 self.position
264 }
265}
266
267#[derive(Clone)]
268pub struct BrepSolid {
269 arena: Arena,
270 shells: SlotMap<BrepShellId, BrepShell>,
271 faces: SlotMap<BrepFaceId, BrepFace>,
272 loops: SlotMap<BrepLoopId, BrepLoop>,
273 edges: SlotMap<BrepEdgeId, BrepEdge>,
274 vertices: SlotMap<BrepVertexId, BrepVertex>,
275 shell_order: Vec<BrepShellId>,
276 face_order: Vec<BrepFaceId>,
277 loop_order: Vec<BrepLoopId>,
278 edge_order: Vec<BrepEdgeId>,
279 vertex_order: Vec<BrepVertexId>,
280 reattach: persist::BrepReattach,
281}
282
283impl BrepSolid {
284 pub fn iter_shells(&self) -> impl Iterator<Item = &BrepShell> {
285 self.shell_order.iter().map(|id| &self.shells[*id])
286 }
287
288 pub fn iter_faces(&self) -> impl Iterator<Item = &BrepFace> {
289 self.face_order.iter().map(|id| &self.faces[*id])
290 }
291
292 #[must_use]
293 pub fn face_plane_basis(&self, face: BrepFaceId) -> Option<SketchPlaneBasis> {
294 self.arena
295 .truck_face(face)
296 .and_then(build::face_plane_basis)
297 }
298
299 #[must_use]
300 pub fn face_fingerprint(&self, face: BrepFaceId) -> Option<FaceFingerprint> {
301 self.arena
302 .truck_face(face)
303 .and_then(build::face_fingerprint)
304 }
305
306 pub fn iter_loops(&self) -> impl Iterator<Item = &BrepLoop> {
307 self.loop_order.iter().map(|id| &self.loops[*id])
308 }
309
310 pub fn iter_edges(&self) -> impl Iterator<Item = &BrepEdge> {
311 self.edge_order.iter().map(|id| &self.edges[*id])
312 }
313
314 pub fn iter_vertices(&self) -> impl Iterator<Item = &BrepVertex> {
315 self.vertex_order.iter().map(|id| &self.vertices[*id])
316 }
317
318 pub fn validate(&self, tolerance: Tolerance) -> Result<(), BrepError> {
319 if self.shells.is_empty() {
320 return Err(BrepError::ShellNotClosed);
321 }
322 self.iter_shells().try_for_each(|shell| {
323 let truck_shell = self.arena.boundary(shell.boundary_index);
324 let closed = truck_shell.shell_condition() == ShellCondition::Closed;
325 if closed && truck_shell.singular_vertices().is_empty() {
326 Ok(())
327 } else {
328 Err(BrepError::ShellNotClosed)
329 }
330 })?;
331 self.iter_edges().try_for_each(|edge| {
332 let length: f64 = edge
333 .handles
334 .iter()
335 .map(|handle| edge_length(self.arena.edge(*handle)))
336 .sum();
337 if length <= tolerance.value() {
338 Err(BrepError::DegenerateEdge { edge: edge.id })
339 } else {
340 Ok(())
341 }
342 })?;
343 let loop_edges: HashSet<BrepEdgeId> = self
344 .iter_loops()
345 .flat_map(|brep_loop| brep_loop.edges().iter().copied())
346 .collect();
347 self.iter_edges().try_for_each(|edge| {
348 if loop_edges.contains(&edge.id)
349 || matches!(
350 edge.label.role,
351 EdgeRole::SideEdge {
352 side: SideKind::Seam,
353 ..
354 }
355 )
356 {
357 Ok(())
358 } else {
359 Err(BrepError::DanglingEdge { edge: edge.id })
360 }
361 })?;
362 let loop_vertices: HashSet<BrepVertexId> = loop_edges
363 .iter()
364 .flat_map(|edge| self.edges[*edge].vertices())
365 .collect();
366 self.iter_vertices().try_for_each(|vertex| {
367 if loop_vertices.contains(&vertex.id) {
368 Ok(())
369 } else {
370 Err(BrepError::DanglingVertex { vertex: vertex.id })
371 }
372 })
373 }
374
375 #[must_use]
376 pub fn bounding_box(&self) -> Option<Aabb3> {
377 let points = self.iter_edges().flat_map(|edge| {
378 edge.handles.iter().flat_map(|handle| {
379 edge_points(self.arena.edge(*handle))
380 .into_iter()
381 .map(point_from_truck)
382 })
383 });
384 Aabb3::from_points(points)
385 }
386
387 pub fn tessellate(
388 &self,
389 chord: ChordHeightTolerance,
390 angle: AngleTolerance,
391 ) -> Result<SolidMesh, MeshError> {
392 tessellate::tessellate_solid(self, chord, angle)
393 }
394
395 #[must_use]
396 pub fn edges_for_render(&self, chord: ChordHeightTolerance) -> EdgePolylines {
397 EdgePolylines::new(
398 self.iter_edges()
399 .filter(|edge| edges::is_render_visible(edge.label))
400 .map(|edge| {
401 let points = edge.curve.tessellate(chord);
402 EdgePolyline::new(edge.id, edge.label, points, edge.curve.clone(), edge.crease)
403 })
404 .collect(),
405 )
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::build::{SolidLabeling, assemble, edge_length};
412 use super::{BrepError, BrepLoop, BrepSolid, LabelKind, MeshError, SolidMesh};
413 use bone_types::{
414 AngleTolerance, BrepLoopId, ChordHeightTolerance, EdgeLabel, EdgeRole, FaceLabel, FaceRole,
415 FeatureId, LoopIndex, SideKind, SketchEntityId, Tolerance, VertexLabel, VertexRole,
416 };
417 use slotmap::SlotMap;
418 use std::collections::{HashMap, HashSet};
419 use truck_modeling::{Face, Point3, Shell, Solid, Vector3, builder};
420
421 const LOW: f64 = 0.25;
422 const HIGH: f64 = 0.75;
423
424 struct Profile {
425 feature: FeatureId,
426 edges: [SketchEntityId; 4],
427 corners: [SketchEntityId; 4],
428 }
429
430 fn profile() -> Profile {
431 let mut features: SlotMap<FeatureId, ()> = SlotMap::with_key();
432 let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key();
433 Profile {
434 feature: features.insert(()),
435 edges: [
436 entities.insert(()),
437 entities.insert(()),
438 entities.insert(()),
439 entities.insert(()),
440 ],
441 corners: [
442 entities.insert(()),
443 entities.insert(()),
444 entities.insert(()),
445 entities.insert(()),
446 ],
447 }
448 }
449
450 fn unit_cube() -> Solid {
451 let corner = builder::vertex(Point3::new(0.0, 0.0, 0.0));
452 let edge = builder::tsweep(&corner, Vector3::new(1.0, 0.0, 0.0));
453 let face = builder::tsweep(&edge, Vector3::new(0.0, 1.0, 0.0));
454 builder::tsweep(&face, Vector3::new(0.0, 0.0, 1.0))
455 }
456
457 fn iteration_fingerprint(solid: &Solid) -> String {
458 let faces = solid
459 .boundaries()
460 .iter()
461 .flat_map(Shell::face_iter)
462 .map(|face| {
463 let centroid = face
464 .boundaries()
465 .iter()
466 .flat_map(|wire| wire.vertex_iter().map(|v| v.point()))
467 .fold((0.0, 0.0, 0.0, 0.0), |(x, y, z, n), p| {
468 (x + p.x, y + p.y, z + p.z, n + 1.0)
469 });
470 format!("f({:.3},{:.3},{:.3})", centroid.0, centroid.1, centroid.2)
471 })
472 .collect::<Vec<_>>()
473 .join(" ");
474 let vertices = solid
475 .vertex_iter()
476 .map(|v| {
477 let p = v.point();
478 format!("v({:.3},{:.3},{:.3})", p.x, p.y, p.z)
479 })
480 .collect::<Vec<_>>()
481 .join(" ");
482 let edges = solid
483 .edge_iter()
484 .map(|e| {
485 let a = e.front().point();
486 let b = e.back().point();
487 format!("e({:.3},{:.3}->{:.3},{:.3})", a.x, a.y, b.x, b.y)
488 })
489 .collect::<Vec<_>>()
490 .join(" ");
491 format!("faces:{faces}\nverts:{vertices}\nedges:{edges}")
492 }
493
494 #[test]
495 fn truck_solid_iteration_order_survives_serde() {
496 let solid = unit_cube();
497 let before = iteration_fingerprint(&solid);
498 let Ok(text) = ron::to_string(&solid) else {
499 panic!("serialize truck solid");
500 };
501 let Ok(restored) = ron::from_str::<Solid>(&text) else {
502 panic!("deserialize truck solid");
503 };
504 let after = iteration_fingerprint(&restored);
505 assert_eq!(before, after);
506 }
507
508 fn corner_index(x: f64, y: f64) -> usize {
509 usize::from(x > 0.5) * 2 + usize::from(y > 0.5)
510 }
511
512 fn wall_index(footprint: &[(f64, f64)]) -> usize {
513 if footprint.iter().all(|&(_, y)| y < LOW) {
514 0
515 } else if footprint.iter().all(|&(x, _)| x > HIGH) {
516 1
517 } else if footprint.iter().all(|&(_, y)| y > HIGH) {
518 2
519 } else {
520 3
521 }
522 }
523
524 fn label_solid(solid: &Solid, profile: &Profile) -> SolidLabeling {
525 let faces: HashMap<_, _> = solid
526 .boundaries()
527 .iter()
528 .flat_map(Shell::face_iter)
529 .map(|face| {
530 let points: Vec<Point3> = face
531 .boundaries()
532 .iter()
533 .flat_map(|wire| wire.vertex_iter().map(|vertex| vertex.point()))
534 .collect();
535 let role = if points.iter().all(|p| p.z < LOW) {
536 FaceRole::StartCap
537 } else if points.iter().all(|p| p.z > HIGH) {
538 FaceRole::EndCap
539 } else {
540 let footprint: Vec<(f64, f64)> = points.iter().map(|p| (p.x, p.y)).collect();
541 FaceRole::Side {
542 loop_index: LoopIndex::OUTER,
543 from: profile.edges[wall_index(&footprint)],
544 }
545 };
546 (
547 face.id(),
548 FaceLabel {
549 feature: profile.feature,
550 role,
551 },
552 )
553 })
554 .collect();
555
556 let edges: HashMap<_, _> = solid
557 .edge_iter()
558 .map(|edge| {
559 let a = edge.front().point();
560 let b = edge.back().point();
561 let footprint = [(a.x, a.y), (b.x, b.y)];
562 let role = if a.z < LOW && b.z < LOW {
563 EdgeRole::StartCapEdge {
564 from: profile.edges[wall_index(&footprint)],
565 }
566 } else if a.z > HIGH && b.z > HIGH {
567 EdgeRole::EndCapEdge {
568 from: profile.edges[wall_index(&footprint)],
569 }
570 } else {
571 EdgeRole::SideEdge {
572 from: profile.corners[corner_index(a.x, a.y)],
573 side: SideKind::Corner,
574 }
575 };
576 (
577 edge.id(),
578 EdgeLabel {
579 feature: profile.feature,
580 role,
581 },
582 )
583 })
584 .collect();
585
586 let vertices: HashMap<_, _> = solid
587 .vertex_iter()
588 .map(|vertex| {
589 let p = vertex.point();
590 let from = profile.corners[corner_index(p.x, p.y)];
591 let role = if p.z < 0.5 {
592 VertexRole::StartCapVertex {
593 from,
594 side: SideKind::Corner,
595 }
596 } else {
597 VertexRole::EndCapVertex {
598 from,
599 side: SideKind::Corner,
600 }
601 };
602 (
603 vertex.id(),
604 VertexLabel {
605 feature: profile.feature,
606 role,
607 },
608 )
609 })
610 .collect();
611
612 SolidLabeling {
613 faces,
614 edges,
615 vertices,
616 closed_curves: HashSet::new(),
617 edge_curves: HashMap::new(),
618 }
619 }
620
621 fn open_shell(solid: &Solid) -> Solid {
622 let faces: Vec<Face> = solid
623 .boundaries()
624 .iter()
625 .flat_map(Shell::face_iter)
626 .take(5)
627 .cloned()
628 .collect();
629 Solid::new_unchecked(vec![faces.into_iter().collect::<Shell>()])
630 }
631
632 #[test]
633 fn unit_cube_iteration_is_label_ordered() {
634 let profile = profile();
635 let solid = unit_cube();
636 let labeling = label_solid(&solid, &profile);
637 let Ok(brep) = assemble(solid, &labeling) else {
638 panic!("unit cube labels are complete");
639 };
640
641 let faces = brep
642 .iter_faces()
643 .map(|face| format!(" {}", face.label()))
644 .collect::<Vec<_>>()
645 .join("\n");
646 let edges = brep
647 .iter_edges()
648 .map(|edge| format!(" {}", edge.label()))
649 .collect::<Vec<_>>()
650 .join("\n");
651 let vertices = brep
652 .iter_vertices()
653 .map(|vertex| format!(" {}", vertex.label()))
654 .collect::<Vec<_>>()
655 .join("\n");
656 let shells = brep
657 .iter_shells()
658 .map(|shell| format!(" shell faces={}", shell.faces().len()))
659 .collect::<Vec<_>>()
660 .join("\n");
661
662 insta::assert_snapshot!(format!(
663 "shells:\n{shells}\nfaces:\n{faces}\nedges:\n{edges}\nvertices:\n{vertices}"
664 ));
665 }
666
667 #[test]
668 fn unit_cube_topology_counts() {
669 let profile = profile();
670 let solid = unit_cube();
671 let labeling = label_solid(&solid, &profile);
672 let Ok(brep) = assemble(solid, &labeling) else {
673 panic!("unit cube labels are complete");
674 };
675 assert_eq!(brep.iter_shells().count(), 1);
676 assert_eq!(brep.iter_faces().count(), 6);
677 assert_eq!(brep.iter_loops().count(), 6);
678 assert_eq!(brep.iter_edges().count(), 12);
679 assert_eq!(brep.iter_vertices().count(), 8);
680 let Some(first) = brep.iter_faces().next() else {
681 panic!("cube has faces");
682 };
683 let Some(last) = brep.iter_faces().last() else {
684 panic!("cube has faces");
685 };
686 assert_eq!(first.label().role, FaceRole::StartCap);
687 assert_eq!(last.label().role, FaceRole::EndCap);
688 }
689
690 #[test]
691 fn loop_iteration_follows_face_order() {
692 let profile = profile();
693 let solid = unit_cube();
694 let labeling = label_solid(&solid, &profile);
695 let Ok(brep) = assemble(solid, &labeling) else {
696 panic!("unit cube labels are complete");
697 };
698 let grouped_by_face: Vec<BrepLoopId> = brep
699 .iter_faces()
700 .flat_map(|face| face.loops().iter().copied())
701 .collect();
702 let iterated: Vec<BrepLoopId> = brep.iter_loops().map(BrepLoop::id).collect();
703 assert_eq!(iterated.len(), 6);
704 assert_eq!(grouped_by_face, iterated);
705 }
706
707 #[test]
708 fn unit_cube_validates() {
709 let profile = profile();
710 let solid = unit_cube();
711 let labeling = label_solid(&solid, &profile);
712 let Ok(brep) = assemble(solid, &labeling) else {
713 panic!("unit cube labels are complete");
714 };
715 assert!(brep.validate(Tolerance::new(1e-9)).is_ok());
716 }
717
718 #[test]
719 fn open_shell_is_not_closed() {
720 let profile = profile();
721 let solid = open_shell(&unit_cube());
722 let labeling = label_solid(&solid, &profile);
723 let Ok(brep) = assemble(solid, &labeling) else {
724 panic!("open shell labels are complete");
725 };
726 assert!(matches!(
727 brep.validate(Tolerance::new(1e-9)),
728 Err(BrepError::ShellNotClosed)
729 ));
730 }
731
732 #[test]
733 fn missing_label_is_reported() {
734 let profile = profile();
735 let solid = unit_cube();
736 let mut labeling = label_solid(&solid, &profile);
737 labeling.faces.clear();
738 assert!(matches!(
739 assemble(solid, &labeling),
740 Err(BrepError::MissingLabel {
741 kind: LabelKind::Face
742 })
743 ));
744 }
745
746 #[test]
747 fn shared_face_label_groups_into_one() {
748 let profile = profile();
749 let solid = unit_cube();
750 let mut labeling = label_solid(&solid, &profile);
751 let Some(shared) = labeling.faces.values().copied().next() else {
752 panic!("cube has faces");
753 };
754 labeling
755 .faces
756 .values_mut()
757 .for_each(|label| *label = shared);
758 let Ok(brep) = assemble(solid, &labeling) else {
759 panic!("faces sharing one label group rather than collide");
760 };
761 assert_eq!(brep.iter_faces().count(), 1);
762 }
763
764 #[test]
765 fn oversized_tolerance_flags_degenerate_edge() {
766 let profile = profile();
767 let solid = unit_cube();
768 let labeling = label_solid(&solid, &profile);
769 let Ok(brep) = assemble(solid, &labeling) else {
770 panic!("unit cube labels are complete");
771 };
772 assert!(matches!(
773 brep.validate(Tolerance::new(2.0)),
774 Err(BrepError::DegenerateEdge { .. })
775 ));
776 }
777
778 #[test]
779 fn closed_curve_edge_is_not_degenerate() {
780 let start = builder::vertex(Point3::new(1.0, 0.0, 0.0));
781 let end = builder::vertex(Point3::new(1.0, 0.05, 0.0));
782 let arc = builder::circle_arc(&start, &end, Point3::new(-1.0, 0.0, 0.0));
783 let gap = end.point() - start.point();
784 let chord = gap.x.hypot(gap.y).hypot(gap.z);
785 assert!(chord < 0.1);
786 assert!(edge_length(&arc) > 5.0);
787 }
788
789 #[test]
790 fn empty_solid_is_not_closed() {
791 let solid = Solid::new_unchecked(vec![]);
792 let labeling = SolidLabeling {
793 faces: HashMap::new(),
794 edges: HashMap::new(),
795 vertices: HashMap::new(),
796 closed_curves: HashSet::new(),
797 edge_curves: HashMap::new(),
798 };
799 let Ok(brep) = assemble(solid, &labeling) else {
800 panic!("empty solid has nothing to label");
801 };
802 assert!(matches!(
803 brep.validate(Tolerance::new(1e-9)),
804 Err(BrepError::ShellNotClosed)
805 ));
806 }
807
808 #[test]
809 fn missing_edge_label_is_reported() {
810 let profile = profile();
811 let solid = unit_cube();
812 let mut labeling = label_solid(&solid, &profile);
813 labeling.edges.clear();
814 assert!(matches!(
815 assemble(solid, &labeling),
816 Err(BrepError::MissingLabel {
817 kind: LabelKind::Edge
818 })
819 ));
820 }
821
822 #[test]
823 fn missing_vertex_label_is_reported() {
824 let profile = profile();
825 let solid = unit_cube();
826 let mut labeling = label_solid(&solid, &profile);
827 labeling.vertices.clear();
828 assert!(matches!(
829 assemble(solid, &labeling),
830 Err(BrepError::MissingLabel {
831 kind: LabelKind::Vertex
832 })
833 ));
834 }
835
836 #[test]
837 fn shared_edge_label_groups_into_one() {
838 let profile = profile();
839 let solid = unit_cube();
840 let mut labeling = label_solid(&solid, &profile);
841 let Some(shared) = labeling.edges.values().copied().next() else {
842 panic!("cube has edges");
843 };
844 labeling
845 .edges
846 .values_mut()
847 .for_each(|label| *label = shared);
848 let Ok(brep) = assemble(solid, &labeling) else {
849 panic!("edges sharing one label group rather than collide");
850 };
851 assert_eq!(brep.iter_edges().count(), 1);
852 }
853
854 #[test]
855 fn shared_vertex_label_groups_into_one() {
856 let profile = profile();
857 let solid = unit_cube();
858 let mut labeling = label_solid(&solid, &profile);
859 let Some(shared) = labeling.vertices.values().copied().next() else {
860 panic!("cube has vertices");
861 };
862 labeling
863 .vertices
864 .values_mut()
865 .for_each(|label| *label = shared);
866 let Ok(brep) = assemble(solid, &labeling) else {
867 panic!("vertices sharing one label group rather than collide");
868 };
869 assert_eq!(brep.iter_vertices().count(), 1);
870 }
871
872 fn unit_cube_brep() -> BrepSolid {
873 let profile = profile();
874 let solid = unit_cube();
875 let labeling = label_solid(&solid, &profile);
876 let Ok(brep) = assemble(solid, &labeling) else {
877 panic!("unit cube labels are complete");
878 };
879 brep
880 }
881
882 fn tessellate_cube(chord: ChordHeightTolerance, angle: AngleTolerance) -> SolidMesh {
883 let Ok(mesh) = unit_cube_brep().tessellate(chord, angle) else {
884 panic!("unit cube tessellates");
885 };
886 mesh
887 }
888
889 #[test]
890 fn tessellate_unit_cube_emits_per_face_slabs() {
891 let brep = unit_cube_brep();
892 let Ok(mesh) = brep.tessellate(
893 ChordHeightTolerance::from_mm(0.05),
894 AngleTolerance::from_radians(0.2),
895 ) else {
896 panic!("unit cube tessellates");
897 };
898 let face_count = brep.iter_faces().count();
899 assert_eq!(mesh.faces().len(), face_count);
900 mesh.faces().iter().for_each(|slab| {
901 assert!(!slab.triangles().is_empty(), "slab has triangles");
902 assert!(!slab.positions().is_empty(), "slab has positions");
903 assert_eq!(slab.positions().len(), slab.normals().len());
904 });
905 }
906
907 #[test]
908 fn tessellate_unit_cube_validates() {
909 let mesh = tessellate_cube(
910 ChordHeightTolerance::from_mm(0.05),
911 AngleTolerance::from_radians(0.2),
912 );
913 assert!(mesh.validate(Tolerance::new(1e-9)).is_ok());
914 }
915
916 #[test]
917 fn tessellate_is_deterministic() {
918 let chord = ChordHeightTolerance::from_mm(0.05);
919 let angle = AngleTolerance::from_radians(0.2);
920 let first = tessellate_cube(chord, angle);
921 let second = tessellate_cube(chord, angle);
922 assert_eq!(first, second);
923 assert_eq!(first.generation(), second.generation());
924 }
925
926 #[test]
927 fn tessellate_generation_changes_with_chord() {
928 let coarse = tessellate_cube(
929 ChordHeightTolerance::from_mm(0.1),
930 AngleTolerance::from_radians(0.2),
931 );
932 let fine = tessellate_cube(
933 ChordHeightTolerance::from_mm(0.001),
934 AngleTolerance::from_radians(0.2),
935 );
936 assert_ne!(coarse.generation(), fine.generation());
937 }
938
939 #[test]
940 fn tessellate_generation_changes_with_angle() {
941 let chord = ChordHeightTolerance::from_mm(0.05);
942 let lo = tessellate_cube(chord, AngleTolerance::from_radians(0.01));
943 let hi = tessellate_cube(chord, AngleTolerance::from_radians(1.0));
944 assert_ne!(lo.generation(), hi.generation());
945 }
946
947 #[test]
948 fn tessellate_oversized_tolerance_flags_degenerate() {
949 let mesh = tessellate_cube(
950 ChordHeightTolerance::from_mm(0.05),
951 AngleTolerance::from_radians(0.2),
952 );
953 assert!(matches!(
954 mesh.validate(Tolerance::new(10.0)),
955 Err(MeshError::DegenerateTriangle { .. })
956 ));
957 }
958
959 proptest::proptest! {
960 #[test]
961 fn tessellate_proptest_is_deterministic(
962 chord_mm in 0.001f64..=0.2f64,
963 angle_rad in 0.01f64..=0.5f64,
964 ) {
965 let chord = ChordHeightTolerance::from_mm(chord_mm);
966 let angle = AngleTolerance::from_radians(angle_rad);
967 let first = tessellate_cube(chord, angle);
968 let second = tessellate_cube(chord, angle);
969 proptest::prop_assert_eq!(first, second);
970 }
971 }
972}