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, pub curves: Vec, } /// 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 { let mut segments = Vec::new(); let mut previous: Option = 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 { 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 { 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 { 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::>(), Curve::XLine(line) => clip_bounds .and_then(|(min, max)| { clip_unbounded_to_bounds( line.base, line.direction, false, min, max, tolerance, ) }) .into_iter() .collect::>(), _ => 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::>(), } }) .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::() / ring.len() as f64, ring.iter().map(|point| point[1]).sum::() / ring.len() as f64, ]) } } fn face_curves(face: &[[f64; 2]]) -> Vec { 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, ) -> Vec { 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, tolerance: f64, ) -> Vec { 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 { 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, tolerance: f64, ) -> Option { 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, tolerance: f64, ) -> (Vec<[f64; 2]>, Vec>) { 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, tolerance: f64, ) -> Option { 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>], plane: WorkingPlane, sources: &rustc_hash::FxHashMap, tolerance: f64, ) -> Vec { 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, tolerance: f64, ) -> Vec { 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) { 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, ) -> Vec { if self.associative_hatch_source_cache.borrow().is_none() { let mut index: HashMap> = 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::>(); (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 = 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 { let mut sources: rustc_hash::FxHashMap = 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 = 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 { 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) { 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> { let (ring, _) = hatch_path_geometry(path); (ring.len() >= 3).then_some(ring) } pub(crate) fn hatch_path_directions(path: &acadrust::entities::BoundaryPath) -> Vec { hatch_path_geometry(path).1 } pub(crate) fn hatch_boundary_rings(hatch: &acadrust::entities::Hatch) -> Vec> { hatch.paths.iter().filter_map(hatch_path_ring).collect() } pub(crate) fn separated_hatch_path_groups( hatch: &acadrust::entities::Hatch, ) -> Vec> { 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, tolerance: f64, ) -> Vec> { 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" ); } }