mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 02:34:33 +00:00
M4: Delete closes a region's loop through the segment that joins its ends (#1951)
A Pen bridge between two degree-2 points leaves the region alone, and Delete on the point between them then rotated the loop open with the bridge in no loop, so even an unrounded fill ran straight across a curved bridge. reopen_loops now pushes the one segment in no loop that joins an open chain's ends onto the loop's end (winding kept, loop by loop), as connect does. This also covers removing segments and the eraser. closing_segment is public, derives its own start point, walks strictly (a loop out of walking order is left alone) and gives up when two segments qualify. The render fix stays as the fallback for files already saved in that state. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Model-Effort: Opus 5.5 drove; Fable 5.1 reviewed; Sonnet 5.5 wrote at medium
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
8e6c342f13
commit
a75d8bf04f
+13
-2
@@ -908,12 +908,23 @@ fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() {
|
||||
/// the stroke's outline, cut by an arc at the chain's ends as at every other point.
|
||||
#[test]
|
||||
fn a_rounded_loop_left_open_where_a_segment_joins_its_ends_follows_that_segment() {
|
||||
open_loop_follows_the_segment_joining_its_ends([(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)], vec![3, 1, 2, 0]);
|
||||
}
|
||||
|
||||
/// The same network with the joining segment listed first, and with the loop walked the other way.
|
||||
#[test]
|
||||
fn an_open_loop_follows_the_segment_joining_its_ends_listed_first_or_walked_backwards() {
|
||||
open_loop_follows_the_segment_joining_its_ends([(3, 4), (0, 3), (1, 2), (2, 0), (4, 1)], vec![4, 2, 3, 1]);
|
||||
open_loop_follows_the_segment_joining_its_ends([(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)], vec![0, 2, 1, 3]);
|
||||
}
|
||||
|
||||
fn open_loop_follows_the_segment_joining_its_ends(segments: [(usize, usize); 5], open_loop: Vec<usize>) {
|
||||
use figcraft_geom::BezPath;
|
||||
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
|
||||
let corners = [Point::new(0.0, 0.0), Point::new(525.0, 525.0), Point::new(0.0, 525.0), Point::new(333.0, 0.0), Point::new(525.0, 183.0)];
|
||||
let mut net = VectorNetwork::from_points(&corners, false);
|
||||
net.segments = [(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)].iter().map(|&(a, b)| Segment::line(a, b)).collect();
|
||||
net.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, paint_space: None });
|
||||
net.segments = segments.iter().map(|&(a, b)| Segment::line(a, b)).collect();
|
||||
net.regions.push(Region { loops: vec![open_loop], winding: WindingRule::NonZero, fills: None, paint_space: None });
|
||||
for v in &mut net.vertices {
|
||||
v.radius = 125.0;
|
||||
}
|
||||
|
||||
+33
-15
@@ -472,15 +472,13 @@ impl VectorNetwork {
|
||||
// order the loop meets them. Each loop is rebuilt in one pass, so a polygon with many
|
||||
// rounded corners isn't quadratic.
|
||||
for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
|
||||
let Some(&first_idx) = lp.first() else { continue };
|
||||
let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) {
|
||||
(Some(first), Some(second)) if first.a == second.a || first.a == second.b => first.b,
|
||||
(Some(first), _) => first.a,
|
||||
_ => 0,
|
||||
};
|
||||
if lp.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let start_v = self.loop_start(lp).unwrap_or(0);
|
||||
let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len()) + 1);
|
||||
let mut cur = start_v;
|
||||
let closing = self.closing_segment(lp, start_v);
|
||||
let closing = self.closing_segment(lp);
|
||||
for &x in lp.iter().chain(closing.iter()) {
|
||||
rebuilt.push(x);
|
||||
let Some(s) = self.segments.get(x) else { continue };
|
||||
@@ -498,23 +496,43 @@ impl VectorNetwork {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The segment that joins the two ends of an open loop `lp` (walked from `start_v`) when it is
|
||||
/// in no loop of its own: a corner cut off by a segment drawn before the corner point was
|
||||
/// deleted leaves the loop open there, and the stroke follows that segment. Rounding walks it
|
||||
/// as the loop's last segment, so the points at the loop's ends are cut by arcs as the stroke's
|
||||
/// are (#1951).
|
||||
fn closing_segment(&self, lp: &[usize], start_v: usize) -> Option<usize> {
|
||||
/// The point loop `lp` is walked from: the end of its first segment that its second does not
|
||||
/// share.
|
||||
fn loop_start(&self, lp: &[usize]) -> Option<usize> {
|
||||
let first = self.segments.get(*lp.first()?)?;
|
||||
match lp.get(1).and_then(|&i| self.segments.get(i)) {
|
||||
Some(second) if first.a == second.a || first.a == second.b => Some(first.b),
|
||||
_ => Some(first.a),
|
||||
}
|
||||
}
|
||||
|
||||
/// The segment that joins the two ends of an open loop `lp` when it is in no loop of its own
|
||||
/// (and the only such segment): a corner cut off by a segment drawn before the corner point was
|
||||
/// deleted leaves the loop open there, and the stroke follows that segment. Only a loop that
|
||||
/// walks in order as one continuous chain has one. Rounding walks it as the loop's last
|
||||
/// segment, so the points at the loop's ends are cut by arcs as the stroke's are, and deleting
|
||||
/// points or segments puts it into the loop (#1951).
|
||||
pub fn closing_segment(&self, lp: &[usize]) -> Option<usize> {
|
||||
let start_v = self.loop_start(lp)?;
|
||||
let mut cur = start_v;
|
||||
for &x in lp {
|
||||
let s = self.segments.get(x)?;
|
||||
cur = if s.a == cur { s.b } else { s.a };
|
||||
cur = if s.a == cur {
|
||||
s.b
|
||||
} else if s.b == cur {
|
||||
s.a
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
}
|
||||
if cur == start_v {
|
||||
return None;
|
||||
}
|
||||
let in_loop = |i: usize| self.regions.iter().flat_map(|r| r.loops.iter().flatten()).any(|&x| x == i);
|
||||
let ends = |s: &Segment| (s.a == cur && s.b == start_v) || (s.b == cur && s.a == start_v);
|
||||
self.segments.iter().position(ends).filter(|&i| !in_loop(i))
|
||||
let mut joining = self.segments.iter().enumerate().filter(|(i, s)| ends(s) && !in_loop(*i)).map(|(i, _)| i);
|
||||
let first = joining.next()?;
|
||||
joining.next().is_none().then_some(first)
|
||||
}
|
||||
|
||||
/// How point `v` (its `incident` segments) is rounded, if it is: not where the two segments
|
||||
|
||||
@@ -468,6 +468,22 @@ fn reopen_loops(net: &mut VectorNetwork) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// An open chain whose two ends a segment in no loop already joins (a bridge drawn between two
|
||||
// points before the point between them was deleted) closes through that segment, at the end so
|
||||
// the loop's winding is kept, as `connect` closes it (#1951). One loop at a time, so two loops
|
||||
// can't both take one segment.
|
||||
let places: Vec<(usize, usize)> = net.regions.iter().enumerate().flat_map(|(ri, r)| (0..r.loops.len()).map(move |li| (ri, li))).collect();
|
||||
for (ri, li) in places {
|
||||
let Some(lp) = net.regions.get(ri).and_then(|r| r.loops.get(li)) else { continue };
|
||||
if lp.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if let Some(closing) = net.closing_segment(lp)
|
||||
&& let Some(lp) = net.regions.get_mut(ri).and_then(|r| r.loops.get_mut(li))
|
||||
{
|
||||
lp.push(closing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cut a segment at `t` into two open ends at the same spot. Returns the two end vertices.
|
||||
@@ -1107,6 +1123,24 @@ mod tests {
|
||||
assert!(d.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite()));
|
||||
}
|
||||
|
||||
/// A curved bridge between two points of a ring, then the point between them deleted (Delete,
|
||||
/// no healing): the region's loop closes through the bridge, so the fill follows its curve and
|
||||
/// is as long as the stroke (#1951).
|
||||
#[test]
|
||||
fn deleting_a_point_behind_a_curved_bridge_keeps_the_bridge_in_the_loop() {
|
||||
use figcraft_geom::BezPath;
|
||||
use figcraft_geom::kurbo::ParamCurveArclen;
|
||||
let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 50.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y));
|
||||
let mut n = VectorNetwork::from_points(&pts, true);
|
||||
connect(&mut n, 1, 3, (25.0, 25.0), (-25.0, -25.0)).unwrap();
|
||||
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (7, 6), "the ring kept");
|
||||
delete_vertices(&mut n, &[2], false);
|
||||
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (5, 5), "the loop holds the bridge: {:?}", n.regions);
|
||||
let length = |p: &BezPath| p.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
|
||||
let (fill, stroke) = (length(&n.fill_path()), length(&n.stroke_path()));
|
||||
assert!((fill - stroke).abs() < 1e-6, "fill {fill} and stroke {stroke} follow the same outline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lasso_polygon() {
|
||||
let pts = [Point::new(5.0, 5.0), Point::new(50.0, 50.0), Point::new(15.0, 5.0)];
|
||||
|
||||
@@ -659,6 +659,27 @@ fn a_rounded_chamfer_made_other_ways_fills_along_its_arcs() {
|
||||
fill_traces_stroke(&s, id, "drawn with the pen");
|
||||
}
|
||||
|
||||
/// The reporter's sequence (#1951): a bridge drawn between the two new points while the corner is
|
||||
/// still there (both have two segments, so the region keeps its ring), then the corner deleted with
|
||||
/// Delete. The region's loop must close through the bridge, so the rounded fill traces the stroke.
|
||||
#[test]
|
||||
fn deleting_the_corner_behind_a_pen_bridge_closes_the_loop_through_the_bridge() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
ex(&mut s, "vector.edit.addVertex", json!({"segment": 0, "t": 0.5}));
|
||||
ex(&mut s, "vector.edit.addVertex", json!({"segment": 1, "t": 0.5}));
|
||||
let n = net(&s, id);
|
||||
let (top, right) = (vertex_at(&n, 50.0, 0.0), vertex_at(&n, 100.0, 50.0));
|
||||
ex(&mut s, "vector.edit.connect", json!({"a": top, "b": right}));
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (7, 6), "bridged, the ring kept: {:?}", n.regions);
|
||||
let corner = vertex_at(&n, 100.0, 0.0);
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
|
||||
ex(&mut s, "vector.edit.delete", json!({}));
|
||||
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
|
||||
fill_traces_stroke(&s, id, "bridged, then the corner deleted");
|
||||
}
|
||||
|
||||
/// The rounded chamfered rectangle `id` (five points on the origin's 100×100 box, the top-right
|
||||
/// corner cut off between (50, 0) and (100, 50)): one region with every segment in its loop, and a
|
||||
/// fill that keeps off every sharp point and traces the same outline as the stroke.
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user