diff --git a/ROADMAP.md b/ROADMAP.md index 753e8740..ce478a91 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,6 +36,7 @@ _Last updated: 2026-10-02._ - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. - **Cutting:** Eraser, Scissors, Knife. - **Transform:** Rotate, Reflect, Scale, Shear, Reshape, Free Transform (distort/perspective). + - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. - **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, Measure, Type, Hand, Zoom, Rotate View. - **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes. @@ -58,7 +59,7 @@ _Last updated: 2026-10-02._ |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP | ✅ mostly done (rotated persistent bbox pending) | 4–6 | -| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape landed; Shaper, Pen modifier nuances) | 15–20 | +| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `` in/out + PDF soft mask); mask-editing mode (click the mask thumbnail; live update) done; freeform gradients pending | 18–27 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 5752e237..a9e60c82 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -16,11 +16,13 @@ pub struct ViewInfo { pub show_bbox: bool, /// View → Snap to Pixel: drawing and moving land on whole pixels (points at 72 ppi). pub snap_to_pixel: bool, + /// View → Show Corner Widget. + pub corner_widgets: bool, } impl Default for ViewInfo { fn default() -> Self { - Self { zoom: 1.0, outline: false, smart_guides: true, snap_to_grid: false, show_bbox: true, snap_to_pixel: false } + Self { zoom: 1.0, outline: false, smart_guides: true, snap_to_grid: false, show_bbox: true, snap_to_pixel: false, corner_widgets: true } } } @@ -63,6 +65,7 @@ impl Session { snap_to_grid: view.snap_to_grid, show_bbox: view.show_bbox, snap_to_pixel: view.snap_to_pixel, + corner_widgets: view.corner_widgets, }; f(self.tool.as_mut(), &cx) } diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs new file mode 100644 index 00000000..705e5b84 --- /dev/null +++ b/crates/tools/src/corners.rs @@ -0,0 +1,239 @@ +//! Live Corners: the widgets inside each corner of a selected live rectangle. Dragging one rounds +//! (or sharpens) all four corners together. The Selection and Direct Selection tools share this, +//! and the canvas draws the widgets from the same geometry. + +use serde_json::json; +use vectorcraft_doc::{Document, LiveShape, NodeId, NodeKind, Selection}; +use vectorcraft_geom::{Affine, Point, Vec2}; + +use crate::{Action, Overlay, ToolContext}; + +/// Widgets sit at least this far inside their corner (screen px), further in once the radius is. +const MIN_INSET_PX: f64 = 10.0; +/// Shapes whose shorter side is smaller than this on screen (px) hide their widgets. +const MIN_SIDE_PX: f64 = 3.0 * MIN_INSET_PX; +/// Corners in radii order (top-left, top-right, bottom-right, bottom-left): position as factors +/// of (w, h) and the inward diagonal. +const CORNERS: [((f64, f64), (f64, f64)); 4] = + [((0.0, 0.0), (1.0, 1.0)), ((1.0, 0.0), (-1.0, 1.0)), ((1.0, 1.0), (-1.0, -1.0)), ((0.0, 1.0), (1.0, -1.0))]; + +/// The corner widgets of a selection that is exactly one editable live rectangle. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CornerWidgets { + pub id: NodeId, + w: f64, + h: f64, + radii: [f64; 4], + xf: Affine, + /// Widget centres in document coordinates, in radii order. + pub points: [Point; 4], +} + +impl CornerWidgets { + /// The widgets at `zoom` (screen px per document point), if the selection has them. + pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option { + let [id] = selection.objects[..] else { return None }; + if !doc.is_editable(id) { + return None; + } + let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf }), .. }) = doc.node(id).map(|n| &n.kind) else { + return None; + }; + let (w, h, radii, xf) = (*w, *h, *radii, *xf); + // 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; + } + 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); + xf * Point::new(fx * w + sx * ix, fy * h + sy * iy) + }); + Some(Self { id, w, h, radii, xf, points }) + } + + /// The widgets the active tool can drag (View → Show Corner Widget on). + pub fn for_tool(cx: &ToolContext) -> Option { + if !cx.corner_widgets { + return None; + } + Self::of(cx.doc, cx.selection, cx.zoom) + } + + /// Index of the widget nearest to `p` within `tol` (document units). + pub fn hit(&self, p: Point, tol: f64) -> Option { + (0..4).filter(|k| self.points[*k].distance(p) <= tol).min_by(|a, b| self.points[*a].distance(p).total_cmp(&self.points[*b].distance(p))) + } +} + +/// Is `p` over a corner widget the active tool would drag? +pub fn over_widget(cx: &ToolContext, p: Point) -> bool { + CornerWidgets::for_tool(cx).and_then(|w| w.hit(p, cx.tol(5.0))).is_some() +} + +/// Dragging a corner widget: the radius follows the pointer along the corner's diagonal. +#[derive(Clone, Copy, Debug)] +pub struct CornerDrag { + widgets: CornerWidgets, + corner: usize, + start: Point, + began: bool, + radius: f64, + at: Point, +} + +impl CornerDrag { + /// Start a drag when `p` is on a widget of the selection. + pub fn hit(cx: &ToolContext, p: Point) -> Option { + let widgets = CornerWidgets::for_tool(cx)?; + let corner = widgets.hit(p, cx.tol(5.0))?; + Some(Self { widgets, corner, start: p, began: false, radius: widgets.radii[corner], at: p }) + } + + /// The start radius 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 { + let w = &self.widgets; + let inv = w.xf.inverse(); + let d = inv * p - inv * self.start; + let (_, (sx, sy)) = CORNERS[self.corner]; + (w.radii[self.corner] + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, w.w.min(w.h) / 2.0) + } + + pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec { + let mut out = vec![]; + if !self.began { + if p.distance(self.start) < cx.tol(3.0) { + return out; + } + self.began = true; + out.push(Action::Begin("Corner Radius".into())); + } + self.radius = self.radius_at(p); + self.at = p; + out.push(Action::Preview("object.setLiveShape".into(), json!({ "id": self.widgets.id.0, "radius": self.radius }))); + out + } + + pub fn finish(self) -> Vec { + if self.began { vec![Action::Commit] } else { vec![] } + } + + /// The radius readout next to the pointer. + pub fn overlays(&self) -> Vec { + if !self.began { + return vec![]; + } + vec![Overlay::Measure { p: self.at, text: format!("Radius: {}", crate::xform::fmt_pt(self.radius)) }] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use crate::{PointerEvent, PointerKind}; + use vectorcraft_doc::{Appearance, Node}; + + /// A live 100 × 100 rectangle at (100, 100) with corner radius `r`. + fn live_rect(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], 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); + } + d.insert(Some(l), 0, n).unwrap(); + (d, id) + } + + fn selected(id: NodeId) -> Selection { + let mut s = Selection::default(); + s.add(id); + s + } + + fn radius(a: &Action) -> f64 { + let Action::Preview(c, v) = a else { panic!("not a preview: {a:?}") }; + assert_eq!(c, "object.setLiveShape"); + v["radius"].as_f64().unwrap() + } + + #[test] + fn widgets_sit_inside_each_corner() { + let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); + let w = CornerWidgets::of(&d, &selected(id), 1.0).unwrap(); + assert_eq!(w.points, [Point::new(110.0, 110.0), Point::new(190.0, 110.0), Point::new(190.0, 190.0), Point::new(110.0, 190.0)]); + // A larger radius moves them to the arc centres; zooming in keeps a 10 px minimum. + let (d, id) = live_rect(20.0, Affine::translate((100.0, 100.0))); + assert_eq!(CornerWidgets::of(&d, &selected(id), 1.0).unwrap().points[0], Point::new(120.0, 120.0)); + assert_eq!(CornerWidgets::of(&d, &selected(id), 4.0).unwrap().points[2], Point::new(180.0, 180.0)); + // Hidden when the shape is tiny on screen, for plain paths and without a single selection. + assert!(CornerWidgets::of(&d, &selected(id), 0.2).is_none()); + let (d, id) = doc_with_rect(); + assert!(CornerWidgets::of(&d, &selected(id), 1.0).is_none()); + assert!(CornerWidgets::of(&d, &Selection::default(), 1.0).is_none()); + } + + #[test] + fn dragging_a_widget_inward_rounds_all_corners_in_one_step() { + let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); + let s = selected(id); + let p = paint(); + let cx = cx(&d, &s, &p); + for mut t in [crate::create("selection"), crate::create("directSelection")] { + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 191.0)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 175.0, 176.0)); + assert_eq!(a[0], Action::Begin("Corner Radius".into())); + assert!((radius(&a[1]) - 15.0).abs() < 1e-9); + assert!(matches!(&a[1], Action::Preview(_, v) if v["id"] == id.0)); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "Radius: 15.00 pt"))); + // Past the middle the corner is fully round; back outside it is square. + assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 100.0))[0]), 50.0); + assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 260.0, 260.0))[0]), 0.0); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 260.0, 260.0)), vec![Action::Commit]); + } + } + + #[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). + let xf = Affine::translate((200.0, 100.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2); + let (d, id) = live_rect(10.0, xf); + let s = selected(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let w = CornerWidgets::of(&d, &s, 1.0).unwrap(); + assert!(w.points[0].distance(Point::new(190.0, 110.0)) < 1e-9); + let mut t = crate::create("directSelection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 110.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 120.0)); + assert!((radius(&a[1]) - 20.0).abs() < 1e-9); + // A click without a drag leaves the shape (and the undo history) alone. + let mut t = crate::create("selection"); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 110.0)).is_empty()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 190.0, 110.0)).is_empty()); + } + + #[test] + fn widgets_off_or_elsewhere_keep_the_tools_behaviour() { + let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0))); + let s = selected(id); + let p = paint(); + let mut c = cx(&d, &s, &p); + assert!(over_widget(&c, Point::new(110.0, 110.0))); + assert!(!over_widget(&c, Point::new(150.0, 150.0))); + c.corner_widgets = false; + assert!(!over_widget(&c, Point::new(110.0, 110.0))); + // With the widgets hidden, a drag from the same spot moves the object. + let mut t = crate::create("selection"); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 110.0, 110.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 110.0)); + assert_eq!(a[0], Action::Begin("Move".into())); + } +} diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index f6e2b6ed..a98cc136 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -2,8 +2,8 @@ //! //! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the //! path's anchors on that segment, drag to move selected anchors, drag a direction handle to -//! reshape, marquee to select anchors. Group Selection: click selects the leaf; each further click -//! on it adds the next enclosing group. +//! reshape, marquee to select anchors, drag a live rectangle's corner widget to round its corners. +//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group. use serde_json::{Value, json}; use vectorcraft_doc::hit::hit_test; @@ -11,6 +11,7 @@ use vectorcraft_doc::{AnchorRef, NodeId, NodeKind}; use vectorcraft_geom::{Point, Rect}; use crate::bbox::move_delta; +use crate::corners::{CornerDrag, over_widget}; use crate::select::matrix_json; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; @@ -21,6 +22,7 @@ enum State { MoveObject { start: Point, began: bool }, Handle { id: NodeId, si: usize, ai: usize, out: bool }, Marquee { start: Point, cur: Point, add: bool }, + Corner(CornerDrag), } pub struct DirectSelectionTool { @@ -117,6 +119,10 @@ impl Tool for DirectSelectionTool { } } (PointerKind::Down, _) => { + if let Some(c) = CornerDrag::hit(cx, p) { + self.state = State::Corner(c); + return vec![]; + } if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) { self.state = State::Handle { id, si, ai, out }; return vec![Action::Begin("Reshape".into())]; @@ -185,6 +191,15 @@ impl Tool for DirectSelectionTool { self.state = State::Marquee { start, cur: p, add }; vec![] } + (PointerKind::Drag, State::Corner(mut c)) => { + let out = c.drag(cx, p); + self.state = State::Corner(c); + out + } + (PointerKind::Up, State::Corner(c)) => { + self.state = State::Idle; + c.finish() + } (PointerKind::Up, State::MoveAnchors { began, .. } | State::MoveObject { began, .. }) => { self.state = State::Idle; if began { vec![Action::Commit] } else { vec![] } @@ -217,12 +232,16 @@ impl Tool for DirectSelectionTool { } } fn overlays(&self, _cx: &ToolContext) -> Vec { - match self.state { - State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(start, cur))], + match &self.state { + State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], + State::Corner(c) => c.overlays(), _ => vec![], } } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p)) { + return Cursor::CornerRadius; + } Cursor::ArrowHollow } } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index e6bcdfb2..2b57c671 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -10,6 +10,7 @@ pub mod bbox; pub mod builder; pub mod catalog; +pub mod corners; pub mod direct; pub mod distort; pub mod draw2; @@ -150,6 +151,8 @@ pub struct ToolContext<'a> { pub show_bbox: bool, /// View → Snap to Pixel. pub snap_to_pixel: bool, + /// View → Show Corner Widget: live rectangles show draggable Live Corners widgets. + pub corner_widgets: bool, } impl ToolContext<'_> { @@ -195,6 +198,8 @@ pub enum Cursor { ResizeNwSe, ResizeNeSw, Rotate, + /// Over a Live Corners widget (drag to round the corners). + CornerRadius, Pen, PenAdd, PenDelete, @@ -315,6 +320,7 @@ pub(crate) mod testutil { snap_to_grid: false, show_bbox: true, snap_to_pixel: false, + corner_widgets: true, } } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index a5999ca3..174e7e82 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1,6 +1,6 @@ //! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains), //! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°), -//! double-click to enter isolation mode. +//! drag a live rectangle's corner widget to round its corners, double-click to enter isolation mode. use serde_json::{Value, json}; use vectorcraft_doc::NodeId; @@ -8,6 +8,7 @@ use vectorcraft_doc::hit::{hit_test, marquee}; use vectorcraft_geom::{Affine, Point, Rect}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; +use crate::corners::{CornerDrag, over_widget}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids}; #[derive(Clone, Debug, Default)] @@ -32,6 +33,8 @@ enum State { cur: Point, add: bool, }, + /// Dragging a Live Corners widget. + Corner(CornerDrag), } #[derive(Default)] @@ -89,7 +92,11 @@ impl Tool for SelectionTool { vec![] } (PointerKind::Down, _) => { - // 1. Bounding-box handles of the current selection. + // 1. Live Corners widgets, then the bounding-box handles of the current selection. + if let Some(c) = CornerDrag::hit(cx, p) { + self.state = State::Corner(c); + return vec![]; + } if cx.show_bbox && let Some(r) = selection_bounds(cx) { @@ -173,6 +180,15 @@ impl Tool for SelectionTool { self.state = State::Marquee { start, cur: p, add }; vec![] } + (PointerKind::Drag, State::Corner(mut c)) => { + let out = c.drag(cx, p); + self.state = State::Corner(c); + out + } + (PointerKind::Up, State::Corner(c)) => { + self.state = State::Idle; + c.finish() + } (PointerKind::Up, State::Moving { began, .. }) => { self.state = State::Idle; self.measure = None; @@ -204,8 +220,10 @@ impl Tool for SelectionTool { fn overlays(&self, _cx: &ToolContext) -> Vec { let mut o = vec![]; - if let State::Marquee { start, cur, .. } = self.state { - o.push(Overlay::Marquee(Rect::from_points(start, cur))); + match &self.state { + State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), + State::Corner(c) => o.extend(c.overlays()), + _ => {} } o.extend(self.guides.iter().cloned()); if let Some((p, t)) = &self.measure { @@ -219,8 +237,12 @@ impl Tool for SelectionTool { State::Rotating { .. } => return Cursor::Rotate, State::Scaling { handle, .. } => return handle_cursor(handle), State::Moving { began: true, .. } => return Cursor::Arrow, + State::Corner(_) => return Cursor::CornerRadius, _ => {} } + if over_widget(cx, p) { + return Cursor::CornerRadius; + } if cx.show_bbox && let Some(r) = selection_bounds(cx) { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b2560f6a..1327b5be 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -314,8 +314,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { fn cursor_icon(c: Cursor) -> egui::CursorIcon { use egui::CursorIcon as C; match c { - Cursor::Arrow => C::Default, - Cursor::ArrowHollow => C::Default, + Cursor::Arrow | Cursor::ArrowHollow | Cursor::CornerRadius => C::Default, Cursor::Move => C::Move, Cursor::Crosshair => C::Crosshair, Cursor::ResizeH => C::ResizeHorizontal, @@ -795,23 +794,19 @@ fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { p.circle_filled(o, 2.5, color); } } - // Bounding box with handles (Selection tool). - // Live corner widgets (Direct Selection on a single live rectangle). - if tool == "directSelection" + // Live Corners widgets (Selection / Direct Selection on a single live rectangle). + if matches!(tool, "selection" | "directSelection") && app.ui.view.corner_widgets - && st.selection.len() == 1 - && let Some(NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Rectangle { w, h, radii, xf: lxf }), .. }) = - st.doc.node(st.selection.objects[0]).map(|n| &n.kind) + && let Some(w) = vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, xf.zoom) { - let color = c32(st.doc.layer_color(st.selection.objects[0])); - let inset = (radii[0].max(10.0 / xf.zoom)).min(w.min(*h) / 2.0); - for (cx, cy) in [(inset, inset), (w - inset, inset), (w - inset, h - inset), (inset, h - inset)] { - let sp = xf.to_screen(*lxf * Point::new(cx, cy)); + let color = c32(st.doc.layer_color(w.id)); + for sp in w.points.map(|q| xf.to_screen(q)) { p.circle_filled(sp, 3.0, Color32::WHITE); p.circle_stroke(sp, 3.0, Stroke::new(1.0, color)); p.circle_filled(sp, 1.0, color); } } + // Bounding box with handles (Selection tool). if tool == "selection" && app.ui.view.bounding_box && !st.selection.is_empty() && st.selection.anchors.is_empty() { let Some(b) = st.doc.bounds_of(&st.selection.objects, false) else { return }; let color = c32(st.doc.layer_color(st.selection.objects[0])); diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index e8f20a81..ef378b84 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -93,6 +93,20 @@ fn rotate(p: &Painter, o: Pos2) { } } +/// Live Corners: the hollow arrow with a rounded-corner badge. +fn corner_radius(p: &Painter, o: Pos2) { + arrow(p, o, true); + let b = o + vec2(12.0, 13.0); + let mut pts = vec![b + vec2(0.0, 10.0)]; + pts.extend((0..=8).map(|i| { + let a = std::f32::consts::PI * (1.0 + 0.5 * i as f32 / 8.0); + b + vec2(4.0 + 4.0 * a.cos(), 4.0 + 4.0 * a.sin()) + })); + pts.push(b + vec2(10.0, 0.0)); + p.add(Shape::line(pts.clone(), Stroke::new(3.0, HALO))); + p.add(Shape::line(pts, Stroke::new(1.2, INK))); +} + fn ibeam(p: &Painter, o: Pos2) { line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0)); line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0)); @@ -116,6 +130,7 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool { Cursor::ResizeNwSe => double_arrow(painter, p, vec2(1.0, 1.0)), Cursor::ResizeNeSw => double_arrow(painter, p, vec2(1.0, -1.0)), Cursor::Rotate => rotate(painter, p), + Cursor::CornerRadius => corner_radius(painter, p), Cursor::Pen => pen(painter, p, "*"), Cursor::PenAdd => pen(painter, p, "+"), Cursor::PenDelete => pen(painter, p, "-"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 52fa59ba..4705a6c0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -212,6 +212,7 @@ impl VectorcraftApp { snap_to_grid: self.ui.view.snap_to_grid, snap_to_pixel: self.ui.view.snap_to_pixel, show_bbox: self.ui.view.bounding_box, + corner_widgets: self.ui.view.corner_widgets, } }