fix: correct hatch geometry workflow

This commit is contained in:
Hakan Seven committed 2026-08-21 20:52:03 +03:00
1 parent f83037271c
commit 4bc02b6b4a
11 files changed
+401 -255

No files matched your search

+139 -31
View File
@@ -1,8 +1,9 @@
use super::*;
use cadkernel::geom2d::{
bounded_faces, contains, distance_to, intersect, segment_crossing, triangulate, Curve, Line,
SegmentCrossing, Tolerance, Transform as CurveTransform,
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;
@@ -13,12 +14,7 @@ pub struct BoundarySource {
pub curves: Vec<Curve>,
}
/// How far apart two points may be and still be taken for the same one.
///
/// The boundary search runs on already-tessellated wire geometry, so the
/// input is a chord approximation of the drawn curves to begin with; this
/// only has to be coarse enough to close the gaps that leaves and fine
/// enough not to weld genuinely separate corners together.
/// Boundary welding tolerance for tessellated wires.
const WELD_TOLERANCE: f64 = 1.0e-6;
fn wire_segments_on_plane(
@@ -188,6 +184,19 @@ fn curve_forward(curve: &Curve, start: [f64; 2], next: [f64; 2]) -> bool {
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],
@@ -213,11 +222,10 @@ fn exact_boundary_edge(
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;
}
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,
@@ -227,8 +235,10 @@ fn exact_boundary_edge(
})
}
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]);
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,
@@ -239,14 +249,10 @@ fn exact_boundary_edge(
}
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;
}
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(
@@ -264,8 +270,13 @@ fn exact_boundary_edge(
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 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,
@@ -294,9 +305,7 @@ fn exact_boundary_edge(
}
}
/// 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.
/// Rebuild detected rings from exact source curves.
pub(crate) fn exact_hatch_paths(
rings: &[Vec<[f64; 2]>],
exterior: &[bool],
@@ -371,7 +380,10 @@ pub(crate) fn exact_hatch_paths(
.collect()
}
pub(crate) fn boundary_entities(rings: &[Vec<[f64; 2]>]) -> Vec<acadrust::EntityType> {
pub(crate) fn boundary_entities(
rings: &[Vec<[f64; 2]>],
plane: WorkingPlane,
) -> Vec<acadrust::EntityType> {
rings
.iter()
.filter_map(|ring| {
@@ -397,7 +409,7 @@ pub(crate) fn boundary_entities(rings: &[Vec<[f64; 2]>]) -> Vec<acadrust::Entity
acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(x, y))
})
.collect();
Some(acadrust::EntityType::LwPolyline(polyline))
Some(plane.place_entity(acadrust::EntityType::LwPolyline(polyline)))
})
.collect()
}
@@ -458,7 +470,7 @@ fn nearest_crossing(a: &Curve, b: &Curve, point: [f64; 2], tolerance: f64) -> Op
}
fn project_to_curve(curve: &Curve, point: [f64; 2]) -> [f64; 2] {
curve.point_at(curve.parameter_at(point))
closest_point(curve, point).point
}
fn refined_boundary_ring(
@@ -571,6 +583,18 @@ pub(crate) fn boundary_polyline_entities(
.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 edit_hatch_boundary_handles(
&mut self,
@@ -593,6 +617,11 @@ impl Scene {
} 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()
@@ -693,6 +722,9 @@ impl Scene {
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;
let sources: rustc_hash::FxHashMap<_, _> = path
@@ -801,6 +833,82 @@ impl Scene {
}
}
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,
+76 -50
View File
@@ -1,41 +1,33 @@
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
use super::*;
/// Convert a HATCH's own resolved pattern line (world-unit `offset` = step to
/// the next parallel line, plus a base angle) into a render `PatFamily` whose
/// geometry is already final — the HatchModel that carries it uses scale 1 and
/// angle_offset 0 (see `prebaked` in `hatch_model_from_dxf`). The world-space
/// offset is rotated into the line's local frame so `pattern_segments` and the
/// GPU shader, which rotate `(dx, dy)` back out by the family angle, reproduce
/// the exact stored step. `x0/y0` are filled in by the caller (from the stored
/// `base_point`, relative to `world_origin`) once the boundary anchor is known;
/// they set the pattern origin, observable for dashed / offset patterns.
/// Order a hatch boundary path's sampled edges into one tip-to-tail loop.
///
/// Real files do not store boundary edges as a sequential walk: associative
/// hatches list them in boundary-source-entity order, with arbitrary
/// direction — the next edge in the list may attach to either end of the
/// chain built so far, or belong to the far side of the loop entirely.
/// Concatenating them verbatim draws a self-crossing "bowtie" outline and
/// flips the even-odd fill over the wrong region.
///
/// Greedy nearest-endpoint assembly: keep the chain open at both ends and, at
/// each step, attach the unused edge whose endpoint lies closest to either
/// end (reversing / prepending as needed). Distance comparison, no tolerance:
/// a correctly-ordered file matches at distance 0 and reproduces exactly.
fn chain_path_edges(mut polys: Vec<Vec<[f64; 2]>>) -> Vec<[f64; 2]> {
/// Order sampled boundary edges into one tip-to-tail loop.
pub(super) fn chain_path_edges(polys: Vec<Vec<[f64; 2]>>) -> Vec<[f64; 2]> {
chain_path_edges_with_directions(polys).0
}
pub(super) fn chain_path_edges_with_directions(
polys: Vec<Vec<[f64; 2]>>,
) -> (Vec<[f64; 2]>, Vec<f64>) {
let d2 = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2);
polys.retain(|p| !p.is_empty());
let mut directions = vec![0.0; polys.len()];
let mut polys: Vec<_> = polys
.into_iter()
.enumerate()
.filter(|(_, points)| !points.is_empty())
.collect();
if polys.is_empty() {
return Vec::new();
return (Vec::new(), directions);
}
let mut chain: std::collections::VecDeque<[f64; 2]> = polys.swap_remove(0).into();
let (first_index, first) = polys.swap_remove(0);
directions[first_index] = 1.0;
let mut chain: std::collections::VecDeque<[f64; 2]> = first.into();
while !polys.is_empty() {
let head = *chain.front().unwrap();
let tail = *chain.back().unwrap();
// (distance, index, reverse-points, attach-at-front)
let mut best = (f64::MAX, 0usize, false, false);
for (i, p) in polys.iter().enumerate() {
for (i, (_, p)) in polys.iter().enumerate() {
let s = p[0];
let e = *p.last().unwrap();
for c in [
@@ -50,7 +42,8 @@ fn chain_path_edges(mut polys: Vec<Vec<[f64; 2]>>) -> Vec<[f64; 2]> {
}
}
let (_, idx, rev, at_front) = best;
let mut p = polys.swap_remove(idx);
let (original_index, mut p) = polys.swap_remove(idx);
directions[original_index] = if rev { -1.0 } else { 1.0 };
if rev {
p.reverse();
}
@@ -71,7 +64,7 @@ fn chain_path_edges(mut polys: Vec<Vec<[f64; 2]>>) -> Vec<[f64; 2]> {
chain.extend(it);
}
}
chain.into()
(chain.into(), directions)
}
fn family_from_stored_line(
@@ -1622,6 +1615,7 @@ impl Scene {
}
let mut rings = Vec::new();
let mut local_rings = Vec::new();
let mut ring_sources = Vec::new();
for path in &dxf.paths {
@@ -1636,26 +1630,27 @@ impl Scene {
for edge in &path.edges {
if let Some(curve) = crate::entities::hatch::edge_curve(edge) {
edge_polys.push(
curve
.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE)
.into_iter()
.map(|point| to_xy(point[0], point[1]))
.collect(),
curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE),
);
}
}
let mut ring = chain_path_edges(edge_polys);
if ring.is_empty() {
let mut local_ring = chain_path_edges(edge_polys);
if local_ring.is_empty() {
continue;
}
if ring.len() >= 3 {
let first = ring[0];
let last = *ring.last().unwrap();
if local_ring.len() >= 3 {
let first = local_ring[0];
let last = *local_ring.last().unwrap();
if (first[0] - last[0]).abs() > 1e-5 || (first[1] - last[1]).abs() > 1e-5 {
ring.push(first);
local_ring.push(first);
}
}
let ring = local_ring
.iter()
.map(|point| to_xy(point[0], point[1]))
.collect();
rings.push(ring);
local_rings.push(local_ring);
ring_sources.push(path.boundary_handles.clone());
}
@@ -1665,9 +1660,15 @@ impl Scene {
let depths = cadkernel::geom2d::ring_nesting_depths(&rings);
let mut boundary = Vec::new();
let mut local_boundary = Vec::new();
let mut boundary_exterior = Vec::new();
let mut boundary_sources = Vec::new();
for ((ring, sources), depth) in rings.into_iter().zip(ring_sources).zip(depths) {
for (((ring, local_ring), sources), depth) in rings
.into_iter()
.zip(local_rings)
.zip(ring_sources)
.zip(depths)
{
let keep = match dxf.style {
acadrust::entities::HatchStyleType::Normal => true,
acadrust::entities::HatchStyleType::Outer => depth <= 1,
@@ -1678,8 +1679,14 @@ impl Scene {
}
if !boundary.is_empty() {
boundary.push([f64::NAN, f64::NAN]);
local_boundary.push([f32::NAN, f32::NAN]);
}
boundary.extend(ring);
local_boundary.extend(
local_ring
.into_iter()
.map(|[x, y]| [x as f32, y as f32]),
);
boundary_exterior.push(depth == 0);
boundary_sources.push(sources);
}
@@ -1879,12 +1886,17 @@ impl Scene {
})
.collect();
let storage = crate::entities::curve::ocs_plane(dxf.normal, dxf.elevation);
Some(HatchModel {
render_instance: None,
boundary: std::sync::Arc::new(boundary_f32),
boundary_wcs: None,
fill_plane: None,
fill_plane_boundary: None,
fill_plane: Some(model::hatch_model::FillPlane {
origin: storage.origin,
x_axis: storage.x_axis,
y_axis: storage.y_axis,
}),
fill_plane_boundary: Some(std::sync::Arc::new(local_boundary)),
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())),
@@ -2229,18 +2241,22 @@ impl Scene {
) -> Handle {
let mut dxf = DxfHatch::new();
dxf.style = model.style;
if let Some(plane) = model.fill_plane {
let x = glam::DVec3::from_array(plane.x_axis);
let y = glam::DVec3::from_array(plane.y_axis);
let normal = x.cross(y).normalize_or(glam::DVec3::Z);
dxf.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z);
dxf.elevation = glam::DVec3::from_array(plane.origin).dot(normal);
}
dxf.is_solid = matches!(
model.pattern,
crate::scene::model::hatch_model::HatchPattern::Solid
);
// 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.
// Persist analytic command geometry when available.
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.
// Otherwise reconstruct every ring from render offsets.
let reconstructed_wcs: Vec<[f64; 2]> = if model.boundary_wcs.is_none() {
let [wx, wy] = model.world_origin;
model
@@ -2328,6 +2344,16 @@ impl Scene {
} else {
1.0
};
let pattern_origin = model
.fill_plane_boundary
.as_deref()
.and_then(|points| {
points
.iter()
.find(|point| point[0].is_finite() && point[1].is_finite())
})
.map(|point| [point[0] as f64, point[1] as f64])
.unwrap_or(model.world_origin);
if let crate::scene::model::hatch_model::HatchPattern::Pattern(families) = &model.pattern {
let mut pattern = acadrust::entities::HatchPattern::new(&model.name);
let rotation = model.angle_offset as f64;
@@ -2345,10 +2371,10 @@ impl Scene {
pattern.lines.push(acadrust::entities::HatchPatternLine {
angle,
base_point: Vector2::new(
model.world_origin[0]
pattern_origin[0]
+ base_x * rotation_cos
- base_y * rotation_sin,
model.world_origin[1]
pattern_origin[1]
+ base_x * rotation_sin
+ base_y * rotation_cos,
),
+3 -2
View File
@@ -35,8 +35,9 @@ mod scene_markers;
mod selection;
pub(crate) use boundary::{
boundary_entities, boundary_faces, boundary_polyline_entities, exact_hatch_paths,
ring_source_handles, BoundarySource,
boundary_entities, boundary_entities_from_sources, boundary_faces,
boundary_polyline_entities, exact_hatch_paths, hatch_boundary_rings, hatch_path_directions,
hatch_path_ring, ring_source_handles, separated_hatch_path_groups, BoundarySource,
};
// Parallel tessellation free functions live in `convert::tess` (alongside the
+2 -5
View File
@@ -203,17 +203,14 @@ pub struct HatchModel {
/// rebuilt from a DXF entity — `add_hatch` then reconstructs the persisted
/// vertices from `boundary` + `world_origin` instead.
pub boundary_wcs: Option<Arc<Vec<[f64; 2]>>>,
/// Optional 3-D placement used by wipeout fills.
/// Optional 3-D placement for planar fills.
pub fill_plane: Option<FillPlane>,
pub fill_plane_boundary: Option<Arc<Vec<[f32; 2]>>>,
/// Per-ring DXF role, aligned with the NaN-separated boundary paths.
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.
/// Exact boundary paths retained for persistence and editing.
pub boundary_paths: Option<Arc<Vec<acadrust::entities::BoundaryPath>>>,
/// Island handling used by the persisted hatch entity.
pub style: acadrust::entities::HatchStyleType,