From f987c2bbb06ea1fc12a2b43cec1095dd8b252d9b Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:39:09 -0500 Subject: [PATCH] Intertwine: the object brought over leaves no hairline of the one below (#5337) The area an Intertwine member is drawn over another in was exactly the overlap face, so its edge lay on the edge of the member below. Both edges were anti-aliased, and the member below showed through as a thin line along them. The area now reaches 2 pt past the face into the parts where only the raised member paints (where drawing it again changes nothing), so its edge lies inside that member. --- crates/engine/src/cmd/intertwine.rs | 53 +++++++++++++++++++++++---- crates/engine/src/tests/intertwine.rs | 22 +++++++++++ docs/gaps.md | 3 +- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/intertwine.rs b/crates/engine/src/cmd/intertwine.rs index 23f1be190..b19206c39 100644 --- a/crates/engine/src/cmd/intertwine.rs +++ b/crates/engine/src/cmd/intertwine.rs @@ -10,7 +10,10 @@ //! //! An overlap area is a face of the planar arrangement of what the members paint (their fills and //! strokes, type outlined; other objects by their bounds): the part where two or more of them -//! overlap, split wherever a member's edge crosses it. +//! overlap, split wherever a member's edge crosses it. The area a member is raised in is that +//! face grown a little ([`SEAM`]) into the parts where only that member paints ([`raised_area`]), +//! so its edge does not lie on the edge of the member it passes over: there, both edges would be +//! anti-aliased and the member below would show through as a hairline (#5337). //! //! Behaviour: (Make, //! Release, clicking or drawing around an overlap, Bring to Front, Bring Forward, Send Backward @@ -23,7 +26,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use vectorcraft_geom::{FillRule, PathData, Point, SubPath}; use vectorcraft_pathops as po; -use vectorcraft_tools::intertwine::{Face, drawn_order, face_at, faces, faces_in, is_intertwine, nonempty}; +use vectorcraft_tools::intertwine::{Face, drawn_order, face_at, faces_in, faces_of, is_intertwine, nonempty, painted_areas}; use super::edit::selected_roots; use super::pathops::Beside; @@ -302,13 +305,46 @@ fn tiers(order: &[usize]) -> Vec<(usize, usize)> { .collect() } +/// How far (pt) the area a member is raised in reaches past its overlap face, into the parts only +/// that member paints ([`raised_area`]). +const SEAM: f64 = 2.0; + +/// The area member `i` is raised in for `face`: the face, and the parts within [`SEAM`] of it +/// where only `i` paints (`areas`: what each member paints, [`painted_areas`]). Drawing `i` again +/// there changes nothing, and the edge of the area then lies inside `i`, not on the edge of a +/// member it passes over (where the anti-aliased edges of both would let that member show +/// through as a hairline). +fn raised_area(face: &PathData, i: usize, areas: &[Option]) -> PathData { + let Some(own) = areas.get(i).and_then(Option::as_ref) else { return face.clone() }; + let ring = po::offset_path(face, SEAM, po::Join::Round, 4.0); + let Some(near) = ring.bounds() else { return face.clone() }; + let mut paths = vec![(&ring, FillRule::NonZero), (own, FillRule::NonZero)]; + paths.extend( + areas + .iter() + .enumerate() + .filter(|(k, _)| *k != i) + .filter_map(|(_, a)| a.as_ref()) + .filter(|a| a.bounds().is_some_and(|b| b.overlaps(near))) + .map(|a| (a, FillRule::NonZero)), + ); + let only = po::boolean_n(&paths, |inside| inside.first() == Some(&true) && inside.get(1) == Some(&true) && !inside.iter().skip(2).any(|b| *b)); + if only.is_empty() { + return face.clone(); + } + nonempty(po::boolean(face, FillRule::NonZero, &only, FillRule::NonZero, po::BoolOp::Union)).unwrap_or_else(|| face.clone()) +} + /// Draw the members of `face` in `order` there (bottom first), leaving the other areas as they -/// are. -fn arrange_face(children: &mut [Arc], face: &Face, order: &[usize]) { +/// are (`areas`: what each member paints, [`painted_areas`]). +fn arrange_face(children: &mut [Arc], face: &Face, order: &[usize], areas: &[Option]) { for (i, tier) in tiers(order) { - if let Some(c) = children.get_mut(i) { - set_tier(Arc::make_mut(c), &face.path, tier); + let Some(c) = children.get_mut(i) else { continue }; + // A member drawn in its own order there with no areas has nothing to change. + if tier == 0 && c.extras().intertwine.is_none() && c.extras().intertwine_tiers.is_empty() { + continue; } + set_tier(Arc::make_mut(c), &raised_area(&face.path, i, areas), tier); } } @@ -379,7 +415,8 @@ fn arrange(s: &mut Session, p: &Value) -> Result { Some(m) => Some(children.iter().position(|c| c.id == m).ok_or_else(|| bad(C, format!("object {} is not in this Intertwine group", m.0)))?), None => None, }; - let all = faces(&children); + let painted = painted_areas(&children); + let all = faces_of(&painted); let found = match (at, &lasso) { (Some(p), _) => face_at(&all, p).into_iter().collect(), (None, Some(l)) => faces_in(&all, l), @@ -423,7 +460,7 @@ fn arrange(s: &mut Session, p: &Value) -> Result { let n = d.node_mut(group).ok_or(EngineError::NoNode(group))?; let NodeKind::Intertwine { children } = &mut n.kind else { return Err(EngineError::NoNode(group)) }; for (face, new) in &plans { - arrange_face(children, face, new); + arrange_face(children, face, new, &painted); } Ok(()) })?; diff --git a/crates/engine/src/tests/intertwine.rs b/crates/engine/src/tests/intertwine.rs index 6895d74a0..ecb9742a3 100644 --- a/crates/engine/src/tests/intertwine.rs +++ b/crates/engine/src/tests/intertwine.rs @@ -82,6 +82,28 @@ fn clicking_an_overlap_brings_the_object_below_to_the_front_there_only() { assert_eq!(hit(&s, 50.0, 70.0), Some(b)); } +/// #5337: where A is brought over B, the edges of B that cross A leave no blue hairline in A. +#[test] +fn the_object_brought_over_shows_no_seam_of_the_one_below() { + let mut s = session(); + intertwined(&mut s); + arrange(&mut s, json!({"at": [50, 30]})); + let zoom = 8.0; + let r = vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 800, 800, Affine::scale(zoom), &Default::default()); + let mut worst = 0; + for y in 160..360 { + for x in 320..480 { + let (px, py) = ((x as f64 + 0.5) / zoom, (y as f64 + 0.5) / zoom); + // Well inside ring A's 8 pt stroke (radius 21 to 29), A covers every pixel. + let d = ((px - 35.0).powi(2) + (py - 50.0).powi(2)).sqrt(); + if (22.0..28.0).contains(&d) { + worst = worst.max(r.pixel(x, y)[2]); + } + } + } + assert!(worst < 8, "blue shows through ring A at the top crossing (blue {worst})"); +} + #[test] fn clicking_the_same_overlap_again_swaps_back() { let mut s = session(); diff --git a/docs/gaps.md b/docs/gaps.md index 027cfdee7..5a8de15d6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -495,7 +495,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall pointer, and a right-click offers Bring to Front, Bring Forward, Send Backward and Send to Back for it in that area (`object.intertwine.arrange` with `order` front, forward, backward or back; ). - The group renders, saves and exports through its live expansion. Still missing: adding objects to the group by + The group renders, saves and exports through its live expansion; an object brought over another shows no hairline + of the one below along that object's edges (#5337). Still missing: adding objects to the group by dragging them in the Layers panel is untested. - **Done (from users' reports):** Object › Create Gradient Mesh on an embedded image turns it into a mesh over its rectangle, with the image's transform kept; each point takes the average colour and opacity of the picture around it