Files
figcraft/crates/engine/src/vectoredit/transform.rs
T
bflatastic a5a706674c M4: vector multi-edit and edit tail
Vector edit mode can now edit several vector layers at once, and the rest of the vector edit tail
from the public documentation landed. A behaviour suite came first
(`crates/engine/src/vector_multi_behaviour_tests.rs`, 5 behaviour tests with about 30 table rows and
steps, plus a junk-parameter sweep of every `vector.*` command run in multi-edit mode).

- **Multi-edit.** Enter, a double-click or `vector.edit.begin {ids}` with several shapes selected
  puts them all in edit mode, converting them in one undo step. The clicked or first one is the
  active layer. Pressing another layer's point, handle or segment makes that layer active. Shift-click,
  marquee and lasso select points in any layer. Dragging a selected point, the arrows, Delete,
  delete and heal, mirroring, smoothing, corner radius and the new cap and join commands reach every
  layer's selected points in one undo step. The pen, bend, paint bucket, cut, eraser, join and
  position work in the active layer. A click away from every edited layer leaves edit mode.
  `vector.edit.state` now reports `layers`, `others` and `handles`.
- **Several handles.** Shift-click Bezier handles to select more than one. Dragging one moves the
  others by the same amount. Also available as `vector.edit.select {handles}` and
  `vector.edit.moveHandles`. Selected handles are drawn filled.
- **Rounding next to curves.** A sharp point with a corner radius rounds even when one of its
  segments is curved. The curve is cut where the arc meets it and keeps the rest of its shape, and
  the arc meets each side smoothly (`VectorNetwork::rounded`).
- **A cap and a join per point.** `Vertex` gets optional `cap` and `join` values. When unset, the
  layer's apply. The commands are `vector.edit.strokeCap` and `vector.edit.strokeJoin` (`null`
  clears). The Design panel's Point section gets End point and Join dropdowns, each with a "Same as
  layer" option. The stroke is drawn run by run and each point's own join is drawn over the break
  (`figcraft_pathops::stroke_outline_of`, new `points.rs`). This applies to the canvas, PNG and PDF,
  and to SVG as outlines. Outline stroke follows it, render bounds count arrowheads from points, and
  the stroke cache key includes the points' styles.
- **Pen outside edit mode.** Clicking one of the path's own points (other than the first or last)
  connects there and ends the path, as a vector network. With an open vector selected, clicking one
  of its end points continues that path in edit mode.
- MCP: headless `key` Enter on shapes now enters vector edit mode, as in the desktop app. The new
  commands have Spanish labels, and the new UI string has en and es entries.
- Docs: target-app-parity (area 4: 98%/72% to 99%/73%, 2-4 h to 1-3 h; area 20: 80%/55% to
  88%/60%, 2-5 h to 1-3 h), ROADMAP, gaps (gap 9 is down to tablet pressure on macOS and Linux; the
  gap 13 note is updated), ui-parity and mcp.md. Totals are recomputed by the doc's method, because
  rows added since the last recompute had left them stale. Feature breadth is now ~83% and ready for
  real work ~52%. Mainstream stays at ~49% (49.7%, rounded down). Full parity goes from
  ~306-595 h to ~294-572 h.
2026-10-12 02:52:18 +07:00

