Complete hatch creation and editing workflow

This commit is contained in:
ramox81 committed 2026-08-21 12:57:19 +03:00
1 parent 573ed3245e
commit f83037271c
18 files changed
+1248 -338

No files matched your search

+282 -117
View File
@@ -21,31 +21,6 @@ pub struct BoundarySource {
/// enough not to weld genuinely separate corners together.
const WELD_TOLERANCE: f64 = 1.0e-6;
fn wire_segments(wire: &WireModel) -> Vec<Line> {
let mut segments = Vec::new();
let mut previous: Option<[f64; 2]> = None;
for (index, high) in wire.points.iter().copied().enumerate() {
if !high[0].is_finite() || !high[1].is_finite() {
previous = None;
continue;
}
let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
let current = [
high[0] as f64 + low[0] as f64,
high[1] as f64 + low[1] as f64,
];
if let Some(start) = previous {
let (dx, dy) = (current[0] - start[0], current[1] - start[1]);
if dx.hypot(dy) > WELD_TOLERANCE {
segments.push(Line { start, end: current });
}
}
previous = Some(current);
}
segments
}
fn wire_segments_on_plane(
wire: &WireModel,
plane: WorkingPlane,
@@ -113,22 +88,12 @@ fn entity_curves_on_plane(
}
}
fn ring_seed(model: &HatchModel, wanted: usize) -> Option<[f64; 2]> {
fn hatch_path_seed(path: &acadrust::entities::BoundaryPath) -> Option<[f64; 2]> {
let mut ring = Vec::new();
let mut index = 0usize;
for &[x, y] in model.boundary.iter() {
if x.is_finite() && y.is_finite() {
if index == wanted {
ring.push([
model.world_origin[0] + x as f64,
model.world_origin[1] + y as f64,
]);
}
} else if index == wanted {
break;
} else {
index += 1;
}
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;
@@ -136,15 +101,16 @@ fn ring_seed(model: &HatchModel, wanted: usize) -> Option<[f64; 2]> {
let (points, triangles) = triangulate(&ring, &[]);
if let Some(triangle) = triangles.first() {
let [a, b, c] = triangle.map(|vertex| points[vertex]);
return Some([
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,
])
}
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> {
@@ -181,7 +147,7 @@ fn matching_face(faces: &[Vec<[f64; 2]>], seed: Option<[f64; 2]>) -> Option<&Vec
pub(crate) fn ring_source_handles(
ring: &[[f64; 2]],
sources: &rustc_hash::FxHashMap<acadrust::Handle, Vec<Line>>,
sources: &rustc_hash::FxHashMap<acadrust::Handle, BoundarySource>,
) -> Vec<acadrust::Handle> {
let mut handles = rustc_hash::FxHashSet::default();
for (&start, &end) in ring
@@ -191,8 +157,8 @@ pub(crate) fn ring_source_handles(
{
let edge = Line { start, end };
let edge_length = (end[0] - start[0]).hypot(end[1] - start[1]);
for (&handle, lines) in sources {
if lines.iter().any(|line| {
for (&handle, source) in sources {
if source.segments.iter().any(|line| {
matches!(
segment_crossing(edge, *line, Tolerance::new(WELD_TOLERANCE)),
SegmentCrossing::Overlap { a, .. }
@@ -208,6 +174,203 @@ pub(crate) fn ring_source_handles(
handles
}
fn curve_forward(curve: &Curve, start: [f64; 2], next: [f64; 2]) -> bool {
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 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 start_angle = (start[1] - circle.centre[1]).atan2(start[0] - circle.centre[0]);
let mut end_angle = (end[1] - circle.centre[1]).atan2(end[0] - circle.centre[0]);
if whole_curve {
end_angle = start_angle + std::f64::consts::TAU;
}
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 start_angle = (start[1] - arc.centre[1]).atan2(start[0] - arc.centre[0]);
let end_angle = (end[1] - arc.centre[1]).atan2(end[0] - arc.centre[0]);
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 mut start_parameter = arc.start_parameter + curve.parameter_at(start) * arc.sweep();
let mut end_parameter = arc.start_parameter + curve.parameter_at(end) * arc.sweep();
if !forward {
std::mem::swap(&mut start_parameter, &mut end_parameter);
}
if whole_curve {
end_parameter = start_parameter + std::f64::consts::TAU;
}
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))
}
.unwrap_or_else(|| source.clone());
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 tessellated rings as analytic hatch paths wherever their
/// source entity exposes an exact curve. Intersections remain the graph's
/// vertices, while the edge between them is stored as a trimmed source curve.
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]>]) -> Vec<acadrust::EntityType> {
rings
.iter()
@@ -409,11 +572,43 @@ pub(crate) fn boundary_polyline_entities(
}
impl Scene {
fn associative_boundary_segments(&self, handles: &[Handle]) -> Vec<Line> {
self.wire_models_for(handles)
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));
}
hatch.is_associative = hatch
.paths
.iter()
.flat_map(wire_segments)
.collect()
.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(
@@ -470,15 +665,7 @@ impl Scene {
return None;
};
Some({
let seeds = self
.hatches
.get(&hatch.common.handle)
.map(|model| {
(0..hatch.paths.len())
.map(|index| ring_seed(model, index))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let seeds = hatch.paths.iter().map(hatch_path_seed).collect::<Vec<_>>();
(handle, hatch.clone(), seeds)
})
})
@@ -486,10 +673,13 @@ impl Scene {
let mut refreshed = Vec::new();
for (handle, mut hatch, seeds) in candidates {
let normal = hatch.normal;
if normal.x.abs() > 1.0e-8 || normal.y.abs() > 1.0e-8 {
continue;
}
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() {
@@ -505,19 +695,36 @@ impl Scene {
.retain(|source| self.document.get_entity(*source).is_some());
association_changed |= path.boundary_handles.len() != old_count;
modified |= association_changed;
let segments = self.associative_boundary_segments(&path.boundary_handles);
let sources: rustc_hash::FxHashMap<_, _> = path
.boundary_handles
.iter()
.filter_map(|source| {
all_sources
.get(source)
.cloned()
.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;
};
path.edges = vec![acadrust::entities::hatch::BoundaryEdge::Polyline(
acadrust::entities::hatch::PolylineEdge::new(
face.iter()
.map(|point| acadrust::types::Vector2::new(point[0], point[1]))
.collect(),
true,
),
)];
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
@@ -543,48 +750,6 @@ impl Scene {
refreshed
}
/// Build closed planar regions from the visible wire geometry.
///
/// Source entities do not need to be closed individually. Intersections are
/// inserted as temporary graph vertices and
/// the bounded faces of that planar graph are returned as hatch candidates.
///
/// Curved entities participate through their already-tessellated WireModel
/// geometry, so arcs, circles, ellipses and splines can take part in the
/// boundary search without modifying the source entities.
///
/// The arrangement itself is the kernel's: splitting at crossings, welding
/// coincident ends and tracing the bounded faces is the same problem a
/// B-rep boolean solves in a face's parameter space, and it is solved
/// once. What stays here is reading the wires — which is where the
/// drawing's own conventions live.
pub fn hatch_boundary_outlines(&self) -> Vec<Vec<[f64; 2]>> {
let mut segments = Vec::<Line>::new();
for wire in self.entity_wires().iter() {
segments.extend(wire_segments(wire));
}
bounded_faces(&segments, Tolerance::new(WELD_TOLERANCE))
}
/// Tessellated boundary segments grouped by their selectable entity.
pub fn hatch_boundary_sources(
&self,
) -> rustc_hash::FxHashMap<acadrust::Handle, Vec<Line>> {
let mut sources = rustc_hash::FxHashMap::default();
for wire in self.entity_wires().iter() {
let Some(handle) = Self::handle_from_wire_name(&wire.name) else {
continue;
};
sources
.entry(handle)
.or_insert_with(Vec::new)
.extend(wire_segments(wire));
}
sources
}
/// Boundary candidates in the active working plane, with exact curves
/// where the source entity exposes them.
pub fn boundary_sources_on_plane(
+51 -38
View File
@@ -1469,6 +1469,8 @@ impl Scene {
fill_plane_boundary,
boundary_exterior: None,
boundary_sources: None,
boundary_paths: None,
style: acadrust::entities::HatchStyleType::Normal,
pattern: model::hatch_model::HatchPattern::Solid,
name: "WIPEOUT_FILL".into(),
color,
@@ -1885,6 +1887,8 @@ impl Scene {
fill_plane_boundary: None,
boundary_exterior: Some(std::sync::Arc::new(boundary_exterior)),
boundary_sources: Some(std::sync::Arc::new(boundary_sources)),
boundary_paths: Some(std::sync::Arc::new(dxf.paths.clone())),
style: dxf.style,
pattern,
name,
// A gradient starts from its first stop; other fills use the
@@ -2203,6 +2207,8 @@ impl Scene {
fill_plane_boundary: None,
boundary_exterior: None,
boundary_sources: None,
boundary_paths: None,
style: acadrust::entities::HatchStyleType::Normal,
pattern: model::hatch_model::HatchPattern::Solid,
name: "SOLID".into(),
color,
@@ -2222,14 +2228,20 @@ impl Scene {
entity_style: Option<(acadrust::types::Color, acadrust::types::Transparency)>,
) -> Handle {
let mut dxf = DxfHatch::new();
dxf.style = model.style;
dxf.is_solid = matches!(
model.pattern,
crate::scene::model::hatch_model::HatchPattern::Solid
);
// Prefer exact command geometry; otherwise reconstruct every ring from
// the render offsets without dropping its separators.
// Build one DXF path per NaN-separated ring and retain each outer/hole role.
let reconstructed_wcs: Vec<[f64; 2]> = if model.boundary_wcs.is_none() {
// Keep analytic command geometry when available. The tessellated model
// remains the render representation only; it must not replace circles,
// ellipse arcs or splines in the persisted entity.
if let Some(paths) = model.boundary_paths.as_deref() {
dxf.paths = paths.clone();
} else {
// Otherwise reconstruct every ring from the render offsets without
// dropping its separators.
let reconstructed_wcs: Vec<[f64; 2]> = if model.boundary_wcs.is_none() {
let [wx, wy] = model.world_origin;
model
.boundary
@@ -2242,18 +2254,18 @@ impl Scene {
}
})
.collect()
} else {
Vec::new()
};
let wcs = model
.boundary_wcs
.as_deref()
.map(|points| points.as_slice())
.unwrap_or(reconstructed_wcs.as_slice());
let mut ring: Vec<Vector2> = Vec::new();
let mut first = true;
let mut ring_index = 0usize;
let mut push_ring = |r: &mut Vec<Vector2>, is_outer: bool, index: usize| {
} else {
Vec::new()
};
let wcs = model
.boundary_wcs
.as_deref()
.map(|points| points.as_slice())
.unwrap_or(reconstructed_wcs.as_slice());
let mut ring: Vec<Vector2> = Vec::new();
let mut first = true;
let mut ring_index = 0usize;
let mut push_ring = |r: &mut Vec<Vector2>, is_outer: bool, index: usize| {
if !r.is_empty() {
let edge = PolylineEdge::new(std::mem::take(r), true);
let handles: Vec<_> = model
@@ -2281,31 +2293,32 @@ impl Scene {
}
dxf.paths.push(path);
}
};
for &[x, y] in wcs {
if x.is_finite() && y.is_finite() {
ring.push(Vector2::new(x, y));
} else {
let is_outer = model
.boundary_exterior
.as_deref()
.and_then(|roles| roles.get(ring_index))
.copied()
.unwrap_or(first);
first = false;
if !ring.is_empty() {
push_ring(&mut ring, is_outer, ring_index);
ring_index += 1;
};
for &[x, y] in wcs {
if x.is_finite() && y.is_finite() {
ring.push(Vector2::new(x, y));
} else {
let is_outer = model
.boundary_exterior
.as_deref()
.and_then(|roles| roles.get(ring_index))
.copied()
.unwrap_or(first);
first = false;
if !ring.is_empty() {
push_ring(&mut ring, is_outer, ring_index);
ring_index += 1;
}
}
}
let is_outer = model
.boundary_exterior
.as_deref()
.and_then(|roles| roles.get(ring_index))
.copied()
.unwrap_or(first);
push_ring(&mut ring, is_outer, ring_index);
}
let is_outer = model
.boundary_exterior
.as_deref()
.and_then(|roles| roles.get(ring_index))
.copied()
.unwrap_or(first);
push_ring(&mut ring, is_outer, ring_index);
dxf.is_associative = dxf
.paths
.iter()
+4 -2
View File
@@ -35,8 +35,8 @@ mod scene_markers;
mod selection;
pub(crate) use boundary::{
boundary_entities, boundary_faces, boundary_polyline_entities, ring_source_handles,
BoundarySource,
boundary_entities, boundary_faces, boundary_polyline_entities, exact_hatch_paths,
ring_source_handles, BoundarySource,
};
// Parallel tessellation free functions live in `convert::tess` (alongside the
@@ -3215,6 +3215,8 @@ impl Scene {
fill_plane_boundary: None,
boundary_exterior: None,
boundary_sources: None,
boundary_paths: None,
style: acadrust::entities::HatchStyleType::Normal,
pattern: crate::scene::model::hatch_model::HatchPattern::Solid,
name: "SOLID".to_string(),
color: self.paper_bg_color,
+7
View File
@@ -210,6 +210,13 @@ pub struct HatchModel {
pub boundary_exterior: Option<Arc<Vec<bool>>>,
/// Source entity handles for each boundary ring.
pub boundary_sources: Option<Arc<Vec<Vec<acadrust::Handle>>>>,
/// Exact persisted boundary paths for draw/edit workflows. Rendering keeps
/// using the compact tessellated boundary above, while persistence can
/// retain analytic arcs, ellipses and splines without rebuilding them as
/// straight polyline chords.
pub boundary_paths: Option<Arc<Vec<acadrust::entities::BoundaryPath>>>,
/// Island handling used by the persisted hatch entity.
pub style: acadrust::entities::HatchStyleType,
/// Fill pattern.
pub pattern: HatchPattern,
/// Catalog name for this pattern (e.g. "ANSI31", "SOLID", "LINEAR").
+2
View File
@@ -162,6 +162,8 @@ impl Scene {
fill_plane_boundary: None,
boundary_exterior: None,
boundary_sources: None,
boundary_paths: None,
style: acadrust::entities::HatchStyleType::Normal,
pattern: HatchPattern::Solid,
name: "AREA_PREVIEW".into(),
color: [0.0; 4],