fix(boundary): clip rays/XLINEs to finite extents for face detection

Infinite construction lines tessellated to a single unit stub at the base
point, which can never close a face (and overwrote the real wire segments),
so XLINEs were silently ignored by BOUNDARY. Clip unbounded curves to the
finite extents via Liang-Barsky; lines missing the geometry contribute
nothing. Same shared helper fixes RAYs.

Tests:
- xline_splits_rectangle_into_two_faces: 10x10 rect split in two
- ray_splits_rectangle_into_two_faces: upward ray splits in two
- xline_missing_the_geometry_contributes_no_face: distant xline is a no-op
This commit is contained in:
Karim Jerbi committed 2026-09-21 23:30:57 +01:00
1 parent 4432f633e7
commit 960d51d955
1 file changed
+202 -11
+202 -11
View File
@@ -895,6 +895,32 @@ impl Scene {
.segments
.extend(segments);
}
// Finite extents of the wire geometry. Rays and infinite
// construction lines are clipped to this box below: tessellating
// them raw yields a single unit stub at the base point, which can
// never close a face (and would overwrite the wire segments with
// it). Expanded by the weld tolerance so clipped endpoints meet
// the segments that produced the box.
let mut bounds: Option<([f64; 2], [f64; 2])> = None;
for source in sources.values() {
for segment in &source.segments {
for point in [segment.start, segment.end] {
bounds = Some(match bounds {
None => ([point[0], point[1]], [point[0], point[1]]),
Some((min, max)) => (
[min[0].min(point[0]), min[1].min(point[1])],
[max[0].max(point[0]), max[1].max(point[1])],
),
});
}
}
}
let bounds = bounds.map(|(min, max)| {
(
[min[0] - tolerance, min[1] - tolerance],
[max[0] + tolerance, max[1] + tolerance],
)
});
for (&handle, source) in &mut sources {
let curves = self
.document
@@ -908,18 +934,46 @@ impl Scene {
let live_segments: Vec<Line> = curves
.iter()
.flat_map(|curve| {
curve
.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE)
.windows(2)
.filter_map(|pair| {
let start = pair[0];
let end = pair[1];
match curve {
Curve::Ray(ray) => bounds
.and_then(|(min, max)| {
clip_unbounded_to_bounds(
ray.origin,
ray.direction,
true,
min,
max,
tolerance,
)
})
.into_iter()
.collect::<Vec<_>>(),
Curve::XLine(line) => bounds
.and_then(|(min, max)| {
clip_unbounded_to_bounds(
line.base,
line.direction,
false,
min,
max,
tolerance,
)
})
.into_iter()
.collect::<Vec<_>>(),
_ => curve
.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE)
.windows(2)
.filter_map(|pair| {
let start = pair[0];
let end = pair[1];
(start.iter().chain(&end).all(|value| value.is_finite())
&& start != end)
.then_some(Line { start, end })
})
.collect::<Vec<_>>()
(start.iter().chain(&end).all(|value| value.is_finite())
&& start != end)
.then_some(Line { start, end })
})
.collect::<Vec<_>>(),
}
})
.collect();
@@ -942,6 +996,56 @@ impl Scene {
}
}
/// Clip an unbounded curve (ray when `forward_only`, infinite line
/// otherwise) to a finite bounding box, so it can participate in face
/// detection. Returns `None` when the direction is degenerate, when the
/// curve misses the box, or when the clipped piece is shorter than
/// `tolerance` and could never close a face on its own.
fn clip_unbounded_to_bounds(
base: [f64; 2],
direction: [f64; 2],
forward_only: bool,
min: [f64; 2],
max: [f64; 2],
tolerance: f64,
) -> Option<Line> {
let (dx, dy) = (direction[0], direction[1]);
if dx == 0.0 && dy == 0.0 {
return None;
}
// Liang-Barsky: narrow the parameter interval per axis.
let mut entered = f64::NEG_INFINITY;
let mut exited = f64::INFINITY;
for axis in 0..2 {
let (base, delta, low, high) = (base[axis], direction[axis], min[axis], max[axis]);
if delta.abs() <= f64::EPSILON {
if base < low || base > high {
return None;
}
} else {
let (mut near, mut far) = ((low - base) / delta, (high - base) / delta);
if near > far {
std::mem::swap(&mut near, &mut far);
}
entered = entered.max(near);
exited = exited.min(far);
if entered > exited {
return None;
}
}
}
if forward_only {
entered = entered.max(0.0);
}
if !(entered <= exited) || !entered.is_finite() || !exited.is_finite() {
return None;
}
let start = [base[0] + entered * dx, base[1] + entered * dy];
let end = [base[0] + exited * dx, base[1] + exited * dy];
let length = (end[0] - start[0]).hypot(end[1] - start[1]);
(length > tolerance).then_some(Line { start, end })
}
fn hatch_path_geometry(
path: &acadrust::entities::BoundaryPath,
) -> (Vec<[f64; 2]>, Vec<f64>) {
@@ -1028,3 +1132,90 @@ pub(crate) fn boundary_faces(
.collect();
bounded_faces(&segments, Tolerance::new(tolerance))
}
#[cfg(test)]
mod boundary_xline_tests {
use super::*;
fn rect_edge(
scene: &mut Scene,
start: [f64; 3],
end: [f64; 3],
) -> acadrust::Handle {
scene.add_entity(EntityType::Line(
acadrust::entities::Line::from_points(
acadrust::types::Vector3::new(start[0], start[1], start[2]),
acadrust::types::Vector3::new(end[0], end[1], end[2]),
),
))
}
#[test]
fn xline_splits_rectangle_into_two_faces() {
let mut scene = Scene::new();
rect_edge(&mut scene, [0.0, 0.0, 0.0], [10.0, 0.0, 0.0]);
rect_edge(&mut scene, [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]);
rect_edge(&mut scene, [10.0, 10.0, 0.0], [0.0, 10.0, 0.0]);
rect_edge(&mut scene, [0.0, 10.0, 0.0], [0.0, 0.0, 0.0]);
// Infinite vertical construction line through x=5.
scene.add_entity(EntityType::XLine(acadrust::entities::XLine::new(
acadrust::types::Vector3::new(5.0, 0.0, 0.0),
acadrust::types::Vector3::new(0.0, 1.0, 0.0),
)));
let sources =
scene.boundary_sources_on_plane(crate::command::WorkingPlane::default(), 1.0e-6);
let faces = boundary_faces(&sources, 1.0e-6);
assert_eq!(
faces.len(),
2,
"vertical xline must split the 10x10 rectangle into two faces"
);
}
#[test]
fn ray_splits_rectangle_into_two_faces() {
let mut scene = Scene::new();
rect_edge(&mut scene, [0.0, 0.0, 0.0], [10.0, 0.0, 0.0]);
rect_edge(&mut scene, [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]);
rect_edge(&mut scene, [10.0, 10.0, 0.0], [0.0, 10.0, 0.0]);
rect_edge(&mut scene, [0.0, 10.0, 0.0], [0.0, 0.0, 0.0]);
// Ray starting below the rectangle, pointing up through x=5.
scene.add_entity(EntityType::Ray(acadrust::entities::Ray::new(
acadrust::types::Vector3::new(5.0, -5.0, 0.0),
acadrust::types::Vector3::new(0.0, 1.0, 0.0),
)));
let sources =
scene.boundary_sources_on_plane(crate::command::WorkingPlane::default(), 1.0e-6);
let faces = boundary_faces(&sources, 1.0e-6);
assert_eq!(
faces.len(),
2,
"upward ray must split the 10x10 rectangle into two faces"
);
}
#[test]
fn xline_missing_the_geometry_contributes_no_face() {
let mut scene = Scene::new();
rect_edge(&mut scene, [0.0, 0.0, 0.0], [10.0, 0.0, 0.0]);
rect_edge(&mut scene, [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]);
rect_edge(&mut scene, [10.0, 10.0, 0.0], [0.0, 10.0, 0.0]);
rect_edge(&mut scene, [0.0, 10.0, 0.0], [0.0, 0.0, 0.0]);
// Far-away vertical construction line: never touches the rectangle.
scene.add_entity(EntityType::XLine(acadrust::entities::XLine::new(
acadrust::types::Vector3::new(50.0, 0.0, 0.0),
acadrust::types::Vector3::new(0.0, 1.0, 0.0),
)));
let sources =
scene.boundary_sources_on_plane(crate::command::WorkingPlane::default(), 1.0e-6);
let faces = boundary_faces(&sources, 1.0e-6);
assert_eq!(
faces.len(),
1,
"a distant xline must leave the single rectangle face alone"
);
}
}