215 lines
9.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The transform box of vector edit mode. With two or more points selected (Move tool) a box in the
//! vector's own axes wraps them: its corners and edges scale them (Shift keeps proportions, Alt
//! scales from the centre), dragging just outside a corner rotates them about its centre (Shift:
//! 15° steps) and dragging inside it moves them; each drag is one undo step (the gestures live in
//! `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP,
//! and while points are selected the flip and 90°/180° rotation commands act on them.
use figcraft_doc::Document;
use figcraft_geom::{Affine, Point};
use serde_json::{Value, json};
use super::{VHit, VTool, VectorEdit, default_points, edit, editing, indices, network, ops, selected_points, selection_position};
use crate::cmd::CommandSpec;
use crate::params::*;
use crate::tools::{Handle, handle_at_corners};
use crate::{Result, Session, bad, spec};
/// Box sizes at or below this have no extent on that axis (nothing to scale).
const EPS: f64 = 1e-9;
/// The selected points' box: the page transform of its top-left corner (axes along the vector's
/// own), its size in those axes and the points in it.
#[derive(Clone, Debug, PartialEq)]
pub struct PointBox {
pub frame: Affine,
pub width: f64,
pub height: f64,
pub points: Vec<usize>,
}
impl PointBox {
/// Corners in page coordinates (tl, tr, br, bl).
pub fn corners(&self) -> [Point; 4] {
let (f, w, h) = (self.frame, self.width, self.height);
[f * Point::ZERO, f * Point::new(w, 0.0), f * Point::new(w, h), f * Point::new(0.0, h)]
}
pub fn center(&self) -> Point {
self.frame * Point::new(self.width / 2.0, self.height / 2.0)
}
/// The page transform mapping this box onto (x0, y0)–(x1, y1) in its own axes (a reversed
/// pair flips). An axis without extent keeps its points where they are.
pub fn onto(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> Affine {
let (x0, sx) = if self.width > EPS { (x0, (x1 - x0) / self.width) } else { (0.0, 1.0) };
let (y0, sy) = if self.height > EPS { (y0, (y1 - y0) / self.height) } else { (0.0, 1.0) };
self.frame * Affine::new([sx, 0.0, 0.0, sy, x0, y0]) * self.frame.inverse()
}
}
/// The box of the selected points (and selected segments' ends); one point makes an empty box.
pub fn point_box(doc: &Document, ve: &VectorEdit) -> Option<PointBox> {
let net = network(doc, ve.id)?;
let points = selected_points(net, ve);
let (x0, y0, x1, y1) = points
.iter()
.filter_map(|i| net.vertices.get(*i))
.fold((f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |(a, b, c, d), v| {
(a.min(v.x), b.min(v.y), c.max(v.x), d.max(v.y))
});
let (width, height) = (x1 - x0, y1 - y0);
if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) {
return None;
}
Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points })
}
/// The box drawn and dragged on the canvas: the Move tool, no open pen path, and two or more
/// points that aren't all in one place, all in the active layer.
/// shortcut: with points selected in several layers there is no box (drags, nudges and the Design panel still reach them all); wrap them all if asked for.
pub fn shown_box(doc: &Document, ve: &VectorEdit) -> Option<PointBox> {
if ve.tool != VTool::Move || ve.anchor.is_some() || ve.others_selected() {
return None;
}
point_box(doc, ve).filter(|b| b.points.len() >= 2 && (b.width > EPS || b.height > EPS))
}
/// What the transform box does under the pointer.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum BoxHit {
/// A corner or edge (scale) or a rotation zone.
Handle(Handle),
/// Inside the box, clear of the network: drag to move the points.
Inside,
}
/// The box's part under page point `p` when it wins over `h`, the network's part there: Bezier
/// handles come first, then the box's corners, points, the box's edges, segments, the rotation
/// zones, and the inside of the box last. Edges along an axis without extent are left out.
pub fn box_hit(doc: &Document, ve: &VectorEdit, h: Option<VHit>, p: Point, zoom: f64) -> Option<BoxHit> {
let b = shown_box(doc, ve)?;
match handle_at_corners(&b.corners(), p, zoom, false) {
Some(handle) => {
let wins = match (handle, h) {
(_, Some(VHit::Handle { .. })) => false,
(Handle::Corner(_), _) => true,
(Handle::Edge(e), None | Some(VHit::Segment { .. })) => {
if e % 2 == 0 {
b.height > EPS
} else {
b.width > EPS
}
}
(Handle::Rotate(_), None) => true,
_ => false,
};
wins.then_some(BoxHit::Handle(handle))
}
None => {
let q = b.frame.inverse() * p;
(h.is_none() && (0.0..=b.width).contains(&q.x) && (0.0..=b.height).contains(&q.y)).then_some(BoxHit::Inside)
}
}
}
/// Map points `verts` of vector `id` (and the handles at them) through page transform `m`, then
/// re-fit the vector.
pub fn transform_points(doc: &mut Document, id: figcraft_doc::NodeId, verts: &[usize], m: Affine) -> Result<()> {
let w = doc.world_transform(id);
let local = w.inverse() * m * w;
edit(doc, id, |net| ops::transform_vertices(net, verts, local))
}
/// Transform the selected points by `f(box)` (page space) when editing with points selected and
/// the command names no layers (`p` has no `ids` or `id`); `None` otherwise (it then acts on
/// layers).
pub fn transform_in_edit_mode(s: &mut Session, p: &Value, f: impl FnOnce(&PointBox) -> Affine) -> Option<Result<Value>> {
if get(p, "ids").is_some() || get(p, "id").is_some() {
return None;
}
let ve = editing(s).ok()?;
let b = point_box(s.doc().ok()?, &ve).filter(|b| !b.points.is_empty())?;
let m = f(&b);
Some(s.doc_mut().and_then(|doc| transform_points(doc, ve.id, &b.points, m)).map(|()| json!({"ok": true})))
}
/// The selected points' box, or an error naming `cmd`.
fn selected_box(s: &Session, cmd: &str) -> Result<(VectorEdit, PointBox)> {
let ve = editing(s)?;
let b = point_box(s.doc()?, &ve).filter(|b| !b.points.is_empty()).ok_or_else(|| bad(cmd, "select points"))?;
Ok((ve, b))
}
/// `[a, b, c, d, e, f]` from params.
fn matrix(cmd: &str, p: &Value) -> Result<Affine> {
let c: Vec<f64> = match get(p, "matrix") {
Some(Value::Array(a)) => a.iter().filter_map(f64_of).collect(),
_ => vec![],
};
match c.as_slice() {
[a, b, c, d, e, f] => Ok(Affine::new([*a, *b, *c, *d, *e, *f])),
_ => Err(bad(cmd, "`matrix` must be [a, b, c, d, e, f]")),
}
}
pub(super) fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"vector.edit.bounds",
"Selected points' bounds",
"{} → {x, y (top-left in the parent's coordinates), width, height (in the vector's axes), corners (page), points}",
false,
|s, _| {
let (ve, b) = selected_box(s, "vector.edit.bounds")?;
let pos = selection_position(s.doc()?, &ve);
let corners: Vec<[f64; 2]> = b.corners().iter().map(|c| [c.x, c.y]).collect();
Ok(
json!({"x": pos.map(|p| p.x), "y": pos.map(|p| p.y), "width": b.width, "height": b.height, "corners": corners, "points": b.points}),
)
}
),
spec!(
"vector.edit.resize",
"Resize points",
"{width?, height?, center? (about the centre; default keeps the top-left)} — scales the selected points' box in the vector's axes",
true,
|s, p| {
let (ve, b) = selected_box(s, "vector.edit.resize")?;
let w = num(p, "width").map_or(b.width, |v| v.clamp(0.0, 1e7));
let h = num(p, "height").map_or(b.height, |v| v.clamp(0.0, 1e7));
let (x0, y0) = if boolean(p, "center").unwrap_or(false) { ((b.width - w) / 2.0, (b.height - h) / 2.0) } else { (0.0, 0.0) };
transform_points(s.doc_mut()?, ve.id, &b.points, b.onto(x0, y0, x0 + w, y0 + h))?;
Ok(json!({"ok": true}))
}
),
spec!(
"vector.edit.rotate",
"Rotate points",
"{angle (degrees, counter-clockwise), cx?, cy? (page pivot; default the box centre)}",
true,
|s, p| {
let (ve, b) = selected_box(s, "vector.edit.rotate")?;
let angle = req_num("vector.edit.rotate", p, "angle")?;
let c = b.center();
let pivot = Point::new(num_or(p, "cx", c.x), num_or(p, "cy", c.y));
transform_points(s.doc_mut()?, ve.id, &b.points, figcraft_geom::rotate_about(Affine::IDENTITY, pivot, angle))?;
Ok(json!({"ok": true}))
}
),
spec!(
"vector.edit.transform",
"Transform points",
"{matrix: [a, b, c, d, e, f] (page space), vertices? (default the selected points)}",
true,
|s, p| {
let ve = editing(s)?;
let m = matrix("vector.edit.transform", p)?;
let verts = indices(p, "vertices").unwrap_or_else(|| default_points(s, &ve));
transform_points(s.doc_mut()?, ve.id, &verts, m)?;
Ok(json!({"ok": true}))
}
),
]
}