Files
OneCAD-Draft/src/scene/boundary.rs
T
Karim Jerbi 9264559fd3 fix(boundary): clip unbounded sources in associative hatch refresh path
Same unit-stub defect as the BOUNDARY tool fix: entity_boundary_source_on_plane
tessellated rays/XLINEs to a single base-point stub. Now takes clip bounds
derived from the full source map, sharing segments_bounds and
live_segments_for_curves instead of duplicating the logic.

Tests:
- xline_source_clips_to_given_bounds: clipped to the box, not the stub
2026-09-22 20:41:35 +01:00

1256 lines
45 KiB
Rust

use super::*;
use cadkernel::geom2d::{
bounded_faces, closest_point, contains, distance_to, intersect, ring_nesting_depths,
segment_crossing, signed_area, triangulate, Curve, Line, SegmentCrossing, Tolerance,
Transform as CurveTransform,
};
use crate::command::WorkingPlane;
#[derive(Clone)]
pub struct BoundarySource {
pub segments: Vec<Line>,
pub curves: Vec<Curve>,
}
/// Boundary welding tolerance for tessellated wires.
const WELD_TOLERANCE: f64 = 1.0e-6;
fn wire_segments_on_plane(
wire: &WireModel,
plane: WorkingPlane,
tolerance: f64,
) -> Vec<Line> {
let mut segments = Vec::new();
let mut previous: Option<glam::DVec3> = None;
for (index, high) in wire.points.iter().copied().enumerate() {
if !high.iter().all(|value| value.is_finite()) {
previous = None;
continue;
}
let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
let world = glam::DVec3::new(
high[0] as f64 + low[0] as f64,
high[1] as f64 + low[1] as f64,
high[2] as f64 + low[2] as f64,
);
let current = plane.to_local(world);
if current.z.abs() > tolerance {
previous = None;
continue;
}
if let Some(start) = previous {
let length = (current.x - start.x).hypot(current.y - start.y);
if length > tolerance {
segments.push(Line {
start: [start.x, start.y],
end: [current.x, current.y],
});
}
}
previous = Some(current);
}
segments
}
fn entity_curves_on_plane(
entity: &EntityType,
plane: WorkingPlane,
tolerance: f64,
) -> Vec<Curve> {
let Some(planar) = crate::entities::curve::entity_curve(entity) else {
return Vec::new();
};
let source = planar.plane;
let origin = plane.to_local(glam::DVec3::from_array(source.origin));
let x_axis = plane.vector_to_local(glam::DVec3::from_array(source.x_axis));
let y_axis = plane.vector_to_local(glam::DVec3::from_array(source.y_axis));
if origin.z.abs() > tolerance || x_axis.z.abs() > 1.0e-9 || y_axis.z.abs() > 1.0e-9 {
return Vec::new();
}
let Some(curve) = planar.curve.transformed(&CurveTransform {
x_axis: [x_axis.x, x_axis.y].into(),
y_axis: [y_axis.x, y_axis.y].into(),
origin: [origin.x, origin.y].into(),
}) else {
return Vec::new();
};
let segments = curve.segments();
if segments.is_empty() {
vec![curve]
} else {
segments
}
}
fn entity_boundary_source_on_plane(
entity: &EntityType,
plane: WorkingPlane,
tolerance: f64,
clip_bounds: Option<([f64; 2], [f64; 2])>,
) -> Option<BoundarySource> {
let curves = entity_curves_on_plane(entity, plane, tolerance);
if curves.is_empty() {
return None;
}
let segments = live_segments_for_curves(&curves, clip_bounds, tolerance);
(!segments.is_empty()).then_some(BoundarySource { segments, curves })
}
/// Finite extents of boundary segments, expanded by `tolerance` so clipped
/// endpoints weld with the segments that produced the box. `None` when
/// there is nothing finite to clip against.
fn segments_bounds(segments: &[Line], tolerance: f64) -> Option<([f64; 2], [f64; 2])> {
let mut bounds: Option<([f64; 2], [f64; 2])> = None;
for segment in 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])],
),
});
}
}
bounds.map(|(min, max)| {
(
[min[0] - tolerance, min[1] - tolerance],
[max[0] + tolerance, max[1] + tolerance],
)
})
}
/// Tessellate live curves to finite segments. Bounded curves tessellate as
/// always; rays and infinite lines are clipped to `clip_bounds` instead —
/// tessellating them raw yields a unit stub that can never close a face.
/// With no bounds there is nothing to clip against, so unbounded curves
/// contribute nothing.
fn live_segments_for_curves(
curves: &[Curve],
clip_bounds: Option<([f64; 2], [f64; 2])>,
tolerance: f64,
) -> Vec<Line> {
curves
.iter()
.flat_map(|curve| {
match curve {
Curve::Ray(ray) => clip_bounds
.and_then(|(min, max)| {
clip_unbounded_to_bounds(
ray.origin,
ray.direction,
true,
min,
max,
tolerance,
)
})
.into_iter()
.collect::<Vec<_>>(),
Curve::XLine(line) => clip_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<_>>(),
}
})
.collect()
}
fn hatch_path_seed(path: &acadrust::entities::BoundaryPath) -> Option<[f64; 2]> {
let mut ring = Vec::new();
for edge in &path.edges {
let curve = crate::entities::hatch::edge_curve(edge)?;
let points = curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE);
ring.extend(points.into_iter().skip(usize::from(!ring.is_empty())));
}
if ring.len() < 3 {
return None;
}
let (points, triangles) = triangulate(&ring, &[]);
if let Some(triangle) = triangles.first() {
let [a, b, c] = triangle.map(|vertex| points[vertex]);
Some([
(a[0] + b[0] + c[0]) / 3.0,
(a[1] + b[1] + c[1]) / 3.0,
])
} else {
Some([
ring.iter().map(|point| point[0]).sum::<f64>() / ring.len() as f64,
ring.iter().map(|point| point[1]).sum::<f64>() / ring.len() as f64,
])
}
}
fn face_curves(face: &[[f64; 2]]) -> Vec<Curve> {
face.iter()
.copied()
.zip(face.iter().copied().cycle().skip(1))
.take(face.len())
.map(|(start, end)| Curve::Line(Line { start, end }))
.collect()
}
fn matching_face(faces: &[Vec<[f64; 2]>], seed: Option<[f64; 2]>) -> Option<&Vec<[f64; 2]>> {
if faces.len() == 1 {
return faces.first();
}
let seed = seed?;
let tolerance = Tolerance::new(WELD_TOLERANCE);
if let Some(face) = faces.iter().find(|face| {
let curves = face_curves(face);
contains(&curves, seed, tolerance)
}) {
return Some(face);
}
faces.iter().min_by(|a, b| {
let nearest = |face: &Vec<[f64; 2]>| {
face_curves(face)
.iter()
.map(|curve| distance_to(curve, seed))
.fold(f64::INFINITY, f64::min)
};
nearest(a).total_cmp(&nearest(b))
})
}
pub(crate) fn ring_source_handles(
ring: &[[f64; 2]],
sources: &rustc_hash::FxHashMap<acadrust::Handle, BoundarySource>,
) -> Vec<acadrust::Handle> {
let mut handles = rustc_hash::FxHashSet::default();
for (&start, &end) in ring
.iter()
.zip(ring.iter().cycle().skip(1))
.take(ring.len())
{
let edge = Line { start, end };
let edge_length = (end[0] - start[0]).hypot(end[1] - start[1]);
for (&handle, source) in sources {
if source.segments.iter().any(|line| {
matches!(
segment_crossing(edge, *line, Tolerance::new(WELD_TOLERANCE)),
SegmentCrossing::Overlap { a, .. }
if (a[1] - a[0]).abs() * edge_length > WELD_TOLERANCE
)
}) {
handles.insert(handle);
}
}
}
let mut handles: Vec<_> = handles.into_iter().collect();
handles.sort_by_key(|handle| handle.value());
handles
}
fn curve_forward(curve: &Curve, start: [f64; 2], next: [f64; 2]) -> bool {
// For circular geometry, determine direction directly from the
// geometric traversal of the detected boundary. This avoids parameter
// wrap-around choosing the major arc instead of the minor arc.
match curve {
Curve::Circle(circle) => {
let ax = start[0] - circle.centre[0];
let ay = start[1] - circle.centre[1];
let bx = next[0] - circle.centre[0];
let by = next[1] - circle.centre[1];
ax * by - ay * bx >= 0.0
}
Curve::Arc(arc) => {
let ax = start[0] - arc.centre[0];
let ay = start[1] - arc.centre[1];
let bx = next[0] - arc.centre[0];
let by = next[1] - arc.centre[1];
ax * by - ay * bx >= 0.0
}
_ => {
let a = curve.parameter_at(start);
let b = curve.parameter_at(next);
let mut delta = b - a;
if curve.is_closed() {
if delta > 0.5 {
delta -= 1.0;
} else if delta < -0.5 {
delta += 1.0;
}
}
delta >= 0.0
}
}
}
fn stored_arc_angles(start: f64, end: f64, counter_clockwise: bool, whole: bool) -> (f64, f64) {
let stored_start = if counter_clockwise { start } else { -start }
.rem_euclid(std::f64::consts::TAU);
let sweep = if whole {
std::f64::consts::TAU
} else if counter_clockwise {
(end - start).rem_euclid(std::f64::consts::TAU)
} else {
(start - end).rem_euclid(std::f64::consts::TAU)
};
(stored_start, stored_start + sweep)
}
fn exact_boundary_edge(
curve: Option<&Curve>,
start: [f64; 2],
end: [f64; 2],
next: [f64; 2],
whole_curve: bool,
) -> acadrust::entities::BoundaryEdge {
use acadrust::entities::{
BoundaryEdge, CircularArcEdge, EllipticArcEdge, LineEdge, SplineEdge,
};
use acadrust::types::{Vector2, Vector3};
let Some(curve) = curve else {
return BoundaryEdge::Line(LineEdge {
start: Vector2::new(start[0], start[1]),
end: Vector2::new(end[0], end[1]),
});
};
let forward = curve_forward(curve, start, next);
match curve {
Curve::Line(_) => BoundaryEdge::Line(LineEdge {
start: Vector2::new(start[0], start[1]),
end: Vector2::new(end[0], end[1]),
}),
Curve::Circle(circle) => {
let true_start = (start[1] - circle.centre[1]).atan2(start[0] - circle.centre[0]);
let true_end = (end[1] - circle.centre[1]).atan2(end[0] - circle.centre[0]);
let (start_angle, end_angle) =
stored_arc_angles(true_start, true_end, forward, whole_curve);
BoundaryEdge::CircularArc(CircularArcEdge {
center: Vector2::new(circle.centre[0], circle.centre[1]),
radius: circle.radius,
start_angle,
end_angle,
counter_clockwise: forward,
})
}
Curve::Arc(arc) => {
let true_start = (start[1] - arc.centre[1]).atan2(start[0] - arc.centre[0]);
let true_end = (end[1] - arc.centre[1]).atan2(end[0] - arc.centre[0]);
let (start_angle, end_angle) =
stored_arc_angles(true_start, true_end, forward, whole_curve);
BoundaryEdge::CircularArc(CircularArcEdge {
center: Vector2::new(arc.centre[0], arc.centre[1]),
radius: arc.radius,
start_angle,
end_angle,
counter_clockwise: forward,
})
}
Curve::Ellipse(arc) => {
let ellipse = arc.ellipse;
let true_start = arc.start_parameter + curve.parameter_at(start) * arc.sweep();
let true_end = arc.start_parameter + curve.parameter_at(end) * arc.sweep();
let (start_parameter, end_parameter) =
stored_arc_angles(true_start, true_end, forward, whole_curve);
BoundaryEdge::EllipticArc(EllipticArcEdge {
center: Vector2::new(ellipse.centre[0], ellipse.centre[1]),
major_axis_endpoint: Vector2::new(
ellipse.major_axis[0] * ellipse.major_radius,
ellipse.major_axis[1] * ellipse.major_radius,
),
minor_axis_ratio: ellipse.minor_radius / ellipse.major_radius,
start_angle: start_parameter,
end_angle: end_parameter,
counter_clockwise: forward,
})
}
Curve::Nurbs(source) => {
let trimmed = if whole_curve {
Some(if forward { source.clone() } else { source.reversed() })
} else {
source.trimmed(source.parameter_at(start), source.parameter_at(end))
};
let Some(trimmed) = trimmed else {
return BoundaryEdge::Line(LineEdge {
start: Vector2::new(start[0], start[1]),
end: Vector2::new(end[0], end[1]),
});
};
let rational = trimmed.is_rational();
BoundaryEdge::Spline(SplineEdge {
degree: trimmed.degree() as i32,
rational,
periodic: trimmed.is_closed(),
knots: trimmed.knots().to_vec(),
control_points: trimmed
.control_points()
.iter()
.zip(trimmed.weights())
.map(|(point, weight)| {
Vector3::new(point[0], point[1], if rational { *weight } else { 1.0 })
})
.collect(),
fit_points: Vec::new(),
start_tangent: Vector2::new(0.0, 0.0),
end_tangent: Vector2::new(0.0, 0.0),
})
}
Curve::Polyline(_) | Curve::Ray(_) | Curve::XLine(_) => {
BoundaryEdge::Line(LineEdge {
start: Vector2::new(start[0], start[1]),
end: Vector2::new(end[0], end[1]),
})
}
}
}
/// Rebuild detected rings from exact source curves.
pub(crate) fn exact_hatch_paths(
rings: &[Vec<[f64; 2]>],
exterior: &[bool],
sources: &rustc_hash::FxHashMap<Handle, BoundarySource>,
tolerance: f64,
) -> Vec<acadrust::entities::BoundaryPath> {
use acadrust::entities::{BoundaryPath, BoundaryPathFlags};
rings
.iter()
.enumerate()
.filter_map(|(ring_index, ring)| {
let (points, curves) = refined_boundary_ring(ring, sources, tolerance);
let count = points.len();
if count < 3 {
return None;
}
let handles = ring_source_handles(ring, sources);
let mut bits = 0;
if exterior.get(ring_index).copied().unwrap_or(ring_index == 0) {
bits |= BoundaryPathFlags::OUTERMOST.bits();
}
if !handles.is_empty() {
bits |= BoundaryPathFlags::EXTERNAL.bits();
}
let mut path = BoundaryPath::with_flags(BoundaryPathFlags::from_bits(bits));
let all_same = curves.first().is_some_and(|first| {
first.is_some() && curves.iter().all(|curve| curve == first)
});
if all_same {
let curve = curves[0].as_ref();
path.add_edge(exact_boundary_edge(
curve,
points[0],
points[0],
points[1],
true,
));
} else {
let start_index = (0..count)
.find(|index| curves[*index] != curves[(*index + count - 1) % count])
.unwrap_or(0);
let mut consumed = 0usize;
while consumed < count {
let edge_index = (start_index + consumed) % count;
let curve = curves.get(edge_index).and_then(Option::as_ref);
let mut length = 1usize;
if curve.is_some() {
while consumed + length < count
&& curves[(edge_index + length) % count].as_ref() == curve
{
length += 1;
}
}
let end_index = (edge_index + length) % count;
path.add_edge(exact_boundary_edge(
curve,
points[edge_index],
points[end_index],
points[(edge_index + 1) % count],
false,
));
consumed += length;
}
}
for handle in handles {
path.add_boundary_handle(handle);
}
Some(path)
})
.collect()
}
pub(crate) fn boundary_entities(
rings: &[Vec<[f64; 2]>],
plane: WorkingPlane,
) -> Vec<acadrust::EntityType> {
rings
.iter()
.filter_map(|ring| {
let mut points = Vec::with_capacity(ring.len());
for &point in ring {
if point.iter().all(|value| value.is_finite())
&& points.last() != Some(&point)
{
points.push(point);
}
}
if points.len() > 1 && points.first() == points.last() {
points.pop();
}
if points.len() < 3 {
return None;
}
let mut polyline = acadrust::entities::LwPolyline::new();
polyline.is_closed = true;
polyline.vertices = points
.into_iter()
.map(|[x, y]| {
acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(x, y))
})
.collect();
Some(plane.place_entity(acadrust::EntityType::LwPolyline(polyline)))
})
.collect()
}
fn edge_curve(
edge: Line,
sources: &rustc_hash::FxHashMap<Handle, BoundarySource>,
tolerance: f64,
) -> Option<Curve> {
let edge_length = (edge.end[0] - edge.start[0]).hypot(edge.end[1] - edge.start[1]);
let mut best = None;
let mut best_overlap = 0.0;
for source in sources.values() {
for segment in &source.segments {
if let SegmentCrossing::Overlap { a, .. } =
segment_crossing(edge, *segment, Tolerance::new(tolerance))
{
let overlap = (a[1] - a[0]).abs() * edge_length;
if overlap > best_overlap {
best = Some(source);
best_overlap = overlap;
}
}
}
}
let candidates: Vec<&Curve> = match best {
Some(source) => source.curves.iter().collect(),
None => sources
.values()
.flat_map(|source| source.curves.iter())
.filter(|curve| {
distance_to(curve, edge.start).min(distance_to(curve, edge.end))
<= tolerance * 4.0
})
.collect(),
};
candidates
.into_iter()
.min_by(|left, right| {
let error = |curve: &Curve| {
distance_to(curve, edge.start) + distance_to(curve, edge.end)
};
error(left).total_cmp(&error(right))
})
.cloned()
}
fn nearest_crossing(a: &Curve, b: &Curve, point: [f64; 2], tolerance: f64) -> Option<[f64; 2]> {
intersect(a, b, Tolerance::new(tolerance))
.into_iter()
.min_by(|left, right| {
let distance = |candidate: [f64; 2]| {
(candidate[0] - point[0]).hypot(candidate[1] - point[1])
};
distance(left.point).total_cmp(&distance(right.point))
})
.map(|crossing| crossing.point)
}
fn project_to_curve(curve: &Curve, point: [f64; 2]) -> [f64; 2] {
closest_point(curve, point).point
}
fn refined_boundary_ring(
ring: &[[f64; 2]],
sources: &rustc_hash::FxHashMap<Handle, BoundarySource>,
tolerance: f64,
) -> (Vec<[f64; 2]>, Vec<Option<Curve>>) {
let count = ring.len();
if count < 3 {
return (ring.to_vec(), Vec::new());
}
let edge_curves: Vec<_> = (0..count)
.map(|index| {
edge_curve(
Line {
start: ring[index],
end: ring[(index + 1) % count],
},
sources,
tolerance,
)
})
.collect();
let points = (0..count)
.map(|index| {
let point = ring[index];
let incoming = edge_curves[(index + count - 1) % count].as_ref();
let outgoing = edge_curves[index].as_ref();
match (incoming, outgoing) {
(Some(a), Some(b)) if a != b => nearest_crossing(a, b, point, tolerance)
.unwrap_or_else(|| project_to_curve(b, point)),
(Some(curve), _) | (_, Some(curve)) => project_to_curve(curve, point),
_ => point,
}
})
.collect();
(points, edge_curves)
}
fn normalized_delta(from: f64, to: f64) -> f64 {
(to - from + std::f64::consts::PI)
.rem_euclid(std::f64::consts::TAU)
- std::f64::consts::PI
}
fn curve_edge_bulge(curve: &Curve, start: [f64; 2], end: [f64; 2]) -> f64 {
let direct = match curve {
Curve::Circle(circle) => Some((circle.centre, circle.radius)),
Curve::Arc(arc) => Some((arc.centre, arc.radius)),
_ => None,
};
if let Some((centre, radius)) = direct {
if radius.abs() <= 1.0e-12 {
return 0.0;
}
let start_angle = (start[1] - centre[1]).atan2(start[0] - centre[0]);
let end_angle = (end[1] - centre[1]).atan2(end[0] - centre[0]);
return (normalized_delta(start_angle, end_angle) * 0.25).tan();
}
0.0
}
fn boundary_polyline(
ring: &[[f64; 2]],
plane: WorkingPlane,
sources: &rustc_hash::FxHashMap<Handle, BoundarySource>,
tolerance: f64,
) -> Option<EntityType> {
let (points, edge_curves) = refined_boundary_ring(ring, sources, tolerance);
if points.len() < 3 {
return None;
}
let mut polyline = acadrust::entities::LwPolyline::new();
polyline.is_closed = true;
polyline.vertices = points
.iter()
.enumerate()
.map(|(index, point)| {
let mut vertex = acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(
point[0], point[1],
));
vertex.bulge = edge_curves
.get(index)
.and_then(|curve| curve.as_ref())
.map(|curve| {
curve_edge_bulge(curve, *point, points[(index + 1) % points.len()])
})
.unwrap_or(0.0);
vertex
})
.collect();
Some(plane.place_entity(EntityType::LwPolyline(polyline)))
}
pub(crate) fn boundary_polyline_entities(
regions: &[Vec<Vec<[f64; 2]>>],
plane: WorkingPlane,
sources: &rustc_hash::FxHashMap<Handle, BoundarySource>,
tolerance: f64,
) -> Vec<EntityType> {
let mut rings = Vec::new();
for ring in regions.iter().flat_map(|region| region.iter()) {
if !rings.iter().any(|existing| *existing == ring) {
rings.push(ring);
}
}
rings
.into_iter()
.filter_map(|ring| boundary_polyline(ring, plane, sources, tolerance))
.collect()
}
pub(crate) fn boundary_entities_from_sources(
rings: &[Vec<[f64; 2]>],
plane: WorkingPlane,
sources: &rustc_hash::FxHashMap<Handle, BoundarySource>,
tolerance: f64,
) -> Vec<EntityType> {
rings
.iter()
.filter_map(|ring| boundary_polyline(ring, plane, sources, tolerance))
.collect()
}
impl Scene {
pub(crate) fn replace_hatch_association(&mut self,handle:Handle,paths:Vec<acadrust::entities::BoundaryPath>) {
let Some(EntityType::Hatch(hatch))=self.document.get_entity_mut(handle) else{return;};
hatch.paths=paths;
hatch.is_associative=true;
self.associative_hatch_source_cache.borrow_mut().take();
self.refresh_fill_model(handle);
}
pub(crate) fn edit_hatch_boundary_handles(
&mut self,
hatch_handle: Handle,
handles: &[Handle],
add: bool,
) -> bool {
let Some(EntityType::Hatch(hatch)) = self.document.get_entity_mut(hatch_handle) else {
return false;
};
let Some(path) = hatch.paths.first_mut() else {
return false;
};
if add {
for handle in handles.iter().copied().filter(|handle| handle.is_valid()) {
if !path.boundary_handles.contains(&handle) {
path.boundary_handles.push(handle);
}
}
} else {
path.boundary_handles.retain(|handle| !handles.contains(handle));
}
if path.boundary_handles.is_empty() {
path.flags.set_external(false);
} else {
path.flags.set_external(true);
}
hatch.is_associative = hatch
.paths
.iter()
.any(|candidate| !candidate.boundary_handles.is_empty());
self.associative_hatch_source_cache.borrow_mut().take();
if add && !handles.is_empty() {
let changes: Vec<_> = handles
.iter()
.copied()
.map(|handle| (handle, ChangeKind::Modified))
.collect();
self.refresh_associative_hatches(&changes);
} else {
self.refresh_fill_model(hatch_handle);
}
true
}
fn associative_hatch_dependents(
&self,
changed: &rustc_hash::FxHashSet<Handle>,
) -> Vec<Handle> {
if self.associative_hatch_source_cache.borrow().is_none() {
let mut index: HashMap<Handle, Vec<Handle>> = HashMap::default();
for entity in self.document.entities() {
let EntityType::Hatch(hatch) = entity else {
continue;
};
if !hatch.is_associative {
continue;
}
for source in hatch
.paths
.iter()
.flat_map(|path| path.boundary_handles.iter().copied())
{
let dependents = index.entry(source).or_default();
if !dependents.contains(&hatch.common.handle) {
dependents.push(hatch.common.handle);
}
}
}
*self.associative_hatch_source_cache.borrow_mut() = Some(index);
}
let cache = self.associative_hatch_source_cache.borrow();
let index = cache.as_ref().expect("associative hatch index");
let mut handles = rustc_hash::FxHashSet::default();
for source in changed {
if let Some(dependents) = index.get(source) {
handles.extend(dependents.iter().copied());
}
}
handles.into_iter().collect()
}
pub(crate) fn refresh_associative_hatches(
&mut self,
changes: &[(Handle, ChangeKind)],
) -> Vec<(Handle, ChangeKind)> {
if changes.is_empty() {
return Vec::new();
}
let changed: rustc_hash::FxHashSet<_> =
changes.iter().map(|(handle, _)| *handle).collect();
let candidates: Vec<_> = self
.associative_hatch_dependents(&changed)
.into_iter()
.filter_map(|handle| {
let EntityType::Hatch(hatch) = self.document.get_entity(handle)? else {
return None;
};
Some({
let seeds = hatch.paths.iter().map(hatch_path_seed).collect::<Vec<_>>();
(handle, hatch.clone(), seeds)
})
})
.collect();
let mut refreshed = Vec::new();
for (handle, mut hatch, seeds) in candidates {
let storage = crate::entities::curve::ocs_plane(hatch.normal, hatch.elevation);
let plane = WorkingPlane::new(
glam::DVec3::from_array(storage.origin),
glam::DVec3::from_array(storage.x_axis),
glam::DVec3::from_array(storage.y_axis),
);
let all_sources = self.boundary_sources_on_plane(plane, WELD_TOLERANCE);
let mut modified = false;
let mut association_changed = false;
for (index, path) in hatch.paths.iter_mut().enumerate() {
if !path
.boundary_handles
.iter()
.any(|source| changed.contains(source))
{
continue;
}
let old_count = path.boundary_handles.len();
path.boundary_handles
.retain(|source| self.document.get_entity(*source).is_some());
if path.boundary_handles.is_empty() {
path.flags.set_external(false);
}
association_changed |= path.boundary_handles.len() != old_count;
modified |= association_changed;
// Finite extents for clipping unbounded live sources (rays /
// construction lines) below. `all_sources` already went
// through the same clipping in `boundary_sources_on_plane`.
let all_segments: Vec<Line> = all_sources
.values()
.flat_map(|source| source.segments.iter().copied())
.collect();
let clip_bounds = segments_bounds(&all_segments, WELD_TOLERANCE);
let sources: rustc_hash::FxHashMap<_, _> = path
.boundary_handles
.iter()
.filter_map(|source| {
// Associative boundaries are explicit document references.
// During a grip drag their display wire can be preview-hidden,
// so prefer the live entity geometry over the resident wire cache.
let geometry = self
.document
.get_entity(*source)
.and_then(|entity| {
entity_boundary_source_on_plane(
entity,
plane,
WELD_TOLERANCE,
clip_bounds,
)
})
.or_else(|| all_sources.get(source).cloned());
geometry.map(|geometry| (*source, geometry))
})
.collect();
let segments: Vec<_> = sources
.values()
.flat_map(|source| source.segments.iter().copied())
.collect();
let faces = bounded_faces(&segments, Tolerance::new(WELD_TOLERANCE));
let Some(face) = matching_face(&faces, seeds.get(index).copied().flatten()) else {
continue;
};
let exterior = [path.flags.is_outermost()];
if let Some(exact) = exact_hatch_paths(
std::slice::from_ref(face),
&exterior,
&sources,
WELD_TOLERANCE,
)
.into_iter()
.next()
{
path.edges = exact.edges;
}
modified = true;
}
hatch.is_associative = hatch
.paths
.iter()
.any(|path| !path.boundary_handles.is_empty());
if !modified {
continue;
}
if association_changed {
self.associative_hatch_source_cache.borrow_mut().take();
}
if self.is_recording_undo() {
let before = self.document.get_entity_arc(handle);
self.record_undo_before(handle, before);
}
if let Some(slot) = self.document.get_entity_mut(handle) {
*slot = EntityType::Hatch(hatch);
self.refresh_fill_model(handle);
refreshed.push((handle, ChangeKind::Modified));
}
}
refreshed
}
/// Boundary candidates in the active working plane, with exact curves
/// where the source entity exposes them.
pub fn boundary_sources_on_plane(
&self,
plane: WorkingPlane,
tolerance: f64,
) -> rustc_hash::FxHashMap<Handle, BoundarySource> {
let mut sources: rustc_hash::FxHashMap<Handle, BoundarySource> =
rustc_hash::FxHashMap::default();
let wires = if let Some(viewport) = self.active_viewport {
self.model_wires_for_viewport_arc(viewport, 0.0)
} else if self.current_layout == "Model" {
let camera = self.camera.borrow().clone();
self.model_tile_wires_arc(0, &camera, 1.0, 1.0)
} else {
self.paper_sheet_wires_arc()
};
for wire in wires.iter() {
let Some(handle) = Self::handle_from_wire_name(&wire.name) else {
continue;
};
let segments = wire_segments_on_plane(wire, plane, tolerance);
if segments.is_empty() {
continue;
}
sources
.entry(handle)
.or_insert_with(|| BoundarySource {
segments: Vec::new(),
curves: Vec::new(),
})
.segments
.extend(segments);
}
// Finite extents of the wire geometry, so rays and infinite
// construction lines clip to something real instead of tessellating
// to a unit stub (see `live_segments_for_curves`).
let all_wire_segments: Vec<Line> = sources
.values()
.flat_map(|source| source.segments.iter().copied())
.collect();
let bounds = segments_bounds(&all_wire_segments, tolerance);
for (&handle, source) in &mut sources {
let curves = self
.document
.get_entity(handle)
.map(|entity| entity_curves_on_plane(entity, plane, tolerance))
.unwrap_or_default();
// Associative hatch regeneration must use the entity's live geometry,
// not a potentially one-frame-old display wire. Grip edits and STRETCH
// mutate the document before the resident wire cache catches up.
let live_segments = live_segments_for_curves(&curves, bounds, tolerance);
if !live_segments.is_empty() {
source.segments = live_segments;
source.curves = curves;
} else {
// Fallback for entity types that do not expose usable native curves:
// retain the existing display-wire geometry.
source.curves = curves;
source.curves.retain(|curve| {
source.segments.iter().any(|segment| {
distance_to(curve, segment.start).max(distance_to(curve, segment.end))
<= tolerance * 4.0
})
});
}
}
sources
}
}
/// 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>) {
let edges: Vec<_> = path
.edges
.iter()
.filter_map(crate::entities::hatch::edge_curve)
.map(|curve| curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE))
.collect();
super::entity::chain_path_edges_with_directions(edges)
}
pub(crate) fn hatch_path_ring(path: &acadrust::entities::BoundaryPath) -> Option<Vec<[f64; 2]>> {
let (ring, _) = hatch_path_geometry(path);
(ring.len() >= 3).then_some(ring)
}
pub(crate) fn hatch_path_directions(path: &acadrust::entities::BoundaryPath) -> Vec<f64> {
hatch_path_geometry(path).1
}
pub(crate) fn hatch_boundary_rings(hatch: &acadrust::entities::Hatch) -> Vec<Vec<[f64; 2]>> {
hatch.paths.iter().filter_map(hatch_path_ring).collect()
}
pub(crate) fn separated_hatch_path_groups(
hatch: &acadrust::entities::Hatch,
) -> Vec<Vec<acadrust::entities::BoundaryPath>> {
let items: Vec<_> = hatch
.paths
.iter()
.filter_map(|path| hatch_path_ring(path).map(|ring| (path.clone(), ring)))
.collect();
let rings: Vec<_> = items.iter().map(|(_, ring)| ring.clone()).collect();
let depths = ring_nesting_depths(&rings);
let outer_indices: Vec<_> = depths
.iter()
.enumerate()
.filter_map(|(index, depth)| (*depth == 0).then_some(index))
.collect();
let mut groups: Vec<_> = outer_indices
.iter()
.map(|index| vec![items[*index].0.clone()])
.collect();
for (index, (path, ring)) in items.iter().enumerate() {
if depths.get(index) == Some(&0) {
continue;
}
let Some(seed) = ring.first().copied() else {
continue;
};
let owner = outer_indices
.iter()
.enumerate()
.filter(|(_, outer)| {
contains(
&face_curves(&rings[**outer]),
seed,
Tolerance::new(WELD_TOLERANCE),
)
})
.min_by(|(_, left), (_, right)| {
signed_area(&rings[**left])
.abs()
.total_cmp(&signed_area(&rings[**right]).abs())
})
.map(|(group, _)| group);
if let Some(owner) = owner {
groups[owner].push(path.clone());
} else {
groups.push(vec![path.clone()]);
}
}
groups
}
pub(crate) fn boundary_faces(
sources: &rustc_hash::FxHashMap<Handle, BoundarySource>,
tolerance: f64,
) -> Vec<Vec<[f64; 2]>> {
let segments: Vec<_> = sources
.values()
.flat_map(|source| source.segments.iter().copied())
.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_source_clips_to_given_bounds() {
let xline = 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 source = entity_boundary_source_on_plane(
&xline,
crate::command::WorkingPlane::default(),
1.0e-6,
Some(([0.0, 0.0], [10.0, 10.0])),
)
.expect("xline crossing the box must produce a source");
assert_eq!(source.segments.len(), 1);
let segment = &source.segments[0];
assert!((segment.start[0] - 5.0).abs() < 1e-6, "got {:?}", segment.start);
assert!((segment.start[1] - 0.0).abs() < 1e-6, "got {:?}", segment.start);
assert!((segment.end[0] - 5.0).abs() < 1e-6, "got {:?}", segment.end);
assert!((segment.end[1] - 10.0).abs() < 1e-6, "got {:?}", segment.end);
}
#[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"
);
}
}