diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index e65b6fe6..8ba47f74 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -63,6 +63,15 @@ pub fn specs() -> Vec { 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 { }) } +/// 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 { + 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 { 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}`")))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 54eadecb..2f425aa9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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"); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index dd41db37..3be72e03 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -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}; diff --git a/crates/geom/src/squircle.rs b/crates/geom/src/squircle.rs new file mode 100644 index 00000000..8dedc972 --- /dev/null +++ b/crates/geom/src/squircle.rs @@ -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 { + 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}"); + } + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e2442b0f..45d68bf5 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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})); + } + }); +}