From f8ab5b2c61515253037b00580cdd95963f4b3c13 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 11 Oct 2026 20:00:52 +0200 Subject: [PATCH 1/2] 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 9a0f71e70..8b073be07 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **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-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change. - **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI smoke-tested under QEMU). diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index df1fd0fa9..6e8ea32e8 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)); From c6d285668875179ad804eb9b3adc5ee6d1e9ab89 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sun, 11 Oct 2026 23:57:34 +0200 Subject: [PATCH 2/2] fix(geom): Trim straight sides exactly for Corner Options Straight sides were trimmed through inv_arclen at 1e-3 accuracy, so a rectangle's corner points landed up to about 1e-4 pt off. Lines are now measured and trimmed along the chord, exactly; curves still use arc length. Co-Authored-By: Claude Opus 5.5 --- crates/geom/src/corners.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 6e8ea32e8..6b0c9cb0a 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -107,7 +107,9 @@ fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) { // 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 lines: Vec = (0..n).map(|i| sp.segment_is_line(i)).collect(); + // Straight sides are measured (and trimmed below) exactly; curves by arc length. + let lens: Vec = segs.iter().zip(&lines).map(|(c, line)| if *line { (c.p3 - c.p0).hypot() } else { c.arclen(ARC_ACCURACY) }).collect(); let size: Vec = (0..n) .map(|i| { let c = opts.corners[i % 4]; @@ -126,6 +128,12 @@ fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) { let trimmed: Vec = (0..n) .map(|i| { let (s0, s1) = (size[i], size[(i + 1) % n]); + if lines[i] { + let c = &segs[i]; + let u = if lens[i] > 1e-12 { (c.p3 - c.p0) / lens[i] } else { Vec2::ZERO }; + let (a, b) = (c.p0 + u * s0, c.p3 - u * s1); + return CubicBez::new(a, a, b, b); + } 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)) @@ -134,7 +142,7 @@ fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) { out.move_to(trimmed[0].p0); for i in 0..n { let seg = &trimmed[i]; - if sp.segment_is_line(i) { + if lines[i] { out.line_to(seg.p3); } else { out.curve_to(seg.p1, seg.p2, seg.p3); @@ -246,6 +254,25 @@ mod tests { } } + #[test] + fn straight_sides_are_trimmed_exactly() { + let r = rectangle(Rect::new(0.0, 0.0, 100.0, 60.0)); + let bp = apply(&r, &CornerOptions::uniform(CornerShape::Bevel, 10.0)); + // Every point a bevelled rectangle goes through sits exactly 10 pt from a corner. + let pts: Vec = bp + .elements() + .iter() + .filter_map(|e| match e { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => Some(*p), + _ => None, + }) + .collect(); + assert_eq!(pts.len(), 9, "{pts:?}"); + for p in pts { + assert!([0.0, 10.0, 90.0, 100.0].contains(&p.x) && [0.0, 10.0, 50.0, 60.0].contains(&p.y), "{p:?}"); + } + } + #[test] fn smooth_points_are_not_corners() { let e = crate::shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 60.0));