Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 b05c14f8eb Fix #381: round single corners with Direct Selection (per-corner Live Corners)
A live rectangle stored four corner radii but drew them all with the largest,
and the corner widgets always rounded all four corners, so one corner could not
be rounded on its own.

- geom: rectangle_with_corners draws each corner with its own radius and kind
  (Round, Inverted Round, Chamfer); a sharp corner is one anchor, a cut one two.
  Uniform corners keep the exact rounded-rectangle anchors.
- doc: LiveShape::Rectangle gains `kinds` (saved only when a corner isn't round;
  older files read as round), plus helpers mapping anchors to corners.
- engine: object.setLiveShape takes `corners` and `kind`; without `corners` it
  edits the Direct-Selected corners, else all four, and keeps the selected
  corners selected as their anchors change.
- tools: with Direct Selection, only the selected corners show widgets and a
  drag rounds just those; Alt-click cycles the corner kind; double-click opens
  the new Corners dialog (also `ui.corners`).
- ui: Properties/Transform radius fields show and set the selected corners;
  CSS export keeps per-corner radii and skips cut-in corners.
- docs: mcp.md (Live Corners), control-protocol.md (Corners dialog), ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:56:32 +07:00
28 changed files with 817 additions and 80 deletions

No files matched your search

+4 -4
View File
@@ -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) and PDF/`.ai` import keeping strokes live and hidden and nested layers (#372); 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) and PDF/`.ai` import keeping strokes live and hidden and nested layers (#372) and per-corner Live Corners (#381); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -113,7 +113,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. 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.
- **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.
@@ -158,7 +158,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|---|---|---|---|
| M0 | Skeleton + vertical slice | ✅ done | — |
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, the remaining Pen modifier nuances) | 15–20 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed; Shaper, the remaining Pen modifier nuances) | 15–20 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
| 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 |
@@ -190,7 +190,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Area | Weight | Done | Missing (main items) | One agent (h) |
|---|---:|---:|---|---:|
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, Touch Type | 10–15 |
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, Touch Type, Live Corners on plain paths and polygons (and Relative rounding) | 10–15 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection and options | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 |
+85 -14
View File
@@ -1,14 +1,17 @@
//! Document nodes.
use std::collections::BTreeSet;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_color::BlendMode;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes};
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
use crate::appearance::Appearance;
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
use crate::pattern::RepeatSpec;
use crate::selection::AnchorRef;
use crate::text::TextObject;
/// Stable per-document object id. Never reused.
@@ -82,6 +85,9 @@ pub enum LiveShape {
/// Corner radii: top-left, top-right, bottom-right, bottom-left. In the same units as
/// `w`/`h`: the corners are circular arcs in the document whenever `xf` doesn't scale.
radii: [f64; 4],
/// Corner kinds, in the same order (Live Corners' corner types).
#[serde(default, skip_serializing_if = "all_round")]
kinds: [CornerKind; 4],
/// Maps the untransformed shape (origin at its top-left) into the document. A rotation
/// or reflection plus a move, unless a shear (or a file saved before transforms folded
/// their scale into `w`/`h`) put more in it.
@@ -118,15 +124,8 @@ impl LiveShape {
/// Regenerate the path.
pub fn to_path(&self) -> PathData {
match self {
LiveShape::Rectangle { w, h, radii, xf } => {
let r = Rect::new(0.0, 0.0, *w, *h);
let p = if radii.iter().all(|x| (x - radii[0]).abs() < 1e-9) {
shapes::rounded_rectangle(r, radii[0])
} else {
// Per-corner radii: generate uniformly then adjust (approximation until M2.5).
shapes::rounded_rectangle(r, radii.iter().cloned().fold(0.0, f64::max))
};
p.transformed(*xf)
LiveShape::Rectangle { w, h, radii, kinds, xf } => {
shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), *radii, *kinds).transformed(*xf)
}
LiveShape::Ellipse { w, h, xf, .. } => shapes::ellipse(Rect::new(0.0, 0.0, *w, *h)).transformed(*xf),
LiveShape::Polygon { radius, sides, xf } => shapes::polygon(Point::ZERO, *radius, *sides, 0.0).transformed(*xf),
@@ -144,7 +143,7 @@ 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 } => {
LiveShape::Rectangle { w, h, radii, xf, .. } => {
let m = 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;
@@ -185,6 +184,44 @@ impl LiveShape {
_ => false,
}
}
/// The corner (radii order) of each anchor of a rectangle's path; empty for other shapes.
pub fn anchor_corners(&self) -> Vec<usize> {
match self {
LiveShape::Rectangle { w, h, radii, .. } => shapes::rectangle_anchor_corners(Rect::new(0.0, 0.0, *w, *h), *radii),
_ => vec![],
}
}
/// The corners of a rectangle that hold one of `anchors` (of its only subpath).
pub fn corners_of(&self, anchors: &BTreeSet<AnchorRef>) -> [bool; 4] {
let map = self.anchor_corners();
let mut out = [false; 4];
for k in anchors.iter().filter(|(si, _)| *si == 0).filter_map(|(_, ai)| map.get(*ai)) {
if let Some(c) = out.get_mut(*k) {
*c = true;
}
}
out
}
/// The corners Live Corners edit for a selection: those holding a selected anchor when the
/// shape is partly selected (Direct Selection), else all four.
pub fn picked_corners(&self, partial: Option<&BTreeSet<AnchorRef>>) -> [bool; 4] {
let picked = partial.map_or([true; 4], |a| self.corners_of(a));
if picked.contains(&true) { picked } else { [true; 4] }
}
/// The anchors of a rectangle's `corners` (to keep them selected as its path changes).
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).
pub fn corner_style(&self, corners: [bool; 4]) -> (Option<f64>, Option<CornerKind>) {
let LiveShape::Rectangle { radii, kinds, .. } = self 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))
}
/// Rotation angle of the live shape in degrees (shown in the Properties panel).
pub fn angle_deg(&self) -> f64 {
match self {
@@ -197,6 +234,10 @@ impl LiveShape {
}
}
fn all_round(kinds: &[CornerKind; 4]) -> bool {
kinds.iter().all(|k| *k == CornerKind::Round)
}
/// What a transform scales besides geometry, by its mean scale (the square root of its
/// determinant). `Scaling::default()` (and `false`) scales nothing else; `true` scales stroke
/// weights and dashes only. Transforms the user asks for take theirs from Scale Strokes & Effects
@@ -1200,14 +1241,44 @@ mod tests {
#[test]
fn rect_live_shape_roundtrip() {
let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], xf: Affine::translate((5.0, 5.0)) };
let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate((5.0, 5.0)) };
assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0)));
assert_eq!(l.label(), "Rectangle");
}
#[test]
fn rectangle_corners_map_to_their_anchors() {
let mut l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY };
// Anchors: top-left, the top-right corner's two, bottom-right, bottom-left.
assert_eq!(l.anchor_corners(), [0, 1, 1, 2, 3]);
assert_eq!(l.to_path().anchor_count(), 5);
let set = |v: &[usize]| v.iter().map(|ai| (0, *ai)).collect::<BTreeSet<AnchorRef>>();
assert_eq!(l.corners_of(&set(&[2, 4])), [false, true, false, true]);
assert_eq!(l.corners_of(&set(&[9])), [false; 4], "out of range");
assert_eq!(l.corner_anchors([false, true, false, true]), set(&[1, 2, 4]));
// Partly selected: the corners of the selected anchors; wholly (or no anchor): all four.
assert_eq!(l.picked_corners(Some(&set(&[0]))), [true, false, false, false]);
assert_eq!(l.picked_corners(None), [true; 4]);
assert_eq!(l.picked_corners(Some(&set(&[]))), [true; 4]);
assert_eq!(l.corner_style([false, true, false, false]), (Some(10.0), Some(CornerKind::Round)));
assert_eq!(l.corner_style([true, true, false, false]), (None, Some(CornerKind::Round)));
assert_eq!(l.corner_style([true, false, true, true]), (Some(0.0), Some(CornerKind::Round)));
// Corner kinds save only when some corner isn't round, and older files read as round.
let json = serde_json::to_value(&l).unwrap();
assert!(json.get("kinds").is_none(), "{json}");
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), l);
if let LiveShape::Rectangle { kinds, .. } = &mut l {
kinds[1] = CornerKind::Chamfer;
}
let json = serde_json::to_value(&l).unwrap();
assert_eq!(json["kinds"], serde_json::json!(["round", "chamfer", "round", "round"]));
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), l);
assert!(LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }.anchor_corners().is_empty());
}
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
let live = LiveShape::Rectangle { w, h, radii: [r; 4], xf };
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(live);
@@ -1216,7 +1287,7 @@ mod tests {
}
fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) {
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf }), .. } = &n.kind else { panic!("not a live rectangle") };
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf, .. }), .. } = &n.kind else { panic!("not a live rectangle") };
(*w, *h, *radii, *xf)
}
+7 -1
View File
@@ -166,7 +166,13 @@ fn rect_of(p: &Value, cmd: &str) -> Result<Rect> {
fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.rectangle")?;
let radius = f64_or(p, "radius", 0.0).max(0.0);
let live = LiveShape::Rectangle { w: r.width(), h: r.height(), radii: [radius; 4], xf: Affine::translate(r.origin().to_vec2()) };
let live = LiveShape::Rectangle {
w: r.width(),
h: r.height(),
radii: [radius; 4],
kinds: Default::default(),
xf: Affine::translate(r.origin().to_vec2()),
};
let label = if radius > 0.0 { "Rounded Rectangle" } else { "Rectangle" };
add_art(s, label, path_kind(live.to_path(), Some(live)), None)
}
+1 -1
View File
@@ -658,7 +658,7 @@ pub(crate) fn detect_shape(path: &PathData) -> Option<LiveShape> {
}
let rot = Affine::rotate(e0.y.atan2(e0.x));
let flip = if e0.cross(e1) < 0.0 { Affine::scale_non_uniform(1.0, -1.0) } else { Affine::IDENTITY };
return Some(LiveShape::Rectangle { w, h, radii: [0.0; 4], xf: Affine::translate(a[0].p.to_vec2()) * rot * flip });
return Some(LiveShape::Rectangle { w, h, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate(a[0].p.to_vec2()) * rot * flip });
}
let b = path.bounds()?;
let c = b.center();
+55 -5
View File
@@ -6,6 +6,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind, OrientedBox, Scaling};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
use super::edit::{duplicate_in, selected_roots};
@@ -180,7 +181,15 @@ pub fn specs() -> Vec<CommandSpec> {
set_bounds
),
cmd!("object.expandShape", "Expand Shape", ["Object", "Shape"], None, "{} convert live shapes to plain paths", has_selection, expand_shape),
cmd!("object.setLiveShape", "Live Shape Properties", [], None, "{id?, radius?: pt (all corners), sides?: n}", has_selection, set_live_shape),
cmd!(
"object.setLiveShape",
"Live Shape Properties",
[],
None,
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [0..3…], sides?: n} (Live Corners: radius and kind set a rectangle's corners: `corners` (0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left), else the corners with a Direct-Selected anchor, else all four; sides: a polygon's)",
has_selection,
set_live_shape
),
]
}
@@ -962,15 +971,50 @@ fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
ok()
}
/// The `corners` of `object.setLiveShape`: a mask of corner indices 0–3.
fn corners_param(p: &Value) -> Result<Option<[bool; 4]>> {
let Some(v) = p.get("corners").filter(|v| !v.is_null()) else { return Ok(None) };
let err = || {
bad(
"object.setLiveShape",
format!("`corners` must list corner indices 0–3 (0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left), not {v}"),
)
};
let mut mask = [false; 4];
for k in v.as_array().ok_or_else(err)? {
let slot = k.as_u64().and_then(|k| mask.get_mut(usize::try_from(k).ok()?)).ok_or_else(err)?;
*slot = true;
}
Ok(Some(mask))
}
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
s.edit("Live Shape", |d, _| {
let corners = corners_param(p)?;
let kind = p
.get("kind")
.filter(|v| !v.is_null())
.map(|v| {
serde_json::from_value::<CornerKind>(v.clone())
.map_err(|_| bad("object.setLiveShape", format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}")))
})
.transpose()?;
let radius = p.get("radius").and_then(Value::as_f64);
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 };
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)));
match live {
vectorcraft_doc::LiveShape::Rectangle { radii, .. } => {
if let Some(r) = p.get("radius").and_then(Value::as_f64) {
*radii = [r.max(0.0); 4];
vectorcraft_doc::LiveShape::Rectangle { radii, kinds, .. } => {
for ((_, r), k) in picked.iter().zip(radii.iter_mut()).zip(kinds.iter_mut()).filter(|((on, _), _)| **on) {
if let Some(radius) = radius {
*r = radius.max(0.0);
}
if let Some(kind) = kind {
*k = kind;
}
}
}
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
@@ -981,6 +1025,12 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
_ => {}
}
*path = live.to_path();
// Direct-Selected corners stay selected as they gain or lose anchors.
if let (Some(anchors), Some(selected)) = (partial, selected)
&& live.anchor_corners() != layout
{
*anchors = live.corner_anchors(selected);
}
}
Ok(())
})?;
+2
View File
@@ -1305,6 +1305,8 @@ mod tests_liquify;
#[cfg(test)]
mod tests_live;
#[cfg(test)]
mod tests_livecorners;
#[cfg(test)]
mod tests_maskview;
#[cfg(test)]
mod tests_menucmds;
+107
View File
@@ -0,0 +1,107 @@
//! Live Corners on live rectangles: `object.setLiveShape` rounds the given corners, the
//! Direct-Selected ones or all four, sets corner kinds, and keeps selected corners selected.
use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::{AnchorRef, LiveShape, NodeKind};
use vectorcraft_geom::shapes::CornerKind;
use super::*;
/// A 100 × 60 pt live rectangle at (10, 10), selected.
fn session() -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap();
(s, NodeId(id))
}
fn run(s: &mut Session, p: Value) {
s.execute("object.setLiveShape", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
}
fn corners(s: &Session, id: NodeId) -> ([f64; 4], [CornerKind; 4], usize) {
let n = s.doc().unwrap().doc.node(id).unwrap();
let NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), path, .. } = &n.kind else { panic!("not a live rectangle") };
(*radii, *kinds, path.anchor_count())
}
fn selected_anchors(s: &Session, id: NodeId) -> Option<BTreeSet<AnchorRef>> {
s.doc().unwrap().selection.partial(id).cloned()
}
fn anchors(v: &[usize]) -> BTreeSet<AnchorRef> {
v.iter().map(|ai| (0, *ai)).collect()
}
#[test]
fn a_whole_rectangle_rounds_all_corners_and_given_corners_alone() {
let (mut s, id) = session();
run(&mut s, json!({"radius": 8}));
assert_eq!(corners(&s, id), ([8.0; 4], [CornerKind::Round; 4], 8));
run(&mut s, json!({"id": id.0, "radius": 0}));
// One corner: only it rounds, and the path gains one anchor (one undo step).
let undo = s.doc().unwrap().history.undo.len();
run(&mut s, json!({"ids": [id.0], "corners": [1], "radius": 12}));
assert_eq!(corners(&s, id), ([0.0, 12.0, 0.0, 0.0], [CornerKind::Round; 4], 5));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
assert!((b.x1 - 110.0).abs() < 1e-9 && (b.y0 - 10.0).abs() < 1e-9, "the shape keeps its bounds: {b:?}");
// Kinds alone, then both; the others stay as they were.
run(&mut s, json!({"corners": [1, 3], "kind": "chamfer"}));
run(&mut s, json!({"corners": [3], "radius": 5, "kind": "invertedRound"}));
assert_eq!(corners(&s, id), ([0.0, 12.0, 0.0, 5.0], [CornerKind::Round, CornerKind::Chamfer, CornerKind::Round, CornerKind::InvertedRound], 6));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(corners(&s, id).1[3], CornerKind::Chamfer);
}
#[test]
fn bad_corners_and_kinds_are_errors() {
let (mut s, id) = session();
for p in
[json!({"corners": [4], "radius": 5}), json!({"corners": ["a"]}), json!({"corners": 1}), json!({"corners": [-1]}), json!({"kind": "wavy"})]
{
assert!(s.execute("object.setLiveShape", &p).is_err(), "{p}");
}
assert_eq!(corners(&s, id), ([0.0; 4], [CornerKind::Round; 4], 4), "nothing changed");
}
#[test]
fn direct_selected_corners_round_alone_and_stay_selected() {
let (mut s, id) = session();
// Direct Selection picks the top-right and bottom-left corners.
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [0, 3]], "mode": "set"})).unwrap();
run(&mut s, json!({"radius": 10}));
assert_eq!(corners(&s, id), ([0.0, 10.0, 0.0, 10.0], [CornerKind::Round; 4], 6));
// Anchors: top-left, top-right's two, bottom-right, bottom-left's two.
assert_eq!(selected_anchors(&s, id), Some(anchors(&[1, 2, 4, 5])));
// The same corners again (a field in the Properties panel), now with their two anchors each.
run(&mut s, json!({"kind": "chamfer"}));
assert_eq!(corners(&s, id).1, [CornerKind::Round, CornerKind::Chamfer, CornerKind::Round, CornerKind::Chamfer]);
run(&mut s, json!({"radius": 0}));
assert_eq!(corners(&s, id).2, 4);
assert_eq!(selected_anchors(&s, id), Some(anchors(&[1, 3])));
// Undo brings back the selection with the shape.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected_anchors(&s, id), Some(anchors(&[1, 2, 4, 5])));
// Explicit corners win over the selection, which follows its own corners.
run(&mut s, json!({"corners": [0], "radius": 4}));
assert_eq!(corners(&s, id).0, [4.0, 10.0, 0.0, 10.0]);
assert_eq!(selected_anchors(&s, id), Some(anchors(&[1, 2, 4, 5])), "the top-left corner comes first and last");
}
#[test]
fn a_drag_previews_one_corner_and_commits_one_step() {
let (mut s, id) = session();
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "set"})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
s.begin_interaction("Corner Radius").unwrap();
for r in [5.0, 9.0, 14.0] {
s.preview("object.setLiveShape", &json!({"id": id.0, "radius": r, "corners": [2]})).unwrap();
}
s.commit_interaction().unwrap();
assert_eq!(corners(&s, id), ([0.0, 0.0, 14.0, 0.0], [CornerKind::Round; 4], 5));
assert_eq!(selected_anchors(&s, id), Some(anchors(&[2, 3])));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
}
+126 -17
View File
@@ -3,6 +3,7 @@
use std::f64::consts::{PI, TAU};
use kurbo::{Point, Rect, Vec2};
use serde::{Deserialize, Serialize};
use crate::path::{Anchor, AnchorKind, PathData, SubPath};
@@ -17,25 +18,85 @@ pub fn rectangle(r: Rect) -> PathData {
/// Rounded rectangle (8 anchors). The radius is clamped to half the shorter side.
pub fn rounded_rectangle(r: Rect, radius: f64) -> PathData {
let r = r.abs();
let rad = radius.max(0.0).min(r.width() / 2.0).min(r.height() / 2.0);
if rad <= 1e-9 {
return rectangle(r);
rectangle_with_corners(r, [radius; 4], [CornerKind::Round; 4])
}
/// How a rectangle's corner is cut (Live Corners).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CornerKind {
/// A quarter circle, bulging outwards.
#[default]
Round,
/// A quarter circle cut into the shape, centred on the corner.
InvertedRound,
/// A straight bevel.
Chamfer,
}
impl CornerKind {
pub const ALL: [CornerKind; 3] = [CornerKind::Round, CornerKind::InvertedRound, CornerKind::Chamfer];
/// The kind after this one (Alt-clicking a corner widget cycles through them).
pub fn next(self) -> Self {
match self {
CornerKind::Round => CornerKind::InvertedRound,
CornerKind::InvertedRound => CornerKind::Chamfer,
CornerKind::Chamfer => CornerKind::Round,
}
}
let k = rad * KAPPA;
let (x0, y0, x1, y1) = (r.x0, r.y0, r.x1, r.y1);
let a = |p: (f64, f64), hin: (f64, f64), hout: (f64, f64)| Anchor { p: p.into(), h_in: hin.into(), h_out: hout.into(), kind: AnchorKind::Corner };
let anchors = vec![
a((x0 + rad, y0), (x0 + rad - k, y0), (x0 + rad, y0)),
a((x1 - rad, y0), (x1 - rad, y0), (x1 - rad + k, y0)),
a((x1, y0 + rad), (x1, y0 + rad - k), (x1, y0 + rad)),
a((x1, y1 - rad), (x1, y1 - rad), (x1, y1 - rad + k)),
a((x1 - rad, y1), (x1 - rad + k, y1), (x1 - rad, y1)),
a((x0 + rad, y1), (x0 + rad, y1), (x0 + rad - k, y1)),
a((x0, y1 - rad), (x0, y1 - rad + k), (x0, y1 - rad)),
a((x0, y0 + rad), (x0, y0 + rad), (x0, y0 + rad - k)),
}
/// 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
/// top-left corner, its anchor on the top side first.
pub fn rectangle_with_corners(r: Rect, radii: [f64; 4], kinds: [CornerKind; 4]) -> PathData {
PathData::single(SubPath::new(corner_anchors(r, radii, kinds).into_iter().map(|(_, a)| a).collect(), true))
}
/// The corner (index into `radii`) of each anchor of [`rectangle_with_corners`].
pub fn rectangle_anchor_corners(r: Rect, radii: [f64; 4]) -> Vec<usize> {
corner_anchors(r, radii, [CornerKind::Round; 4]).into_iter().map(|(k, _)| k).collect()
}
/// 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)),
(Point::new(r.x1, r.y0), Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)),
(Point::new(r.x1, r.y1), Vec2::new(0.0, 1.0), Vec2::new(-1.0, 0.0)),
(Point::new(r.x0, r.y1), Vec2::new(-1.0, 0.0), Vec2::new(0.0, -1.0)),
];
PathData::single(SubPath::new(anchors, true))
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);
if rad <= 1e-9 {
out.push((k, Anchor::corner(c)));
continue;
}
// The cut runs from `start` on the arriving side to `end` on the leaving one.
let (start, end, h) = (c - din * rad, c + dout * rad, rad * KAPPA);
let (h_out, h_in) = match kind {
CornerKind::Round => (start + din * h, end - dout * h),
CornerKind::InvertedRound => (start + dout * h, end - din * h),
CornerKind::Chamfer => (start, end),
};
let start = Anchor { p: start, h_in: start, h_out, kind: AnchorKind::Corner };
let end = Anchor { p: end, h_in, h_out: end, kind: AnchorKind::Corner };
if k == 0 {
top_left_start = Some(start);
} else {
out.push((k, start));
}
out.push((k, end));
}
out.extend(top_left_start.map(|a| (0, a)));
out
}
/// Ellipse inscribed in `r` with 4 smooth anchors (left, top, right, bottom).
@@ -203,6 +264,54 @@ mod tests {
assert_eq!(rounded_rectangle(Rect::new(0.0, 0.0, 20.0, 10.0), 0.0).anchor_count(), 4);
}
#[test]
fn uniform_corners_keep_the_rounded_rectangle_layout() {
// The 8 anchors a rounded rectangle always had, from the top side's first one.
let (r, rad) = (Rect::new(10.0, 20.0, 110.0, 70.0), 7.5);
let (x0, y0, x1, y1, k) = (10.0, 20.0, 110.0, 70.0, rad * KAPPA);
let a =
|p: (f64, f64), hin: (f64, f64), hout: (f64, f64)| Anchor { p: p.into(), h_in: hin.into(), h_out: hout.into(), kind: AnchorKind::Corner };
let want = vec![
a((x0 + rad, y0), (x0 + rad - k, y0), (x0 + rad, y0)),
a((x1 - rad, y0), (x1 - rad, y0), (x1 - rad + k, y0)),
a((x1, y0 + rad), (x1, y0 + rad - k), (x1, y0 + rad)),
a((x1, y1 - rad), (x1, y1 - rad), (x1, y1 - rad + k)),
a((x1 - rad, y1), (x1 - rad + k, y1), (x1 - rad, y1)),
a((x0 + rad, y1), (x0 + rad, y1), (x0 + rad - k, y1)),
a((x0, y1 - rad), (x0, y1 - rad + k), (x0, y1 - rad)),
a((x0, y0 + rad), (x0, y0 + rad), (x0, y0 + rad - k)),
];
assert_eq!(rounded_rectangle(r, rad).subpaths[0].anchors, want);
assert_eq!(rectangle_anchor_corners(r, [rad; 4]), [0, 1, 1, 2, 2, 3, 3, 0]);
assert_eq!(rounded_rectangle(r, 0.0), rectangle(r));
}
#[test]
fn each_corner_takes_its_own_radius_and_kind() {
let r = Rect::new(0.0, 0.0, 100.0, 60.0);
// Only the top-right corner rounded: 5 anchors, the corner's two on its sides.
let radii = [0.0, 10.0, 0.0, 0.0];
let p = rectangle_with_corners(r, radii, [CornerKind::Round; 4]);
let pts: Vec<Point> = p.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(pts, [Point::new(0.0, 0.0), Point::new(90.0, 0.0), Point::new(100.0, 10.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0)]);
assert_eq!(rectangle_anchor_corners(r, radii), [0, 1, 1, 2, 3]);
let quarter = PI * 25.0;
assert!((p.subpaths[0].area() - (6000.0 - 100.0 + quarter)).abs() < 0.1, "a quarter circle off one corner");
// The top-left corner rounded: its top-side anchor first, its left-side one last.
assert_eq!(rectangle_anchor_corners(r, [10.0, 0.0, 0.0, 200.0]), [0, 1, 2, 3, 3, 0]);
// Inverted round cuts a quarter circle out; a chamfer cuts a triangle.
let inverted = rectangle_with_corners(r, radii, [CornerKind::InvertedRound; 4]);
assert!((inverted.subpaths[0].area() - (6000.0 - quarter)).abs() < 0.1);
let chamfer = rectangle_with_corners(r, radii, [CornerKind::Chamfer; 4]);
assert!((chamfer.subpaths[0].area() - (6000.0 - 50.0)).abs() < 1e-9);
assert!(chamfer.subpaths[0].anchors.iter().all(|a| !a.has_in() && !a.has_out()), "straight");
// Radii clamp to half the shorter side; negative and NaN radii are none.
let p = rectangle_with_corners(r, [500.0, -5.0, f64::NAN, 0.0], [CornerKind::Round; 4]);
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(30.0, 0.0));
assert_eq!(p.anchor_count(), 5);
assert_eq!(CornerKind::ALL.map(CornerKind::next), [CornerKind::InvertedRound, CornerKind::Chamfer, CornerKind::Round]);
}
#[test]
fn polygon_first_vertex_up() {
let p = polygon(Point::new(0.0, 0.0), 10.0, 6, 0.0);
+5 -1
View File
@@ -10,6 +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::{Affine, PathData, Point, Rect};
use crate::export::sanitize_id;
@@ -600,7 +601,10 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option<Corners> {
b.abs() < 1e-9 && c.abs() < 1e-9
};
match live {
Some(LiveShape::Rectangle { radii, xf, .. }) if upright(xf) => {
// CSS rounds corners only: an inverted round or chamfered corner has no border radius.
Some(LiveShape::Rectangle { 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();
if radii.iter().all(|r| *r <= 0.0) {
+19 -2
View File
@@ -6,7 +6,8 @@
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Document, Effect, Justify, LiveShape, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_svg::{CssOptions, CssRule, CssUnits, ExportOptions, Styling, css_rules, export};
fn doc() -> Document {
@@ -29,7 +30,7 @@ fn rect(d: &mut Document, (x, y, w, h): (f64, f64, f64, f64), radius: f64) -> No
Appearance::basic(fill, Paint::solid(Color::rgb8(0, 0, 255)), 2.0),
);
if let NodeKind::Path { live, .. } = &mut n.kind {
*live = Some(LiveShape::Rectangle { w, h, radii: [radius; 4], xf: Affine::translate((x, y)) });
*live = Some(LiveShape::Rectangle { w, h, radii: [radius; 4], kinds: Default::default(), xf: Affine::translate((x, y)) });
}
n
}
@@ -69,6 +70,22 @@ fn rectangle_gives_background_border_and_radius() {
);
}
#[test]
fn live_corners_give_per_corner_radii_and_only_round_ones() {
let mut d = doc();
let mut n = rect(&mut d, (0.0, 0.0, 100.0, 50.0), 0.0);
if let NodeKind::Path { live: Some(LiveShape::Rectangle { radii, kinds, .. }), .. } = &mut n.kind {
*radii = [0.0, 8.0, 0.0, 4.0];
kinds[0] = CornerKind::Chamfer;
}
let id = add(&mut d, n);
assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("0 8px 0 4px"), "a square corner's kind doesn't matter");
if let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { kinds, .. }), .. }) = d.node_mut(id).map(|n| &mut n.kind) {
kinds[1] = CornerKind::InvertedRound;
}
assert!(prop(&one(&d, id, &CssOptions::default()), "border-radius").is_none(), "CSS can't cut a corner in");
}
#[test]
fn ellipses_round_fully_and_options_change_units_and_names() {
let mut d = doc();
+118 -21
View File
@@ -1,12 +1,19 @@
//! 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.
//! Live Corners: the widgets inside the corners of a selected live rectangle. Dragging one rounds
//! (or sharpens) the corners whose widgets show together: all four when the whole shape is
//! selected, those with a selected anchor when Direct Selection picked some. Alt-clicking a widget
//! cycles their corner kind (round, inverted round, chamfer); double-clicking one opens the
//! Corners dialog. The Selection and Direct Selection tools share this, and the canvas draws the
//! widgets from the same geometry.
use serde_json::json;
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 crate::{Action, Overlay, ToolContext};
use crate::{Action, Overlay, PointerEvent, ToolContext};
/// The dialog a double-click on a corner widget opens: `{id, corners}`.
pub const DIALOG: &str = "corners";
/// Widgets sit at least this far inside their corner (screen px), further in once the radius is.
const MIN_INSET_PX: f64 = 10.0;
@@ -24,9 +31,12 @@ pub struct CornerWidgets {
w: f64,
h: f64,
radii: [f64; 4],
kinds: [CornerKind; 4],
xf: Affine,
/// Widget centres in document coordinates, in radii order.
pub points: [Point; 4],
/// The corners whose widgets show (and which a drag edits).
pub shown: [bool; 4],
}
impl CornerWidgets {
@@ -36,10 +46,9 @@ impl CornerWidgets {
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);
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);
// 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);
@@ -52,7 +61,7 @@ impl CornerWidgets {
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 })
Some(Self { id, w, h, radii, kinds, xf, points, shown: live.picked_corners(selection.partial(id)) })
}
/// The widgets the active tool can drag (View → Show Corner Widget on).
@@ -63,9 +72,32 @@ impl CornerWidgets {
Self::of(cx.doc, cx.selection, cx.zoom)
}
/// Index of the widget nearest to `p` within `tol` (document units).
/// The centres of the widgets that show.
pub fn visible(&self) -> impl Iterator<Item = Point> + '_ {
self.points.iter().zip(self.shown).filter(|(_, on)| *on).map(|(p, _)| *p)
}
/// The indices of the shown corners.
fn shown_indices(&self) -> Value {
(0..4).filter(|k| self.shown[*k]).collect()
}
/// Index of the shown widget nearest to `p` within `tol` (document units).
pub fn hit(&self, p: Point, tol: f64) -> Option<usize> {
(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)))
let d = |k: &usize| self.points[*k].distance(p);
(0..4).filter(|k| self.shown[*k] && d(k) <= tol).min_by(|a, b| d(a).total_cmp(&d(b)))
}
/// `object.setLiveShape` params setting `key` on the shown corners (all four unless some are
/// hidden, which `corners` then leaves out).
fn command(&self, key: &str, value: Value) -> Value {
let mut p = Map::new();
p.insert("id".into(), json!(self.id.0));
p.insert(key.into(), value);
if self.shown != [true; 4] {
p.insert("corners".into(), self.shown_indices());
}
Value::Object(p)
}
}
@@ -74,23 +106,32 @@ 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.
/// A double-click on a corner widget opens the Corners dialog for the shown corners.
pub fn double_click(cx: &ToolContext, p: Point) -> Option<Action> {
let w = CornerWidgets::for_tool(cx)?;
w.hit(p, cx.tol(5.0))?;
Some(Action::Dialog(DIALOG.into(), json!({ "id": w.id.0, "corners": w.shown_indices() })))
}
/// Dragging a corner widget: the radius follows the pointer along the corner's diagonal. An
/// Alt-click (no drag) cycles the corner kind instead.
#[derive(Clone, Copy, Debug)]
pub struct CornerDrag {
widgets: CornerWidgets,
corner: usize,
start: Point,
began: bool,
alt: 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<Self> {
let widgets = CornerWidgets::for_tool(cx)?;
/// Start a drag when the press `ev` is on a widget of the selection.
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, radius: widgets.radii[corner], at: p })
Some(Self { widgets, corner, start: p, began: false, alt: ev.mods.alt, radius: widgets.radii[corner], at: p })
}
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
@@ -115,12 +156,19 @@ impl CornerDrag {
}
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.push(Action::Preview("object.setLiveShape".into(), self.widgets.command("radius", json!(self.radius))));
out
}
pub fn finish(self) -> Vec<Action> {
if self.began { vec![Action::Commit] } else { vec![] }
if self.began {
vec![Action::Commit]
} else if self.alt {
let kind = self.widgets.kinds[self.corner].next();
vec![Action::Exec("object.setLiveShape".into(), self.widgets.command("kind", json!(kind)))]
} else {
vec![]
}
}
/// The radius readout next to the pointer.
@@ -136,7 +184,7 @@ impl CornerDrag {
mod tests {
use super::*;
use crate::testutil::*;
use crate::{PointerEvent, PointerKind};
use crate::{Mods, PointerKind};
use vectorcraft_doc::{Appearance, Node};
/// A live 100 × 100 rectangle at (100, 100) with corner radius `r`.
@@ -144,7 +192,7 @@ mod tests {
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 live = LiveShape::Rectangle { w: 100.0, h: 100.0, 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);
@@ -221,6 +269,55 @@ mod tests {
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 190.0, 110.0)).is_empty());
}
#[test]
fn direct_selected_corners_show_their_widgets_alone_and_round_alone() {
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
// Direct Selection picked the bottom-right anchor.
let mut s = selected(id);
s.anchors.insert(id, [(0, 2)].into());
let p = paint();
let cx = cx(&d, &s, &p);
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
assert_eq!(w.shown, [false, false, true, false]);
assert_eq!(w.visible().collect::<Vec<_>>(), [Point::new(190.0, 190.0)]);
assert!(!over_widget(&cx, Point::new(110.0, 110.0)), "the other corners hide theirs");
let mut t = 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_eq!(a[1], Action::Preview("object.setLiveShape".into(), json!({"id": id.0, "radius": 15.0, "corners": [2]})));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 175.0, 176.0)), vec![Action::Commit]);
}
#[test]
fn alt_click_cycles_the_kind_and_double_click_opens_the_dialog() {
let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0)));
let p = paint();
let alt = Mods { alt: true, ..Mods::default() };
let whole = selected(id);
let mut part = selected(id);
part.anchors.insert(id, [(0, 0), (0, 7)].into());
for (s, extra) in [(&whole, json!({})), (&part, json!({"corners": [0]}))] {
let cx = cx(&d, s, &p);
let mut want = json!({"id": id.0, "kind": "invertedRound"});
want.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
for tool in ["selection", "directSelection"] {
let mut t = crate::create(tool);
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 110.0).with_mods(alt)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 110.0, 110.0).with_mods(alt));
assert_eq!(a, vec![Action::Exec("object.setLiveShape".into(), want.clone())], "{tool}");
}
}
// A double-click on a widget opens Corners for the shown corners; elsewhere it doesn't.
let cx1 = cx(&d, &part, &p);
let a = crate::create("directSelection").pointer(&cx1, &PointerEvent::new(PointerKind::DoubleClick, 110.0, 110.0));
assert_eq!(a, vec![Action::Dialog(DIALOG.into(), json!({"id": id.0, "corners": [0]}))]);
let cx2 = cx(&d, &whole, &p);
let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 190.0, 110.0));
assert_eq!(a, vec![Action::Dialog(DIALOG.into(), json!({"id": id.0, "corners": [0, 1, 2, 3]}))]);
assert!(crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.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)));
+6 -3
View File
@@ -3,7 +3,9 @@
//! 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 (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap
//! it), marquee to select anchors, drag a live rectangle's corner widget to round its corners.
//! it), marquee to select anchors, drag a live rectangle's corner widget to round its corners
//! (the selected ones when anchors are selected; Alt-click cycles their kind, double-click opens
//! the Corners dialog).
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group.
//! Both pick the key objects of a blend. Direct Selection also edits a blend's spine: drag its
//! points (a key object on a point moves with it) and, once a point is clicked, its handles; and
@@ -23,7 +25,7 @@ use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind};
use vectorcraft_geom::{PathData, Point, Rect};
use crate::bbox::move_delta;
use crate::corners::{CornerDrag, over_widget};
use crate::corners::{self, CornerDrag, over_widget};
use crate::meshedit::MeshEdit;
use crate::select::{is_area_type, matrix_json};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
@@ -257,8 +259,9 @@ impl Tool for DirectSelectionTool {
vec![Action::Exec("select.set".into(), json!({"ids": [target.0]}))]
}
}
(PointerKind::DoubleClick, _) if !self.group => corners::double_click(cx, p).into_iter().collect(),
(PointerKind::Down, _) => {
if let Some(c) = CornerDrag::hit(cx, p) {
if let Some(c) = CornerDrag::hit(cx, ev) {
self.state = State::Corner(c);
return vec![];
}
+7 -3
View File
@@ -1,6 +1,7 @@
//! 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°),
//! drag a live rectangle's corner widget to round its corners, double-click to enter isolation mode.
//! drag a live rectangle's corner widget to round its corners (Alt-click cycles their kind,
//! double-click opens the Corners dialog), double-click to enter isolation mode.
//! The bounding box stands at the selection's own angle after a rotation, so its handles scale
//! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of
//! scaling its type: the text reflows at its size.
@@ -11,7 +12,7 @@ use vectorcraft_doc::{NodeId, OrientedBox};
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::corners::{self, CornerDrag, over_widget};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids};
#[derive(Clone, Debug, Default)]
@@ -108,6 +109,9 @@ impl Tool for SelectionTool {
match (ev.kind, self.state.clone()) {
(PointerKind::DoubleClick, _) => {
self.state = State::Idle;
if let Some(a) = corners::double_click(cx, p) {
return vec![a];
}
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
let top = h.top_object(cx.isolation);
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. })) {
@@ -123,7 +127,7 @@ impl Tool for SelectionTool {
}
(PointerKind::Down, _) => {
// 1. Live Corners widgets, then the bounding-box handles of the current selection.
if let Some(c) = CornerDrag::hit(cx, p) {
if let Some(c) = CornerDrag::hit(cx, ev) {
self.state = State::Corner(c);
return vec![];
}
+1 -1
View File
@@ -1239,7 +1239,7 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
&& let Some(w) = vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, xf.zoom)
{
let color = c32(st.doc.layer_color(w.id));
for sp in w.points.map(|q| xf.to_screen(q)) {
for sp in w.visible().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);
+199
View File
@@ -0,0 +1,199 @@
//! Corners: double-clicking a Live Corners widget (or `ui.corners`) sets the corner kind and
//! radius of a live rectangle's corners: those whose widgets show (the Direct-Selected ones, else
//! all four). OK runs `object.setLiveShape` for them, one undo step.
//!
//! Fields: `id`, `corners` (indices 0–3: top-left, top-right, bottom-right, bottom-left), `kind`
//! ("round", "invertedRound" or "chamfer") and `radius` (pt). While the corners differ in kind
//! or radius, that field is absent and OK leaves it as it is.
use serde_json::{Value, json};
use vectorcraft_engine::doc::{NodeId, NodeKind};
use vectorcraft_geom::shapes::CornerKind;
use super::swatch_options::{grid, label};
use super::{DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Corners (the corner widgets' double-click).
pub const KIND: &str = vectorcraft_tools::corners::DIALOG;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Corners").into(), body, confirm, min_width: 280.0, ..DialogSpec::FORM };
/// Open Corners for `{id?, corners?}`: the live rectangle `id` (default: the selected one) and its
/// `corners` (default: the selected corners), filled in with their kind and radius.
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
let id = match p.get("id").and_then(Value::as_u64) {
Some(id) => NodeId(id),
None => match st.selection.objects[..] {
[id] => id,
_ => return Err("select one live rectangle, or give its `id`".into()),
},
};
let Some(NodeKind::Path { live: Some(live @ vectorcraft_engine::doc::LiveShape::Rectangle { .. }), .. }) = st.doc.node(id).map(|n| &n.kind)
else {
return Err("Corners edits a live rectangle".into());
};
let corners = match p.get("corners").and_then(Value::as_array) {
Some(a) => std::array::from_fn(|k| a.iter().any(|v| v.as_u64() == Some(k as u64))),
None => live.picked_corners(st.selection.partial(id)),
};
if !corners.contains(&true) {
return Err("give corners 0–3".into());
}
let (radius, kind) = live.corner_style(corners);
let mut fields = json!({"id": id.0, "corners": (0..4).filter(|k| corners[*k]).collect::<Vec<_>>()});
if let Some(k) = kind {
fields["kind"] = json!(k);
}
if let Some(r) = radius {
fields["radius"] = json!(r);
}
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let unit = app.session.general_unit();
let current = d.fields.get("kind").and_then(|k| serde_json::from_value::<CornerKind>(k.clone()).ok());
grid(ui, |ui| {
label(ui, tl!("Corner:"));
ui.vertical(|ui| {
for k in CornerKind::ALL {
let name = match k {
CornerKind::Round => tl!("Round"),
CornerKind::InvertedRound => tl!("Inverted Round"),
CornerKind::Chamfer => tl!("Chamfer"),
};
if widgets::radio(ui, name, Some(k) == current, true) {
d.fields.insert("kind".into(), json!(k));
}
}
});
ui.end_row();
label(ui, tl!("Radius:"));
form::length(ui, d, "radius", unit, 120.0);
ui.end_row();
});
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = json!({"id": d.fields.get("id"), "corners": d.fields.get("corners")});
if let Some(k) = d.fields.get("kind") {
p["kind"] = k.clone();
}
if d.fields.contains_key("radius") {
p["radius"] = json!(d.f64("radius", 0.0));
}
run_and_close(app, "object.setLiveShape", p)
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
use vectorcraft_engine::doc::LiveShape;
use vectorcraft_tools::corners::CornerWidgets;
use vectorcraft_tools::{PointerEvent, PointerKind};
/// A selected 100 pt live square at (100, 100).
fn app() -> (VectorcraftApp, u64) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
(app, id)
}
fn live(app: &VectorcraftApp, id: u64) -> LiveShape {
let Some(NodeKind::Path { live: Some(l), .. }) = app.session.active().unwrap().doc.node(NodeId(id)).map(|n| n.kind.clone()) else {
panic!("not live")
};
l
}
fn corners(app: &VectorcraftApp, id: u64) -> ([f64; 4], [CornerKind; 4]) {
let LiveShape::Rectangle { radii, kinds, .. } = live(app, id) else { panic!("not a rectangle") };
(radii, kinds)
}
/// The radius the Properties panel's Corner Radius field shows.
fn panel_radius(app: &VectorcraftApp, id: u64) -> Option<f64> {
let n = app.session.active().unwrap().doc.node(NodeId(id)).unwrap().clone();
crate::panels::corner_radius(app, &n, &live(app, id))
}
fn shown(app: &mut VectorcraftApp) -> Vec<String> {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
// A new window lays itself out in its first frame and paints in the next.
ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())).textures_delta.clear();
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
}
#[test]
fn double_clicking_a_direct_selected_corner_edits_it_alone() {
let (mut app, id) = app();
// Direct Selection picks the bottom-right anchor: only its widget shows.
app.run("select.anchors", json!({"id": id, "anchors": [[0, 2]], "mode": "set"})).unwrap();
app.select_tool("directSelection");
let view = app.view_info();
let st = app.session.active().unwrap();
let at: Vec<_> = CornerWidgets::of(&st.doc, &st.selection, view.zoom).unwrap().visible().collect();
assert_eq!(at.len(), 1);
crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::DoubleClick, at[0].x, at[0].y), view);
let d = app.ui.dialog.clone().expect("Corners opened");
assert_eq!(
(d.kind.as_str(), d.fields.get("corners"), d.fields.get("kind"), d.f64("radius", -1.0)),
(KIND, Some(&json!([2])), Some(&json!("round")), 0.0)
);
let texts = shown(&mut app);
for s in ["Corners", "Corner:", "Round", "Inverted Round", "Chamfer", "Radius:"] {
assert!(texts.iter().any(|t| t == s), "{s} in {texts:?}");
}
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("kind".into(), json!("chamfer"));
d.fields.insert("radius".into(), json!(12));
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(corners(&app, id), ([0.0, 0.0, 12.0, 0.0], [CornerKind::Round, CornerKind::Round, CornerKind::Chamfer, CornerKind::Round]));
// The panels' radius field shows the selected corner's radius, blank for the whole
// shape (its corners differ).
assert_eq!(panel_radius(&app, id), Some(12.0));
app.run("select.set", json!({"ids": [id]})).unwrap();
assert_eq!(panel_radius(&app, id), None);
}
#[test]
fn ui_corners_opens_on_the_selection_and_leaves_mixed_values_alone() {
let (mut app, id) = app();
app.run("object.setLiveShape", json!({"corners": [0], "radius": 5, "kind": "invertedRound"})).unwrap();
app.run("ui.corners", json!({})).unwrap();
let d = app.ui.dialog.clone().unwrap();
assert_eq!(d.fields.get("corners"), Some(&json!([0, 1, 2, 3])));
assert!(d.fields.get("kind").is_none() && d.fields.get("radius").is_none(), "mixed: {d:?}");
let before = corners(&app, id);
super::super::confirm(&mut app).unwrap();
assert_eq!(corners(&app, id), before);
// A radius for all four keeps their kinds.
app.run("ui.corners", json!({"id": id})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("radius".into(), json!("3 pt"));
super::super::confirm(&mut app).unwrap();
assert_eq!(corners(&app, id), ([3.0; 4], before.1));
// Only live rectangles, and only real corners.
assert!(app.run("ui.corners", json!({"id": 9999})).is_err());
assert!(app.run("ui.corners", json!({"corners": [7]})).is_err());
}
}
+2
View File
@@ -15,6 +15,7 @@ pub mod color_guide_options;
mod color_picker;
mod command;
pub mod confirm;
pub mod corners;
mod document_setup;
pub mod dxf_import;
pub mod dxf_options;
@@ -230,6 +231,7 @@ registry! {
SpotColors: [spot_colors::KIND] => spot_colors::SPEC,
TransformEach: [transform_each::KIND] => transform_each::SPEC,
WidthPoint: [width_point::KIND] => width_point::SPEC,
Corners: [corners::KIND] => corners::SPEC,
SavePdf: [save_pdf::KIND] => save_pdf::SPEC,
SvgOptions: [svg_options::KIND] => svg_options::SPEC,
NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC,
+7
View File
@@ -70,6 +70,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
}
return;
}
// Double-clicking a Live Corners widget: Corners.
super::corners::KIND => {
if let Err(e) = super::corners::open(app, &p) {
app.status(e);
}
return;
}
// Double-clicking a slice with the Slice Selection tool: Slice Options.
super::slices::OPTIONS => {
if let Err(e) = super::slices::open_options(app) {
+5
View File
@@ -466,6 +466,11 @@
Corner Radius: Radio de esquina:
Corner: Esquina:
Corners Esquinas
Chamfer Chaflán
Corners… Esquinas…
Inverted Round Redondeada invertida
Radius: Radio:
Round Redondeada
Couldn't copy to the system clipboard: {e} No se pudo copiar al portapapeles del sistema: {e}
Couldn't paste from the clipboard: {e} No se pudo pegar desde el portapapeles: {e}
Couldn't save recovery data: {e} No se pudieron guardar los datos de recuperación: {e}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -940,6 +940,11 @@
Corner Radius: 角丸の半径:
Corner: 角の形状:
Corners コーナー
Round 丸型
Radius: 半径:
Inverted Round 反転した丸型
Corners… コーナー…
Chamfer 面取り
Create Clipping Mask クリッピングマスクを作成
Create Document ドキュメントを作成
Create From: 作成元:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -322,6 +322,7 @@
Cascade Em Cascata
Center Artwork Centralizar Arte
Centimeters Centímetros
Chamfer Chanfro
Change Alterar
Change All Alterar Tudo
Change Case Alterar Maiúsculas/Minúsculas
@@ -477,6 +478,7 @@
Corner Radius: Raio do Canto:
Corner: Canto:
Corners Cantos
Corners… Cantos…
Create Criar
Create Clipping Mask Criar Máscara de Recorte
Create Document Criar Documento
@@ -1085,6 +1087,7 @@
Invert Inverter
Invert Colors Inverter Cores
Invert Mask Inverter Máscara
Inverted Round Arredondado Invertido
Isolate Isolar
Isolate Blending Isolar Mesclagem
Isolate Group Isolar Grupo
@@ -1703,6 +1706,7 @@
Radial Radial
Radial Glows Brilhos Radiais
Radial Gradient Gradiente Radial
Radius: Raio:
Rainbow Arco-íris
Random Aleatório
Randomize Colors Randomizar Cores
@@ -1871,6 +1875,7 @@
Rotate… Girar…
Rotation Rotação
Roughen… Enrugar…
Round Arredondado
Round Cap Extremidade Redonda
Round Corners… Cantos Arredondados…
Round Join Junção Redonda
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -467,6 +467,10 @@
Corner Radius: 圆角半径:
Corner: 转角:
Corners 转角
Round 圆角
Inverted Round 反向圆角
Corners… 转角…
Chamfer 倒角
Couldn't copy to the system clipboard: {e} 无法复制到系统剪贴板:{e}
Couldn't paste from the clipboard: {e} 无法从剪贴板粘贴:{e}
Couldn't save recovery data: {e} 无法存储恢复数据:{e}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -480,6 +480,11 @@
Corner Radius: 圓角半徑:
Corner: 轉角:
Corners 轉角
Round 圓角
Radius: 半徑:
Inverted Round 反向圓角
Corners… 轉角…
Chamfer 倒角
Couldn't copy to the system clipboard: {e} 無法複製到系統剪貼簿:{e}
Couldn't paste from the clipboard: {e} 無法從剪貼簿貼上:{e}
Couldn't save recovery data: {e} 無法儲存復原資料:{e}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -441,6 +441,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{id, index} open Width Point Edit for width point `index` of path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking a width point with the Width tool opens it too): OK runs stroke.widthPoint.set, discard: true (the Delete button) stroke.widthPoint.remove",
),
(
"ui.corners",
"Corners…",
"",
"{id?, corners?: [0..3…]} open Corners for live rectangle `id` (default: the selected one) and its corners (0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else all four) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape",
),
(
"ui.colorGuideLimit",
"Limit Color Guide to Library",
@@ -1126,6 +1132,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
},
},
"ui.widthPointEdit" => crate::dialogs::width_point::open(app, p),
"ui.corners" => crate::dialogs::corners::open(app, p),
"ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p),
"ui.savePdfDialog" => crate::dialogs::open_save_pdf(app, p),
"file.exportAs" if s("path").is_none() => {
+8 -1
View File
@@ -45,7 +45,7 @@ pub mod transparency;
use egui::{Rect, Sense, Ui, vec2};
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Node, StrokeLayer};
use vectorcraft_doc::{LiveShape, Node, StrokeLayer};
use vectorcraft_engine::inspect::StrokeMixed;
use crate::theme::Tokens;
@@ -57,6 +57,13 @@ pub fn first_selected(app: &VectorcraftApp) -> Option<Node> {
first_node(app).cloned()
}
/// The radius the Live Corners of live rectangle `n` show in the panels: that of the corners the
/// panels set (the Direct-Selected ones, else all four), blank when they differ.
pub(crate) fn corner_radius(app: &VectorcraftApp, n: &Node, live: &LiveShape) -> Option<f64> {
let partial = app.session.active().and_then(|d| d.selection.partial(n.id));
live.corner_style(live.picked_corners(partial)).0
}
/// Number of selected objects.
pub(crate) fn selection_len(app: &VectorcraftApp) -> usize {
app.session.active().map(|d| d.selection.len()).unwrap_or(0)
+3 -3
View File
@@ -4,7 +4,7 @@ use egui::Ui;
use serde_json::json;
use vectorcraft_doc::{NodeKind, Unit};
use super::{first_selected, pstate, set_pstate};
use super::{corner_radius, first_selected, pstate, set_pstate};
use crate::theme::Tokens;
use crate::widgets::{self, dim_label, divider, section_header};
use crate::{VectorcraftApp, icons};
@@ -317,10 +317,10 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
&& let NodeKind::Path { live: Some(live), .. } = &n.kind
{
match live {
vectorcraft_doc::LiveShape::Rectangle { radii, .. } => {
vectorcraft_doc::LiveShape::Rectangle { .. } => {
ui.horizontal(|ui| {
dim_label(ui, tl!("Corner Radius:"));
if let Some(r) = widgets::num_field(ui, "radius", Some(radii[0]), units, 80.0) {
if let Some(r) = widgets::num_field(ui, "radius", corner_radius(app, &n, live), units, 80.0) {
app.run("object.setLiveShape", json!({"radius": r})).ok();
}
});
+3 -3
View File
@@ -7,7 +7,7 @@ use serde_json::json;
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::Rect;
use super::{first_selected, pstate, set_pstate};
use super::{corner_radius, first_selected, pstate, set_pstate};
use crate::theme::Tokens;
use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons};
@@ -170,11 +170,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
{
widgets::divider(ui);
match live {
vectorcraft_doc::LiveShape::Rectangle { radii, .. } => {
vectorcraft_doc::LiveShape::Rectangle { .. } => {
widgets::subheader(ui, tl!("Rectangle Properties:"));
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Corner Radius:"));
if let Some(r) = widgets::num_field(ui, "xfp-radius", Some(radii[0]), units, 80.0) {
if let Some(r) = widgets::num_field(ui, "xfp-radius", corner_radius(app, &n, live), units, 80.0) {
app.run("object.setLiveShape", json!({"radius": r})).ok();
}
});
+5
View File
@@ -253,6 +253,11 @@ Width Point Edit: double-clicking a width point with the Width tool, or `ui.widt
`adjustAdjoining`). `ui.dialog.confirm` runs `stroke.widthPoint.set` with them; `ui.dialog.set {field: "discard",
value: true}` then confirm (the Delete button) removes the point with `stroke.widthPoint.remove`.
Corners: double-clicking a Live Corners widget with the Selection or Direct Selection tool, or `ui.corners {id?,
corners?}`, opens the `corners` dialog for a live rectangle's corners (the Direct-Selected ones, else all four; fields
`id`, `corners`: indices 0–3 clockwise from the top-left, `kind`: round, invertedRound or chamfer, `radius` in points;
`kind` or `radius` is absent while the corners differ). `ui.dialog.confirm` runs `object.setLiveShape` with them.
Perspective plane options: double-clicking a plane widget of the perspective grid, or `ui.perspectivePlane {plane}`,
opens the `perspectivePlane` dialog (fields `plane`: left, right or ground; `location`: points along the plane's
normal; `objects`: none, move or copy). `ui.dialog.confirm` runs `perspective.plane.move` with them.
+16
View File
@@ -1039,6 +1039,22 @@ size. The journal entry of a scaling command records the `strokes` and `corners`
{"name":"run_command","arguments":{"command":"object.transformEach","params":{"scaleH":50,"scaleV":50,"strokes":false}}}
```
## Live Corners
`object.setLiveShape {id?, ids?, radius?, kind?, corners?}` sets the corners of live rectangles (one undo step):
`radius` (pt) and `kind` (`round`, `invertedRound` or `chamfer`) go to the `corners` given (0 top-left, 1 top-right,
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`).
```json
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"id":12,"corners":[1],"radius":16}}}
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"id":12,"corners":[0,3],"radius":8,"kind":"chamfer"}}}
```
## Use Preview Bounds
With the preference `usePreviewBounds` on (`prefs.set {key: "usePreviewBounds", value: true}`; also the Align panel