1use std::collections::{BTreeMap, BTreeSet};
8
9use axiolid_contracts::{
10 Backend, BackendDescriptor, BackendId, CancellationGranularity, ExecutionOptions,
11 ExecutionTarget, GeomError, GeomResult, ScratchRequirement, Sign,
12};
13use axiolid_core::{Frame3, Point2, Point3};
14use axiolid_mesh::TriMesh;
15use axiolid_mesh_section_contract::{
16 MeshPlaneSection, SectionContour, SectionEvidence, SectionLimits, SectionOutcome,
17};
18
19use crate::orient3::orient3d;
20use crate::orientation::orient2d;
21
22#[derive(Debug, Default, Clone, Copy)]
24pub struct ScalarSection;
25
26impl ScalarSection {
27 pub const ID: BackendId = BackendId::new("scalar-section");
29
30 #[must_use]
32 pub const fn new() -> Self {
33 Self
34 }
35}
36
37impl Backend for ScalarSection {
38 fn descriptor(&self) -> BackendDescriptor {
39 BackendDescriptor::new(Self::ID, ExecutionTarget::PortableCpu)
40 }
41}
42
43impl MeshPlaneSection for ScalarSection {
44 fn scratch_requirement(&self) -> ScratchRequirement {
45 ScratchRequirement::PerElement {
48 bytes_per_element: 512,
49 }
50 }
51
52 fn cancellation_granularity(&self) -> CancellationGranularity {
53 CancellationGranularity::Incremental
54 }
55
56 fn section(
57 &self,
58 mesh: &TriMesh,
59 frame: Frame3,
60 limits: SectionLimits,
61 options: &ExecutionOptions,
62 ) -> GeomResult<SectionOutcome> {
63 options.check_cancelled()?;
64 check_source_limits(mesh, limits)?;
65
66 let plane = ExactSectionPlane::new(frame)?;
67 let mut classifications = Vec::new();
68 classifications
69 .try_reserve_exact(mesh.positions.len())
70 .map_err(|_| GeomError::BudgetExceeded { resource: "memory" })?;
71 for &point in &mesh.positions {
72 let classification = plane.classify(point)?;
73 classifications.push(classification);
74 }
75
76 let mut segments = BTreeSet::<Segment>::new();
77 let mut on_plane_edges = BTreeMap::<EdgeKey, Vec<Sign>>::new();
78 for (triangle_index, triangle) in mesh.triangles().enumerate() {
79 options.check_cancelled()?;
80 let vertices = [
81 mesh_index(triangle[0])?,
82 mesh_index(triangle[1])?,
83 mesh_index(triangle[2])?,
84 ];
85 let signs = vertices.map(|index| classifications[index].sign);
86 let zero_count = signs.iter().filter(|&&sign| sign == Sign::Zero).count();
87 match zero_count {
88 3 => {
89 return Err(GeomError::Degenerate(format!(
90 "section plane contains source triangle {triangle_index}; a two-dimensional overlap is not a curve"
91 )))
92 }
93 2 => {
94 let mut zeros = triangle
95 .into_iter()
96 .zip(signs)
97 .filter_map(|(index, sign)| (sign == Sign::Zero).then_some(index));
98 let left = zeros.next().ok_or_else(internal_topology_error)?;
99 let right = zeros.next().ok_or_else(internal_topology_error)?;
100 let third = signs
101 .into_iter()
102 .find(|&sign| sign != Sign::Zero)
103 .ok_or_else(internal_topology_error)?;
104 on_plane_edges
105 .entry(EdgeKey::new(left, right))
106 .or_default()
107 .push(third);
108 }
109 1 => {
110 let zero_corner = (0..3)
111 .find(|&corner| signs[corner] == Sign::Zero)
112 .ok_or_else(internal_topology_error)?;
113 let (first, second) = match zero_corner {
114 0 => (1, 2),
115 1 => (0, 2),
116 2 => (0, 1),
117 _ => return Err(internal_topology_error()),
118 };
119 if opposite(signs[first], signs[second]) {
120 let segment = Segment::new(
121 NodeKey::Vertex(triangle[zero_corner]),
122 NodeKey::Edge(EdgeKey::new(triangle[first], triangle[second])),
123 )?;
124 insert_segment(&mut segments, segment, limits)?;
125 }
126 }
127 0 => {
128 let mut crossing = [None, None];
129 let mut crossing_count = 0usize;
130 for (left, right) in [(0, 1), (1, 2), (2, 0)] {
131 if opposite(signs[left], signs[right]) {
132 if crossing_count >= crossing.len() {
133 return Err(internal_topology_error());
134 }
135 crossing[crossing_count] = Some(NodeKey::Edge(EdgeKey::new(
136 triangle[left],
137 triangle[right],
138 )));
139 crossing_count += 1;
140 }
141 }
142 match (crossing[0], crossing[1]) {
143 (Some(first), Some(second)) => insert_segment(
144 &mut segments,
145 Segment::new(first, second)?,
146 limits,
147 )?,
148 (None, None) => {}
149 _ => return Err(internal_topology_error()),
150 }
151 }
152 _ => return Err(internal_topology_error()),
153 }
154 }
155
156 for (edge, incident_signs) in on_plane_edges {
157 options.check_cancelled()?;
158 if incident_signs.len() != 2 {
159 return Err(GeomError::NotManifold(format!(
160 "on-plane mesh edge {:?} has {} incident triangles",
161 edge,
162 incident_signs.len()
163 )));
164 }
165 if opposite(incident_signs[0], incident_signs[1]) {
166 insert_segment(
167 &mut segments,
168 Segment::new(NodeKey::Vertex(edge.0), NodeKey::Vertex(edge.1))?,
169 limits,
170 )?;
171 }
172 }
173
174 let contours =
175 assemble_contours(mesh, frame, &classifications, &segments, limits, options)?;
176 let output_vertices = contours.iter().map(|contour| contour.points.len()).sum();
177 let evidence =
178 SectionEvidence::input_mesh(mesh.triangle_count(), output_vertices, contours.len());
179 Ok(SectionOutcome::new(frame, contours, evidence))
180 }
181}
182
183#[derive(Debug, Clone, Copy)]
184struct Classification {
185 sign: Sign,
186 distance: f64,
187}
188
189#[derive(Debug, Clone, Copy)]
190struct ExactSectionPlane {
191 origin: Point3,
192 x_point: Point3,
193 y_point: Point3,
194 normal: axiolid_core::Vec3,
195}
196
197impl ExactSectionPlane {
198 fn new(frame: Frame3) -> GeomResult<Self> {
199 let x_point = frame.origin + frame.x;
200 let y_point = frame.origin + frame.y;
201 if !x_point.is_finite()
202 || !y_point.is_finite()
203 || x_point == frame.origin
204 || y_point == frame.origin
205 || x_point == y_point
206 {
207 return Err(GeomError::Degenerate(
208 "section frame cannot resolve a finite affine plane at this coordinate magnitude"
209 .into(),
210 ));
211 }
212 let normal = (x_point - frame.origin).cross(y_point - frame.origin);
213 let normal_length = normal.length();
214 if !normal_length.is_finite() || normal_length == 0.0 {
215 return Err(GeomError::Degenerate(
216 "section affine plane has no finite normal".into(),
217 ));
218 }
219 Ok(Self {
220 origin: frame.origin,
221 x_point,
222 y_point,
223 normal: normal / normal_length,
224 })
225 }
226
227 fn classify(self, point: Point3) -> GeomResult<Classification> {
228 let sign = match orient3d(self.origin, self.x_point, self.y_point, point) {
229 axiolid_contracts::Certified::Certain { sign, .. } => sign,
230 _ => {
231 return Err(GeomError::Degenerate(
232 "certified plane-side predicate returned an uncertain sign".into(),
233 ))
234 }
235 };
236 let distance = self.normal.dot(point - self.origin);
237 if !distance.is_finite() {
238 return Err(GeomError::Degenerate(
239 "section signed distance is not finite".into(),
240 ));
241 }
242 if sign != Sign::Zero && distance == 0.0 {
243 return Err(GeomError::Degenerate(
244 "section interpolation lost a certified nonzero plane offset".into(),
245 ));
246 }
247 Ok(Classification { sign, distance })
248 }
249}
250
251fn opposite(left: Sign, right: Sign) -> bool {
252 matches!(
253 (left, right),
254 (Sign::Negative, Sign::Positive) | (Sign::Positive, Sign::Negative)
255 )
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
259struct EdgeKey(u32, u32);
260
261impl EdgeKey {
262 fn new(left: u32, right: u32) -> Self {
263 Self(left.min(right), left.max(right))
264 }
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
268enum NodeKey {
269 Vertex(u32),
270 Edge(EdgeKey),
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
274struct Segment(NodeKey, NodeKey);
275
276impl Segment {
277 fn new(left: NodeKey, right: NodeKey) -> GeomResult<Self> {
278 if left == right {
279 return Err(GeomError::Degenerate(
280 "plane section collapsed a segment to one source-topology node".into(),
281 ));
282 }
283 Ok(Self(left.min(right), left.max(right)))
284 }
285}
286
287fn insert_segment(
288 segments: &mut BTreeSet<Segment>,
289 segment: Segment,
290 limits: SectionLimits,
291) -> GeomResult<()> {
292 if segments.contains(&segment) {
293 return Err(GeomError::NotManifold(
294 "two source faces produced the same non-coplanar section segment".into(),
295 ));
296 }
297 if segments.len() >= limits.max_output_vertices {
298 return Err(GeomError::BudgetExceeded {
299 resource: "section output vertices",
300 });
301 }
302 segments.insert(segment);
303 Ok(())
304}
305
306fn assemble_contours(
307 mesh: &TriMesh,
308 frame: Frame3,
309 classifications: &[Classification],
310 segments: &BTreeSet<Segment>,
311 limits: SectionLimits,
312 options: &ExecutionOptions,
313) -> GeomResult<Vec<SectionContour>> {
314 let mut adjacency = BTreeMap::<NodeKey, Vec<NodeKey>>::new();
315 for &Segment(left, right) in segments {
316 adjacency.entry(left).or_default().push(right);
317 adjacency.entry(right).or_default().push(left);
318 }
319 for neighbours in adjacency.values_mut() {
320 neighbours.sort_unstable();
321 if neighbours.len() != 2 {
322 return Err(GeomError::NotManifold(format!(
323 "section graph has degree {}, expected 2",
324 neighbours.len()
325 )));
326 }
327 }
328
329 let mut visited = BTreeSet::<Segment>::new();
330 let mut contours = Vec::new();
331 for &start in adjacency.keys() {
332 let start_is_complete = adjacency[&start].iter().try_fold(true, |complete, &next| {
333 let segment = Segment::new(start, next)?;
334 Ok::<bool, GeomError>(complete && visited.contains(&segment))
335 })?;
336 if start_is_complete {
337 continue;
338 }
339 options.check_cancelled()?;
340 if contours.len() >= limits.max_contours {
341 return Err(GeomError::BudgetExceeded {
342 resource: "section contours",
343 });
344 }
345 let mut nodes = Vec::new();
346 let mut previous = None;
347 let mut current = start;
348 loop {
349 if nodes.len() >= limits.max_output_vertices {
350 return Err(GeomError::BudgetExceeded {
351 resource: "section output vertices",
352 });
353 }
354 nodes.push(current);
355 let neighbours = adjacency
356 .get(¤t)
357 .ok_or_else(internal_topology_error)?;
358 let next = match previous {
359 None => neighbours[0],
360 Some(previous) if neighbours[0] == previous => neighbours[1],
361 Some(_) => neighbours[0],
362 };
363 let edge = Segment::new(current, next)?;
364 if !visited.insert(edge) && next != start {
365 return Err(GeomError::NotManifold(
366 "section graph revisited an edge before closing a contour".into(),
367 ));
368 }
369 previous = Some(current);
370 current = next;
371 if current == start {
372 break;
373 }
374 if nodes.len() > segments.len() {
375 return Err(internal_topology_error());
376 }
377 }
378 if nodes.len() < 3 {
379 return Err(GeomError::Degenerate(
380 "section contour has fewer than three source-topology nodes".into(),
381 ));
382 }
383 let mut points = Vec::new();
384 points
385 .try_reserve_exact(nodes.len())
386 .map_err(|_| GeomError::BudgetExceeded { resource: "memory" })?;
387 for node in nodes {
388 let world = node_point(mesh, classifications, node)?;
389 let local = world - frame.origin;
390 let point = Point2::new(local.dot(frame.x), local.dot(frame.y));
391 if !point.is_finite() {
392 return Err(GeomError::Degenerate(
393 "section projection exceeded finite arithmetic".into(),
394 ));
395 }
396 points.push(point);
397 }
398 simplify_collinear(&mut points, options.tolerance().linear());
399 if points.len() < 3 {
400 return Err(GeomError::Degenerate(
401 "section contour collapsed below three vertices".into(),
402 ));
403 }
404 let area = signed_area(&points);
405 if !area.is_finite() || area == 0.0 {
406 return Err(GeomError::Degenerate(
407 "section contour has no finite signed area".into(),
408 ));
409 }
410 if area < 0.0 {
411 points[1..].reverse();
412 }
413 contours.push(SectionContour::new(points));
414 }
415 contours.sort_by(|left, right| point_order(&left.points[0], &right.points[0]));
416 Ok(contours)
417}
418
419fn mesh_index(index: u32) -> GeomResult<usize> {
420 usize::try_from(index)
421 .map_err(|_| GeomError::InvalidInput("mesh index does not fit usize".into()))
422}
423
424fn node_point(
425 mesh: &TriMesh,
426 classifications: &[Classification],
427 node: NodeKey,
428) -> GeomResult<Point3> {
429 match node {
430 NodeKey::Vertex(index) => Ok(mesh.positions[mesh_index(index)?]),
431 NodeKey::Edge(EdgeKey(left, right)) => {
432 let a = mesh.positions[mesh_index(left)?];
433 let b = mesh.positions[mesh_index(right)?];
434 let da = classifications[mesh_index(left)?].distance.abs();
435 let db = classifications[mesh_index(right)?].distance.abs();
436 if !(da > 0.0 && db > 0.0 && da.is_finite() && db.is_finite()) {
437 return Err(GeomError::Degenerate(
438 "crossing edge has no representable endpoint distance".into(),
439 ));
440 }
441 let t = if da >= db {
442 1.0 / (1.0 + db / da)
443 } else {
444 let ratio = da / db;
445 ratio / (1.0 + ratio)
446 };
447 let point = a + (b - a) * t;
448 if !point.is_finite() {
449 return Err(GeomError::Degenerate(
450 "edge-plane intersection exceeded finite arithmetic".into(),
451 ));
452 }
453 Ok(point)
454 }
455 }
456}
457
458fn simplify_collinear(points: &mut Vec<Point2>, tolerance: f64) {
459 loop {
460 if points.len() <= 3 {
461 return;
462 }
463 let mut removed = false;
464 for index in 0..points.len() {
465 let previous = points[(index + points.len() - 1) % points.len()];
466 let current = points[index];
467 let next = points[(index + 1) % points.len()];
468 let chord = next - previous;
469 let scale = chord.length();
470 let cross = (current - previous).perp_dot(chord).abs();
471 let within_tolerance = scale > 0.0 && cross <= tolerance * scale;
472 if orient2d(previous, current, next).sign() == Some(Sign::Zero) || within_tolerance {
473 points.remove(index);
474 removed = true;
475 break;
476 }
477 }
478 if !removed {
479 return;
480 }
481 }
482}
483
484fn signed_area(points: &[Point2]) -> f64 {
485 let mut twice = 0.0;
486 for index in 0..points.len() {
487 let current = points[index];
488 let next = points[(index + 1) % points.len()];
489 twice += current.x * next.y - current.y * next.x;
490 }
491 twice * 0.5
492}
493
494fn point_order(left: &Point2, right: &Point2) -> std::cmp::Ordering {
495 left.x
496 .total_cmp(&right.x)
497 .then_with(|| left.y.total_cmp(&right.y))
498}
499
500fn check_source_limits(mesh: &TriMesh, limits: SectionLimits) -> GeomResult<()> {
501 if mesh.positions.len() > limits.max_source_vertices {
502 return Err(GeomError::BudgetExceeded {
503 resource: "section source vertices",
504 });
505 }
506 if mesh.triangle_count() > limits.max_source_triangles {
507 return Err(GeomError::BudgetExceeded {
508 resource: "section source triangles",
509 });
510 }
511 Ok(())
512}
513
514fn internal_topology_error() -> GeomError {
515 GeomError::BackendContractViolation {
516 backend: ScalarSection::ID,
517 detail: "internal section topology state is inconsistent".into(),
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn exact_plane_sign_keeps_a_tiny_nonzero_binary64_offset() {
527 let frame = Frame3 {
528 origin: Point3::ZERO,
529 x: Point3::X,
530 y: Point3::Y,
531 z: Point3::Z,
532 };
533 let plane = ExactSectionPlane::new(frame).expect("resolvable plane");
534 let point = Point3::new(1.0, -1.0, f64::from_bits(1));
535 let certified = orient3d(plane.origin, plane.x_point, plane.y_point, point);
536 assert!(matches!(
537 certified,
538 axiolid_contracts::Certified::Certain {
539 precision: axiolid_contracts::Precision::Exact,
540 ..
541 }
542 ));
543 let positive = plane.classify(point).expect("finite subnormal");
544 assert_ne!(positive.sign, Sign::Zero);
545 }
546}