forked from 1b-agent/opencadstudio-upstream
Preserve narrow spline clearances when tessellating solid fills
Recover failed small spline boundaries with the kernel chord-error sampler, using the same coordinate frame as the fill mesh. Preserve source curves and enforce recovery and total hatch vertex limits. Fixes #1268
This commit is contained in:
1 parent
7aff9fa046
commit
e16ab7c86f
4 files changed
+442
-7
No files matched your search
+28
-7
@@ -1870,7 +1870,7 @@ impl Scene {
|
||||
.map(|point| to_xy(point[0], point[1]))
|
||||
.collect();
|
||||
rings.push(ring);
|
||||
local_rings.push(local_ring);
|
||||
local_rings.push((local_ring, path));
|
||||
ring_sources.push(path.boundary_handles.clone());
|
||||
}
|
||||
|
||||
@@ -1883,7 +1883,8 @@ impl Scene {
|
||||
let mut local_boundary = Vec::new();
|
||||
let mut boundary_exterior = Vec::new();
|
||||
let mut boundary_sources = Vec::new();
|
||||
for (((ring, local_ring), sources), depth) in rings
|
||||
let mut spline_paths = Vec::new();
|
||||
for (((ring, (local_ring, path)), sources), depth) in rings
|
||||
.into_iter()
|
||||
.zip(local_rings)
|
||||
.zip(ring_sources)
|
||||
@@ -1901,12 +1902,14 @@ impl Scene {
|
||||
boundary.push([f64::NAN, f64::NAN]);
|
||||
local_boundary.push([f32::NAN, f32::NAN]);
|
||||
}
|
||||
let range = boundary.len()..boundary.len() + ring.len();
|
||||
boundary.extend(ring);
|
||||
local_boundary.extend(
|
||||
local_ring
|
||||
.into_iter()
|
||||
.map(|[x, y]| [x as f32, y as f32]),
|
||||
);
|
||||
local_boundary.extend(local_ring.iter().map(|&[x, y]| [x as f32, y as f32]));
|
||||
if path.edges.iter().any(|edge| {
|
||||
matches!(edge, acadrust::entities::BoundaryEdge::Spline(_))
|
||||
}) {
|
||||
spline_paths.push((path, local_ring, range));
|
||||
}
|
||||
boundary_exterior.push(depth == 0);
|
||||
boundary_sources.push(sources);
|
||||
}
|
||||
@@ -2095,6 +2098,24 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
}
|
||||
let project = |point: [f64; 2]| {
|
||||
let [x, y] = to_xy(point[0], point[1]);
|
||||
[(x - world_origin[0]) as f32 as f64, (y - world_origin[1]) as f32 as f64]
|
||||
};
|
||||
for (path, ring, range) in spline_paths.into_iter().rev() {
|
||||
if range.end > boundary.len() {
|
||||
continue;
|
||||
}
|
||||
let curves = || path.edges.iter().filter_map(crate::entities::hatch::edge_curve).collect();
|
||||
if let Some(refined) = super::hatch_boundary::refine(&ring, curves, &project) {
|
||||
if boundary.len() - range.len() + refined.len() > MAX_HATCH_MODEL_VERTS {
|
||||
continue;
|
||||
}
|
||||
boundary.splice(range.clone(), refined.iter().map(|p| to_xy(p[0], p[1])));
|
||||
local_boundary.splice(range, refined.into_iter().map(|[x, y]| [x as f32, y as f32]));
|
||||
}
|
||||
}
|
||||
|
||||
let boundary_f32: Vec<[f32; 2]> = boundary
|
||||
.iter()
|
||||
.map(|&[x, y]| {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
use cadkernel::geom2d::{triangulate_rings, Curve};
|
||||
use cadkernel::tessellation::DEFAULT_ANGLE;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
const MAX_RECOVERY_POINTS: usize = 512;
|
||||
const MAX_EXTRA_POINTS: usize = 128;
|
||||
const MAX_PASSES: usize = 16;
|
||||
const MAX_INTERSECTION_CHECKS: usize = 1_000_000;
|
||||
|
||||
/// Resample a crossed spline boundary without changing ordinary boundaries.
|
||||
/// `project` must use the same coordinate frame as the eventual fill mesh.
|
||||
pub(super) fn refine(
|
||||
ring: &[[f64; 2]],
|
||||
curves: impl FnOnce() -> Vec<Curve>,
|
||||
project: impl Fn([f64; 2]) -> [f64; 2],
|
||||
) -> Option<Vec<[f64; 2]>> {
|
||||
if ring.len() > MAX_RECOVERY_POINTS {
|
||||
return None;
|
||||
}
|
||||
let mut work_left = MAX_INTERSECTION_CHECKS;
|
||||
if !crossings(&[ring], &project, &mut work_left)?[0] {
|
||||
return None;
|
||||
}
|
||||
let projected = ring.iter().copied().map(&project).collect();
|
||||
if !triangulate_rings(&[projected]).1.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let limit = (ring.len() + MAX_EXTRA_POINTS).min(MAX_RECOVERY_POINTS);
|
||||
let mut spans = Vec::new();
|
||||
for curve in curves() {
|
||||
if let Curve::Nurbs(spline) = curve {
|
||||
let (start, end) = spline.domain();
|
||||
if end <= start || spline.control_points().len() > limit {
|
||||
return None;
|
||||
}
|
||||
for pair in spline.knots().windows(2) {
|
||||
if pair[0] >= start && pair[1] <= end && pair[0] < pair[1] {
|
||||
spans.push(Curve::Nurbs(spline.trimmed(
|
||||
(pair[0] - start) / (end - start),
|
||||
(pair[1] - start) / (end - start),
|
||||
)?));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
spans.push(curve);
|
||||
}
|
||||
}
|
||||
let mut remaining = limit;
|
||||
let mut points: Vec<_> = spans
|
||||
.iter()
|
||||
.map(|curve| sample(curve, &mut remaining))
|
||||
.collect::<Option<_>>()?;
|
||||
for _ in 0..MAX_PASSES {
|
||||
let marked = crossings(&points, &project, &mut work_left)?;
|
||||
if !marked.iter().any(|marked| *marked) {
|
||||
let ring = super::entity::chain_path_edges(points);
|
||||
let projected = ring.iter().copied().map(&project).collect();
|
||||
return (!triangulate_rings(&[projected]).1.is_empty()).then_some(ring);
|
||||
}
|
||||
let mut next = Vec::new();
|
||||
let mut sampled = Vec::new();
|
||||
let mut changed = false;
|
||||
let mut remaining = limit;
|
||||
for ((curve, points), marked) in spans.into_iter().zip(points).zip(marked) {
|
||||
if marked {
|
||||
if let Curve::Nurbs(spline) = &curve {
|
||||
let (a, b) = spline.split_at(0.5)?;
|
||||
for part in [Curve::Nurbs(a), Curve::Nurbs(b)] {
|
||||
sampled.push(sample(&part, &mut remaining)?);
|
||||
next.push(part);
|
||||
}
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
remaining = remaining.checked_sub(points.len())?;
|
||||
next.push(curve);
|
||||
sampled.push(points);
|
||||
}
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
spans = next;
|
||||
points = sampled;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn sample(curve: &Curve, remaining: &mut usize) -> Option<Vec<[f64; 2]>> {
|
||||
let Curve::Nurbs(spline) = curve else {
|
||||
let points = curve.tessellate_angle(DEFAULT_ANGLE);
|
||||
*remaining = remaining.checked_sub(points.len())?;
|
||||
return Some(points);
|
||||
};
|
||||
let mut min = [f64::INFINITY; 2];
|
||||
let mut max = [f64::NEG_INFINITY; 2];
|
||||
for point in spline.control_points() {
|
||||
for axis in 0..2 {
|
||||
min[axis] = min[axis].min(point[axis]);
|
||||
max[axis] = max[axis].max(point[axis]);
|
||||
}
|
||||
}
|
||||
// Scale chord error to this span; splitting tightens only crossed spans.
|
||||
let tolerance = (max[0] - min[0]).hypot(max[1] - min[1]) * (1.0 - (DEFAULT_ANGLE * 0.5).cos());
|
||||
if !tolerance.is_finite() || tolerance <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let points = curve.tessellate_within(tolerance);
|
||||
*remaining = remaining.checked_sub(points.len())?;
|
||||
Some(points)
|
||||
}
|
||||
|
||||
fn crossings(
|
||||
edges: &[impl AsRef<[[f64; 2]]>],
|
||||
project: &impl Fn([f64; 2]) -> [f64; 2],
|
||||
work_left: &mut usize,
|
||||
) -> Option<Vec<bool>> {
|
||||
let mut marked = vec![false; edges.len()];
|
||||
for projected in [false, true] {
|
||||
let mut segments = Vec::new();
|
||||
for (edge, points) in edges.iter().enumerate() {
|
||||
for pair in points.as_ref().windows(2) {
|
||||
let [a, b] = [pair[0], pair[1]].map(|p| if projected { project(p) } else { p });
|
||||
if !a.into_iter().chain(b).all(f64::is_finite) {
|
||||
return None;
|
||||
}
|
||||
segments.push((a[0].min(b[0]), a[0].max(b[0]), edge, a, b));
|
||||
}
|
||||
}
|
||||
segments.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
|
||||
for (index, &(_, right, edge, a, b)) in segments.iter().enumerate() {
|
||||
for &(left, _, other, c, d) in &segments[index + 1..] {
|
||||
if left > right {
|
||||
break;
|
||||
}
|
||||
*work_left = work_left.checked_sub(1)?;
|
||||
if a[1].max(b[1]) < c[1].min(d[1]) || c[1].max(d[1]) < a[1].min(b[1]) {
|
||||
continue;
|
||||
}
|
||||
let cross = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
|
||||
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
||||
};
|
||||
let opposite = |a: f64, b: f64| (a < 0.0 && b > 0.0) || (a > 0.0 && b < 0.0);
|
||||
let inside = |p: [f64; 2], a: [f64; 2], b: [f64; 2]| {
|
||||
cross(a, b, p) == 0.0
|
||||
&& (0..2).any(|axis| {
|
||||
p[axis] > a[axis].min(b[axis]) && p[axis] < a[axis].max(b[axis])
|
||||
})
|
||||
};
|
||||
if opposite(cross(a, b, c), cross(a, b, d))
|
||||
&& opposite(cross(c, d, a), cross(c, d, b))
|
||||
|| (inside(a, c, d)
|
||||
|| inside(b, c, d)
|
||||
|| inside(c, a, b)
|
||||
|| inside(d, a, b)
|
||||
|| a != b && (a == c && b == d || a == d && b == c))
|
||||
{
|
||||
marked[edge] = true;
|
||||
marked[other] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(marked)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use super::*;
|
||||
use crate::scene::Scene;
|
||||
use acadrust::entities::hatch::{BoundaryEdge, BoundaryPath, Hatch, PolylineEdge, SplineEdge};
|
||||
use acadrust::types::{Vector2, Vector3};
|
||||
use cadkernel::geom2d::NurbsCurve;
|
||||
|
||||
fn narrow(clearance: f64, offset: [f64; 2]) -> NurbsCurve {
|
||||
let line = |a: [f64; 2], b: [f64; 2]| {
|
||||
[
|
||||
a,
|
||||
std::array::from_fn(|i| a[i] + (b[i] - a[i]) / 3.0),
|
||||
std::array::from_fn(|i| a[i] + 2.0 * (b[i] - a[i]) / 3.0),
|
||||
b,
|
||||
]
|
||||
};
|
||||
let [a, b, c, d, e, f] = [
|
||||
[-1.0, -1.0],
|
||||
[0.0, -1.0],
|
||||
[0.0, 1.0],
|
||||
[clearance, 0.999],
|
||||
[-0.5, 1.5],
|
||||
[-1.0, 1.5],
|
||||
];
|
||||
// The returning cubic passes close to the vertical side b--c.
|
||||
let spans = [
|
||||
line(a, b),
|
||||
line(b, c),
|
||||
line(c, d),
|
||||
[d, [clearance, 1.1], [-0.3, 1.5], e],
|
||||
line(e, f),
|
||||
line(f, a),
|
||||
];
|
||||
let mut controls = vec![a];
|
||||
for span in spans {
|
||||
controls.extend_from_slice(&span[1..]);
|
||||
}
|
||||
for p in &mut controls {
|
||||
for i in 0..2 {
|
||||
p[i] += offset[i];
|
||||
}
|
||||
}
|
||||
let mut knots = vec![0.0; 4];
|
||||
for i in 1..6 {
|
||||
knots.extend([i as f64; 3]);
|
||||
}
|
||||
knots.extend([6.0; 4]);
|
||||
NurbsCurve::new(3, controls, knots, None).unwrap()
|
||||
}
|
||||
|
||||
fn edge(spline: NurbsCurve) -> BoundaryEdge {
|
||||
BoundaryEdge::Spline(SplineEdge {
|
||||
degree: spline.degree() as i32,
|
||||
rational: false,
|
||||
periodic: false,
|
||||
knots: spline.knots().to_vec(),
|
||||
control_points: spline
|
||||
.control_points()
|
||||
.iter()
|
||||
.map(|p| Vector3::new(p[0], p[1], 0.0))
|
||||
.collect(),
|
||||
fit_points: Vec::new(),
|
||||
start_tangent: Vector2::new(0.0, 0.0),
|
||||
end_tangent: Vector2::new(0.0, 0.0),
|
||||
})
|
||||
}
|
||||
|
||||
fn rectangle(a: [f64; 2], b: [f64; 2], offset: [f64; 2]) -> BoundaryPath {
|
||||
let points = [a, [b[0], a[1]], b, [a[0], b[1]]]
|
||||
.map(|p| Vector2::new(p[0] + offset[0], p[1] + offset[1]));
|
||||
let mut path = BoundaryPath::new();
|
||||
path.add_edge(BoundaryEdge::Polyline(PolylineEdge::new(
|
||||
points.to_vec(),
|
||||
true,
|
||||
)));
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_fill_preserves_corners_holes_and_shared_origin() {
|
||||
let curve = narrow(0.00001, [0.0; 2]);
|
||||
let area: f64 = (0..6)
|
||||
.map(|i| {
|
||||
Curve::Nurbs(curve.trimmed(i as f64 / 6.0, (i + 1) as f64 / 6.0).unwrap())
|
||||
.enclosed_area()
|
||||
})
|
||||
.sum();
|
||||
for (offset, shuffled, multiple) in [
|
||||
([0.0; 2], false, false),
|
||||
([1e6, -1e6], false, false),
|
||||
([0.0; 2], true, true),
|
||||
([1e6, -1e6], true, true),
|
||||
] {
|
||||
let spline = narrow(0.00001, offset);
|
||||
let mut path = BoundaryPath::new();
|
||||
path.edges = if shuffled {
|
||||
let (a, b) = spline.split_at(0.5).unwrap();
|
||||
vec![edge(b), edge(a.reversed())]
|
||||
} else {
|
||||
vec![edge(spline.clone())]
|
||||
};
|
||||
let mut hatch = Hatch::new();
|
||||
hatch.is_solid = true;
|
||||
hatch.paths.push(path);
|
||||
if multiple {
|
||||
hatch.paths.extend([
|
||||
rectangle([-0.8, -0.8], [-0.2, -0.2], offset),
|
||||
rectangle([-0.6, -0.6], [-0.4, -0.4], offset),
|
||||
rectangle([1.0, -1.0], [2.0, 2.5], offset),
|
||||
]);
|
||||
}
|
||||
let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap();
|
||||
if multiple {
|
||||
assert_eq!(model.world_origin, [offset[0] + 0.5, offset[1] + 0.75]);
|
||||
}
|
||||
let project = |p: [f64; 2]| {
|
||||
[
|
||||
(p[0] - model.world_origin[0]) as f32,
|
||||
(p[1] - model.world_origin[1]) as f32,
|
||||
]
|
||||
};
|
||||
let original = Curve::Nurbs(spline.clone()).tessellate_angle(DEFAULT_ANGLE);
|
||||
let original: Vec<_> = original
|
||||
.into_iter()
|
||||
.map(|p| project(p).map(f64::from))
|
||||
.collect();
|
||||
assert!(triangulate_rings(&[original]).1.is_empty());
|
||||
for i in 0..=6 {
|
||||
assert!(model
|
||||
.boundary
|
||||
.contains(&project(spline.point_at(i as f64 / 6.0))));
|
||||
}
|
||||
assert_eq!(model.boundary_paths.as_deref().unwrap(), &hatch.paths);
|
||||
let (vertices, indices) = model.fill_mesh();
|
||||
assert!(!indices.is_empty());
|
||||
let mut filled_area = 0.0;
|
||||
for triangle in indices.chunks_exact(3) {
|
||||
let [a, b, c] = [triangle[0], triangle[1], triangle[2]]
|
||||
.map(|i| vertices[i as usize].map(f64::from));
|
||||
let twice_area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
||||
assert!(twice_area > 0.0);
|
||||
filled_area += twice_area * 0.5;
|
||||
if multiple {
|
||||
let p =
|
||||
[0, 1].map(|i| (a[i] + b[i] + c[i]) / 3.0 + model.world_origin[i] - offset[i]);
|
||||
let inside = |lo, hi| p.iter().all(|&v| v > lo && v < hi);
|
||||
assert!(!inside(-0.8, -0.2) || inside(-0.6, -0.4));
|
||||
}
|
||||
}
|
||||
let expected = area + if multiple { 3.5 - 0.36 + 0.04 } else { 0.0 };
|
||||
assert!(
|
||||
(filled_area - expected).abs() < 0.001,
|
||||
"{filled_area} vs {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_samples_are_unchanged_and_invalid_samples_stay_rejected() {
|
||||
for clearance in [0.01, 0.000001, 0.000002, 0.000005] {
|
||||
let spline = narrow(clearance, [0.0; 2]);
|
||||
let original = Curve::Nurbs(spline.clone()).tessellate_angle(DEFAULT_ANGLE);
|
||||
let mut path = BoundaryPath::new();
|
||||
path.edges.push(edge(spline));
|
||||
let mut hatch = Hatch::new();
|
||||
hatch.is_solid = true;
|
||||
hatch.paths.push(path);
|
||||
let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap();
|
||||
let expected: Vec<_> = original
|
||||
.iter()
|
||||
.map(|p| {
|
||||
[
|
||||
(p[0] - model.world_origin[0]) as f32,
|
||||
(p[1] - model.world_origin[1]) as f32,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(model.boundary.as_ref(), &expected);
|
||||
assert_eq!(model.fill_mesh().1.is_empty(), clearance < 0.01);
|
||||
}
|
||||
let curve = Curve::Nurbs(narrow(0.01, [0.0; 2]));
|
||||
assert!(refine(
|
||||
&curve.tessellate_angle(DEFAULT_ANGLE),
|
||||
|| panic!("valid path resampled"),
|
||||
|p| p
|
||||
)
|
||||
.is_none());
|
||||
assert!(refine(
|
||||
&vec![[0.0; 2]; MAX_RECOVERY_POINTS + 1],
|
||||
|| panic!("size cap ignored"),
|
||||
|p| p
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contacts_and_intersection_work_are_bounded() {
|
||||
for (a, b, expected) in [
|
||||
([[0.0, 0.0], [1.0, 1.0]], [[1.0, 1.0], [2.0, 0.0]], false),
|
||||
([[0.0, 0.0], [2.0, 0.0]], [[1.0, 0.0], [1.0, 1.0]], true),
|
||||
([[0.0, 0.0], [2.0, 0.0]], [[1.0, 0.0], [3.0, 0.0]], true),
|
||||
] {
|
||||
assert_eq!(crossings(&[a, b], &|p| p, &mut 10).unwrap(), [expected; 2]);
|
||||
}
|
||||
let edges = vec![[[0.0, 0.0], [1.0, 1.0]]; 100];
|
||||
let mut budget = 7;
|
||||
assert!(crossings(&edges, &|p| p, &mut budget).is_none());
|
||||
assert_eq!(budget, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_respects_the_total_hatch_vertex_cap() {
|
||||
let curve = Curve::Nurbs(narrow(0.00001, [0.0; 2]));
|
||||
let original = curve.tessellate_angle(DEFAULT_ANGLE);
|
||||
let Curve::Nurbs(spline) = curve.clone() else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut path = BoundaryPath::new();
|
||||
path.edges.push(edge(spline));
|
||||
let mut hatch = Hatch::new();
|
||||
hatch.is_solid = true;
|
||||
hatch.paths.push(path);
|
||||
let count = 16_384 - original.len() - 2;
|
||||
let points = (0..count)
|
||||
.map(|i| {
|
||||
let angle = std::f64::consts::TAU * i as f64 / count as f64;
|
||||
Vector2::new(1.5 + 0.5 * angle.cos(), 0.75 + 1.75 * angle.sin())
|
||||
})
|
||||
.collect();
|
||||
let mut other = BoundaryPath::new();
|
||||
other.add_edge(BoundaryEdge::Polyline(PolylineEdge::new(points, true)));
|
||||
hatch.paths.push(other);
|
||||
let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap();
|
||||
let project = |p: [f64; 2]| {
|
||||
[
|
||||
(p[0] - model.world_origin[0]) as f32 as f64,
|
||||
(p[1] - model.world_origin[1]) as f32 as f64,
|
||||
]
|
||||
};
|
||||
assert!(refine(&original, || vec![curve], project).unwrap().len() > original.len());
|
||||
assert_eq!(model.boundary.len(), 16_384);
|
||||
let expected: Vec<_> = original
|
||||
.into_iter()
|
||||
.map(|p| project(p).map(|v| v as f32))
|
||||
.collect();
|
||||
assert_eq!(&model.boundary[..expected.len()], expected.as_slice());
|
||||
}
|
||||
@@ -33,6 +33,7 @@ pub(crate) mod dimension_assoc;
|
||||
pub(crate) mod centermark;
|
||||
mod dwg_native_constraints;
|
||||
mod entity;
|
||||
mod hatch_boundary;
|
||||
mod group_layer;
|
||||
mod layout;
|
||||
mod limits;
|
||||
|
||||
Reference in new issue
Block a user