diff --git a/ROADMAP.md b/ROADMAP.md index 23a8c946..85d305ec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -119,7 +119,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). - - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. + - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). - **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on), Measure, Type, Hand, Zoom, Rotate View. diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 9317a518..a51102d1 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -143,24 +143,11 @@ impl LiveShape { /// scale in `xf` keep their exact geometry until they're next transformed). pub fn transform(&mut self, a: Affine) -> bool { match self { - LiveShape::Rectangle { w, h, radii, xf, .. } => { - let m = a * *xf; + LiveShape::Rectangle { xf, .. } => { + *xf = a * *xf; let [a0, a1, a2, a3, _, _] = a.as_coeffs(); let moves_only = (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12; - let [m0, m1, m2, m3, m4, m5] = m.as_coeffs(); - let (sx, sy) = (m0.hypot(m1), m2.hypot(m3)); - let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy; - let finite = m.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite(); - if moves_only || !orthogonal || !finite { - *xf = m; - return false; - } - let k = (sx * sy).sqrt(); - radii.iter_mut().for_each(|r| *r *= k); - *w *= sx; - *h *= sy; - *xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]); - true + !moves_only && self.fold_scale() } LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => { *xf = a * *xf; @@ -173,6 +160,35 @@ impl LiveShape { } } } + /// Move the scale of a rectangle's `xf` into `w`/`h` (and its radii, by the mean scale), so + /// `xf` keeps only a rotation or reflection plus a move and the corners are circular in the + /// document. False (and nothing changes) when there is no scale to move, when `xf` shears or + /// isn't finite, and for other shapes. Files saved before transforms did this (#291) carry an + /// uneven scale in `xf`: their corners are elliptical until this runs. + pub fn fold_scale(&mut self) -> bool { + let LiveShape::Rectangle { w, h, radii, xf, .. } = self else { return false }; + let [m0, m1, m2, m3, m4, m5] = xf.as_coeffs(); + let (sx, sy) = (m0.hypot(m1), m2.hypot(m3)); + let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy; + let finite = xf.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite(); + let unscaled = (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12; + if !orthogonal || !finite || unscaled { + return false; + } + let k = (sx * sy).sqrt(); + radii.iter_mut().for_each(|r| *r *= k); + *w *= sx; + *h *= sy; + *xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]); + true + } + /// This shape with [`LiveShape::fold_scale`] applied: a rectangle's size and radii in document + /// units (what Live Corners show and edit). + pub fn folded(&self) -> Self { + let mut s = self.clone(); + s.fold_scale(); + s + } /// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners /// keep their size (Scale Corners off). False when nothing changed. pub fn keep_corners(&mut self, k: f64) -> bool { @@ -212,15 +228,16 @@ impl LiveShape { pub fn corner_anchors(&self, corners: [bool; 4]) -> BTreeSet { self.anchor_corners().into_iter().enumerate().filter(|(_, k)| corners.get(*k) == Some(&true)).map(|(ai, _)| (0, ai)).collect() } - /// The radius and kind the `corners` of a rectangle share (each `None` when they differ). + /// The radius (in document units, see [`LiveShape::folded`]) and kind the `corners` of a + /// rectangle share (each `None` when they differ). pub fn corner_style(&self, corners: [bool; 4]) -> (Option, Option) { - let LiveShape::Rectangle { radii, kinds, .. } = self else { return (None, None) }; + let LiveShape::Rectangle { radii, kinds, .. } = self.folded() else { return (None, None) }; fn shared(v: [T; 4], corners: [bool; 4], same: impl Fn(T, T) -> bool) -> Option { let mut it = v.into_iter().zip(corners).filter(|(_, on)| *on).map(|(x, _)| x); let first = it.next()?; it.all(|x| same(x, first)).then_some(first) } - (shared(*radii, corners, |a, b| (a - b).abs() < 1e-9), shared(*kinds, corners, |a, b| a == b)) + (shared(radii, corners, |a, b| (a - b).abs() < 1e-9), shared(kinds, corners, |a, b| a == b)) } /// Rotation angle of the live shape in degrees (shown in the Properties panel). pub fn angle_deg(&self) -> f64 { @@ -1395,6 +1412,33 @@ mod tests { assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0); } + /// A rectangle saved with an uneven scale in `xf` (before #291) has elliptical corners; Live + /// Corners see and edit it in document units, with circular corners (#442). + #[test] + fn a_scale_left_in_the_transform_folds_into_the_size() { + let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(4.0, 1.0); + let n = live_rect(50.0, 80.0, 20.0, legacy); + let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("not live") }; + let folded = live.folded(); + assert_eq!(live_of(&n).3, legacy, "folded() leaves the shape alone"); + let LiveShape::Rectangle { w, h, radii, xf, .. } = &folded else { panic!("not a rectangle") }; + // Drawn 200 × 80 with 80 × 20 corners; folded, the corners are circles of the mean radius. + assert!(near(*w, 200.0) && near(*h, 80.0) && radii.iter().all(|r| near(*r, 40.0)), "{w} × {h} r {radii:?}"); + assert!(near_xf(*xf, Affine::translate((5.0, 5.0)))); + let (a, b) = (folded.to_path().bounds().unwrap(), live.to_path().bounds().unwrap()); + assert!(near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1), "same place and size: {a:?} {b:?}"); + assert_eq!(live.corner_style([true; 4]).0, Some(40.0), "the radius in document units"); + // Nothing to fold: a moved, rotated or reflected rectangle and a sheared one stay as they are. + let place = Affine::translate((5.0, 5.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(-1.0, 1.0); + let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 0.0, 0.0]); + for xf in [place, shear] { + let mut l = live_rect(100.0, 160.0, 20.0, xf); + let NodeKind::Path { live: Some(live), .. } = &mut l.kind else { panic!("not live") }; + assert!(!live.fold_scale()); + assert_eq!(live_of(&l), (100.0, 160.0, [20.0; 4], xf)); + } + } + #[test] fn default_transparency() { let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 4b2309ff..b5c85344 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1038,6 +1038,9 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { s.edit("Live Shape", |d, sel| { for id in &ids { let Some(NodeKind::Path { path, live: Some(live), .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + // Radii are document lengths and corners circular, also on a rectangle from a file + // that kept an uneven scale in its transform (#442). + live.fold_scale(); let partial = sel.anchors.get_mut(id); let picked = corners.unwrap_or_else(|| live.picked_corners(partial.as_deref())); let (layout, selected) = (live.anchor_corners(), partial.as_deref().map(|a| live.corners_of(a))); diff --git a/crates/engine/src/tests_livecorners.rs b/crates/engine/src/tests_livecorners.rs index f6b90a20..f0a0ff4a 100644 --- a/crates/engine/src/tests_livecorners.rs +++ b/crates/engine/src/tests_livecorners.rs @@ -5,6 +5,7 @@ use std::collections::BTreeSet; use serde_json::{Value, json}; use vectorcraft_doc::{AnchorRef, LiveShape, NodeKind}; +use vectorcraft_geom::Affine; use vectorcraft_geom::shapes::CornerKind; use super::*; @@ -105,3 +106,47 @@ fn a_drag_previews_one_corner_and_commits_one_step() { assert_eq!(selected_anchors(&s, id), Some(anchors(&[2, 3]))); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); } + +/// How far each corner's cut reaches along x and y (top-left, top-right, bottom-right, +/// bottom-left) of a rectangle with four cut corners: equal for a circular arc. +fn spans(s: &Session, id: NodeId) -> Vec<(f64, f64)> { + let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("not a path") }; + let a: Vec<_> = path.subpaths[0].anchors.iter().map(|a| a.p).collect(); + assert_eq!(a.len(), 8, "four cut corners"); + [(7, 0), (1, 2), (3, 4), (5, 6)].iter().map(|(i, j)| ((a[*j].x - a[*i].x).abs(), (a[*j].y - a[*i].y).abs())).collect() +} + +fn assert_spans(s: &Session, id: NodeId, want: [f64; 4]) { + let got = spans(s, id); + let ok = got.iter().zip(want).all(|((x, y), r)| (x - r).abs() < 1e-9 && (y - r).abs() < 1e-9); + assert!(ok, "corners span {got:?}, want circles of radii {want:?}"); +} + +/// #442: on a rectangle that isn't square, every corner is the same circle, also past the limit +/// and on a shape from a file that kept an uneven scale in its transform (before #291). +#[test] +fn corners_of_a_non_square_rectangle_are_alike_circles() { + let (mut s, id) = session(); + run(&mut s, json!({"radius": 12})); + assert_spans(&s, id, [12.0; 4]); + // Past half the shorter side, every corner stops there. + run(&mut s, json!({"radius": 45})); + assert_spans(&s, id, [30.0; 4]); + // A 50 × 60 rectangle with 10 pt corners, saved stretched to 100 × 60: 20 × 10 ellipses. + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(id).unwrap().kind else { panic!("not live") }; + let xf = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(2.0, 1.0); + *live = LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; + *path = live.to_path(); + } + assert_eq!(spans(&s, id)[0], (20.0, 10.0)); + // Rounding one corner makes every corner a circle: the others of the mean radius. + run(&mut s, json!({"corners": [1], "radius": 12})); + let mean = 10.0 * 2f64.sqrt(); + assert_spans(&s, id, [mean, 12.0, mean, mean]); + let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + assert!((b.x0 - 10.0).abs() < 1e-9 && (b.x1 - 110.0).abs() < 1e-9 && (b.y1 - 70.0).abs() < 1e-9, "same bounds: {b:?}"); + run(&mut s, json!({"radius": 8})); + assert_spans(&s, id, [8.0; 4]); +} diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index 6c67e322..16e9fab1 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -47,6 +47,18 @@ impl CornerKind { } } +/// The largest corner radius a `w` × `h` rectangle draws: half its shorter side. +pub fn max_corner_radius(w: f64, h: f64) -> f64 { + w.abs().min(h.abs()) / 2.0 +} + +/// The radius a corner of a `w` × `h` rectangle is drawn with: `radius`, no larger than +/// [`max_corner_radius`] (the same limit for every corner, so opposite corners match), 0 when +/// negative or not a number. +pub fn fitted_corner_radius(w: f64, h: f64, radius: f64) -> f64 { + radius.max(0.0).min(max_corner_radius(w, h)) +} + /// Rectangle whose corners (top-left, top-right, bottom-right, bottom-left, y down) each have /// their own radius and kind, every radius clamped to half the shorter side. A corner without a /// radius is one anchor, the others two (where the cut meets each side). Clockwise from the @@ -63,7 +75,6 @@ pub fn rectangle_anchor_corners(r: Rect, radii: [f64; 4]) -> Vec { /// The anchors of [`rectangle_with_corners`], each with its corner. fn corner_anchors(r: Rect, radii: [f64; 4], kinds: [CornerKind; 4]) -> Vec<(usize, Anchor)> { let r = r.abs(); - let max = r.width().min(r.height()) / 2.0; // Each corner, with the directions of the side arriving at it and of the side leaving it. let corners = [ (Point::new(r.x0, r.y0), Vec2::new(0.0, -1.0), Vec2::new(1.0, 0.0)), @@ -74,7 +85,7 @@ fn corner_anchors(r: Rect, radii: [f64; 4], kinds: [CornerKind; 4]) -> Vec<(usiz let mut out = Vec::with_capacity(8); let mut top_left_start = None; for (k, ((c, din, dout), (rad, kind))) in corners.into_iter().zip(radii.into_iter().zip(kinds)).enumerate() { - let rad = rad.max(0.0).min(max); + let rad = fitted_corner_radius(r.width(), r.height(), rad); if rad <= 1e-9 { out.push((k, Anchor::corner(c))); continue; @@ -312,6 +323,24 @@ mod tests { assert_eq!(CornerKind::ALL.map(CornerKind::next), [CornerKind::InvertedRound, CornerKind::Chamfer, CornerKind::Round]); } + /// #442: on a rectangle that isn't square, every corner is a circular arc of the same radius, + /// and radii past the limit stop at half the shorter side, whichever side a corner is on. + #[test] + fn corners_of_a_non_square_rectangle_are_alike() { + let r = Rect::new(0.0, 0.0, 200.0, 80.0); + assert_eq!(max_corner_radius(-200.0, 80.0), 40.0); + assert_eq!([30.0, 60.0, -1.0, f64::NAN].map(|x| fitted_corner_radius(200.0, 80.0, x)), [30.0, 40.0, 0.0, 0.0]); + for (radius, drawn) in [(30.0, 30.0), (60.0, 40.0)] { + for kinds in [[CornerKind::Round; 4], [CornerKind::Chamfer, CornerKind::InvertedRound, CornerKind::Round, CornerKind::Chamfer]] { + let a: Vec = rectangle_with_corners(r, [radius; 4], kinds).subpaths[0].anchors.iter().map(|a| a.p).collect(); + for (i, j) in [(7, 0), (1, 2), (3, 4), (5, 6)] { + let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs()); + assert!((x - drawn).abs() < 1e-9 && (y - drawn).abs() < 1e-9, "radius {radius}: a corner spans {x} × {y}"); + } + } + } + } + #[test] fn polygon_first_vertex_up() { let p = polygon(Point::new(0.0, 0.0), 10.0, 6, 0.0); diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index e964427c..3fab8ddc 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{AppearanceItem, CharStyle, Document, Justify, LineCap, LiveShape, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind}; use vectorcraft_effects::RasterFx; -use vectorcraft_geom::shapes::CornerKind; +use vectorcraft_geom::shapes::{self, CornerKind}; use vectorcraft_geom::{Affine, PathData, Point, Rect}; use crate::export::sanitize_id; @@ -600,19 +600,21 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option { let [_, b, c, _, _, _] = xf.as_coeffs(); b.abs() < 1e-9 && c.abs() < 1e-9 }; - match live { + // Sizes and radii in document units. + match live.map(LiveShape::folded).as_ref() { // CSS rounds corners only: an inverted round or chamfered corner has no border radius. - Some(LiveShape::Rectangle { radii, kinds, xf, .. }) + Some(LiveShape::Rectangle { w, h, radii, kinds, xf }) if upright(xf) && radii.iter().zip(kinds).all(|(r, k)| *r <= 0.0 || *k == CornerKind::Round) => { - let [a, _, _, d, _, _] = xf.as_coeffs(); - let s = (a * d).abs().sqrt(); + // The radii as drawn, none past half the shorter side (CSS draws one further when its + // neighbours leave room). + let radii = radii.map(|r| shapes::fitted_corner_radius(*w, *h, r)); if radii.iter().all(|r| *r <= 0.0) { Some(Corners::Square) } else if radii.iter().all(|r| (r - radii[0]).abs() < 1e-9) { - Some(Corners::Round(vec![radii[0] * s])) + Some(Corners::Round(vec![radii[0]])) } else { - Some(Corners::Round(radii.iter().map(|r| r.max(0.0) * s).collect())) + Some(Corners::Round(radii.to_vec())) } } Some(LiveShape::Ellipse { pie, xf, .. }) if upright(xf) => { diff --git a/crates/svg/tests/css_rules.rs b/crates/svg/tests/css_rules.rs index 40f7a807..d3065416 100644 --- a/crates/svg/tests/css_rules.rs +++ b/crates/svg/tests/css_rules.rs @@ -84,6 +84,13 @@ fn live_corners_give_per_corner_radii_and_only_round_ones() { kinds[1] = CornerKind::InvertedRound; } assert!(prop(&one(&d, id, &CssOptions::default()), "border-radius").is_none(), "CSS can't cut a corner in"); + // A radius past half the shorter side is drawn at half of it (#442), and exported so: CSS + // would draw it larger, its neighbours leaving room. + if let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), .. }) = d.node_mut(id).map(|n| &mut n.kind) { + *radii = [60.0, 8.0, 0.0, 4.0]; + *kinds = Default::default(); + } + assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("25px 8px 0 4px")); } #[test] diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index da053754..1141782c 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -7,8 +7,8 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::{Document, LiveShape, NodeId, NodeKind, Selection}; -use vectorcraft_geom::shapes::CornerKind; -use vectorcraft_geom::{Affine, Point, Vec2}; +use vectorcraft_geom::shapes::{self, CornerKind}; +use vectorcraft_geom::{Affine, BezPath, Point, Rect, Vec2}; use crate::{Action, Overlay, PointerEvent, ToolContext}; @@ -47,18 +47,20 @@ impl CornerWidgets { return None; } let Some(NodeKind::Path { live: Some(live), .. }) = doc.node(id).map(|n| &n.kind) else { return None }; - let LiveShape::Rectangle { w, h, radii, kinds, xf } = live else { return None }; - let (w, h, radii, kinds, xf) = (*w, *h, *radii, *kinds, *xf); + // In document units, as `object.setLiveShape` edits them. + let LiveShape::Rectangle { w, h, radii, kinds, xf } = live.folded() else { return None }; // Screen pixels per shape unit along each side (the shape may be scaled or skewed). let c = xf.as_coeffs(); let (px, py) = (Vec2::new(c[0], c[1]).hypot() * zoom, Vec2::new(c[2], c[3]).hypot() * zoom); if xf.determinant().abs() < 1e-12 || (w * px).min(h * py) < MIN_SIDE_PX { return None; } + // Each widget sits on its corner's diagonal, at the centre of the arc as drawn. let points = std::array::from_fn(|k| { let ((fx, fy), (sx, sy)) = CORNERS[k]; - let ix = radii[k].max(MIN_INSET_PX / px).min(w / 2.0); - let iy = radii[k].max(MIN_INSET_PX / py).min(h / 2.0); + let r = shapes::fitted_corner_radius(w, h, radii[k]); + let ix = r.max(MIN_INSET_PX / px).min(w / 2.0); + let iy = r.max(MIN_INSET_PX / py).min(h / 2.0); xf * Point::new(fx * w + sx * ix, fy * h + sy * iy) }); Some(Self { id, w, h, radii, kinds, xf, points, shown: live.picked_corners(selection.partial(id)) }) @@ -138,6 +140,8 @@ pub struct CornerDrag { began: bool, alt: bool, radius: f64, + /// The pointer went past fully round: the corners are at their largest radius. + at_limit: bool, at: Point, } @@ -146,18 +150,41 @@ impl CornerDrag { pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { let (widgets, p) = (CornerWidgets::for_tool(cx)?, ev.pos); let corner = widgets.hit(p, cx.tol(5.0))?; - Some(Self { widgets, corner, start: p, began: false, alt: ev.mods.alt, radius: widgets.radii[corner], at: p }) + Some(Self { widgets, corner, start: p, began: false, alt: ev.mods.alt, radius: widgets.radii[corner], at_limit: false, at: p }) } /// The start radius (as drawn: no larger than fits) plus the pointer's travel along the - /// corner's inward diagonal (in the shape's own units), from square to fully round. - fn radius_at(&self, p: Point) -> f64 { + /// corner's inward diagonal (in the shape's own units), from square to fully round; and + /// whether the travel reached fully round (the limit every corner shares). + fn radius_at(&self, p: Point) -> (f64, bool) { let w = &self.widgets; let inv = w.xf.inverse(); let d = inv * p - inv * self.start; let (_, (sx, sy)) = CORNERS[self.corner]; - let max = (w.w.abs().min(w.h.abs()) / 2.0).max(0.0); - (w.radii[self.corner].min(max) + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, max) + let max = shapes::max_corner_radius(w.w, w.h); + let r = shapes::fitted_corner_radius(w.w, w.h, w.radii[self.corner]) + (d.x * sx + d.y * sy) / 2.0; + (r.min(max).max(0.0), max > 0.0 && r >= max) + } + + /// The edited corners' cuts at the dragged radius, in document coordinates. + fn edited_corners(&self) -> BezPath { + let w = &self.widgets; + let rect = Rect::new(0.0, 0.0, w.w, w.h); + let radii = std::array::from_fn(|k| if w.shown[k] { self.radius } else { w.radii[k] }); + let corner_of = shapes::rectangle_anchor_corners(rect, radii); + let path = shapes::rectangle_with_corners(rect, radii, w.kinds).transformed(w.xf); + let mut out = BezPath::new(); + let Some(sp) = path.subpaths.first() else { return out }; + // A corner's cut is the segment between its two anchors (the last one, for the top-left). + for i in 0..sp.segment_count() { + let (a, b) = (corner_of.get(i), corner_of.get(i + 1).or(corner_of.first())); + if a == b && a.is_some_and(|k| w.shown.get(*k) == Some(&true)) { + let c = sp.segment(i); + out.move_to(c.p0); + out.curve_to(c.p1, c.p2, c.p3); + } + } + out } pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec { @@ -169,7 +196,7 @@ impl CornerDrag { self.began = true; out.push(Action::Begin("Corner Radius".into())); } - self.radius = self.radius_at(p); + (self.radius, self.at_limit) = self.radius_at(p); self.at = p; out.push(Action::Preview("object.setLiveShape".into(), self.widgets.command("radius", json!(self.radius)))); out @@ -186,12 +213,16 @@ impl CornerDrag { } } - /// The radius readout next to the pointer. + /// The radius readout next to the pointer; at the largest radius, the edited corners in red. pub fn overlays(&self, cx: &ToolContext) -> Vec { if !self.began { return vec![]; } - vec![Overlay::Measure { p: self.at, text: format!("Radius: {}", cx.len(self.radius)) }] + let mut out = vec![Overlay::Measure { p: self.at, text: format!("Radius: {}", cx.len(self.radius)) }]; + if self.at_limit { + out.push(Overlay::Path { path: self.edited_corners(), color: crate::builder::HIGHLIGHT_RED, width: 2.0, dashed: false }); + } + out } } @@ -204,10 +235,15 @@ mod tests { /// A live 100 × 100 rectangle at (100, 100) with corner radius `r`. fn live_rect(r: f64, xf: Affine) -> (Document, NodeId) { + live_shape(100.0, 100.0, r, xf) + } + + /// A live `w` × `h` rectangle with corner radius `r`. + fn live_shape(w: f64, h: f64, r: f64, xf: Affine) -> (Document, NodeId) { let mut d = Document::new(500.0, 500.0); let l = d.layers[0].id; let id = d.alloc_id(); - let live = LiveShape::Rectangle { w: 100.0, h: 100.0, radii: [r; 4], kinds: Default::default(), xf }; + let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf }; let mut n = Node::path(id, live.to_path(), Appearance::default_art()); if let NodeKind::Path { live: slot, .. } = &mut n.kind { *slot = Some(live); @@ -264,6 +300,55 @@ mod tests { } } + /// #442: on a 200 × 80 rectangle the widgets sit at the centres of the corners as drawn, also + /// with a radius past half the shorter side; a drag past fully round stops every corner there + /// and outlines them in red. + #[test] + fn widgets_of_a_non_square_rectangle_follow_the_drawn_corners() { + let (d, id) = live_shape(200.0, 80.0, 60.0, Affine::translate((100.0, 100.0))); + let s = selected(id); + let w = CornerWidgets::of(&d, &s, 1.0).unwrap(); + let centres = [Point::new(140.0, 140.0), Point::new(260.0, 140.0), Point::new(260.0, 140.0), Point::new(140.0, 140.0)]; + assert_eq!(w.points, centres); + let p = paint(); + let cx = cx(&d, &s, &p); + let red = |t: &dyn crate::Tool| { + t.overlays(&cx).into_iter().find_map(|o| match o { + Overlay::Path { path, color, .. } if color == crate::builder::HIGHLIGHT_RED => Some(path), + _ => None, + }) + }; + let mut t = crate::create("selection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 140.0, 140.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 200.0)); + assert_eq!(radius(&a[1]), 40.0); + // The four arcs, each a quarter circle of radius 40 starting on a side. + let arcs = red(t.as_ref()).expect("the limit shows"); + let starts: Vec<_> = + arcs.elements().iter().filter_map(|e| if let vectorcraft_geom::PathEl::MoveTo(p) = e { Some(*p) } else { None }).collect(); + assert_eq!(starts, [Point::new(260.0, 100.0), Point::new(300.0, 140.0), Point::new(140.0, 180.0), Point::new(100.0, 140.0)]); + // Back below the limit, the outline goes. + assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0))[0]), 30.0); + assert!(red(t.as_ref()).is_none()); + } + + /// #442: a rectangle saved stretched (an uneven scale in its transform, before #291) shows its + /// widgets and drags its radius in document units, as `object.setLiveShape` sets it. + #[test] + fn a_stretched_rectangle_drags_its_radius_in_document_units() { + // 50 × 80 with 10 pt corners stretched 4 × 1: drawn 200 × 80, a mean radius of 20. + let (d, id) = live_shape(50.0, 80.0, 10.0, Affine::translate((100.0, 100.0)) * Affine::scale_non_uniform(4.0, 1.0)); + let s = selected(id); + let w = CornerWidgets::of(&d, &s, 1.0).unwrap(); + assert_eq!(w.points[0], Point::new(120.0, 120.0)); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = crate::create("directSelection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0, 120.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 110.0, 110.0)); + assert!((radius(&a[1]) - 10.0).abs() < 1e-9, "{a:?}"); + } + #[test] fn drag_follows_a_rotated_shape_and_a_click_does_nothing() { // Rotated 90°: the shape's top-left corner is at document (200, 100). diff --git a/docs/mcp.md b/docs/mcp.md index 9b6fa0b2..f2487dfb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1088,9 +1088,12 @@ size. The journal entry of a scaling command records the `strokes` and `corners` 2 bottom-right, 3 bottom-left), else to the corners holding a Direct-Selected anchor (`select.anchors`), else to all four. Each corner keeps its own radius and kind (the shape's `live` in queries has `radii` and, when a corner isn't round, `kinds`); a corner with no radius is one anchor, a cut one two, and Direct-Selected corners stay selected as -that changes. With the Selection or Direct Selection tool, dragging a corner widget rounds the corners whose widgets -show (all four, or the Direct-Selected ones), Alt-clicking one cycles their kind and double-clicking one opens Corners -(`ui.corners {id?, corners?}`, dialog `corners`: `kind`, `radius`; OK runs `object.setLiveShape`). +that changes. Every corner is a circular arc, whatever the rectangle's proportions: a radius past half the shorter +side draws at half of it (the same limit for every corner), and a rectangle from a file that kept an uneven scale in its +transform (elliptical corners) gets circular ones in document units when its corners are next set. With the Selection or +Direct Selection tool, dragging a corner widget rounds the corners whose widgets show (all four, or the Direct-Selected +ones), outlining them in red once they reach that limit; Alt-clicking one cycles their kind and double-clicking one +opens Corners (`ui.corners {id?, corners?}`, dialog `corners`: `kind`, `radius`; OK runs `object.setLiveShape`). ```json {"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"id":12,"corners":[1],"radius":16}}}