1use axiolid_contracts::{
8 Backend, BackendDescriptor, BackendId, ExecutionOptions, ExecutionTarget, GeomError,
9 GeomResult, Operation, ScratchRequirement,
10};
11use axiolid_core::{Point3, Scalar, Tolerance, Transform3, Vec3};
12use axiolid_mesh::TriMesh;
13use axiolid_mesh_boolean_contract::MeshBoolean;
14use axiolid_mesh_compile_contract::{
15 CompileOutcome, MeshCompiler, PolylineCompiler, PolylineOutcome,
16};
17use axiolid_model::{GeometryGraph, GeometryNode, NodeId, SolidOperation};
18
19use crate::half_space_boundary::PrismAxis;
20
21use axiolid_construct::extrude::extrude_profile;
22use axiolid_construct::profile::profile_rings;
23
24use crate::channels::{self, Built};
25use crate::station::{PlacementAccuracy, UNCERTIFIED_STATION_FRAME};
26
27#[derive(Debug, Clone)]
33pub struct ReferenceMeshCompiler<B> {
34 boolean: B,
35}
36
37impl<B> ReferenceMeshCompiler<B> {
38 pub const fn new(boolean: B) -> Self {
40 Self { boolean }
41 }
42
43 pub const fn boolean(&self) -> &B {
45 &self.boolean
46 }
47}
48
49impl<B: MeshBoolean> Backend for ReferenceMeshCompiler<B> {
50 fn descriptor(&self) -> BackendDescriptor {
51 BackendDescriptor::new(
52 BackendId::new("scalar-compile"),
53 ExecutionTarget::PortableCpu,
54 )
55 }
56}
57
58#[derive(Debug)]
60enum CutRefusal {
61 Contact(GeomError),
65 Other(GeomError),
67}
68
69impl CutRefusal {
70 fn into_error(self) -> GeomError {
71 match self {
72 Self::Contact(error) | Self::Other(error) => error,
73 }
74 }
75}
76
77struct MeshCut {
81 built: Built,
82 unsettled: bool,
83 skin: Option<PendingSkin>,
87}
88
89#[derive(Debug, Clone)]
92pub(crate) struct PendingSkin {
93 pub error: GeomError,
94 pub reach: Scalar,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99enum Decided {
100 Mesh,
102 Referee,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
111struct EvalKey {
112 id: NodeId,
113 linear_bits: u64,
114 angular_bits: u64,
115 chord_bits: u64,
119 placed_bits: u64,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq)]
132struct Budget {
133 tolerance: Tolerance,
134 chord: Scalar,
135 placed: Scalar,
136}
137
138impl Budget {
139 fn of(options: &ExecutionOptions) -> Self {
140 let tolerance = options.tolerance();
141 Self {
142 tolerance,
143 chord: options.chord_error().unwrap_or(tolerance.linear()),
144 placed: 0.0,
145 }
146 }
147}
148
149impl EvalKey {
150 fn new(id: NodeId, budget: Budget) -> Self {
151 let bits = |value: Scalar| if value == 0.0 { 0 } else { value.to_bits() };
152 Self {
153 id,
154 linear_bits: bits(budget.tolerance.linear()),
155 angular_bits: bits(budget.tolerance.angular()),
156 chord_bits: bits(budget.chord),
157 placed_bits: bits(budget.placed),
158 }
159 }
160
161 fn budget(self) -> Budget {
162 Budget {
163 tolerance: self.tolerance(),
164 chord: Scalar::from_bits(self.chord_bits),
165 placed: Scalar::from_bits(self.placed_bits),
166 }
167 }
168
169 fn tolerance(self) -> Tolerance {
170 Tolerance::new(
171 Scalar::from_bits(self.linear_bits),
172 Scalar::from_bits(self.angular_bits),
173 )
174 .expect("evaluation keys only contain validated tolerances")
175 }
176}
177
178#[derive(Debug, Clone, Copy)]
179enum Step {
180 Enter(EvalKey),
181 Exit(EvalKey),
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190enum Measure<'m> {
191 Skip,
192 Deviation(&'m std::collections::HashSet<NodeId>),
195}
196
197#[derive(Debug, Default)]
205struct Cache {
206 built: std::collections::HashMap<EvalKey, Built>,
207 regions: crate::regions::RegionMemo,
208}
209
210impl Cache {
211 fn new() -> Self {
212 Self::default()
213 }
214}
215
216impl std::ops::Deref for Cache {
217 type Target = std::collections::HashMap<EvalKey, Built>;
218
219 fn deref(&self) -> &Self::Target {
220 &self.built
221 }
222}
223
224impl std::ops::DerefMut for Cache {
225 fn deref_mut(&mut self) -> &mut Self::Target {
226 &mut self.built
227 }
228}
229
230impl<B: MeshBoolean> ReferenceMeshCompiler<B> {
231 fn boundary_rings(
241 &self,
242 graph: &GeometryGraph,
243 id: NodeId,
244 chord: Scalar,
245 options: &ExecutionOptions,
246 ) -> GeomResult<axiolid_construct::profile::Rings> {
247 let node = self.node(graph, id)?;
248 let curve = match node {
249 GeometryNode::Curve2(curve) => curve,
250 GeometryNode::Profile(shape) => {
251 crate::half_space_boundary::check(shape, options.tolerance())?;
252 return profile_rings(shape, chord, options.tolerance());
253 }
254 _ => {
255 return Err(GeomError::InvalidInput(format!(
256 "half-space boundary {id:?} is neither a Curve2 nor a Profile node"
257 )))
258 }
259 };
260 match curve {
261 axiolid_curve::Curve2::Polyline(p) => {
262 let mut pts = p.points.clone();
263 if pts.len() >= 2 && pts[0] == pts[pts.len() - 1] {
266 pts.pop();
267 }
268 if pts.len() < 3 {
269 return Err(GeomError::InvalidInput(
270 "half-space boundary needs at least 3 distinct points".to_owned(),
271 ));
272 }
273 Ok(axiolid_construct::profile::Rings {
274 outer: pts,
275 holes: Vec::new(),
276 })
277 }
278 _ => Err(GeomError::Unsupported {
279 backend: self.descriptor().id,
280 operation: Operation::CurveEvaluation,
281 }),
282 }
283 }
284
285 fn bounded_half_space(
303 &self,
304 graph: &GeometryGraph,
305 (half_space, boundary): (NodeId, NodeId),
306 placement: Transform3,
307 prism_axis: PrismAxis,
308 depth: axiolid_construct::half_space::SlabDepth,
309 options: &ExecutionOptions,
310 ) -> GeomResult<TriMesh> {
311 use axiolid_construct::half_space::{bounded_half_space_prism, BoundaryPrism};
312 let node = self.node(graph, half_space)?;
313 let GeometryNode::HalfSpace(hs) = node else {
314 return Err(GeomError::InvalidInput(format!(
315 "half-space {half_space:?} is not a HalfSpace node"
316 )));
317 };
318 let tolerance = options.tolerance();
319 let prism = match prism_axis {
320 PrismAxis::PlaneNormal => {
321 BoundaryPrism::along_plane_normal(placement, hs.boundary, tolerance)?
322 }
323 PrismAxis::PlacementZ => BoundaryPrism::along_placement_z(placement, tolerance)?,
324 };
325 let stretch = prism.x.length().max(prism.y.length());
326 let chord = chord_error(options) / stretch.max(1.0);
327 let rings = self.boundary_rings(graph, boundary, chord, options)?;
328 bounded_half_space_prism(&rings, hs.boundary, prism, hs.agreement, depth, tolerance)
329 }
330
331 fn clip_tool_mesh(
344 &self,
345 graph: &GeometryGraph,
346 tool: ClipTool,
347 placement: Transform3,
348 subject: &TriMesh,
349 options: &ExecutionOptions,
350 ) -> GeomResult<TriMesh> {
351 let tolerance = options.tolerance();
352 let placed = placement != Transform3::IDENTITY;
353 let inverse = placement.inverse();
354 if placed
355 && !(placement.matrix3.determinant() != 0.0
356 && inverse.matrix3.is_finite()
357 && inverse.translation.is_finite())
358 {
359 return Err(GeomError::InvalidInput(
360 "half-space placed by a singular transform".to_owned(),
361 ));
362 }
363 let local_subject;
364 let subject = if placed {
365 local_subject = TriMesh::new(
366 subject
367 .positions
368 .iter()
369 .map(|&p| inverse.transform_point3(p))
370 .collect(),
371 subject.indices.clone(),
372 );
373 &local_subject
374 } else {
375 subject
376 };
377 let local = match tool {
378 ClipTool::Unbounded(half_space) => {
379 axiolid_construct::half_space::for_subject(subject, half_space, tolerance)?
380 }
381 ClipTool::Bounded {
382 half_space,
383 boundary,
384 placement: frame,
385 prism_axis,
386 } => {
387 let Some(GeometryNode::HalfSpace(hs)) = graph.get(half_space) else {
388 return Err(GeomError::InvalidInput(format!(
389 "half-space {half_space:?} is not a HalfSpace node"
390 )));
391 };
392 let normal = hs.boundary.normal.normalize_or_zero();
393 if normal == Vec3::ZERO || subject.positions.is_empty() {
394 return Err(GeomError::InvalidInput(
395 "bounded half-space boolean needs a plane and a subject with bounds".into(),
396 ));
397 }
398 let kept = if hs.agreement { normal } else { -normal };
399 let (mut lo, mut hi) = (
400 Vec3::splat(Scalar::INFINITY),
401 Vec3::splat(Scalar::NEG_INFINITY),
402 );
403 let mut farthest = Scalar::NEG_INFINITY;
404 for &point in &subject.positions {
405 if !point.is_finite() {
406 return Err(GeomError::InvalidInput(
407 "bounded half-space subject contains non-finite points".into(),
408 ));
409 }
410 lo = lo.min(point);
411 hi = hi.max(point);
412 farthest = farthest.max((point - hs.boundary.origin).dot(kept));
413 }
414 let span = (hi - lo).max_element().max(tolerance.linear());
415 let reach = farthest.max(0.0) + 0.1 * span + tolerance.linear();
416 let local_options;
417 let options = if placed {
418 let chord = chord_error(options) / crate::deviation::stretch(placement);
419 local_options = options.clone().with_chord_error(chord).ok_or_else(|| {
420 GeomError::InvalidInput(format!(
421 "chord budget {chord} is not positive and finite"
422 ))
423 })?;
424 &local_options
425 } else {
426 options
427 };
428 self.bounded_half_space(
429 graph,
430 (half_space, boundary),
431 frame,
432 prism_axis,
433 axiolid_construct::half_space::SlabDepth::Reach(reach),
434 options,
435 )?
436 }
437 };
438 Ok(if placed {
439 channels::transform(&Built::leaf(local), placement, 0.0).mesh
440 } else {
441 local
442 })
443 }
444
445 fn region_tool_mesh(
451 &self,
452 graph: &GeometryGraph,
453 leaves: &[(ClipTool, Transform3)],
454 subject: &TriMesh,
455 options: &ExecutionOptions,
456 placed: Scalar,
457 ) -> GeomResult<Option<Built>> {
458 let mut tool: Option<Built> = None;
459 for &(leaf, placement) in leaves {
460 let around = tool.as_ref().map_or(subject, |built| &built.mesh);
461 let side = self.clip_tool_mesh(graph, leaf, placement, around, options)?;
462 tool = Some(match tool {
463 None => Built::leaf(side),
464 Some(so_far) => {
465 let cut = self
466 .boolean_of(
467 &so_far,
468 (&side, None, None),
469 axiolid_core::BooleanOperator::Intersection,
470 options,
471 placed,
472 )
473 .map_err(CutRefusal::into_error)?;
474 if let Some(skin) = cut.skin {
475 return Err(skin.error);
476 }
477 if cut.built.mesh.indices.is_empty() {
478 return Ok(None);
479 }
480 cut.built
481 }
482 });
483 }
484 Ok(tool)
485 }
486
487 fn surface_of(
493 graph: &GeometryGraph,
494 id: axiolid_model::NodeId,
495 ) -> GeomResult<&axiolid_surface::Surface> {
496 match graph.get(id) {
497 Some(GeometryNode::Surface(surface)) => Ok(surface),
498 Some(GeometryNode::SurfaceRelation(_)) => Err(GeomError::UnsupportedInput {
501 backend: crate::BACKEND_ID,
502 operation: axiolid_contracts::Operation::SurfaceEvaluation,
503 input: "a surface relation as a reference surface",
504 }),
505 Some(_) => Err(GeomError::InvalidInput(format!(
506 "reference surface {id:?} is not a Surface node"
507 ))),
508 None => Err(GeomError::InvalidInput(format!(
509 "reference surface {id:?} does not belong to this graph"
510 ))),
511 }
512 }
513
514 fn surface_normals(
515 &self,
516 graph: &GeometryGraph,
517 id: NodeId,
518 path: &[Point3],
519 options: &ExecutionOptions,
520 ) -> GeomResult<Vec<axiolid_core::Vec3>> {
521 if let Some(GeometryNode::SurfaceRelation(
522 axiolid_model::SurfaceRelation::LinearExtrusion { direction, .. },
523 )) = graph.get(id)
524 {
525 return axiolid_construct::sweep::linear_extrusion_normals(path, *direction);
526 }
527 let surface = Self::surface_of(graph, id)?;
528 path.iter()
529 .map(|point| {
530 let (u, v) =
531 axiolid_reference::surface::invert(surface, *point, options.tolerance())?;
532 axiolid_reference::surface::normal(surface, u, v)
533 })
534 .collect()
535 }
536
537 fn rings_of(
539 &self,
540 graph: &GeometryGraph,
541 id: NodeId,
542 chord: Scalar,
543 options: &ExecutionOptions,
544 what: &str,
545 ) -> GeomResult<axiolid_construct::profile::Rings> {
546 let node = self.node(graph, id)?;
547 let GeometryNode::Profile(shape) = node else {
548 return Err(GeomError::InvalidInput(format!(
549 "{what} {id:?} is not a Profile node"
550 )));
551 };
552 profile_rings(shape, chord, options.tolerance())
553 }
554
555 fn directrix_sampled(
558 &self,
559 graph: &GeometryGraph,
560 id: NodeId,
561 range: Option<(Scalar, Scalar)>,
562 options: &ExecutionOptions,
563 chord: Scalar,
564 ) -> GeomResult<axiolid_construct::sweep::SampledPath> {
565 let options = options.clone().with_chord_error(chord).ok_or_else(|| {
566 GeomError::InvalidInput(format!(
567 "directrix chord budget {chord} is not positive and finite"
568 ))
569 })?;
570 crate::directrix::sampled(graph, id, range, &options)
571 }
572
573 fn directrix_points(
581 &self,
582 graph: &GeometryGraph,
583 id: NodeId,
584 range: Option<(Scalar, Scalar)>,
585 options: &ExecutionOptions,
586 ) -> GeomResult<Vec<Point3>> {
587 crate::directrix::points(graph, id, range, options)
588 }
589
590 fn node<'g>(&self, graph: &'g GeometryGraph, id: NodeId) -> GeomResult<&'g GeometryNode> {
591 graph.get(id).ok_or_else(|| {
592 GeomError::InvalidInput(format!("node {id:?} does not belong to this graph"))
593 })
594 }
595
596 fn mesh_dependencies(
602 &self,
603 graph: &GeometryGraph,
604 regions: &Regions<'_>,
605 node: &GeometryNode,
606 key: EvalKey,
607 ) -> GeomResult<Vec<EvalKey>> {
608 let budget = key.budget();
609 let same = |id| EvalKey::new(id, budget);
610 if let Some((source, transform)) = placed_source(graph, node, key.id)? {
615 return Ok(vec![EvalKey::new(
616 source,
617 instance_local_budget(transform, budget, 0.0)?,
618 )]);
619 }
620 Ok(match node {
621 GeometryNode::Collection(members) => members.iter().copied().map(same).collect(),
622 GeometryNode::SolidOperation(SolidOperation::Boolean {
623 left,
624 right,
625 operator,
626 }) => {
627 if let Some(clip) = clip_of(graph, regions, (*left, *right), *operator)? {
628 vec![same(clip.subject)]
630 } else if regions.has(*left) || regions.has(*right) {
631 Vec::new()
634 } else {
635 vec![same(*left), same(*right)]
636 }
637 }
638 _ => Vec::new(),
639 })
640 }
641
642 fn evaluate(
644 &self,
645 graph: &GeometryGraph,
646 root: NodeId,
647 options: &ExecutionOptions,
648 cache: &mut Cache,
649 measure: Measure<'_>,
650 ) -> GeomResult<Built> {
651 let root_key = EvalKey::new(root, Budget::of(options));
652 let mut stack = vec![Step::Enter(root_key)];
653 let mut rooms: std::collections::HashMap<EvalKey, Scalar> =
657 std::collections::HashMap::new();
658 let mut accuracies: std::collections::HashMap<EvalKey, PlacementAccuracy> =
661 std::collections::HashMap::new();
662 while let Some(step) = stack.pop() {
663 match step {
664 Step::Enter(key) => {
665 if cache.contains_key(&key) {
666 continue;
667 }
668 let node = self.node(graph, key.id)?;
669 let deps = self.mesh_dependencies(
670 graph,
671 &Regions::new(graph, &cache.regions),
672 node,
673 key,
674 )?;
675 stack.push(Step::Exit(key));
677 for dep in deps {
678 if !cache.contains_key(&dep) {
679 stack.push(Step::Enter(dep));
680 }
681 }
682 }
683 Step::Exit(key) => {
684 if cache.contains_key(&key) {
685 continue;
686 }
687 let budget = key.budget();
688 let node = self.node(graph, key.id)?;
689 if matches!(node, GeometryNode::InstanceAtStation(_)) {
690 if let std::collections::hash_map::Entry::Vacant(slot) =
691 accuracies.entry(key)
692 {
693 slot.insert(crate::station::placement_accuracy(
694 graph,
695 key.id,
696 crate::station::CERTIFICATION_SHARE * budget.chord,
697 )?);
698 }
699 }
700 let accuracy = accuracies.get(&key).copied();
701 if let std::collections::hash_map::Entry::Vacant(slot) = rooms.entry(key) {
702 if let Some((source, transform, room)) =
703 self.placement_retry(graph, node, key, cache, accuracy)?
704 {
705 slot.insert(room);
706 stack.push(Step::Exit(key));
707 stack.push(Step::Enter(EvalKey::new(
708 source,
709 instance_local_budget(transform, budget, room)?,
710 )));
711 continue;
712 }
713 }
714 let room = rooms.get(&key).copied().unwrap_or(0.0);
715 let local_options = options
716 .clone()
717 .with_tolerance(budget.tolerance)
718 .with_chord_error(budget.chord)
719 .ok_or_else(|| {
720 GeomError::InvalidInput(format!(
721 "chord budget {} is not positive and finite",
722 budget.chord
723 ))
724 })?;
725 let built = self.build(
726 graph,
727 (key, room, accuracy),
728 &local_options,
729 (cache, &Regions::new(graph, &cache.regions)),
730 (measure, root),
731 )?;
732 cache.insert(key, built);
733 }
734 }
735 }
736 cache
737 .get(&root_key)
738 .cloned()
739 .ok_or_else(|| GeomError::InvalidInput(format!("root {root:?} produced no mesh")))
740 }
741
742 fn placement_retry(
753 &self,
754 graph: &GeometryGraph,
755 node: &GeometryNode,
756 key: EvalKey,
757 cache: &Cache,
758 accuracy: Option<PlacementAccuracy>,
759 ) -> GeomResult<Option<(NodeId, Transform3, Scalar)>> {
760 let Some((source_id, transform)) = placed_source(graph, node, key.id)? else {
761 return Ok(None);
762 };
763 let budget = key.budget();
764 let source = self.cached(
765 cache,
766 source_id,
767 instance_local_budget(transform, budget, 0.0)?,
768 )?;
769 let rounding = placed_rounding(&source.mesh, transform, budget.chord)?;
772 let shown = rounding + frame_term(accuracy, &source.mesh).unwrap_or(0.0);
773 if shown <= placement_room(transform, budget.chord, 0.0)? || shown >= budget.chord {
774 return Ok(None);
775 }
776 let Some(bound) = source.deviation.bound() else {
777 return Ok(None);
778 };
779 let factor = crate::deviation::stretch(transform);
780 if crate::deviation::grown(bound, factor, shown) <= budget.chord {
781 return Ok(None);
782 }
783 let room = (shown * 1.125).min(shown + (budget.chord - shown) * 0.5);
784 Ok(Some((source_id, transform, room)))
785 }
786
787 fn build(
793 &self,
794 graph: &GeometryGraph,
795 (key, room, accuracy): (EvalKey, Scalar, Option<PlacementAccuracy>),
796 options: &ExecutionOptions,
797 (cache, regions): (&Cache, &Regions<'_>),
798 (measure, root): (Measure<'_>, NodeId),
799 ) -> GeomResult<Built> {
800 let id = key.id;
801 let budget = key.budget();
804 let node = self.node(graph, id)?;
805 match node {
806 GeometryNode::TriMesh(mesh) => Ok(authored_mesh(mesh.clone(), options)),
807 GeometryNode::PolygonMesh(mesh) => {
808 compile_authored_polygons(mesh, options).map(|(mesh, warp, steiner_offset)| {
809 let built = authored_mesh(mesh, options);
810 let mut deviation = built.deviation.clone();
811 for (detail, bound) in [
812 (crate::deviation::WARPED_AUTHORED_FACE, warp),
813 (crate::deviation::KEYHOLE_STEINER_POINT, steiner_offset),
814 ] {
815 if let Some(bound) = bound {
816 deviation.add(
817 crate::deviation::DeviationPath::AuthoredMesh,
818 detail,
819 crate::deviation::DeviationBound::Certified(bound),
820 );
821 }
822 }
823 built.with_deviation(deviation)
824 })
825 }
826 GeometryNode::Instance(instance) => {
827 let source_budget = instance_local_budget(instance.transform, budget, room)?;
828 let source = self.cached(cache, instance.source, source_budget)?;
829 let rounding = placed_rounding(&source.mesh, instance.transform, budget.chord)?;
830 Ok(channels::transform(source, instance.transform, rounding))
831 }
832 GeometryNode::InstanceAtStation(placed) => {
836 let placement = crate::station::placement(graph, id)?;
837 let source_budget = instance_local_budget(placement.transform, budget, room)?;
838 let source = self.cached(cache, placed.source, source_budget)?;
839 let rounding = placed_rounding(&source.mesh, placement.transform, budget.chord)?;
840 let accuracy = accuracy.ok_or_else(|| {
841 GeomError::InvalidInput(format!(
842 "node {id:?} placed at a station was built before its accuracy was read"
843 ))
844 })?;
845 let unbounded = |reason| {
846 let mut built = channels::transform(source, placement.transform, rounding);
847 built.deviation.add(
848 crate::deviation::DeviationPath::StationPlacement,
849 crate::deviation::NUMERICAL_STATION_FRAME,
850 crate::deviation::DeviationBound::Unbounded(reason),
851 );
852 built
853 };
854 Ok(match accuracy {
855 PlacementAccuracy::Exact => {
856 channels::transform(source, placement.transform, rounding)
857 }
858 PlacementAccuracy::Certified(_) => {
859 match frame_term(Some(accuracy), &source.mesh) {
860 Some(frame) => channels::transform_within(
861 source,
862 placement.transform,
863 rounding,
864 frame,
865 ),
866 None => unbounded(UNCERTIFIED_STATION_FRAME),
867 }
868 }
869 PlacementAccuracy::Unbounded(reason) => unbounded(reason),
870 })
871 }
872 GeometryNode::Collection(members) => {
873 let members = members
874 .iter()
875 .map(|&member| self.cached(cache, member, budget))
876 .collect::<GeomResult<Vec<_>>>()?;
877 Ok(channels::merge(&members))
878 }
879 GeometryNode::SolidOperation(SolidOperation::Boolean {
880 left,
881 right,
882 operator,
883 }) => {
884 let (built, decided) = self.build_boolean(
885 graph,
886 (id, (*left, *right)),
887 *operator,
888 options,
889 budget,
890 (cache, regions, root),
891 )?;
892 Ok(match measure {
893 Measure::Deviation(_) | Measure::Skip if decided == Decided::Referee => built,
896 Measure::Deviation(emitted) if emitted.contains(&id) => {
900 let mut deviation =
901 crate::deviation::of_boolean(graph, id, options, &built.mesh);
902 deviation.carry_perturbations(&built.deviation);
903 built.with_deviation(deviation)
904 }
905 Measure::Deviation(_) | Measure::Skip => built,
906 })
907 }
908 GeometryNode::SolidOperation(operation) => {
909 let (mesh, deviation) = self.build_solid_within(graph, operation, options)?;
910 Ok(Built::leaf(mesh).with_deviation(deviation))
911 }
912 GeometryNode::BRep(brep) => {
913 crate::brep::tessellate(brep, graph, options.tolerance(), chord_error(options)).map(
914 |(mesh, closure, deviation)| {
915 Built::with_closure(mesh, closure).with_deviation(deviation)
916 },
917 )
918 }
919 GeometryNode::SurfaceRelation(axiolid_model::SurfaceRelation::CurveBounded {
921 basis,
922 boundaries,
923 implicit_outer,
924 }) => crate::bounded::curve_bounded(
925 self.descriptor().id,
926 graph,
927 *basis,
928 boundaries,
929 *implicit_outer,
930 options,
931 )
932 .map(|(mesh, merged)| {
933 Built::with_closure(mesh, axiolid_mesh_compile_contract::MeshClosure::Surface)
934 .with_deviation(crate::deviation::of_curve_bounded(
935 graph, *basis, boundaries, options, merged,
936 ))
937 }),
938 GeometryNode::SurfaceRelation(axiolid_model::SurfaceRelation::SectionedSurface {
940 directrix,
941 sections,
942 frame,
943 }) => {
944 let sections: Vec<_> = sections.iter().cloned().map(Into::into).collect();
945 crate::station::sectioned_surface(graph, *directrix, §ions, *frame, options)
946 .map(|(mesh, bound)| {
947 Built::with_closure(
948 mesh,
949 axiolid_mesh_compile_contract::MeshClosure::Surface,
950 )
951 .with_deviation(crate::deviation::of_sectioned_surface(bound))
952 })
953 }
954 GeometryNode::SurfaceRelation(
956 axiolid_model::SurfaceRelation::OpenSectionsAtStations {
957 directrix,
958 sections,
959 frame,
960 },
961 ) => crate::station::sectioned_surface(graph, *directrix, sections, *frame, options)
962 .map(|(mesh, bound)| {
963 Built::with_closure(mesh, axiolid_mesh_compile_contract::MeshClosure::Surface)
964 .with_deviation(crate::deviation::of_sectioned_surface(bound))
965 }),
966 GeometryNode::SurfaceRelation(axiolid_model::SurfaceRelation::RectangularTrimmed {
968 basis,
969 u,
970 v,
971 u_sense,
972 v_sense,
973 }) => crate::trimmed::rectangular(
974 graph,
975 *basis,
976 crate::trimmed::Trim {
977 u: *u,
978 v: *v,
979 u_sense: *u_sense,
980 v_sense: *v_sense,
981 },
982 options,
983 )
984 .map(|(mesh, family, bound)| {
985 Built::with_closure(mesh, axiolid_mesh_compile_contract::MeshClosure::Surface)
986 .with_deviation(crate::deviation::Deviation::one(
987 crate::deviation::DeviationPath::TrimmedSurface,
988 family,
989 bound,
990 ))
991 }),
992 GeometryNode::SurfaceRelation(axiolid_model::SurfaceRelation::Ruled {
994 first,
995 second,
996 }) => crate::ruled::ruled(graph, first, second, options).map(|(mesh, bound)| {
997 Built::with_closure(mesh, axiolid_mesh_compile_contract::MeshClosure::Surface)
998 .with_deviation(crate::deviation::Deviation::one(
999 crate::deviation::DeviationPath::RuledSurface,
1000 "ruled",
1001 bound,
1002 ))
1003 }),
1004 GeometryNode::Primitive(primitive) => {
1007 axiolid_reference::primitive::tessellate_primitive(
1008 primitive,
1009 chord_tolerance(options)?,
1010 )
1011 .map(|mesh| {
1012 let deviation = crate::deviation::of_primitive(primitive, chord_error(options));
1013 Built::leaf(mesh).with_deviation(deviation)
1014 })
1015 }
1016 GeometryNode::HalfSpace(_) => Err(unsupported_input(HALF_SPACE_WITHOUT_SUBJECT)),
1019 other => Err(GeomError::Unsupported {
1020 backend: self.descriptor().id,
1021 operation: unsupported_operation(other),
1022 }),
1023 }
1024 }
1025
1026 fn cached<'c>(&self, cache: &'c Cache, id: NodeId, budget: Budget) -> GeomResult<&'c Built> {
1028 cache.get(&EvalKey::new(id, budget)).ok_or_else(|| {
1029 GeomError::InvalidInput(format!(
1030 "dependency {id:?} was not evaluated first at {budget:?}"
1031 ))
1032 })
1033 }
1034
1035 fn build_boolean(
1069 &self,
1070 graph: &GeometryGraph,
1071 (id, (left, right)): (NodeId, (NodeId, NodeId)),
1072 operator: axiolid_core::BooleanOperator,
1073 options: &ExecutionOptions,
1074 budget: Budget,
1075 (cache, regions, root): (&Cache, &Regions<'_>, NodeId),
1076 ) -> GeomResult<(Built, Decided)> {
1077 let clip = clip_of(graph, regions, (left, right), operator)?;
1081 if clip.is_none() && (regions.has(left) || regions.has(right)) {
1082 return Err(unsupported_input(UNCLIPPED_HALF_SPACE));
1083 }
1084 let (left, right) = clip
1085 .as_ref()
1086 .map_or((left, right), |clip| (clip.subject, clip.tool));
1087 let subject = self.cached(cache, left, budget)?;
1088 refuse_surface_operand(subject, "subject")?;
1089 let built_tool = match &clip {
1091 Some(_) => None,
1092 None => {
1093 let built = self.cached(cache, right, budget)?;
1094 refuse_surface_operand(built, "tool")?;
1095 Some(built)
1096 }
1097 };
1098 let identity = crate::empty::identity(
1101 operator,
1102 subject.mesh.indices.is_empty(),
1103 built_tool.is_some_and(|built| built.mesh.indices.is_empty()),
1104 );
1105 if identity.is_none() {
1106 if let Ok(Some(built)) = self.boolean_in_shared_frame(
1107 graph,
1108 (left, right),
1109 operator,
1110 options,
1111 budget,
1112 (cache, regions),
1113 ) {
1114 return Ok((built, Decided::Mesh));
1115 }
1116 if let Ok(Some(built)) = self.clip_in_subject_frame(
1120 graph,
1121 (left, right),
1122 operator,
1123 options,
1124 budget,
1125 (cache, regions),
1126 ) {
1127 return Ok((built, Decided::Mesh));
1128 }
1129 }
1130 let region_tool = match &clip {
1131 Some(clip) if identity.is_none() => {
1132 self.region_tool_mesh(graph, &clip.leaves, &subject.mesh, options, budget.placed)?
1133 }
1134 _ => None,
1135 };
1136 let identity = match (&clip, ®ion_tool, identity) {
1139 (Some(_), None, None) => crate::empty::identity(operator, false, true),
1140 (.., identity) => identity,
1141 };
1142 let nothing = TriMesh::default();
1146 let (tool, tool_fates, tool_deviation) = match (®ion_tool, built_tool, &clip) {
1147 (Some(built), _, Some(clip)) => (
1148 &built.mesh,
1149 None,
1150 (clip.leaves.len() > 1).then_some(&built.deviation),
1151 ),
1152 (None, Some(built), _) => (&built.mesh, Some(&built.fates), Some(&built.deviation)),
1153 _ => (¬hing, None, None),
1154 };
1155 let cut = match identity {
1156 Some(identity) => {
1157 identity_cut(identity, subject, built_tool).map_err(CutRefusal::Other)
1158 }
1159 None => self.boolean_of(
1160 subject,
1161 (tool, tool_fates, tool_deviation),
1162 operator,
1163 options,
1164 budget.placed,
1165 ),
1166 };
1167 let half_space_tool = clip.is_some();
1173 let referee = |reason: &mut Option<String>,
1174 against: Option<crate::referee::Against<'_>>,
1175 rounding: Option<Scalar>| {
1176 if half_space_tool && rounding.is_none() {
1182 *reason = Some("the tool is a half-space clipped to the subject".to_owned());
1183 return Ok(None);
1184 }
1185 match crate::referee::boolean(
1186 graph,
1187 id,
1188 operator,
1189 options,
1190 (subject, built_tool),
1191 against,
1192 rounding,
1193 ) {
1194 Ok(built) => Ok::<_, GeomError>(Some(built)),
1195 Err(why) => {
1196 *reason = Some(why);
1197 Ok(None)
1198 }
1199 }
1200 };
1201 let mut reason = None;
1202 match cut {
1203 Ok(MeshCut {
1208 mut built,
1209 skin: Some(skin),
1210 ..
1211 }) => {
1212 if clip_chain_continues(graph, regions, id, root) {
1213 built.pending_skin = Some(skin);
1214 return Ok((built, Decided::Mesh));
1215 }
1216 match referee(&mut reason, None, Some(skin.reach))? {
1217 Some(decided) => Ok((decided, Decided::Referee)),
1218 None => Err(refused_by_referee(skin.error, reason)),
1219 }
1220 }
1221 Ok(cut) if !cut.unsettled => Ok((cut.built, Decided::Mesh)),
1222 Ok(cut) => Ok(
1223 match referee(
1224 &mut reason,
1225 (operator != axiolid_core::BooleanOperator::Union).then(|| {
1226 crate::referee::Against {
1227 mesh: &cut.built.mesh,
1228 reach: crate::snap::reach(
1229 &subject.mesh,
1230 tool,
1231 options.tolerance(),
1232 budget.placed,
1233 ),
1234 }
1235 }),
1236 None,
1237 )? {
1238 Some(built) => (built, Decided::Referee),
1239 None => (cut.built, Decided::Mesh),
1240 },
1241 ),
1242 Err(CutRefusal::Contact(error))
1245 if operator != axiolid_core::BooleanOperator::Union
1246 && subject.pending_skin.is_some() =>
1247 {
1248 let reach =
1249 crate::snap::reach(&subject.mesh, tool, options.tolerance(), budget.placed);
1250 match referee(&mut reason, None, Some(reach))? {
1251 Some(decided) => Ok((decided, Decided::Referee)),
1252 None => Err(refused_by_referee(error, reason)),
1253 }
1254 }
1255 Err(CutRefusal::Contact(error)) if operator != axiolid_core::BooleanOperator::Union => {
1258 Err(error)
1259 }
1260 Err(CutRefusal::Contact(error)) => match referee(&mut reason, None, None)? {
1261 Some(built) => Ok((built, Decided::Referee)),
1262 None => Err(refused_by_referee(error, reason)),
1263 },
1264 Err(CutRefusal::Other(error)) => Err(error),
1265 }
1266 }
1267
1268 fn frame_of<'c>(
1275 &self,
1276 graph: &GeometryGraph,
1277 id: NodeId,
1278 budget: Budget,
1279 cache: &'c Cache,
1280 ) -> GeomResult<Option<(Transform3, &'c Built)>> {
1281 let node = self.node(graph, id)?;
1282 if let GeometryNode::Instance(instance) = node {
1283 if self
1284 .placement_retry(graph, node, EvalKey::new(id, budget), cache, None)?
1285 .is_some()
1286 {
1287 return Ok(None);
1288 }
1289 let source_budget = instance_local_budget(instance.transform, budget, 0.0)?;
1290 let source = self.cached(cache, instance.source, source_budget)?;
1291 return Ok(Some((instance.transform, source)));
1292 }
1293 Ok(self
1294 .cached(cache, id, budget)?
1295 .local
1296 .as_deref()
1297 .map(|local| (local.transform, &local.built)))
1298 }
1299
1300 fn boolean_in_shared_frame(
1319 &self,
1320 graph: &GeometryGraph,
1321 (left, right): (NodeId, NodeId),
1322 operator: axiolid_core::BooleanOperator,
1323 options: &ExecutionOptions,
1324 budget: Budget,
1325 (cache, regions): (&Cache, &Regions<'_>),
1326 ) -> GeomResult<Option<Built>> {
1327 if clip_of(graph, regions, (left, right), operator)?.is_some() {
1328 return Ok(None);
1329 }
1330 let Some((subject_frame, subject)) = self.frame_of(graph, left, budget, cache)? else {
1331 return Ok(None);
1332 };
1333 let Some((tool_frame, tool)) = self.frame_of(graph, right, budget, cache)? else {
1334 return Ok(None);
1335 };
1336 if !crate::frame::shared_linear(subject_frame, tool_frame) {
1337 return Ok(None);
1338 }
1339 if let (Some(a), Some(b)) = (
1343 chain_linear(graph, left, subject_frame),
1344 chain_linear(graph, right, tool_frame),
1345 ) {
1346 if !crate::frame::shared_linear(a, b) {
1347 return Ok(None);
1348 }
1349 }
1350 let offset = crate::frame::relative_offset(subject_frame, tool_frame).ok_or_else(|| {
1351 GeomError::InvalidInput("operand translations have no finite offset".into())
1352 })?;
1353 let shifted;
1354 let tool = if offset == Vec3::ZERO {
1355 tool
1356 } else {
1357 let shift = Transform3::from_translation(offset);
1358 let rounding = crate::deviation::placement_rounding(&tool.mesh.positions, shift);
1359 shifted = channels::transform(tool, shift, rounding);
1360 &shifted
1361 };
1362 let local_budget = instance_local_budget(subject_frame, budget, 0.0)?;
1363 let tool_budget = instance_local_budget(tool_frame, budget, 0.0)?;
1364 let local_options = local_options(options, local_budget)?;
1365 let local = self
1366 .boolean_of(
1367 subject,
1368 (&tool.mesh, Some(&tool.fates), Some(&tool.deviation)),
1369 operator,
1370 &local_options,
1371 local_budget.placed.max(tool_budget.placed),
1372 )
1373 .map_err(CutRefusal::into_error)?;
1374 if let Some(skin) = local.skin {
1376 return Err(skin.error);
1377 }
1378 let local = local.built;
1379 let rounding = placed_rounding(&local.mesh, subject_frame, budget.chord)?;
1380 let mut placed = channels::transform(&local, subject_frame, rounding);
1381 if operator == axiolid_core::BooleanOperator::Union {
1386 let eps = options.tolerance().linear();
1387 if let Some(found) = crate::contact::unmerged(&placed.mesh, eps) {
1388 return Err(crate::contact::refusal(&found, eps));
1389 }
1390 }
1391 placed.local = Some(Box::new(channels::LocalBoolean {
1392 transform: subject_frame,
1393 built: local,
1394 }));
1395 Ok(Some(placed))
1396 }
1397
1398 fn clip_in_subject_frame(
1416 &self,
1417 graph: &GeometryGraph,
1418 (left, right): (NodeId, NodeId),
1419 operator: axiolid_core::BooleanOperator,
1420 options: &ExecutionOptions,
1421 budget: Budget,
1422 (cache, regions): (&Cache, &Regions<'_>),
1423 ) -> GeomResult<Option<Built>> {
1424 let Some(clip) = clip_of(graph, regions, (left, right), operator)? else {
1425 return Ok(None);
1426 };
1427 let Some((frame, subject)) = self.frame_of(graph, left, budget, cache)? else {
1428 return Ok(None);
1429 };
1430 let mut leaves = Vec::with_capacity(clip.leaves.len());
1433 for &(leaf, chain) in &clip.leaves {
1434 let Some(relative) = crate::frame::relative(frame, chain) else {
1435 return Ok(None);
1436 };
1437 leaves.push((leaf, relative));
1438 }
1439 let local_budget = instance_local_budget(frame, budget, 0.0)?;
1440 let local_options = local_options(options, local_budget)?;
1441 let Some(tool) = self.region_tool_mesh(
1444 graph,
1445 &leaves,
1446 &subject.mesh,
1447 &local_options,
1448 local_budget.placed,
1449 )?
1450 else {
1451 return Ok(None);
1452 };
1453 let local = self
1454 .boolean_of(
1455 subject,
1456 (
1457 &tool.mesh,
1458 None,
1459 (leaves.len() > 1).then_some(&tool.deviation),
1460 ),
1461 operator,
1462 &local_options,
1463 local_budget.placed,
1464 )
1465 .map_err(CutRefusal::into_error)?;
1466 if let Some(skin) = local.skin {
1467 return Err(skin.error);
1468 }
1469 let local = local.built;
1470 let rounding = placed_rounding(&local.mesh, frame, budget.chord)?;
1471 let mut placed = channels::transform(&local, frame, rounding);
1472 placed.local = Some(Box::new(channels::LocalBoolean {
1473 transform: frame,
1474 built: local,
1475 }));
1476 Ok(Some(placed))
1477 }
1478
1479 fn boolean_of(
1483 &self,
1484 subject: &Built,
1485 (tool, tool_fates, tool_deviation): (
1486 &TriMesh,
1487 Option<&channels::Fates>,
1488 Option<&crate::deviation::Deviation>,
1489 ),
1490 operator: axiolid_core::BooleanOperator,
1491 options: &ExecutionOptions,
1492 placed: Scalar,
1493 ) -> Result<MeshCut, CutRefusal> {
1494 let reach = if operator == axiolid_core::BooleanOperator::Union {
1502 crate::snap::contact_reach(options.tolerance())
1503 } else {
1504 crate::snap::reach(&subject.mesh, tool, options.tolerance(), placed)
1505 };
1506 let settled = crate::snap::settle(
1507 &subject.mesh,
1508 tool,
1509 reach,
1510 operator != axiolid_core::BooleanOperator::Union,
1511 );
1512 let snapped = if settled.moved > 0.0 {
1513 self.cut(
1518 settled.subject.as_ref().unwrap_or(&subject.mesh),
1519 settled.tool.as_ref().unwrap_or(tool),
1520 operator,
1521 options,
1522 reach,
1523 )
1524 .ok()
1525 .filter(|(_, skin)| skin.is_none())
1526 } else {
1527 None
1528 };
1529 let ((outcome, skin), moved) = match snapped {
1530 Some(cut) => (cut, settled.moved),
1531 None => (
1532 self.cut(&subject.mesh, tool, operator, options, reach)?,
1533 0.0,
1534 ),
1535 };
1536 let snapped_pair = moved > 0.0;
1541 let cut_subject = settled
1542 .subject
1543 .as_ref()
1544 .filter(|_| snapped_pair)
1545 .unwrap_or(&subject.mesh);
1546 let cut_tool = settled
1547 .tool
1548 .as_ref()
1549 .filter(|_| snapped_pair)
1550 .unwrap_or(tool);
1551 let eps = options.tolerance().linear();
1552 let unsettled = eps > 0.0
1559 && (operator == axiolid_core::BooleanOperator::Union
1560 || crate::deviation::geometric_components(&outcome.mesh) > 1)
1561 && axiolid_inspect::near_coplanar(cut_subject, cut_tool, eps).is_some();
1562 let mut built = channels::after_boolean(
1563 outcome.mesh,
1564 &subject.fates,
1565 tool_fates,
1566 outcome.evidence.attribute_fates,
1567 );
1568 built.deviation.carry_perturbations(&subject.deviation);
1569 if let Some(tool) = tool_deviation {
1570 built.deviation.carry_perturbations(tool);
1571 }
1572 if moved > 0.0 {
1573 built.deviation.add(
1574 crate::deviation::DeviationPath::Boolean,
1575 crate::deviation::SNAPPED_OPERANDS,
1576 crate::deviation::DeviationBound::Certified(moved),
1577 );
1578 }
1579 Ok(MeshCut {
1580 built,
1581 unsettled,
1582 skin,
1583 })
1584 }
1585
1586 fn cut(
1595 &self,
1596 subject: &TriMesh,
1597 tool: &TriMesh,
1598 operator: axiolid_core::BooleanOperator,
1599 options: &ExecutionOptions,
1600 reach: Scalar,
1601 ) -> Result<
1602 (
1603 axiolid_mesh_boolean_contract::BooleanOutcome,
1604 Option<PendingSkin>,
1605 ),
1606 CutRefusal,
1607 > {
1608 let outcome = self
1609 .boolean
1610 .boolean(subject, tool, operator, options)
1611 .map_err(CutRefusal::Other)?;
1612 if let Some(pinch) = crate::pinch::find(&outcome.mesh) {
1613 return Err(CutRefusal::Contact(GeomError::Degenerate(match pinch {
1614 crate::pinch::Pinch::Edge { from, to } => format!(
1615 "{operator:?} result touches itself along the edge {from:?} to {to:?}: \
1616 the operands meet tangentially there (a void tangent to its host's face), \
1617 leaving no material between two faces"
1618 ),
1619 crate::pinch::Pinch::Vertex { at } => format!(
1620 "{operator:?} result touches itself at the point {at:?}: the operands \
1621 meet tangentially there, leaving no material between two faces"
1622 ),
1623 })));
1624 }
1625 if operator == axiolid_core::BooleanOperator::Union {
1626 let eps = options.tolerance().linear();
1627 if let Some(found) = crate::contact::unmerged(&outcome.mesh, eps) {
1628 return Err(CutRefusal::Contact(crate::contact::refusal(&found, eps)));
1629 }
1630 }
1631 let skin = if matches!(
1635 operator,
1636 axiolid_core::BooleanOperator::Difference | axiolid_core::BooleanOperator::Intersection
1637 ) {
1638 axiolid_inspect::thin_sheet(&outcome.mesh, reach).map(|found| PendingSkin {
1639 error: crate::skin::refusal(&found, &outcome.mesh, reach, operator),
1640 reach,
1641 })
1642 } else {
1643 None
1644 };
1645 Ok((outcome, skin))
1646 }
1647
1648 fn build_solid_within(
1656 &self,
1657 graph: &GeometryGraph,
1658 operation: &SolidOperation,
1659 options: &ExecutionOptions,
1660 ) -> GeomResult<(TriMesh, crate::deviation::Deviation)> {
1661 if let Some(run) = crate::deviation::stationed_spine(graph, operation, options) {
1663 return run;
1664 }
1665 let mut mesh = self.build_solid(graph, operation, options)?;
1666 let mut deviation = crate::deviation::of_solid(graph, operation, options, options, &mesh)?;
1667 if !matches!(operation, SolidOperation::SweptDisk { .. }) {
1668 return Ok((mesh, deviation));
1669 }
1670 let budget = chord_error(options);
1671 let mut chord = budget;
1672 for _ in 0..REFINE_ROUNDS {
1673 let Some(over) = deviation.certified_over(budget) else {
1674 break;
1675 };
1676 chord *= REFINE_MARGIN * budget / over;
1677 let Some(finer) = options.clone().with_chord_error(chord) else {
1678 break;
1679 };
1680 let refined = self.build_solid(graph, operation, &finer)?;
1681 let certified =
1682 crate::deviation::of_solid(graph, operation, options, &finer, &refined)?;
1683 match certified.certified_over(budget) {
1684 Some(next) if next >= over => break,
1686 _ => (mesh, deviation) = (refined, certified),
1687 }
1688 }
1689 Ok((mesh, deviation))
1690 }
1691
1692 fn build_solid(
1696 &self,
1697 graph: &GeometryGraph,
1698 operation: &SolidOperation,
1699 options: &ExecutionOptions,
1700 ) -> GeomResult<TriMesh> {
1701 match operation {
1702 SolidOperation::Extrusion {
1703 profile,
1704 direction,
1705 depth,
1706 } => {
1707 let node = self.node(graph, *profile)?;
1708 let GeometryNode::Profile(shape) = node else {
1709 return Err(GeomError::InvalidInput(format!(
1710 "extrusion profile {profile:?} is not a Profile node"
1711 )));
1712 };
1713 let rings = profile_rings(shape, chord_error(options), options.tolerance())?;
1714 extrude_profile(&rings, *direction, *depth, options.tolerance())
1715 }
1716 SolidOperation::Revolution {
1717 profile,
1718 axis_origin,
1719 axis_direction,
1720 angle,
1721 } => {
1722 let node = self.node(graph, *profile)?;
1723 let GeometryNode::Profile(shape) = node else {
1724 return Err(GeomError::InvalidInput(format!(
1725 "revolution profile {profile:?} is not a Profile node"
1726 )));
1727 };
1728 let half = 0.5 * chord_error(options);
1732 let rings = profile_rings(shape, half, options.tolerance())?;
1733 axiolid_construct::revolve::revolve(
1734 &rings,
1735 *axis_origin,
1736 *axis_direction,
1737 *angle,
1738 half_tolerance(options)?,
1739 )
1740 }
1741 SolidOperation::TaperedExtrusion {
1742 start_profile,
1743 end_profile,
1744 direction,
1745 depth,
1746 } => {
1747 let chord = chord_error(options);
1748 let a =
1749 self.rings_of(graph, *start_profile, chord, options, "taper start profile")?;
1750 let b = self.rings_of(graph, *end_profile, chord, options, "taper end profile")?;
1751 axiolid_construct::sweep::tapered_extrude(&a, &b, *direction, *depth)
1752 }
1753 SolidOperation::TaperedRevolution {
1754 start_profile,
1755 end_profile,
1756 axis_origin,
1757 axis_direction,
1758 angle,
1759 } => {
1760 let half = 0.5 * chord_error(options);
1761 let a =
1762 self.rings_of(graph, *start_profile, half, options, "taper start profile")?;
1763 let b = self.rings_of(graph, *end_profile, half, options, "taper end profile")?;
1764 axiolid_construct::sweep::tapered_revolve(
1765 &a,
1766 &b,
1767 *axis_origin,
1768 *axis_direction,
1769 *angle,
1770 half_tolerance(options)?,
1771 )
1772 }
1773 SolidOperation::SweptDisk {
1780 directrix,
1781 radius,
1782 inner_radius,
1783 parameter_range,
1784 fillet_radius,
1785 } => match crate::directrix::pieces(graph, *directrix, *parameter_range, options)? {
1786 Some(_) if fillet_radius.is_some() && parameter_range.is_some() => {
1787 Err(GeomError::UnsupportedInput {
1788 backend: self.descriptor().id,
1789 operation: axiolid_contracts::Operation::Sweep,
1790 input: "a parameter range on a swept disk with a fillet radius",
1791 })
1792 }
1793 Some(path) if path.closed => axiolid_construct::pipe::swept_disk_along_loop(
1794 &path.pieces,
1795 *radius,
1796 *inner_radius,
1797 *fillet_radius,
1798 chord_error(options),
1799 ),
1800 Some(path) => axiolid_construct::pipe::swept_disk_along_pieces(
1801 &path.pieces,
1802 *radius,
1803 *inner_radius,
1804 *fillet_radius,
1805 chord_error(options),
1806 ),
1807 None => {
1808 if let Some(path) = crate::directrix::exact_pieces(
1812 graph,
1813 *directrix,
1814 *parameter_range,
1815 options,
1816 )? {
1817 if let [arc @ axiolid_construct::pipe::PathPiece::Arc { .. }] =
1818 path.pieces.as_slice()
1819 {
1820 axiolid_construct::pipe::check_disk_along(arc, *radius)?;
1821 }
1822 }
1823 axiolid_construct::sweep::swept_disk_within(
1824 |chord| {
1825 self.directrix_sampled(
1826 graph,
1827 *directrix,
1828 *parameter_range,
1829 options,
1830 chord,
1831 )
1832 },
1833 *radius,
1834 *inner_radius,
1835 *fillet_radius,
1836 chord_error(options),
1837 )
1838 }
1839 },
1840 SolidOperation::FixedReferenceSweep {
1841 profile,
1842 directrix,
1843 reference_direction,
1844 parameter_range,
1845 } => {
1846 let half = 0.5 * chord_error(options);
1847 let rings = self.rings_of(graph, *profile, half, options, "sweep profile")?;
1848 if let Some(path) = crate::directrix::fixed_reference_pieces(
1853 graph,
1854 *directrix,
1855 *parameter_range,
1856 options,
1857 )? {
1858 let plan = axiolid_construct::pipe::fixed_reference_plan(
1859 &path.pieces,
1860 path.closed,
1861 *reference_direction,
1862 axiolid_construct::pipe::ring_reach(&rings),
1863 chord_error(options),
1864 options.tolerance(),
1865 )?;
1866 if let axiolid_construct::pipe::FixedReferencePlan::Untwisted(chain) = plan {
1867 return axiolid_construct::pipe::fixed_reference_along_pieces(
1868 &rings, &chain,
1869 );
1870 }
1871 }
1872 axiolid_construct::sweep::fixed_reference_sweep_within(
1873 &rings,
1874 |chord| {
1875 self.directrix_sampled(graph, *directrix, *parameter_range, options, chord)
1876 },
1877 *reference_direction,
1878 half,
1879 )
1880 }
1881 SolidOperation::SurfaceCurveSweep {
1882 profile,
1883 directrix,
1884 reference_surface,
1885 parameter_range,
1886 } => {
1887 let half = 0.5 * chord_error(options);
1888 let rings = self.rings_of(
1889 graph,
1890 *profile,
1891 half,
1892 options,
1893 "surface curve sweep profile",
1894 )?;
1895 axiolid_construct::sweep::surface_curve_sweep_within(
1896 &rings,
1897 |chord| {
1898 let path = self.directrix_sampled(
1899 graph,
1900 *directrix,
1901 *parameter_range,
1902 options,
1903 chord,
1904 )?;
1905 let normals =
1906 self.surface_normals(graph, *reference_surface, &path.points, options)?;
1907 Ok((path, normals))
1908 },
1909 half,
1910 )
1911 }
1912 SolidOperation::SectionedSpine { spine, sections } => {
1913 let path = self.directrix_points(graph, *spine, None, options)?;
1914 if sections.len() != path.len() {
1915 return Err(GeomError::InvalidInput(format!(
1916 "a sectioned spine needs one section per spine point: {} sections, {} points",
1917 sections.len(),
1918 path.len()
1919 )));
1920 }
1921 let mut placed = Vec::with_capacity(sections.len());
1922 for (section, origin) in sections.iter().zip(&path) {
1923 let rings = self.rings_of(
1924 graph,
1925 section.profile,
1926 chord_error(options),
1927 options,
1928 "spine section profile",
1929 )?;
1930 let pts = rings
1933 .outer
1934 .iter()
1935 .chain(rings.holes.iter().flatten())
1936 .map(|p| {
1937 section
1938 .placement
1939 .transform_point3(Point3::new(p.x, p.y, 0.0))
1940 + *origin
1941 })
1942 .collect();
1943 placed.push((rings, pts));
1944 }
1945 axiolid_construct::sweep::sectioned_spine(&placed)
1946 }
1947 SolidOperation::StationedSpine {
1949 directrix,
1950 sections,
1951 frame,
1952 } => {
1953 let sections: Vec<_> = sections.iter().copied().map(Into::into).collect();
1954 crate::station::stationed_spine(graph, *directrix, §ions, *frame, options)
1955 }
1956 SolidOperation::SectionsAtStations {
1958 directrix,
1959 sections,
1960 frame,
1961 } => crate::station::stationed_spine(graph, *directrix, sections, *frame, options),
1962 SolidOperation::BoundedHalfSpace { .. }
1968 | SolidOperation::BoundedHalfSpaceAlongZ { .. } => Err(GeomError::UnsupportedInput {
1969 backend: self.descriptor().id,
1970 operation: Operation::GraphCompilation,
1971 input: "bounded half-space without a subject to size it by: it is unbounded \
1972 on its kept side, and is compiled only as the tool of a difference \
1973 or an intersection",
1974 }),
1975 SolidOperation::Boolean { .. } => Err(GeomError::InvalidInput(
1977 "boolean must be built through build_boolean".into(),
1978 )),
1979 other => Err(GeomError::UnsupportedInput {
1987 backend: self.descriptor().id,
1988 operation: Operation::Sweep,
1989 input: crate::exact::solid_operation_family(other),
1990 }),
1991 }
1992 }
1993}
1994
1995fn identity_cut(
2000 identity: crate::empty::Identity,
2001 subject: &Built,
2002 tool: Option<&Built>,
2003) -> GeomResult<MeshCut> {
2004 use crate::empty::Identity;
2005 let built = match identity {
2006 Identity::Subject => {
2007 let mut built = subject.clone();
2008 let skin = built.pending_skin.take();
2009 return Ok(MeshCut {
2010 built,
2011 unsettled: false,
2012 skin,
2013 });
2014 }
2015 Identity::Tool => tool
2016 .ok_or_else(|| {
2017 GeomError::InvalidInput("boolean identity names a tool that was not built".into())
2018 })?
2019 .clone(),
2020 Identity::Empty => channels::after_boolean(
2021 TriMesh::default(),
2022 &subject.fates,
2023 tool.map(|built| &built.fates),
2024 Vec::new(),
2025 ),
2026 };
2027 Ok(MeshCut {
2028 built,
2029 unsettled: false,
2030 skin: None,
2031 })
2032}
2033
2034fn refused_by_referee(error: GeomError, reason: Option<String>) -> GeomError {
2036 match error {
2037 GeomError::Degenerate(text) => GeomError::Degenerate(format!(
2038 "{text}; the exact referee refused: {}",
2039 reason.unwrap_or_default()
2040 )),
2041 other => other,
2042 }
2043}
2044
2045fn clip_chain_continues(
2051 graph: &GeometryGraph,
2052 regions: &Regions<'_>,
2053 id: NodeId,
2054 root: NodeId,
2055) -> bool {
2056 if id == root || graph.roots().contains(&id) {
2057 return false;
2058 }
2059 let mut continues = false;
2060 for (_, node) in graph.iter() {
2061 if !node.references().contains(&id) {
2062 continue;
2063 }
2064 match node {
2065 GeometryNode::SolidOperation(SolidOperation::Boolean {
2066 left,
2067 right,
2068 operator:
2069 axiolid_core::BooleanOperator::Difference
2070 | axiolid_core::BooleanOperator::Intersection,
2071 }) if *left == id && *right != id => continues = true,
2072 GeometryNode::SolidOperation(SolidOperation::Boolean {
2074 left,
2075 right,
2076 operator: axiolid_core::BooleanOperator::Intersection,
2077 }) if *right == id && regions.has(*left) => {
2078 continues = true;
2079 }
2080 _ => return false,
2081 }
2082 }
2083 continues
2084}
2085
2086fn local_options(options: &ExecutionOptions, local_budget: Budget) -> GeomResult<ExecutionOptions> {
2089 options
2090 .clone()
2091 .with_tolerance(local_budget.tolerance)
2092 .with_chord_error(local_budget.chord)
2093 .ok_or_else(|| {
2094 GeomError::InvalidInput(format!(
2095 "chord budget {} is not positive and finite",
2096 local_budget.chord
2097 ))
2098 })
2099}
2100
2101fn instance_local_budget(
2117 transform: Transform3,
2118 budget: Budget,
2119 room: Scalar,
2120) -> GeomResult<Budget> {
2121 let (tolerance, stretch) = instance_local_tolerance(transform, budget.tolerance)?;
2122 let room = placement_room(transform, budget.chord, room)?;
2123 Ok(Budget {
2124 tolerance,
2125 chord: crate::deviation::shrunk_budget_within(budget.chord, stretch, room),
2126 placed: crate::snap::placed_scale(transform, budget.placed).ok_or_else(|| {
2127 GeomError::InvalidInput("instance translation has no finite magnitude".into())
2128 })?,
2129 })
2130}
2131
2132const ROOM_SHARE: Scalar = 1.0 / 1_048_576.0;
2137
2138pub(crate) fn placement_room(
2145 transform: Transform3,
2146 chord: Scalar,
2147 shown: Scalar,
2148) -> GeomResult<Scalar> {
2149 let floor = crate::deviation::placement_rounding(&[Point3::ZERO], transform);
2150 refuse_below_rounding(chord, floor)?;
2151 Ok((floor + (chord - floor) * ROOM_SHARE).max(shown))
2152}
2153
2154fn frame_term(accuracy: Option<PlacementAccuracy>, source: &TriMesh) -> Option<Scalar> {
2160 let Some(PlacementAccuracy::Certified(bound)) = accuracy else {
2161 return None;
2162 };
2163 let reach = source
2164 .positions
2165 .iter()
2166 .map(|p| p.x.abs() + p.y.abs() + p.z.abs())
2167 .fold(0.0, Scalar::max)
2168 * (1.0 + 1e-12);
2169 let frame = bound.displacement(Point3::new(reach, 0.0, 0.0));
2170 frame.is_finite().then_some(frame)
2171}
2172
2173fn placed_rounding(source: &TriMesh, transform: Transform3, chord: Scalar) -> GeomResult<Scalar> {
2177 let rounding = crate::deviation::placement_rounding(&source.positions, transform);
2178 refuse_below_rounding(chord, rounding)?;
2179 Ok(rounding)
2180}
2181
2182pub(crate) fn refuse_below_rounding(chord: Scalar, rounding: Scalar) -> GeomResult<()> {
2185 if rounding < chord {
2186 return Ok(());
2187 }
2188 Err(GeomError::UnsupportedInput {
2189 backend: BackendId::new("scalar-compile"),
2190 operation: Operation::GraphCompilation,
2191 input: crate::deviation::BUDGET_BELOW_PLACEMENT_ROUNDING,
2192 })
2193}
2194
2195fn placed_source(
2199 graph: &GeometryGraph,
2200 node: &GeometryNode,
2201 id: NodeId,
2202) -> GeomResult<Option<(NodeId, Transform3)>> {
2203 Ok(match node {
2204 GeometryNode::Instance(instance) => Some((instance.source, instance.transform)),
2205 GeometryNode::InstanceAtStation(placed) => Some((
2206 placed.source,
2207 crate::station::placement(graph, id)?.transform,
2208 )),
2209 _ => None,
2210 })
2211}
2212
2213pub(crate) fn instance_local_tolerance(
2214 transform: Transform3,
2215 tolerance: Tolerance,
2216) -> GeomResult<(Tolerance, Scalar)> {
2217 let m = transform.matrix3;
2218 let max_scale = m
2219 .x_axis
2220 .length()
2221 .max(m.y_axis.length())
2222 .max(m.z_axis.length());
2223 if !max_scale.is_finite() || max_scale == 0.0 {
2224 return Err(GeomError::InvalidInput(
2225 "instance transform has no finite scale".into(),
2226 ));
2227 }
2228 let stretch = crate::deviation::stretch(transform);
2229 if !stretch.is_finite() {
2230 return Err(GeomError::InvalidInput(
2231 "instance transform scale overflow".into(),
2232 ));
2233 }
2234 Tolerance::new(tolerance.linear() / stretch, tolerance.angular())
2235 .map(|local| (local, stretch))
2236 .map_err(|error| GeomError::InvalidInput(error.to_string()))
2237}
2238
2239const REFINE_ROUNDS: usize = 3;
2247
2248const REFINE_MARGIN: Scalar = 0.9;
2251
2252pub(crate) fn chord_error(options: &ExecutionOptions) -> Scalar {
2253 options
2254 .chord_error()
2255 .unwrap_or_else(|| options.tolerance().linear())
2256}
2257
2258fn chord_tolerance(options: &ExecutionOptions) -> GeomResult<Tolerance> {
2261 Tolerance::new(chord_error(options), options.tolerance().angular())
2262 .map_err(|error| GeomError::InvalidInput(error.to_string()))
2263}
2264
2265fn half_tolerance(options: &ExecutionOptions) -> GeomResult<Tolerance> {
2269 Tolerance::new(0.5 * chord_error(options), options.tolerance().angular())
2270 .map_err(|error| GeomError::InvalidInput(error.to_string()))
2271}
2272
2273const HALF_SPACE_WITHOUT_SUBJECT: &str = "half-space without a subject to size it by: it is \
2276 unbounded on its kept side, and is compiled only as the tool of a difference or an \
2277 intersection";
2278
2279const UNCLIPPED_HALF_SPACE: &str = "boolean of a half-space that is not a clip: a half-space, or \
2281 an intersection of half-spaces, is unbounded, and is compiled only as the tool of a \
2282 difference or an intersection, or as one operand of an intersection with a solid";
2283
2284fn unsupported_input(input: &'static str) -> GeomError {
2285 GeomError::UnsupportedInput {
2286 backend: crate::BACKEND_ID,
2287 operation: Operation::GraphCompilation,
2288 input,
2289 }
2290}
2291
2292struct Clip {
2295 subject: NodeId,
2296 tool: NodeId,
2297 leaves: Vec<(ClipTool, Transform3)>,
2298}
2299
2300struct Regions<'a> {
2304 graph: &'a GeometryGraph,
2305 memo: &'a crate::regions::RegionMemo,
2306}
2307
2308impl<'a> Regions<'a> {
2309 fn new(graph: &'a GeometryGraph, memo: &'a crate::regions::RegionMemo) -> Self {
2310 Self { graph, memo }
2311 }
2312
2313 fn has(&self, id: NodeId) -> bool {
2314 self.memo
2315 .has(self.graph, id, |leaf| clip_tool(self.graph, leaf).is_some())
2316 }
2317}
2318
2319fn clip_of(
2325 graph: &GeometryGraph,
2326 regions: &Regions<'_>,
2327 (left, right): (NodeId, NodeId),
2328 operator: axiolid_core::BooleanOperator,
2329) -> GeomResult<Option<Clip>> {
2330 use axiolid_core::BooleanOperator::{Difference, Intersection};
2331 if !matches!(operator, Difference | Intersection) {
2332 return Ok(None);
2333 }
2334 let (subject, tool) = match (regions.has(left), regions.has(right)) {
2335 (false, true) => (left, right),
2336 (true, false) if operator == Intersection => (right, left),
2337 _ => return Ok(None),
2338 };
2339 let leaves = region(graph, tool)?;
2340 Ok(Some(Clip {
2341 subject,
2342 tool,
2343 leaves,
2344 }))
2345}
2346
2347fn region(graph: &GeometryGraph, id: NodeId) -> GeomResult<Vec<(ClipTool, Transform3)>> {
2352 let mut leaves = Vec::new();
2353 let mut work = vec![(id, Transform3::IDENTITY)];
2354 while let Some((mut id, mut outer)) = work.pop() {
2355 loop {
2356 let node = graph.get(id).ok_or_else(|| {
2357 GeomError::InvalidInput(format!("operand {id:?} does not belong to this graph"))
2358 })?;
2359 match node {
2360 GeometryNode::Instance(instance) => {
2361 outer *= instance.transform;
2362 id = instance.source;
2363 }
2364 GeometryNode::SolidOperation(SolidOperation::Boolean {
2365 left,
2366 right,
2367 operator: axiolid_core::BooleanOperator::Intersection,
2368 }) => {
2369 work.push((*right, outer));
2370 work.push((*left, outer));
2371 break;
2372 }
2373 _ => {
2374 let tool = clip_tool(graph, id).ok_or_else(|| {
2375 GeomError::InvalidInput(format!("operand {id:?} is not a half-space"))
2376 })?;
2377 if leaves.len() >= graph.len() {
2378 return Err(unsupported_input(
2379 "half-space intersection with more half-spaces than its graph has nodes",
2380 ));
2381 }
2382 leaves.push((tool, outer));
2383 break;
2384 }
2385 }
2386 }
2387 }
2388 Ok(leaves)
2389}
2390
2391#[derive(Debug, Clone, Copy)]
2393enum ClipTool {
2394 Unbounded(axiolid_primitive::HalfSpace),
2396 Bounded {
2398 half_space: NodeId,
2399 boundary: NodeId,
2400 placement: Transform3,
2401 prism_axis: PrismAxis,
2402 },
2403}
2404
2405fn clip_tool(graph: &GeometryGraph, id: NodeId) -> Option<ClipTool> {
2408 match graph.get(id)? {
2409 GeometryNode::HalfSpace(half_space) => Some(ClipTool::Unbounded(*half_space)),
2410 GeometryNode::SolidOperation(operation) => {
2411 let (half_space, boundary, frame, prism_axis) =
2412 crate::half_space_boundary::bounded(operation)?;
2413 Some(ClipTool::Bounded {
2414 half_space,
2415 boundary,
2416 placement: frame,
2417 prism_axis,
2418 })
2419 }
2420 _ => None,
2421 }
2422}
2423
2424fn unsupported_operation(node: &GeometryNode) -> Operation {
2429 match node {
2430 GeometryNode::Curve2(_) | GeometryNode::Curve3(_) | GeometryNode::OpenProfile(_) => {
2431 Operation::CurveEvaluation
2432 }
2433 GeometryNode::Surface(_) => Operation::SurfaceEvaluation,
2434 GeometryNode::Profile(_) => Operation::ProfileTriangulation,
2435 GeometryNode::BRep(_) | GeometryNode::PolygonMesh(_) => Operation::Tessellation,
2436 _ => Operation::GraphCompilation,
2437 }
2438}
2439
2440impl<B: MeshBoolean> MeshCompiler for ReferenceMeshCompiler<B> {
2441 fn scratch_requirement(&self) -> ScratchRequirement {
2444 ScratchRequirement::Unbounded
2445 }
2446
2447 fn compile_mesh(
2448 &self,
2449 graph: &GeometryGraph,
2450 root: NodeId,
2451 options: &ExecutionOptions,
2452 ) -> GeomResult<TriMesh> {
2453 self.admit_budget(options)?;
2454 let mut cache = Cache::new();
2455 self.evaluate(graph, root, options, &mut cache, Measure::Skip)
2456 .map(|built| built.mesh)
2457 }
2458
2459 fn compile_mesh_reported(
2462 &self,
2463 graph: &GeometryGraph,
2464 root: NodeId,
2465 options: &ExecutionOptions,
2466 ) -> GeomResult<CompileOutcome> {
2467 self.admit_budget(options)?;
2468 let mut cache = Cache::new();
2469 let built = self.evaluate(graph, root, options, &mut cache, Measure::Skip)?;
2470 Ok(CompileOutcome::tracked(built.mesh, built.fates.into_vec()).with_closure(built.closure))
2471 }
2472
2473 fn compile_mesh_batch_into(
2476 &self,
2477 graph: &GeometryGraph,
2478 roots: &[NodeId],
2479 options: &ExecutionOptions,
2480 destination: &mut Vec<TriMesh>,
2481 ) -> GeomResult<()> {
2482 self.admit_budget(options)?;
2483 destination.reserve(roots.len());
2484 let mut cache = Cache::new();
2485 for &root in roots {
2486 destination.push(
2487 self.evaluate(graph, root, options, &mut cache, Measure::Skip)?
2488 .mesh,
2489 );
2490 }
2491 Ok(())
2492 }
2493}
2494
2495impl<B: MeshBoolean> PolylineCompiler for ReferenceMeshCompiler<B> {
2500 fn compile_polyline(
2501 &self,
2502 graph: &GeometryGraph,
2503 root: NodeId,
2504 options: &ExecutionOptions,
2505 ) -> GeomResult<PolylineOutcome> {
2506 crate::polyline::compile(graph, root, options)
2507 }
2508}
2509
2510impl<B: MeshBoolean> ReferenceMeshCompiler<B> {
2511 fn admit_budget(&self, options: &ExecutionOptions) -> GeomResult<()> {
2523 if self.scratch_requirement().fits_budget(options, 0) {
2524 return Ok(());
2525 }
2526 Err(GeomError::BudgetExceeded {
2527 resource: "graph compilation intermediate meshes",
2528 })
2529 }
2530
2531 pub const fn boolean_provider(&self) -> &B {
2537 &self.boolean
2538 }
2539
2540 pub fn compile_mesh_with_deviation(
2564 &self,
2565 graph: &GeometryGraph,
2566 root: NodeId,
2567 options: &ExecutionOptions,
2568 ) -> GeomResult<(CompileOutcome, crate::DeviationReport)> {
2569 self.admit_budget(options)?;
2570 let mut cache = Cache::new();
2571 let emitted = crate::deviation::emitted_booleans(graph, root);
2572 let built = self.evaluate(
2573 graph,
2574 root,
2575 options,
2576 &mut cache,
2577 Measure::Deviation(&emitted),
2578 )?;
2579 let report = built.deviation.report(chord_error(options));
2580 Ok((
2581 CompileOutcome::tracked(built.mesh, built.fates.into_vec()).with_closure(built.closure),
2582 report,
2583 ))
2584 }
2585}
2586
2587pub(crate) fn compile_authored_polygons(
2614 mesh: &axiolid_mesh::PolygonMesh,
2615 options: &ExecutionOptions,
2616) -> GeomResult<(TriMesh, Option<Scalar>, Option<Scalar>)> {
2617 let position_count = mesh.positions.len();
2618 if let Some(index) = mesh
2619 .faces
2620 .iter()
2621 .flat_map(|face| face_rings(face).flatten().copied())
2622 .find(|&index| index as usize >= position_count)
2623 {
2624 return Err(GeomError::InvalidInput(format!(
2625 "authored polygon index {index} exceeds position count {position_count}"
2626 )));
2627 }
2628
2629 let mut positions = Vec::new();
2630 positions
2631 .try_reserve_exact(position_count)
2632 .map_err(|_| GeomError::BudgetExceeded {
2633 resource: "authored polygon positions",
2634 })?;
2635 positions.extend_from_slice(&mesh.positions);
2636
2637 let index_bound = mesh
2640 .faces
2641 .iter()
2642 .try_fold(0_usize, |total, face| {
2643 let corners = face_rings(face).map(Vec::len).sum::<usize>();
2644 corners
2645 .checked_add(2 * face.holes.len())
2646 .and_then(|triangles| triangles.checked_mul(3))
2647 .and_then(|indices| total.checked_add(indices))
2648 })
2649 .ok_or(GeomError::BudgetExceeded {
2650 resource: "authored polygon indices",
2651 })?;
2652 let mut indices = Vec::new();
2653 indices
2654 .try_reserve_exact(index_bound)
2655 .map_err(|_| GeomError::BudgetExceeded {
2656 resource: "authored polygon indices",
2657 })?;
2658
2659 let linear = options.tolerance().linear();
2660 let splits = authored_edge_splits(mesh);
2661 let mut warp: Option<Scalar> = None;
2662 let mut steiner_offset: Option<Scalar> = None;
2663 for (face_index, face) in mesh.faces.iter().enumerate() {
2664 let mut rings: Vec<Vec<u32>> = face_rings(face).cloned().collect();
2665 splits.split(&mut rings, |i| (i, mesh.positions[i as usize]), |i, _| i);
2669 if rings.len() == 1 && rings[0].len() == 3 {
2670 indices.extend_from_slice(&rings[0]);
2671 continue;
2672 }
2673 let points: Vec<Vec<axiolid_core::Point3>> = rings
2678 .iter()
2679 .map(|ring| ring.iter().map(|&i| mesh.positions[i as usize]).collect())
2680 .collect();
2681 let views: Vec<&[axiolid_core::Point3]> = points.iter().map(Vec::as_slice).collect();
2682 let local = crate::planar::triangulate_authored_polygon(&views, linear)
2683 .map_err(|refusal| crate::planar::face_error(face_index, refusal))?;
2684 if let Some(face_warp) = local.warp {
2685 warp = Some(warp.map_or(face_warp, |w| w.max(face_warp)));
2686 }
2687 if let Some(offset) = local.steiner_offset {
2688 steiner_offset = Some(steiner_offset.map_or(offset, |w| w.max(offset)));
2689 }
2690 let mut corners: Vec<u32> = rings.into_iter().flatten().collect();
2691 for point in local.steiner {
2694 let index = u32::try_from(positions.len()).map_err(|_| GeomError::BudgetExceeded {
2695 resource: "authored polygon positions",
2696 })?;
2697 positions.push(point);
2698 corners.push(index);
2699 }
2700 indices.extend(local.indices.into_iter().map(|corner| corners[corner]));
2701 }
2702
2703 let triangles = TriMesh::new(positions, indices);
2704 triangles.validate_structure().map_err(|error| {
2705 GeomError::InvalidInput(format!("invalid authored polygon mesh: {error}"))
2706 })?;
2707 Ok((triangles, warp, steiner_offset))
2708}
2709
2710fn authored_edge_splits(mesh: &axiolid_mesh::PolygonMesh) -> crate::weld::EdgeSplits<u32> {
2714 let mut splits = crate::weld::EdgeSplits::new();
2715 for face in &mesh.faces {
2716 if face.outer.len() == 3 && face.holes.is_empty() {
2718 continue;
2719 }
2720 let points: Vec<Vec<axiolid_core::Point3>> = face_rings(face)
2721 .map(|ring| ring.iter().map(|&i| mesh.positions[i as usize]).collect())
2722 .collect();
2723 let views: Vec<&[axiolid_core::Point3]> = points.iter().map(Vec::as_slice).collect();
2724 let touches = crate::planar::authored_edge_touches(&views);
2725 if !touches.is_empty() {
2726 let corners: Vec<(u32, axiolid_core::Point3)> = face_rings(face)
2727 .flatten()
2728 .map(|&i| (i, mesh.positions[i as usize]))
2729 .collect();
2730 splits.record(&corners, &touches);
2731 }
2732 }
2733 splits
2734}
2735
2736fn face_rings(face: &axiolid_mesh::PolygonFace) -> impl Iterator<Item = &Vec<u32>> {
2738 std::iter::once(&face.outer).chain(face.holes.iter())
2739}
2740
2741fn refuse_surface_operand(built: &Built, role: &'static str) -> GeomResult<()> {
2748 match built.closure {
2749 axiolid_mesh_compile_contract::MeshClosure::Solid => Ok(()),
2750 axiolid_mesh_compile_contract::MeshClosure::OpenSolid => {
2751 Err(GeomError::InvalidInput(format!(
2752 "boolean {role} is a solid whose mesh is not closed: it encloses no volume to \
2753 combine"
2754 )))
2755 }
2756 _ => Err(GeomError::InvalidInput(format!(
2757 "boolean {role} is a surface model: it encloses no volume to combine"
2758 ))),
2759 }
2760}
2761
2762fn authored_mesh(mesh: TriMesh, _options: &ExecutionOptions) -> Built {
2778 let closure = if authored_mesh_is_closed(&mesh) {
2779 axiolid_mesh_compile_contract::MeshClosure::Solid
2780 } else {
2781 axiolid_mesh_compile_contract::MeshClosure::Surface
2782 };
2783 Built::with_closure(mesh, closure).with_deviation(crate::deviation::Deviation::one(
2785 crate::deviation::DeviationPath::AuthoredMesh,
2786 "",
2787 crate::deviation::DeviationBound::Proven(0.0),
2788 ))
2789}
2790
2791fn authored_mesh_is_closed(mesh: &TriMesh) -> bool {
2795 if mesh.indices.is_empty() || mesh.positions.iter().any(|p| !p.is_finite()) {
2796 return false;
2797 }
2798 let adjacency = axiolid_mesh::EdgeAdjacency::build(mesh);
2799 adjacency.edge_count() > 0 && adjacency.is_closed_two_manifold()
2800}
2801
2802fn chain_linear(graph: &GeometryGraph, id: NodeId, outer: Transform3) -> Option<Transform3> {
2807 let Some(GeometryNode::Instance(first)) = graph.get(id) else {
2808 return None;
2809 };
2810 let mut composed = outer;
2811 let mut source = first.source;
2812 while let Some(GeometryNode::Instance(next)) = graph.get(source) {
2813 composed *= next.transform;
2814 source = next.source;
2815 }
2816 Some(composed)
2817}