mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
Commands that read the selection through edit::selected_roots leave out an object whose group is also selected (edit::roots_of). Fourteen places that read `ids` kept an object listed together with its group. Given ids that list an object and a group that holds it, Make Repeat failed with "no such object", New Symbol failed or stored the object twice in the symbol, and object.transform moved it twice (20 pt for a 10 pt move). These places now pass explicit ids through edit::outermost, which drops an id whose ancestor is also listed, with the filter that roots_of runs on the selection (now edit::without_inner). Nothing else is dropped or replaced: unlike roots_of, it keeps layers and compound-path members. A list without nesting gives the same result as before. Commands that take both ids and the selection as listed, such as paint.setFill without item, are unchanged. Docs: docs/mcp.md, docs/target-app-parity.md.
409 lines
16 KiB
Rust
409 lines
16 KiB
Rust
//! Commands backing transform/utility tools: free distort, eyedropper colour sampling, artboard move.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::Paint;
|
||
use vectorcraft_doc::{Node, NodeKind, Scaling};
|
||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||
|
||
use super::edit::{outermost, selected_roots};
|
||
use super::*;
|
||
use crate::EngineError;
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"object.distort",
|
||
"Free Distort",
|
||
[],
|
||
None,
|
||
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles",
|
||
has_doc,
|
||
distort
|
||
),
|
||
cmd!(
|
||
"paint.sampleColor",
|
||
"Sample Color",
|
||
[],
|
||
None,
|
||
"{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index} sample a colour into the active fill/stroke (and the selection); with `stop`, recolour that stop of the gradient behind the proxy instead (paint.editGradient)",
|
||
has_doc,
|
||
sample_color
|
||
),
|
||
cmd!(
|
||
"eyedropper.setOptions",
|
||
"Eyedropper Options",
|
||
[],
|
||
None,
|
||
"{sampleSize?: 1|3|5 (pixels square averaged when sampling an image: point, 3×3, 5×5), pickUp?, apply?: {appearance?: {transparency?, fill?: {color?, transparency?, overprint?}, stroke?: {color?, transparency?, overprint?, weight?, cap?, join?, miter?, dash?}}, character?, paragraph?} (bools; a bool for a branch sets all of it)} Eyedropper Options, kept in the preferences: what appearance.copyFrom picks up from the clicked object and applies (only attributes in both trees are copied; every fill and stroke attribute copies the whole appearance stack); {} reads them → the options",
|
||
always,
|
||
eyedropper_options
|
||
),
|
||
cmd!(
|
||
"artboard.move",
|
||
"Move Artboard",
|
||
[],
|
||
None,
|
||
"{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} move artboards (and the art fully inside them; locked and hidden art only with prefs moveLockedWithArtboard), several in one step; `copy` (Alt-drag) leaves them and moves copies → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}",
|
||
has_doc,
|
||
artboard_move
|
||
),
|
||
]
|
||
}
|
||
|
||
// ---------- projective distort ----------
|
||
|
||
/// A 2D projective transform (homography) mapping the unit square to a quad.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct Projective {
|
||
m: [f64; 8],
|
||
src: Rect,
|
||
}
|
||
|
||
impl Projective {
|
||
/// Map `src`'s corners (TL, TR, BR, BL) onto `q`.
|
||
pub fn from_rect(src: Rect, q: [Point; 4]) -> Self {
|
||
let [p0, p1, p2, p3] = q;
|
||
let (dx1, dx2, dx3) = (p1.x - p2.x, p3.x - p2.x, p0.x - p1.x + p2.x - p3.x);
|
||
let (dy1, dy2, dy3) = (p1.y - p2.y, p3.y - p2.y, p0.y - p1.y + p2.y - p3.y);
|
||
let (g, h) = if dx3.abs() < 1e-12 && dy3.abs() < 1e-12 {
|
||
(0.0, 0.0)
|
||
} else {
|
||
let den = dx1 * dy2 - dx2 * dy1;
|
||
if den.abs() < 1e-12 { (0.0, 0.0) } else { ((dx3 * dy2 - dx2 * dy3) / den, (dx1 * dy3 - dx3 * dy1) / den) }
|
||
};
|
||
let a = p1.x - p0.x + g * p1.x;
|
||
let b = p3.x - p0.x + h * p3.x;
|
||
let d = p1.y - p0.y + g * p1.y;
|
||
let e = p3.y - p0.y + h * p3.y;
|
||
Self { m: [a, b, p0.x, d, e, p0.y, g, h], src }
|
||
}
|
||
|
||
pub fn apply(&self, p: Point) -> Point {
|
||
let [a, b, c, d, e, f, g, h] = self.m;
|
||
let u = if self.src.width().abs() > 1e-12 { (p.x - self.src.x0) / self.src.width() } else { 0.0 };
|
||
let v = if self.src.height().abs() > 1e-12 { (p.y - self.src.y0) / self.src.height() } else { 0.0 };
|
||
let w = g * u + h * v + 1.0;
|
||
let w = if w.abs() < 1e-9 { 1e-9_f64.copysign(w) } else { w };
|
||
Point::new((a * u + b * v + c) / w, (d * u + e * v + f) / w)
|
||
}
|
||
|
||
/// Affine approximation at a point (for objects that can't be warped: text, images, symbols).
|
||
fn affine_near(&self, p: Point) -> Affine {
|
||
let eps = 1.0;
|
||
let o = self.apply(p);
|
||
let ex = self.apply(p + Vec2::new(eps, 0.0)) - o;
|
||
let ey = self.apply(p + Vec2::new(0.0, eps)) - o;
|
||
let lin = Affine::new([ex.x / eps, ex.y / eps, ey.x / eps, ey.y / eps, 0.0, 0.0]);
|
||
Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2())
|
||
}
|
||
}
|
||
|
||
/// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine
|
||
/// approximation at their centre.
|
||
fn warp_node(n: &mut Node, pr: &Projective) {
|
||
n.pin_gradients();
|
||
match &mut n.kind {
|
||
NodeKind::Path { path, live, .. } => {
|
||
*live = None;
|
||
for sp in &mut path.subpaths {
|
||
for a in &mut sp.anchors {
|
||
a.p = pr.apply(a.p);
|
||
a.h_in = pr.apply(a.h_in);
|
||
a.h_out = pr.apply(a.h_out);
|
||
}
|
||
}
|
||
}
|
||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
|
||
for c in children.iter_mut() {
|
||
warp_node(Arc::make_mut(c), pr);
|
||
}
|
||
}
|
||
_ => {
|
||
// No editable points: the affine approximation (which maps the gradients too).
|
||
if let Some(b) = n.geometric_bounds() {
|
||
n.transform(pr.affine_near(b.center()), false);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
n.appearance.warp_gradients(&|p| pr.affine_near(p));
|
||
}
|
||
|
||
fn distort(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "object.distort";
|
||
// All four corners or nothing, each exactly [x, y]: nothing malformed is left out.
|
||
let corners: Option<Vec<Point>> = p
|
||
.get("corners")
|
||
.and_then(Value::as_array)
|
||
.filter(|a| a.len() == 4)
|
||
.and_then(|a| a.iter().map(|c| finite_numbers(c).map(|[x, y]| Point::new(x, y))).collect());
|
||
let Some(&[tl, tr, br, bl]) = corners.as_deref() else { return Err(bad(C, "corners must be 4 [x, y] points (TL, TR, BR, BL)")) };
|
||
let ids =
|
||
if p.get("ids").is_some_and(|v| !v.is_null()) { outermost(&s.doc()?.doc, checked_ids_param(s, p, "ids", C)?) } else { selected_roots(s)? };
|
||
if ids.is_empty() {
|
||
return Err(bad(C, "nothing to distort"));
|
||
}
|
||
let src = match p.get("from").filter(|v| !v.is_null()) {
|
||
None => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| bad(C, "nothing to distort"))?,
|
||
Some(v) => {
|
||
let [x0, y0, x1, y1] = finite_numbers(v).ok_or_else(|| bad(C, "from must be [x0, y0, x1, y1]"))?;
|
||
Rect::new(x0, y0, x1, y1)
|
||
}
|
||
};
|
||
if !src.width().is_finite() || !src.height().is_finite() || src.width().abs() < 1e-9 || src.height().abs() < 1e-9 {
|
||
return Err(bad(C, "cannot distort a zero-size or non-finite bounding box"));
|
||
}
|
||
let pr = Projective::from_rect(src, [tl, tr, br, bl]);
|
||
s.edit("Free Distort", |d, _| {
|
||
for id in &ids {
|
||
if let Some(n) = d.node_mut(*id) {
|
||
warp_node(n, &pr);
|
||
}
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||
}
|
||
|
||
// ---------- eyedropper ----------
|
||
|
||
/// The focal (topmost) fill's attributes the Eyedropper picks up or applies.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
#[serde(default)]
|
||
pub struct FillAttrs {
|
||
pub color: bool,
|
||
/// Its own opacity and blend mode.
|
||
pub transparency: bool,
|
||
pub overprint: bool,
|
||
}
|
||
|
||
impl FillAttrs {
|
||
pub const ALL: Self = Self { color: true, transparency: true, overprint: true };
|
||
}
|
||
|
||
impl Default for FillAttrs {
|
||
fn default() -> Self {
|
||
Self::ALL
|
||
}
|
||
}
|
||
|
||
/// The focal (topmost) stroke's attributes the Eyedropper picks up or applies.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
#[serde(default)]
|
||
pub struct StrokeAttrs {
|
||
pub color: bool,
|
||
/// Its own opacity and blend mode.
|
||
pub transparency: bool,
|
||
pub overprint: bool,
|
||
pub weight: bool,
|
||
pub cap: bool,
|
||
pub join: bool,
|
||
/// The miter limit.
|
||
pub miter: bool,
|
||
/// The dash pattern.
|
||
pub dash: bool,
|
||
}
|
||
|
||
impl StrokeAttrs {
|
||
pub const ALL: Self = Self { color: true, transparency: true, overprint: true, weight: true, cap: true, join: true, miter: true, dash: true };
|
||
}
|
||
|
||
impl Default for StrokeAttrs {
|
||
fn default() -> Self {
|
||
Self::ALL
|
||
}
|
||
}
|
||
|
||
/// Appearance attributes: the object's transparency (opacity and blend mode) and its focal fill and
|
||
/// stroke. With every fill and stroke attribute, the whole appearance stack (every fill and stroke,
|
||
/// with their effects, and the object's effects) is copied.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
#[serde(default)]
|
||
pub struct AppearanceAttrs {
|
||
pub transparency: bool,
|
||
pub fill: FillAttrs,
|
||
pub stroke: StrokeAttrs,
|
||
}
|
||
|
||
impl Default for AppearanceAttrs {
|
||
fn default() -> Self {
|
||
Self { transparency: true, fill: FillAttrs::ALL, stroke: StrokeAttrs::ALL }
|
||
}
|
||
}
|
||
|
||
/// One of the Eyedropper Options' trees: what it picks up, or what it applies.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
#[serde(default)]
|
||
pub struct EyedropperAttrs {
|
||
pub appearance: AppearanceAttrs,
|
||
/// Type's character attributes (font, size, leading, tracking…; its paints follow `appearance`).
|
||
pub character: bool,
|
||
/// Type's paragraph attributes.
|
||
pub paragraph: bool,
|
||
}
|
||
|
||
impl Default for EyedropperAttrs {
|
||
fn default() -> Self {
|
||
Self { appearance: Default::default(), character: true, paragraph: true }
|
||
}
|
||
}
|
||
|
||
impl EyedropperAttrs {
|
||
/// No attribute at all.
|
||
pub fn is_empty(&self) -> bool {
|
||
fn any(v: &Value) -> bool {
|
||
match v {
|
||
Value::Bool(b) => *b,
|
||
Value::Object(o) => o.values().any(any),
|
||
_ => false,
|
||
}
|
||
}
|
||
!any(&json!(self))
|
||
}
|
||
}
|
||
|
||
/// Eyedropper Options (stored in the preferences): the raster sample size and what the Eyedropper
|
||
/// picks up from the object it clicks and applies to the selection (`appearance.copyFrom`).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct EyedropperOptions {
|
||
/// Pixels square averaged when sampling an image: 1 (point), 3 or 5.
|
||
pub sample_size: u32,
|
||
pub pick_up: EyedropperAttrs,
|
||
pub apply: EyedropperAttrs,
|
||
}
|
||
|
||
impl Default for EyedropperOptions {
|
||
fn default() -> Self {
|
||
Self { sample_size: 1, pick_up: Default::default(), apply: Default::default() }
|
||
}
|
||
}
|
||
|
||
impl EyedropperOptions {
|
||
/// These options with `sampleSize`, `pickUp` and `apply` from `p` applied: a tree given as an
|
||
/// object sets the flags it names, a bool sets every flag of its branch.
|
||
pub(crate) fn merged(&self, p: &Value) -> std::result::Result<Self, String> {
|
||
let mut v = serde_json::to_value(self).map_err(|e| e.to_string())?;
|
||
if let Some(n) = p.get("sampleSize").filter(|n| !n.is_null()) {
|
||
let n = n.as_u64().filter(|n| matches!(n, 1 | 3 | 5)).ok_or("`sampleSize` must be 1, 3 or 5")?;
|
||
v["sampleSize"] = json!(n);
|
||
}
|
||
for key in ["pickUp", "apply"] {
|
||
if let Some(t) = p.get(key).filter(|t| !t.is_null()) {
|
||
merge_flags(&mut v[key], t, key)?;
|
||
}
|
||
}
|
||
serde_json::from_value(v).map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// What one copy takes: the attributes both picked up and applied, with the `pickUp` / `apply`
|
||
/// params of that call merged over the options.
|
||
pub(crate) fn attrs(&self, p: &Value) -> std::result::Result<EyedropperAttrs, String> {
|
||
let o = self.merged(p)?;
|
||
let mut both = json!(o.pick_up);
|
||
and_flags(&mut both, &json!(o.apply));
|
||
serde_json::from_value(both).map_err(|e| e.to_string())
|
||
}
|
||
}
|
||
|
||
/// Set the flags of `dst` named by `src` (`path`: where `dst` is, for errors).
|
||
fn merge_flags(dst: &mut Value, src: &Value, path: &str) -> std::result::Result<(), String> {
|
||
match (dst, src) {
|
||
(Value::Bool(d), Value::Bool(b)) => {
|
||
*d = *b;
|
||
Ok(())
|
||
}
|
||
(Value::Object(d), Value::Bool(_)) => d.values_mut().try_for_each(|v| merge_flags(v, src, path)),
|
||
(Value::Object(d), Value::Object(s)) => s.iter().try_for_each(|(k, v)| match d.get_mut(k) {
|
||
Some(dv) => merge_flags(dv, v, &format!("{path}.{k}")),
|
||
None => Err(format!("unknown option `{path}.{k}`")),
|
||
}),
|
||
_ => Err(format!("`{path}` must be a bool or an object of bools")),
|
||
}
|
||
}
|
||
|
||
/// `dst` and `other` flag by flag.
|
||
fn and_flags(dst: &mut Value, other: &Value) {
|
||
match (dst, other) {
|
||
(Value::Bool(d), Value::Bool(b)) => *d &= *b,
|
||
(Value::Object(d), Value::Object(o)) => d.iter_mut().for_each(|(k, v)| and_flags(v, &o[k])),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn eyedropper_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||
s.prefs.eyedropper = s.prefs.eyedropper.merged(p).map_err(|e| bad("eyedropper.setOptions", e))?;
|
||
Ok(json!(s.prefs.eyedropper))
|
||
}
|
||
|
||
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "paint.sampleColor";
|
||
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active);
|
||
let c = p.get("color").ok_or_else(|| bad(C, "missing color"))?;
|
||
let (id, mut q) = match p.get("stop") {
|
||
None | Some(Value::Null) => (if stroke { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": c })),
|
||
Some(v) => {
|
||
let i = v.as_u64().ok_or_else(|| bad(C, "`stop` must be a stop index"))? as usize;
|
||
let color = color_value(c).ok_or_else(|| bad(C, format!("bad color {c}")))?;
|
||
let (fill, stroke_paint) = s.proxy_paints();
|
||
let Paint::Gradient(g) = (if stroke { stroke_paint } else { fill }) else {
|
||
return Err(bad(C, "the paint behind the proxy is not a gradient"));
|
||
};
|
||
let mut stops = g.gradient.stops;
|
||
let n = stops.len();
|
||
stops.get_mut(i).ok_or_else(|| bad(C, format!("no stop {i} (the gradient has {n})")))?.set_color(color, None);
|
||
("paint.editGradient", json!({ "stops": vectorcraft_tools::params::stops_json(&stops), "stroke": stroke }))
|
||
}
|
||
};
|
||
if let Some(ids) = p.get("ids") {
|
||
q["ids"] = ids.clone();
|
||
}
|
||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||
(spec.run)(s, &q)
|
||
}
|
||
|
||
// ---------- artboards ----------
|
||
|
||
fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.move")?;
|
||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||
let move_art = bool_or(p, "moveArt", false);
|
||
// Each artboard's art, an object counted once (artboards can overlap).
|
||
let mut arts: Vec<(usize, Vec<NodeId>)> = vec![];
|
||
for &i in &indices {
|
||
let art = super::panelcmds::artboard_art(s, i, move_art)?;
|
||
let art = art.into_iter().filter(|id| !arts.iter().any(|(_, a)| a.contains(id))).collect();
|
||
arts.push((i, art));
|
||
}
|
||
let ids: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
|
||
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &ids)?, ..Scaling::default() };
|
||
let copy = bool_or(p, "copy", false);
|
||
let (index, moved) = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| {
|
||
let (mut index, mut moved) = (vec![], vec![]);
|
||
for (i, art) in &arts {
|
||
// A copy keeps the artboard (and its art) where they are and moves duplicates instead.
|
||
if copy {
|
||
let (at, copies) = super::panelcmds::copy_artboard(d, *i, dv, art, sc)?;
|
||
index.push(at);
|
||
moved.extend(copies);
|
||
continue;
|
||
}
|
||
let a = d.artboards.get_mut(*i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||
a.rect = a.rect + dv;
|
||
let id = a.id;
|
||
d.move_artboard_guides(id, dv);
|
||
for id in art {
|
||
if let Some(n) = d.node_mut(*id) {
|
||
n.transform(Affine::translate(dv), sc);
|
||
}
|
||
}
|
||
index.push(*i);
|
||
moved.extend(art.iter().copied());
|
||
}
|
||
Ok((index, moved))
|
||
})?;
|
||
Ok(json!({ "index": index.first(), "indices": index, "moved": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||
}
|