* Straight edges stay one edge after cuts (#1049) Co-Authored-By: Claude Opus 5.5 <[email protected]> * Robustness goldens: cuts join straight edges along untouched walls (#1049) Co-Authored-By: Claude Opus 5.5 <[email protected]> * fmt Co-Authored-By: Claude Opus 5.5 <[email protected]> * Robustness goldens note: what each changed seed shows when replayed on both builds Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ec64564f21
commit
d019f2d5bf
7 files changed
+271
-41
No files matched your search
@@ -318,12 +318,13 @@ fn merge_collinear(shell: &mt::Shell, trace: &mut Trace) -> Option<mt::Shell> {
|
||||
es.push(e.clone());
|
||||
continue;
|
||||
};
|
||||
// The run's pieces become its joined edge, in this wire's direction.
|
||||
if done.insert(r)
|
||||
&& let Some((line, pos)) = joined.get(r)
|
||||
{
|
||||
// The run's pieces become its joined edge, in this wire's direction: once each
|
||||
// way, as a seam runs along its wire both ways.
|
||||
if let Some((line, pos)) = joined.get(r) {
|
||||
let forward = pos.get(&e.front().id()) < pos.get(&e.back().id());
|
||||
es.push(if forward { line.clone() } else { line.inverse() });
|
||||
if done.insert((r, forward)) {
|
||||
es.push(if forward { line.clone() } else { line.inverse() });
|
||||
}
|
||||
}
|
||||
}
|
||||
wires.push(mt::Wire::from(es));
|
||||
@@ -547,9 +548,9 @@ pub(crate) fn heal_traced(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> (Solid
|
||||
healed(solid, size, keep, true, false)
|
||||
}
|
||||
|
||||
/// [`heal_traced`] that only merges coplanar neighbouring faces, leaving every edge, curve and
|
||||
/// surface as it is: for results whose geometry is already exact (a prism boolean's), where
|
||||
/// joining edges would change more than the faces split in pieces.
|
||||
/// [`heal_traced`] that only merges coplanar neighbouring faces and joins the straight edges
|
||||
/// they leave split in line (#1049), leaving every curve and surface as it is: for results
|
||||
/// whose geometry is already exact (a prism boolean's).
|
||||
pub(crate) fn merge_faces_traced(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> (Solid, Option<HealMap>) {
|
||||
healed(solid, size, keep, true, true)
|
||||
}
|
||||
@@ -632,7 +633,9 @@ fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>], traced: bool, faces
|
||||
from = groups.iter().map(|g| g.iter().filter_map(|i| from.get(*i)).flatten().copied().collect()).collect();
|
||||
changed = true;
|
||||
}
|
||||
if !faces_only && let Some(m) = merge_collinear(&cur, &mut trace) {
|
||||
// Straight edges the merged pieces leave split at vertices nothing else uses join
|
||||
// into one, after any boolean (#1049): later features meet one edge, not the pieces.
|
||||
if let Some(m) = merge_collinear(&cur, &mut trace) {
|
||||
cur = m;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -148,3 +148,66 @@ fn random_cuts_and_joins_never_split_other_faces() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The edges of `b` lying along the line through `a` and `z`.
|
||||
fn edges_along(b: &Body, a: Vec3, z: Vec3) -> usize {
|
||||
let on = |p: Vec3| p.dist_to_segment(a, z) < 1e-6;
|
||||
let edges: std::collections::HashSet<_> = b
|
||||
.solid
|
||||
.edge_iter()
|
||||
.filter(|e| on(crate::body::from_p3(e.front().point())) && on(crate::body::from_p3(e.back().point())))
|
||||
.map(|e| e.id())
|
||||
.collect();
|
||||
edges.len()
|
||||
}
|
||||
|
||||
/// Vertices of `b` where just two straight edges meet in line (a straight edge split in two).
|
||||
fn stations(b: &Body) -> usize {
|
||||
use std::collections::HashMap;
|
||||
let mut at: HashMap<truck_modeling::VertexID, Vec<truck_modeling::Edge>> = HashMap::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for e in b.solid.edge_iter() {
|
||||
if seen.insert(e.id()) {
|
||||
for v in [e.front(), e.back()] {
|
||||
at.entry(v.id()).or_default().push(e.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
at.values()
|
||||
.filter(|es| match es.as_slice() {
|
||||
[a, b] => {
|
||||
let dir = |e: &truck_modeling::Edge| (crate::body::from_p3(e.back().point()) - crate::body::from_p3(e.front().point())).normalized();
|
||||
matches!((a.curve(), b.curve()), (truck_modeling::Curve::Line(_), truck_modeling::Curve::Line(_)))
|
||||
&& dir(a).zip(dir(b)).is_some_and(|(u, w)| u.dot(w).abs() > 1. - 1e-9)
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Cuts at several heights through a block's walls leave no vertices along its straight edges
|
||||
/// (#1049): the edges they never touch stay one edge each, and so does an edge a later join
|
||||
/// lies beside, instead of being drawn (and met by later features) as many pieces.
|
||||
#[test]
|
||||
fn cuts_leave_straight_edges_whole() {
|
||||
let mut b = box_solid(Vec3::new(0., 0., 0.), Vec3::new(40., 20., 10.)).unwrap().with_box_certificate(7).unwrap();
|
||||
// Notches through the front wall at different heights, a pocket in the top and a slot
|
||||
// through the end: each cut is sliced at its own heights.
|
||||
b = cut(&b, Vec3::new(5., -1., 2.), Vec3::new(10., 3., 4.));
|
||||
b = cut(&b, Vec3::new(15., -1., 5.), Vec3::new(20., 3., 8.));
|
||||
b = cut(&b, Vec3::new(25., 8., 6.), Vec3::new(30., 12., 11.));
|
||||
b = cut(&b, Vec3::new(37., 5., 3.), Vec3::new(41., 15., 7.));
|
||||
assert!(b.history().is_some(), "the cuts report their history");
|
||||
assert_eq!(stations(&b), 0, "a straight edge is split at a vertex nothing else uses");
|
||||
// The block's four upright edges and the top's far edges are one edge each.
|
||||
for (x, y) in [(0., 0.), (40., 0.), (0., 20.), (40., 20.)] {
|
||||
assert_eq!(edges_along(&b, Vec3::new(x, y, 0.), Vec3::new(x, y, 10.)), 1, "upright edge at ({x}, {y})");
|
||||
}
|
||||
assert_eq!(edges_along(&b, Vec3::new(0., 20., 10.), Vec3::new(40., 20., 10.)), 1, "the top's back edge");
|
||||
assert_eq!(edges_along(&b, Vec3::new(0., 0., 0.), Vec3::new(0., 20., 0.)), 1, "the bottom's left edge");
|
||||
// A boss joined on the top beside its back edge, then another cut: still one edge.
|
||||
b = boolean(&b, &box_solid(Vec3::new(2., 15., 10.), Vec3::new(8., 20., 14.)).unwrap(), BoolOp::Union).unwrap().unwrap();
|
||||
b = cut(&b, Vec3::new(12., 4., 1.), Vec3::new(14., 21., 9.));
|
||||
assert_eq!(stations(&b), 0, "after a join and another cut");
|
||||
assert_eq!(edges_along(&b, Vec3::new(0., 0., 0.), Vec3::new(0., 0., 10.)), 1, "upright edge after a join and a cut");
|
||||
}
|
||||
@@ -356,6 +356,44 @@ fn same_geometry(a: &[(LCurve, Vec<Vec2>)], b: &[(LCurve, Vec<Vec2>)]) -> bool {
|
||||
}
|
||||
|
||||
/// The point indices a layout curve uses, in order.
|
||||
/// Is `fresh` the boundary `old` was, with only the stations along its straight lines gone
|
||||
/// (#1049: straight edges that booleans left split at vertices nothing else used are now one
|
||||
/// edge)? Every old line lies along exactly one fresh line and the old pieces cover it end to
|
||||
/// end; every other curve is unchanged. A link saved with the stations keeps its pieces then:
|
||||
/// the region they bound is the same, and the features that name them by id still find them.
|
||||
fn only_stations_gone(old: &[(LCurve, Vec<Vec2>)], fresh: &[(LCurve, Vec<Vec2>)]) -> bool {
|
||||
const TOL: f64 = 1e-6;
|
||||
let is_line = |c: &&(LCurve, Vec<Vec2>)| matches!(c.0, LCurve::Line(..));
|
||||
let ends = |c: &(LCurve, Vec<Vec2>)| match c.1.as_slice() {
|
||||
[a, b] => Some((*a, *b)),
|
||||
_ => None,
|
||||
};
|
||||
let (old_lines, old_rest): (Vec<_>, Vec<_>) = old.iter().partition(is_line);
|
||||
let (fresh_lines, fresh_rest): (Vec<_>, Vec<_>) = fresh.iter().partition(is_line);
|
||||
if fresh_lines.len() >= old_lines.len() || old_lines.len() > 512 {
|
||||
return false;
|
||||
}
|
||||
let rest = |xs: Vec<&(LCurve, Vec<Vec2>)>| xs.into_iter().cloned().collect::<Vec<_>>();
|
||||
if !same_geometry(&rest(old_rest), &rest(fresh_rest)) {
|
||||
return false;
|
||||
}
|
||||
let Some(fresh_segs) = fresh_lines.iter().map(|c| ends(c)).collect::<Option<Vec<_>>>() else { return false };
|
||||
let mut covered = vec![0.0; fresh_segs.len()];
|
||||
for c in &old_lines {
|
||||
let Some((p, q)) = ends(c) else { return false };
|
||||
let on = |x: Vec2, (a, b): (Vec2, Vec2)| {
|
||||
let ab = b - a;
|
||||
let t = ((x - a).dot(ab) / ab.dot(ab).max(f64::MIN_POSITIVE)).clamp(0.0, 1.0);
|
||||
x.dist(a + ab * t) <= TOL * (1.0 + a.len().max(b.len()))
|
||||
};
|
||||
let Some(k) = fresh_segs.iter().position(|seg| on(p, *seg) && on(q, *seg)) else { return false };
|
||||
if let Some(slot) = covered.get_mut(k) {
|
||||
*slot += p.dist(q);
|
||||
}
|
||||
}
|
||||
fresh_segs.iter().zip(&covered).all(|((a, b), len)| (a.dist(*b) - len).abs() <= TOL * (1.0 + a.len().max(b.len())))
|
||||
}
|
||||
|
||||
fn curve_points(c: &LCurve) -> Vec<usize> {
|
||||
match *c {
|
||||
LCurve::Line(a, b) => vec![a, b],
|
||||
@@ -677,9 +715,8 @@ impl Sketch {
|
||||
let lone_old: Vec<Vec2> = points.iter().filter_map(|pi| pos(*pi)).collect();
|
||||
if let (Some(old), Some(fresh)) = (old, fresh)
|
||||
&& lay.lone.is_empty()
|
||||
&& points.len() == lay.pts.len()
|
||||
&& lone_old.len() == points.len()
|
||||
&& same_geometry(&old, &fresh)
|
||||
&& (points.len() == lay.pts.len() && same_geometry(&old, &fresh) || only_stations_gone(&old, &fresh))
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
@@ -883,3 +883,41 @@ fn exact_profiles_for_high_degree_and_cut_free_form_curves() {
|
||||
let uncut: f64 = find_profiles(&sk).iter().map(|p| p.area).sum();
|
||||
assert!((whole - uncut).abs() < 1e-6 * uncut, "{whole} vs {uncut}");
|
||||
}
|
||||
|
||||
/// A face boundary saved with stations along its straight edges (#1049: booleans used to leave
|
||||
/// vertices nothing else used) keeps its saved pieces when the face comes back with each
|
||||
/// straight edge whole: the same region, and features that name the pieces still find them.
|
||||
#[test]
|
||||
fn projected_boundary_keeps_its_pieces_when_only_stations_go() {
|
||||
use crate::{LinkGeom, LinkKind, LinkSource};
|
||||
let mut sk = Sketch::new();
|
||||
let v = Vec2::new;
|
||||
let split = vec![
|
||||
LinkGeom::Line(v(0.0, 0.0), v(5.0, 0.0)),
|
||||
LinkGeom::Line(v(5.0, 0.0), v(10.0, 0.0)),
|
||||
LinkGeom::Line(v(10.0, 0.0), v(10.0, 4.0)),
|
||||
LinkGeom::Line(v(10.0, 4.0), v(10.0, 10.0)),
|
||||
LinkGeom::Line(v(10.0, 10.0), v(0.0, 10.0)),
|
||||
LinkGeom::Line(v(0.0, 10.0), v(0.0, 0.0)),
|
||||
];
|
||||
let j = sk.add_link(LinkKind::Project, LinkSource::Origin, &split).unwrap();
|
||||
let ids: Vec<String> = sk.link_curves(&j).iter().map(|c| sk.curves[*c].id.clone()).collect();
|
||||
let whole = vec![
|
||||
LinkGeom::Line(v(0.0, 0.0), v(10.0, 0.0)),
|
||||
LinkGeom::Line(v(10.0, 0.0), v(10.0, 10.0)),
|
||||
LinkGeom::Line(v(10.0, 10.0), v(0.0, 10.0)),
|
||||
LinkGeom::Line(v(0.0, 10.0), v(0.0, 0.0)),
|
||||
];
|
||||
assert!(!sk.update_link(&j, &whole).unwrap(), "only the stations went: the pieces stay");
|
||||
let kept: Vec<String> = sk.link_curves(&j).iter().map(|c| sk.curves[*c].id.clone()).collect();
|
||||
assert_eq!(ids, kept);
|
||||
// A boundary that really changed is rebuilt.
|
||||
let smaller = vec![
|
||||
LinkGeom::Line(v(0.0, 0.0), v(8.0, 0.0)),
|
||||
LinkGeom::Line(v(8.0, 0.0), v(8.0, 10.0)),
|
||||
LinkGeom::Line(v(8.0, 10.0), v(0.0, 10.0)),
|
||||
LinkGeom::Line(v(0.0, 10.0), v(0.0, 0.0)),
|
||||
];
|
||||
assert!(sk.update_link(&j, &smaller).unwrap());
|
||||
assert_eq!(sk.link_curves(&j).len(), 4);
|
||||
}
|
||||
Reference in new issue
Block a user