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