mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Fix-up in the merge (review): shape.smoothCorners re-smooths a custom outline only when it is a squircle it drew with the given radius, so a freeform is never replaced by a rectangle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("shape.flip", "Flip", ["Shape Format", "Arrange", "Rotate"], None, "{axis: horizontal|vertical, ids?}", has_selection, flip),
|
||||
cmd!("shape.adjust", "Adjust Shape", [], None, "{index, value (file units), ids?}", has_selection, adjust),
|
||||
cmd!("shape.change", "Change Shape", ["Shape Format", "Insert Shapes", "Edit Shape"], None, "{preset, ids?}", has_selection, change),
|
||||
cmd!(
|
||||
"shape.smoothCorners",
|
||||
"Smooth Corners",
|
||||
["Shape Format", "Insert Shapes", "Edit Shape"],
|
||||
None,
|
||||
"{smoothing: 0..100 (%), radius?: pt (default: the rounded rectangle's own), ids?} → rectangle outline with smoothed (squircle) corners",
|
||||
has_selection,
|
||||
smooth_corners
|
||||
),
|
||||
cmd!(
|
||||
"shape.fill",
|
||||
"Shape Fill",
|
||||
@@ -404,6 +413,47 @@ fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Rectangles and rounded rectangles become custom geometry whose corners are smoothed
|
||||
/// ([`deckcraft_geom::squircle`]); the radius is the rounded rectangle's own unless `radius` says.
|
||||
fn smooth_corners(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "shape.smoothCorners";
|
||||
let smoothing =
|
||||
f64_param(p, "smoothing").filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "missing `smoothing` (0–100)"))?.clamp(0.0, 100.0) / 100.0;
|
||||
let radius = f64_param(p, "radius").filter(|v| v.is_finite()).map(|v| v.max(0.0));
|
||||
edit_geometry(s, p, CMD, |sh| {
|
||||
let Some(x) = sh.xfrm else { return Err(bad(CMD, "the shape has no size of its own")) };
|
||||
let ss = x.w.min(x.h).max(0.0);
|
||||
let own = match &sh.geom {
|
||||
Geom::Preset { name, adj } if name == "roundRect" => Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0),
|
||||
Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0),
|
||||
_ => None,
|
||||
};
|
||||
// A custom outline qualifies only when it is a squircle this command drew with `radius`
|
||||
// (re-smoothing as the slider moves), never an arbitrary freeform.
|
||||
let custom = match (&sh.geom, radius) {
|
||||
(Geom::Custom { paths }, Some(r)) => match paths.as_slice() {
|
||||
[only] => (0..=100).any(|k| deckcraft_geom::squircle::path_d(only.w, only.h, r, f64::from(k) / 100.0) == only.d),
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
let r = match (radius, own) {
|
||||
(Some(r), _) if own.is_some() || custom => r,
|
||||
(None, Some(r)) => r,
|
||||
_ => return Err(bad(CMD, "only rectangles, rounded rectangles and shapes already smoothed with this `radius` can be smoothed")),
|
||||
};
|
||||
sh.geom = Geom::Custom {
|
||||
paths: vec![deckcraft_model::CustomPath {
|
||||
w: x.w,
|
||||
h: x.h,
|
||||
d: deckcraft_geom::squircle::path_d(x.w, x.h, r, smoothing),
|
||||
..Default::default()
|
||||
}],
|
||||
};
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?;
|
||||
let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?;
|
||||
|
||||
@@ -2521,3 +2521,40 @@ fn language_marks_text_and_no_proof_and_repeats_are_reported() {
|
||||
assert!(s.execute("review.spelling", &json!({})).unwrap().as_array().unwrap().is_empty());
|
||||
assert!(s.execute("format.language", &json!({"lang": "en US!"})).is_err());
|
||||
}
|
||||
|
||||
/// Smooth Corners (#286): a rounded rectangle becomes a squircle outline that survives PPTX.
|
||||
#[test]
|
||||
fn smooth_corners_makes_a_squircle_outline() {
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "roundRect", "rect": [100, 100, 200, 100]})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
s.execute("shape.smoothCorners", &json!({"smoothing": 60})).unwrap();
|
||||
let d = |s: &Session| match &s.doc().unwrap().shape(id).unwrap().geom {
|
||||
deckcraft_model::Geom::Custom { paths } => paths[0].d.clone(),
|
||||
g => panic!("{g:?}"),
|
||||
};
|
||||
// The default roundRect radius is 16.667% of the short side: 16.667 pt, smoothed 60%.
|
||||
assert_eq!(d(&s), deckcraft_geom::squircle::path_d(200.0, 100.0, 16.667, 0.6));
|
||||
// The smoothed outline takes a new smoothing with the radius it was drawn with; without a
|
||||
// radius, or with another one (it could be any freeform), it is refused.
|
||||
s.execute("shape.smoothCorners", &json!({"smoothing": 100, "radius": 16.667})).unwrap();
|
||||
assert_eq!(d(&s), deckcraft_geom::squircle::path_d(200.0, 100.0, 16.667, 1.0));
|
||||
assert!(s.execute("shape.smoothCorners", &json!({"smoothing": 50})).is_err());
|
||||
assert!(s.execute("shape.smoothCorners", &json!({"smoothing": 50, "radius": 30})).is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(d(&s), deckcraft_geom::squircle::path_d(200.0, 100.0, 16.667, 0.6));
|
||||
// Other shapes are refused.
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [0, 0, 50, 50]})).unwrap();
|
||||
assert!(s.execute("shape.smoothCorners", &json!({"smoothing": 50})).is_err());
|
||||
// So is a freeform, whatever radius is given.
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 50, 50]})).unwrap();
|
||||
s.execute("shape.editPoints", &json!({"on": true})).unwrap();
|
||||
s.execute("shape.editPoints", &json!({"on": false})).unwrap();
|
||||
assert!(matches!(s.doc().unwrap().shapes().last().unwrap().geom, deckcraft_model::Geom::Custom { .. }), "Edit Points made a freeform");
|
||||
assert!(s.execute("shape.smoothCorners", &json!({"smoothing": 50, "radius": 8})).is_err());
|
||||
// The outline round-trips through PPTX as custom geometry.
|
||||
let bytes = crate::cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap();
|
||||
let back = crate::cmd::file::open_presentation("x.pptx", &bytes).unwrap();
|
||||
let sh = back.slides.iter().flat_map(|sl| sl.shapes.iter()).find(|sh| matches!(sh.geom, deckcraft_model::Geom::Custom { .. }));
|
||||
assert!(sh.is_some(), "a custom outline in the saved file");
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
pub mod morph;
|
||||
pub mod preset;
|
||||
pub mod squircle;
|
||||
pub mod warp;
|
||||
|
||||
pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2};
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Rectangles with smoothed ("squircle") corners, as design tools offer next to the corner radius
|
||||
//! (#286). A plain rounded corner joins the straight edge with a sudden change of curvature; a
|
||||
//! smoothed corner starts curving earlier, along a cubic Bézier, and keeps a shorter circular arc
|
||||
//! in the middle, so the outline flows into the edge.
|
||||
//!
|
||||
//! For a corner of radius `r` and smoothing `s` (0 = circular, 1 = no arc left), the corner takes
|
||||
//! `p = (1 + s) · r` of each edge. The arc keeps `90° · (1 − s)` around the corner's diagonal and
|
||||
//! each side of it is a cubic whose handles lie on the edge, so the curve is tangent to the edge
|
||||
//! where it starts and to the arc where it meets it. When `p` would exceed half the shorter side,
|
||||
//! the smoothing is reduced first, then the radius. The construction is our own derivation from
|
||||
//! these constraints.
|
||||
|
||||
/// The outline of a `w` x `h` rectangle with corners of radius `radius` smoothed by `smoothing`
|
||||
/// (0..=1), as path data in the rectangle's own coordinates (`M`, `L`, `C`, `Z`; y down).
|
||||
pub fn path_d(w: f64, h: f64, radius: f64, smoothing: f64) -> String {
|
||||
let w = if w.is_finite() { w.max(0.0) } else { 0.0 };
|
||||
let h = if h.is_finite() { h.max(0.0) } else { 0.0 };
|
||||
let half = w.min(h) / 2.0;
|
||||
let r = if radius.is_finite() { radius.clamp(0.0, half) } else { 0.0 };
|
||||
let mut s = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 };
|
||||
if r <= 0.0 {
|
||||
return format!("M 0 0 L {w} 0 L {w} {h} L 0 {h} Z");
|
||||
}
|
||||
s = s.min(half / r - 1.0).max(0.0);
|
||||
let c = corner(r, s);
|
||||
// The top-right corner in local terms: `u` runs back along the top edge from the corner,
|
||||
// `v` down the right edge. Each corner maps (u, v) to the rectangle by symmetry.
|
||||
let corners: [&dyn Fn(f64, f64) -> (f64, f64); 4] = [
|
||||
&|u, v| (w - u, v), // top right: from the top edge to the right edge
|
||||
&|u, v| (w - v, h - u), // bottom right: from the right edge to the bottom edge
|
||||
&|u, v| (u, h - v), // bottom left: from the bottom edge to the left edge
|
||||
&|u, v| (v, u), // top left: from the left edge to the top edge
|
||||
];
|
||||
let mut d = String::new();
|
||||
let pt = |f: &dyn Fn(f64, f64) -> (f64, f64), (u, v): (f64, f64)| {
|
||||
let (x, y) = f(u, v);
|
||||
format!("{} {}", round(x), round(y))
|
||||
};
|
||||
for (i, f) in corners.iter().enumerate() {
|
||||
let segs = c.segments();
|
||||
if i == 0 {
|
||||
d.push_str(&format!("M {}", pt(*f, segs[0])));
|
||||
} else {
|
||||
d.push_str(&format!(" L {}", pt(*f, segs[0])));
|
||||
}
|
||||
for k in 0..3 {
|
||||
let b = 1 + k * 3;
|
||||
d.push_str(&format!(" C {} {} {}", pt(*f, segs[b]), pt(*f, segs[b + 1]), pt(*f, segs[b + 2])));
|
||||
}
|
||||
}
|
||||
d.push_str(" Z");
|
||||
d
|
||||
}
|
||||
|
||||
fn round(v: f64) -> f64 {
|
||||
(v * 1000.0).round() / 1000.0 + 0.0
|
||||
}
|
||||
|
||||
/// One corner in (u, v) terms: distance back along the incoming edge, distance along the outgoing.
|
||||
struct Corner {
|
||||
p: f64,
|
||||
a: f64,
|
||||
b: f64,
|
||||
c: f64,
|
||||
d: f64,
|
||||
r: f64,
|
||||
arc: f64,
|
||||
}
|
||||
|
||||
fn corner(r: f64, s: f64) -> Corner {
|
||||
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4};
|
||||
let p = (1.0 + s) * r;
|
||||
let arc = FRAC_PI_2 * (1.0 - s);
|
||||
// The arc's chord, and where the edge curve meets the arc.
|
||||
let arc_chord = (arc / 2.0).sin() * r * std::f64::consts::SQRT_2;
|
||||
let alpha = (FRAC_PI_2 - arc) / 2.0;
|
||||
let p3_p4 = r * (alpha / 2.0).tan();
|
||||
let beta = FRAC_PI_4 * s;
|
||||
let c = p3_p4 * beta.cos();
|
||||
let d = c * beta.tan();
|
||||
let b = ((p - arc_chord - c - d) / 3.0).max(0.0);
|
||||
let a = 2.0 * b;
|
||||
Corner { p, a, b, c, d, r, arc }
|
||||
}
|
||||
|
||||
impl Corner {
|
||||
/// Ten points: the start on the incoming edge, then three cubics (control, control, end):
|
||||
/// edge → arc start, the arc, arc end → outgoing edge.
|
||||
fn segments(&self) -> [(f64, f64); 10] {
|
||||
let Corner { p, a, b, c, d, r, arc } = *self;
|
||||
// Edge curve into the arc: handles on the edge, ending where the arc starts.
|
||||
let e0 = (p, 0.0);
|
||||
let e1 = (p - a, 0.0);
|
||||
let e2 = (p - a - b, 0.0);
|
||||
let e3 = (p - a - b - c, d);
|
||||
// The arc around the centre (r, r) in (u, v), symmetric about the corner's diagonal.
|
||||
// Angles from +u towards +v: the corner itself is at 5π/4 from the centre; the arc runs
|
||||
// from the incoming edge's side (larger angle) to the outgoing edge's side.
|
||||
let start = std::f64::consts::FRAC_PI_4 * 5.0 + arc / 2.0;
|
||||
let end = start - arc;
|
||||
let at = |t: f64| (r + r * t.cos(), r + r * t.sin());
|
||||
let k = 4.0 / 3.0 * (arc / 4.0).tan() * r;
|
||||
let a0 = at(start);
|
||||
let a3 = at(end);
|
||||
// Tangent of the arc travelled towards smaller angles: (sin t, −cos t).
|
||||
let a1 = (a0.0 + k * start.sin(), a0.1 - k * start.cos());
|
||||
let a2 = (a3.0 - k * end.sin(), a3.1 + k * end.cos());
|
||||
// Mirror of the edge curve onto the outgoing edge (swap u and v), reversed.
|
||||
let o1 = (e2.1, e2.0);
|
||||
let o2 = (e1.1, e1.0);
|
||||
let o3 = (e0.1, e0.0);
|
||||
let _ = (e3, a0);
|
||||
[e0, e1, e2, a0, a1, a2, a3, o1, o2, o3]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn numbers(d: &str) -> Vec<f64> {
|
||||
d.split_whitespace().filter_map(|t| t.parse().ok()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_radius_is_a_rectangle() {
|
||||
assert_eq!(path_d(100.0, 50.0, 0.0, 0.6), "M 0 0 L 100 0 L 100 50 L 0 50 Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_curve_meets_the_arc() {
|
||||
for s in [0.0, 0.3, 0.6, 1.0] {
|
||||
let c = corner(10.0, s);
|
||||
let seg = c.segments();
|
||||
let e3 = (c.p - c.a - c.b - c.c, c.d);
|
||||
// The arc starts on the circle and where the edge curve ends.
|
||||
assert!((seg[3].0 - e3.0).abs() < 1e-6 && (seg[3].1 - e3.1).abs() < 1e-6, "s={s}: {:?} vs {:?}", seg[3], e3);
|
||||
// The arc is symmetric about the diagonal u = v.
|
||||
assert!((seg[3].0 - seg[6].1).abs() < 1e-9 && (seg[3].1 - seg[6].0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_smoothing_is_a_circular_corner() {
|
||||
let c = corner(10.0, 0.0);
|
||||
let seg = c.segments();
|
||||
assert!((c.p - 10.0).abs() < 1e-9);
|
||||
assert!((seg[3].0 - 10.0).abs() < 1e-9 && seg[3].1.abs() < 1e-9);
|
||||
assert!((seg[6].0).abs() < 1e-9 && (seg[6].1 - 10.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoothing_uses_more_of_the_edge() {
|
||||
let d0 = numbers(&path_d(200.0, 100.0, 20.0, 0.0));
|
||||
let d6 = numbers(&path_d(200.0, 100.0, 20.0, 0.6));
|
||||
// The first point is where the top-right corner starts on the top edge.
|
||||
assert_eq!((d0[0], d0[1]), (180.0, 0.0));
|
||||
assert_eq!((d6[0], d6[1]), (168.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stays_inside_and_reduces_smoothing_when_the_rectangle_is_small() {
|
||||
for (w, h, r, s) in [(100.0, 40.0, 20.0, 1.0), (40.0, 40.0, 50.0, 0.8), (10.0, 300.0, 4.0, 0.6), (300.0, 120.0, 30.0, 0.6)] {
|
||||
let n = numbers(&path_d(w, h, r, s));
|
||||
assert!(n.chunks(2).all(|p| p[0] >= -1e-6 && p[0] <= w + 1e-6 && p[1] >= -1e-6 && p[1] <= h + 1e-6), "{w}x{h} r{r} s{s}");
|
||||
// Corners never cross the middle of an edge.
|
||||
assert!(n[0] >= w / 2.0 - 1e-6);
|
||||
}
|
||||
assert!(numbers(&path_d(f64::NAN, 10.0, f64::INFINITY, f64::NAN)).iter().all(|v| v.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_symmetric_corners() {
|
||||
let n = numbers(&path_d(120.0, 80.0, 16.0, 0.6));
|
||||
// 4 corners x 10 points, 2 numbers each.
|
||||
assert_eq!(n.len(), 80);
|
||||
let pts: Vec<(f64, f64)> = n.chunks(2).map(|p| (p[0], p[1])).collect();
|
||||
for &(x, y) in &pts {
|
||||
let mirrored = (120.0 - x, 80.0 - y);
|
||||
assert!(pts.iter().any(|q| (q.0 - mirrored.0).abs() < 0.01 && (q.1 - mirrored.1).abs() < 0.01), "{x},{y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2356,6 +2356,8 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
|
||||
run(app, "shape.editPoints", json!({"on": true}));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
smooth_corners_row(app, ui);
|
||||
});
|
||||
let can_merge = enabled(app, "shape.merge");
|
||||
let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text("Merge Shapes");
|
||||
@@ -3434,3 +3436,45 @@ mod drawtable_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "inktoshape_tests.rs"]
|
||||
mod inktoshape_tests;
|
||||
|
||||
/// Edit Shape ▸ Smooth Corners (#286): a slider that smooths a rectangle's corners into a
|
||||
/// squircle. The radius is taken from the rectangle when the slider is first used and kept (in egui
|
||||
/// memory, per shape) so later moves re-smooth the same corners.
|
||||
fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) {
|
||||
let Some((id, radius)) = app.session.active().and_then(|d| {
|
||||
let id = *d.selection.shapes.first()?;
|
||||
let sh = d.shape(id)?;
|
||||
let x = sh.xfrm?;
|
||||
let ss = x.w.min(x.h).max(0.0);
|
||||
let own = match &sh.geom {
|
||||
deckcraft_model::Geom::Preset { name, adj } if name == "roundRect" => {
|
||||
Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0)
|
||||
}
|
||||
deckcraft_model::Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0),
|
||||
_ => None,
|
||||
};
|
||||
Some((id, own))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let key = egui::Id::new(("deckcraft-smooth-corners", id.0));
|
||||
let mut state: (f64, f64) = ui.data(|d| d.get_temp(key)).unwrap_or((radius.unwrap_or(0.0), 0.0));
|
||||
if let Some(r) = radius {
|
||||
state.0 = r;
|
||||
}
|
||||
let usable = radius.is_some() || ui.data(|d| d.get_temp::<(f64, f64)>(key)).is_some();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Smooth Corners");
|
||||
let mut pct = state.1;
|
||||
let resp = ui.add_enabled(usable, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%").integer());
|
||||
let resp = if usable { resp } else { resp.on_disabled_hover_text("For rectangles and rounded rectangles") };
|
||||
if resp.changed() {
|
||||
state.1 = pct;
|
||||
ui.data_mut(|d| d.insert_temp(key, state));
|
||||
}
|
||||
// One undo step per adjustment: apply when the drag ends (or on a click / keyboard step).
|
||||
if resp.drag_stopped() || (resp.changed() && !resp.dragged()) {
|
||||
run(app, "shape.smoothCorners", json!({"smoothing": state.1, "radius": state.0}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user