mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 03:16:53 -06:00
#1184: boolean operands use the rounded per-side strokes too
One side_strokes helper (square or rounded, as #1044 paints them) serves stroke_area and the boolean operand outline, instead of a second, square-only copy; test for a rounded per-side border.
This commit is contained in:
@@ -253,3 +253,23 @@ fn stroke_only_text_operands_use_glyphs_instead_of_the_text_box() {
|
||||
text.characters = " ".into();
|
||||
assert!(outline(&d, b).elements().is_empty(), "spaces have no stroked glyphs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_side_borders_follow_their_corners_in_booleans() {
|
||||
use figcraft_doc::StrokeAlign;
|
||||
use figcraft_geom::Point;
|
||||
let mut d = Document::new("rounded borders");
|
||||
let page = d.first_page().unwrap();
|
||||
let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let id = d.create(b, NodeKind::Rectangle, "operand", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let n = d.get_mut(id).unwrap();
|
||||
n.fills.clear();
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.stroke.align = StrokeAlign::Inside;
|
||||
n.stroke.sides = Some([8.0; 4]);
|
||||
n.corners.radii = [30.0; 4];
|
||||
let p = outline(&d, b);
|
||||
assert_ne!(p.winding(Point::new(50.0, 4.0)), 0, "top border");
|
||||
assert_eq!(p.winding(Point::new(3.0, 3.0)), 0, "the rounded corner leaves the square's tip out");
|
||||
assert_eq!(p.winding(Point::new(50.0, 50.0)), 0, "hollow inside");
|
||||
}
|
||||
|
||||
+26
-21
@@ -156,19 +156,26 @@ pub(crate) fn hash_path(p: &BezPath, h: &mut impl Hasher) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-side strokes on rectangles and frames (a ring under non-zero winding too), if `n` has them.
|
||||
fn side_strokes(n: &Node) -> Option<Arc<BezPath>> {
|
||||
let props = &n.stroke;
|
||||
let sides = props.sides?;
|
||||
if !matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) {
|
||||
return None;
|
||||
}
|
||||
let c = &n.corners;
|
||||
Some(Arc::new(if c.uniform() == Some(0.0) {
|
||||
figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)
|
||||
} else {
|
||||
figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align)
|
||||
}))
|
||||
}
|
||||
|
||||
/// The stroke area of a node in local coordinates.
|
||||
pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
let props = &n.stroke;
|
||||
// Per-side strokes on rectangles/frames.
|
||||
if let Some(sides) = props.sides
|
||||
&& matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. })
|
||||
{
|
||||
let c = &n.corners;
|
||||
return Arc::new(if c.uniform() == Some(0.0) {
|
||||
figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)
|
||||
} else {
|
||||
figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align)
|
||||
});
|
||||
if let Some(p) = side_strokes(n) {
|
||||
return p;
|
||||
}
|
||||
// Rect/ellipse inside/outside strokes without booleans: an even-odd ring of two outlines.
|
||||
if props.dashes.is_empty() && props.profile().is_none() && props.brush.is_none() && closed && props.align != StrokeAlign::Center {
|
||||
@@ -212,15 +219,15 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
cached_stroke_outline(n, path, closed)
|
||||
}
|
||||
|
||||
/// A non-zero stroke outline, shared by painting and boolean operands.
|
||||
/// The stroke a boolean operand contributes: like [`stroke_area`], but never an even-odd ring
|
||||
/// (boolean folding uses non-zero winding).
|
||||
fn operand_stroke(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
side_strokes(n).unwrap_or_else(|| cached_stroke_outline(n, path, closed))
|
||||
}
|
||||
|
||||
/// A non-zero stroke outline (cached), shared by painting and boolean operands.
|
||||
fn cached_stroke_outline(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
let props = &n.stroke;
|
||||
if let Some(sides) = props.sides
|
||||
&& matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. })
|
||||
&& n.corners.uniform() == Some(0.0)
|
||||
{
|
||||
return Arc::new(figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align));
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
hash_path(path, &mut h);
|
||||
(props.weight.to_bits(), props.align as u8, props.cap_start as u8, props.cap_end as u8, props.join as u8, props.miter_limit.to_bits(), closed)
|
||||
@@ -334,7 +341,7 @@ fn subtree_outline_at(doc: &Document, n: &Node, depth: usize, stroke_only: bool)
|
||||
if n.fills.iter().any(|f| f.visible) {
|
||||
out.extend(p.elements().iter().copied());
|
||||
} else if stroke_only && n.has_visible_stroke() {
|
||||
out.extend(cached_stroke_outline(n, &p, true).elements().iter().copied());
|
||||
out.extend(operand_stroke(n, &p, true).elements().iter().copied());
|
||||
}
|
||||
}
|
||||
for c in n.children.iter().filter_map(|c| doc.node(*c)) {
|
||||
@@ -352,9 +359,7 @@ fn subtree_outline_at(doc: &Document, n: &Node, depth: usize, stroke_only: bool)
|
||||
let (path, _) = node_outline_at(doc, n, depth)?;
|
||||
let path = if matches!(n.kind, NodeKind::Text(_)) { path } else { figcraft_doc::geometry::stroke_geometry(n).unwrap_or(path) };
|
||||
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. });
|
||||
// Boolean folding uses non-zero winding; the canvas stroke cache also contains
|
||||
// even-odd rectangle rings, so use the winding-correct stroke outline here.
|
||||
Some(((*cached_stroke_outline(n, &path, closed)).clone(), FillRule::Winding))
|
||||
Some(((*operand_stroke(n, &path, closed)).clone(), FillRule::Winding))
|
||||
}
|
||||
_ => node_outline_at(doc, n, depth),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user