From a175245f95fcb946d793d967d3f1a96b096e053f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 11 Oct 2026 20:00:52 +0200 Subject: [PATCH] fix(geom): Corner Options keep curves and round every corner point Corners were applied only where two straight segments meet, and every other segment was redrawn as a straight line: a Pathfinder quarter circle with rounded corners rendered as a triangle with one round corner. Corner points are now where the path turns; the sides next to a corner are trimmed along their length, curves included, and stay curves. Smooth points are left alone; rectangles are unchanged. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/geom/src/corners.rs | 152 +++++++++++++++++++++++++++---------- 2 files changed, 112 insertions(+), 41 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c7..ecadb88f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-11** · Corner Options keep curved sides curved and round every corner point, including where an arc meets a line (a Pathfinder quarter circle with rounded corners stayed a triangle). No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index df1fd0fa..6e8ea32e 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -1,9 +1,10 @@ //! Live corner options (Object → Corner Options): rounded, inverse rounded, inset, bevel, fancy. //! -//! Corners are applied at render time to the straight-segment corners of a closed path, so the -//! frame keeps its simple editable geometry (as in InDesign). +//! Corners are applied at render time to the corner points of a closed path (where its direction +//! turns), so the frame keeps its simple editable geometry (as in InDesign). Curved sides stay +//! curved: a corner where an arc meets a line trims the arc along its length. -use kurbo::{BezPath, Point, Vec2}; +use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, Point, Vec2}; use serde::{Deserialize, Serialize}; use crate::path::{PathData, SubPath}; @@ -63,8 +64,9 @@ impl CornerOptions { } } -/// Apply corner options to `path`. Corners are applied to anchors joining two straight segments of -/// closed subpaths; other anchors are kept. Anchor `i` of every subpath uses `opts.corners[i % 4]`. +/// Apply corner options to `path`. Corners are applied to the corner points of closed subpaths +/// (where the incoming and outgoing directions differ, curved sides included); smooth points are +/// kept. Anchor `i` of every subpath uses `opts.corners[i % 4]`. pub fn apply(path: &PathData, opts: &CornerOptions) -> BezPath { if opts.is_none() { return path.to_bezpath(); @@ -76,50 +78,76 @@ pub fn apply(path: &PathData, opts: &CornerOptions) -> BezPath { out } +/// Arc length accuracy (points) when trimming curved sides. +const ARC_ACCURACY: f64 = 1e-3; +/// Directions closer than this (cosine, about 1°) are a smooth point, not a corner. +const SMOOTH: f64 = 0.9998; + +/// The unit direction a segment leaves its start in (None for a point). +fn start_dir(c: &CubicBez) -> Option { + [c.p1, c.p2, c.p3].into_iter().map(|q| q - c.p0).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot()) +} + +/// The unit direction a segment arrives at its end in (None for a point). +fn end_dir(c: &CubicBez) -> Option { + [c.p2, c.p1, c.p0].into_iter().map(|q| c.p3 - q).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot()) +} + +/// Does the path turn where `prev` ends and `next` starts? +fn is_corner(prev: &CubicBez, next: &CubicBez) -> bool { + matches!((end_dir(prev), start_dir(next)), (Some(a), Some(b)) if a.dot(b) < SMOOTH) +} + fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) { let n = sp.anchors.len(); - let straight_corner = |i: usize| { - let a = &sp.anchors[i]; - sp.closed && !a.has_in() && !a.has_out() - }; - if !sp.closed || n < 3 || !(0..n).any(straight_corner) { + if !sp.closed || n < 2 { sp.to_bezpath_into(out); return; } - let pts: Vec = sp.anchors.iter().map(|a| a.p).collect(); - let mut first = true; - for i in 0..n { - let c = opts.corners[i % 4]; - let p = pts[i]; - let prev = pts[(i + n - 1) % n]; - let next = pts[(i + 1) % n]; - if c.is_none() || !straight_corner(i) || !straight_corner((i + n - 1) % n) && sp.anchors[(i + n - 1) % n].has_out() { - if first { - out.move_to(p); - first = false; - } else { - out.line_to(p); + // Segment `i` runs from anchor `i` to anchor `i + 1`; the corner at anchor `i` joins segments + // `i - 1` and `i`, and takes at most half of each. + let segs: Vec = (0..n).map(|i| sp.segment(i)).collect(); + let lens: Vec = segs.iter().map(|c| c.arclen(ARC_ACCURACY)).collect(); + let size: Vec = (0..n) + .map(|i| { + let c = opts.corners[i % 4]; + let prev = (i + n - 1) % n; + if c.is_none() || !is_corner(&segs[prev], &segs[i]) { + return 0.0; } - continue; - } - let din = prev - p; - let dout = next - p; - let (lin, lout) = (din.hypot(), dout.hypot()); - if lin < 1e-9 || lout < 1e-9 { - continue; - } - let s = c.size.min(lin / 2.0).min(lout / 2.0); - let ui = din / lin; - let uo = dout / lout; - let a = p + ui * s; // entry point on incoming edge - let b = p + uo * s; // exit point on outgoing edge - if first { - out.move_to(a); - first = false; + c.size.min(lens[prev] / 2.0).min(lens[i] / 2.0).max(0.0) + }) + .collect(); + if size.iter().all(|s| *s <= 0.0) { + sp.to_bezpath_into(out); + return; + } + // Each segment without the parts its corners take. + let trimmed: Vec = (0..n) + .map(|i| { + let (s0, s1) = (size[i], size[(i + 1) % n]); + let t0 = if s0 > 0.0 { segs[i].inv_arclen(s0, ARC_ACCURACY) } else { 0.0 }; + let t1 = if s1 > 0.0 { segs[i].inv_arclen(lens[i] - s1, ARC_ACCURACY) } else { 1.0 }; + segs[i].subsegment(t0..t1.max(t0)) + }) + .collect(); + out.move_to(trimmed[0].p0); + for i in 0..n { + let seg = &trimmed[i]; + if sp.segment_is_line(i) { + out.line_to(seg.p3); } else { - out.line_to(a); + out.curve_to(seg.p1, seg.p2, seg.p3); + } + // The corner at the segment's end, from where this segment stops to where the next starts. + let j = (i + 1) % n; + let next = &trimmed[j]; + if size[j] > 0.0 { + match (end_dir(seg).or_else(|| end_dir(&segs[i])), start_dir(next).or_else(|| start_dir(&segs[j]))) { + (Some(ta), Some(tb)) => corner_shape(out, opts.corners[j % 4].shape, sp.anchors[j].p, seg.p3, next.p0, -ta, tb, size[j]), + _ => out.line_to(next.p0), + } } - corner_shape(out, c.shape, p, a, b, ui, uo, s); } out.close_path(); } @@ -185,6 +213,48 @@ mod tests { assert!((a - std::f64::consts::PI * 100.0).abs() < 1.0, "{a}"); } + fn nearest(bp: &BezPath, p: Point) -> f64 { + use kurbo::ParamCurveNearest; + bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq.sqrt()).fold(f64::INFINITY, f64::min) + } + + /// A quarter disc of radius 100 centred on (0, 100): its arc from (0, 0) to (100, 100). + fn quarter_disc() -> PathData { + let k = 100.0 * crate::shapes::KAPPA; + let mut a0 = crate::path::Anchor::corner(Point::new(0.0, 0.0)); + a0.h_out = Point::new(k, 0.0); + let mut a1 = crate::path::Anchor::corner(Point::new(100.0, 100.0)); + a1.h_in = Point::new(100.0, 100.0 - k); + let a2 = crate::path::Anchor::corner(Point::new(0.0, 100.0)); + PathData::new(vec![SubPath { anchors: vec![a0, a1, a2], closed: true }]) + } + + #[test] + fn rounded_corners_keep_curved_sides_and_round_where_an_arc_meets_a_line() { + let p = quarter_disc(); + let bp = apply(&p, &CornerOptions::uniform(CornerShape::Rounded, 20.0)); + // Still a quarter disc (area pi/4 * 100^2 = 7854), less three small rounded corners; a + // triangle would be 5000. + let area = bp.area().abs(); + assert!(area > 7300.0 && area < 7854.0, "area {area}"); + // The middle of the arc is kept. + let mid = Point::new(100.0 * std::f64::consts::FRAC_1_SQRT_2, 100.0 - 100.0 * std::f64::consts::FRAC_1_SQRT_2); + assert!(nearest(&bp, mid) < 0.05, "{}", nearest(&bp, mid)); + // All three corners are rounded off: the line-line one and both where the arc meets a line. + for corner in [Point::new(0.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)] { + assert!(nearest(&bp, corner) > 3.0, "{corner:?} is still sharp"); + } + } + + #[test] + fn smooth_points_are_not_corners() { + let e = crate::shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 60.0)); + for shape in CornerShape::ALL.into_iter().skip(1) { + let bp = apply(&e, &CornerOptions::uniform(shape, 20.0)); + assert!((bp.area().abs() - e.to_bezpath().area().abs()).abs() < 1e-6, "{shape:?}"); + } + } + #[test] fn size_is_clamped_to_half_edge() { let r = rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));