mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 18:10:01 -06:00
Fix #442: alike circular corners on rectangles that aren't square
Live rectangles drew elliptical corners after an uneven scale in v0.4.0; inconsistent on a rectangle that isn't square: - doc: LiveShape::fold_scale moves an uneven scale left in `xf` (files saved before #291) into the size and radii; `transform` uses it, and `folded()`/`corner_style` give Live Corners document units. - engine: object.setLiveShape folds that scale first, so setting a radius or kind (fields, dialog, widgets, MCP) gives circular corners on such shapes instead of keeping the ellipses. - geom: max_corner_radius/fitted_corner_radius are the one limit every corner shares (half the shorter side). - tools: corner widgets sit at the centres of the corners as drawn (a radius past the limit put them off the arc, further in along the long side), drag in document units on stretched shapes, and outline the edited corners in red at the limit, as the reference app does. - svg: CSS export writes the drawn radii (CSS would draw a radius past the limit when its neighbours leave room). - docs: mcp.md Live Corners, ROADMAP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7c7ce7f4fb
commit
44c7e64e1b
+63
-19
@@ -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<AnchorRef> {
|
||||
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<f64>, Option<CornerKind>) {
|
||||
let LiveShape::Rectangle { radii, kinds, .. } = self else { return (None, None) };
|
||||
let LiveShape::Rectangle { radii, kinds, .. } = self.folded() else { return (None, None) };
|
||||
fn shared<T: Copy>(v: [T; 4], corners: [bool; 4], same: impl Fn(T, T) -> bool) -> Option<T> {
|
||||
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());
|
||||
|
||||
@@ -1038,6 +1038,9 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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)));
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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<usize> {
|
||||
/// 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<Point> = 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);
|
||||
|
||||
@@ -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<Corners> {
|
||||
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) => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
+100
-15
@@ -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<Self> {
|
||||
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<Action> {
|
||||
@@ -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<Overlay> {
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user