mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 19:27:21 -06:00
These commands edited the document even when nothing changed, so the call recorded an undo step, emptied Redo and marked the document modified: - All Artwork Above and Other Layers (Object › Lock and Object › Hide) with nothing to lock or hide: no object above the selection, no other layer, or every one already locked (Lock) or hidden (Hide). - Transform Each with no object to transform, and without Copy when both scales are 100% and there is no move, rotation or reflection. - Rearrange All Artboards when no artboard moves, or with no artboard. - stroke.widthPoint.set and stroke.widthPoint.copy when the profile comes out as it was, and stroke.widthProfile.set with the profile the strokes have already. - Liquify when the stroke reshapes no path, and Puppet Warp given the pins the art holds already (rest: true), or without rest when every pin stays where it is and turns the art by 0° or is free. - Define Perspective Grid with the grid the document has, Attach to Active Plane with no object, Release with Perspective on art that is not in perspective, Move Plane to where the plane is, without copies, and Move Plane to Match Object when the plane is there and active already. - Move, Nudge and Transform in Perspective without Copy when no object moves (1e-9 pt or less along the plane or its normal, Snap to Grid included, or the identity), and Transform Again after one. These commands now return without editing the document. The replies have the same form as before. Transform Each with Copy and no object to transform no longer empties the selection. Puppet Warp without rest, with every pin where it is and turning the art by 0° or free, no longer turns a live shape into a plain path. All Artwork Above still deselects selected objects that are hidden or locked. Width Point counts a change of 1e-9 or less in a point's place or width factor (its width over half the stroke weight) as none. OK in Width Point Edit with the widths it shows, with or without Adjust Adjoining Width Points, records no undo step. After a move in perspective that moved nothing, Transform Again repeats the identity. One line in the evidence list of docs/target-app-parity.md names the commands.
916 lines
40 KiB
Rust
916 lines
40 KiB
Rust
//! Object menu long tail: Lock/Hide All Artwork Above & Other Layers, Transform Each, Reset
|
||
//! Bounding Box, Rasterize, Crop Image, Mask (an image), Create Trim Marks, Convert to Shape and
|
||
//! Artboards → Convert / Rearrange. (Live blends are in `live.rs`.)
|
||
|
||
use std::sync::Arc;
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::marks::{MarkStyle, TrimMarks};
|
||
use vectorcraft_doc::{Appearance, ImageObject, LiveShape, Node, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings};
|
||
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, Vec2};
|
||
use vectorcraft_render::AntiAlias;
|
||
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat};
|
||
|
||
use super::edit::{duplicate_in, selected_roots};
|
||
use super::*;
|
||
|
||
/// Session-level (not saved) state owned by the menu commands.
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct MenuState {
|
||
/// View → Guides → Lock Guides.
|
||
pub guides_locked: bool,
|
||
/// Transparency panel menu: "New Opacity Masks Are Clipping" turned off.
|
||
pub new_masks_unclipped: bool,
|
||
/// Transparency panel menu: "New Opacity Masks Are Inverted".
|
||
pub new_masks_inverted: bool,
|
||
/// Magic Wand panel settings.
|
||
pub wand: super::wand::WandSettings,
|
||
/// View → Hide Slices.
|
||
pub slices_hidden: bool,
|
||
/// View → Lock Slices.
|
||
pub slices_locked: bool,
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"object.lock.above",
|
||
"All Artwork Above",
|
||
["Object", "Lock"],
|
||
None,
|
||
"{} lock objects in the same layer that are above and overlap the selection, deselecting every selected object left locked or hidden (the key object too) → {count}",
|
||
has_selection,
|
||
|s, _| above(s, true)
|
||
),
|
||
cmd!(
|
||
"object.lock.otherLayers",
|
||
"Other Layers",
|
||
["Object", "Lock"],
|
||
None,
|
||
"{} lock every layer that holds no selected object → {count}",
|
||
has_selection,
|
||
|s, _| other_layers(s, true)
|
||
),
|
||
cmd!(
|
||
"object.hide.above",
|
||
"All Artwork Above",
|
||
["Object", "Hide"],
|
||
None,
|
||
"{} hide objects in the same layer that are above and overlap the selection, deselecting every selected object left hidden or locked (the key object too) → {count}",
|
||
has_selection,
|
||
|s, _| above(s, false)
|
||
),
|
||
cmd!(
|
||
"object.hide.otherLayers",
|
||
"Other Layers",
|
||
["Object", "Hide"],
|
||
None,
|
||
"{} hide every layer that holds no selected object → {count}",
|
||
has_selection,
|
||
|s, _| other_layers(s, false)
|
||
),
|
||
cmd!(
|
||
"object.transformEach",
|
||
"Transform Each…",
|
||
["Object", "Transform"],
|
||
Some("Cmd+Alt+Shift+D"),
|
||
"{scaleH?: % (100), scaleV?: % (100), moveH?: pt, moveV?: pt (down = +), rotate?: deg (counter-clockwise), reflectX?: bool (flip vertically), reflectY?: bool (flip horizontally), random?: bool, seed?: n, reference?: 0..8 (9-point grid, 4 = centre), copy?: bool, strokes?: bool (Scale Strokes & Effects), corners?: bool (Scale Corners; both default to the preferences), patterns?: bool (Transform Patterns; default: prefs transformPatternTiles)} transform every selected object about its own reference point → {ids}",
|
||
has_selection,
|
||
transform_each
|
||
),
|
||
cmd!(
|
||
"object.resetBoundingBox",
|
||
"Reset Bounding Box",
|
||
["Object", "Transform"],
|
||
None,
|
||
"{} square the bounding box of the selected objects to the page again after a rotation (their geometry stays; one undo step, none when no box is rotated) → {changed: objects reset}",
|
||
has_selection,
|
||
reset_bounding_box
|
||
),
|
||
cmd!(
|
||
"object.rasterize",
|
||
"Rasterize…",
|
||
["Object"],
|
||
None,
|
||
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height}",
|
||
has_selection,
|
||
rasterize
|
||
),
|
||
cmd!(
|
||
"object.createObjectMosaic",
|
||
"Create Object Mosaic…",
|
||
["Object"],
|
||
None,
|
||
"{columns=10, rows=10, width?, height? (new size in pt; default the image's), spacingX=0, spacingY=0 (pt between tiles), gray?: bool, deleteRaster?: bool} a group of rectangles coloured by the selected image's average tile colours → {id, tiles}",
|
||
has_image,
|
||
object_mosaic
|
||
),
|
||
cmd!(
|
||
"object.cropImage",
|
||
"Crop Image",
|
||
["Object"],
|
||
None,
|
||
"{rect?: [x, y, width, height] | trim?: true (to the pixels that aren't fully transparent, pixel for pixel; an image with none to cut is left as it is, trimmed: false)} crop the selected image (default: to the artboard it sits on) → {id, width, height, trimmed? (with trim)}",
|
||
has_image,
|
||
crop_image
|
||
),
|
||
cmd!(
|
||
"object.maskImage",
|
||
"Mask",
|
||
[],
|
||
None,
|
||
"{} clip the selected image with a rectangle around it (its own outline, rotated with it) and select that clipping path, whose handles then crop it; one undo step → {id: the clip group, path: the clipping path}",
|
||
has_image,
|
||
mask_image
|
||
),
|
||
cmd!(
|
||
"object.createTrimMarks",
|
||
"Create Trim Marks",
|
||
["Object"],
|
||
None,
|
||
"{style?: \"roman\"|\"japanese\" (default: the japaneseCropMarks preference; japanese: double lines at the trim and bleed edges plus centre marks), allArtboards?: bool (default: true when nothing is selected), offset?: pt (9, roman), length?: pt (18), bleed?: pt (8.5, japanese), weight?: pt (0.3)} trim marks stroked in [Registration] (printing on every plate) around the selection, or around every artboard, one group each, as one undo step → {id: the first group, ids}",
|
||
has_doc,
|
||
trim_marks
|
||
),
|
||
cmd!(
|
||
"object.shape.convertToShape",
|
||
"Convert to Shape",
|
||
["Object", "Shape"],
|
||
None,
|
||
"{} turn paths that are rectangles (any rotation) or axis-aligned ellipses into live shapes → {converted}",
|
||
has_selection,
|
||
convert_to_shape
|
||
),
|
||
cmd!(
|
||
"artboard.convertToArtboards",
|
||
"Convert to Artboards",
|
||
["Object", "Artboards"],
|
||
None,
|
||
"{} turn each selected object's bounds into a new artboard (the objects are removed) → {artboards}",
|
||
has_selection,
|
||
convert_to_artboards
|
||
),
|
||
cmd!(
|
||
"artboard.rearrange",
|
||
"Rearrange All Artboards…",
|
||
["Object", "Artboards"],
|
||
None,
|
||
"{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (locked and hidden art only with prefs moveLockedWithArtboard)} lay the artboards out in their order, the grid's top-left at the first artboard's → {artboards, rows, columns}",
|
||
has_doc,
|
||
rearrange_artboards
|
||
),
|
||
]
|
||
}
|
||
|
||
fn ids_json(ids: &[NodeId]) -> Value {
|
||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||
}
|
||
|
||
/// Run a command's implementation without the enablement check or journaling.
|
||
pub(crate) fn run_raw(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||
let c = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||
(c.run)(s, p)
|
||
}
|
||
|
||
/// Merge the undo entries pushed since the history had `from` entries into one labelled step.
|
||
pub(crate) fn squash(s: &mut Session, from: usize, label: &str) {
|
||
if let Some(st) = s.active_mut() {
|
||
let h = &mut st.history.undo;
|
||
if h.len() > from {
|
||
h.truncate(from + 1);
|
||
h[from].label = label.to_string();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- Reset Bounding Box ----------
|
||
|
||
fn reset_bounding_box(s: &mut Session, _: &Value) -> Result<Value> {
|
||
let ids = selected_roots(s)?;
|
||
let d = &s.doc()?.doc;
|
||
let turned: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.bbox_angle != 0.0)).collect();
|
||
if !turned.is_empty() {
|
||
s.edit("Reset Bounding Box", |d, _| {
|
||
for id in &turned {
|
||
if let Some(n) = d.node_mut(*id) {
|
||
n.bbox_angle = 0.0;
|
||
}
|
||
}
|
||
Ok(())
|
||
})?;
|
||
}
|
||
Ok(json!({ "changed": turned.len() }))
|
||
}
|
||
|
||
// ---------- Lock / Hide ----------
|
||
|
||
fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||
let roots = selected_roots(s)?;
|
||
let d = &s.doc()?.doc;
|
||
let mut hits: Vec<NodeId> = vec![];
|
||
for id in &roots {
|
||
let Some(layer) = d.layer_of(*id) else { continue };
|
||
// The selected object's ancestor directly inside the layer.
|
||
let anc = d.ancestry(*id).unwrap_or_default();
|
||
let Some(pos) = anc.iter().position(|a| *a == layer) else { continue };
|
||
let top = anc.get(pos + 1).copied().unwrap_or(*id);
|
||
let Some(b) = d.node(*id).and_then(|n| n.visual_bounds()) else { continue };
|
||
let Some((_, idx, _)) = d.position(top) else { continue };
|
||
for n in d.children(Some(layer)).into_iter().flatten().skip(idx + 1) {
|
||
if roots.contains(&n.id) || hits.contains(&n.id) {
|
||
continue;
|
||
}
|
||
if n.visual_bounds().is_some_and(|nb| nb.intersect(b).area() > 0.0 || nb.contains(b.center())) {
|
||
hits.push(n.id);
|
||
}
|
||
}
|
||
}
|
||
// When there is no object above, or every one is locked (Lock) or hidden (Hide) already, the
|
||
// document and its undo history stay as they are, and selected objects that are hidden or
|
||
// locked are deselected.
|
||
if hits.iter().all(|id| d.node(*id).is_none_or(|n| if lock { n.locked } else { !n.visible })) {
|
||
s.select(|d, sel| sel.deselect_uneditable(d))?;
|
||
return Ok(json!({ "count": hits.len() }));
|
||
}
|
||
let label = if lock { "Lock" } else { "Hide" };
|
||
let n = hits.len();
|
||
s.edit(label, |d, sel| {
|
||
for id in &hits {
|
||
if let Some(n) = d.node_mut(*id) {
|
||
if lock {
|
||
n.locked = true;
|
||
} else {
|
||
n.visible = false;
|
||
}
|
||
}
|
||
}
|
||
sel.deselect_uneditable(d);
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({ "count": n }))
|
||
}
|
||
|
||
fn other_layers(s: &mut Session, lock: bool) -> Result<Value> {
|
||
let st = s.doc()?;
|
||
let keep: Vec<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.ancestry(*id)).flatten().collect();
|
||
let others: Vec<NodeId> = st.doc.layers.iter().map(|l| l.id).filter(|l| !keep.contains(l)).collect();
|
||
let n = others.len();
|
||
// When there is no other layer, or every one is locked (Lock) or hidden (Hide) already, the
|
||
// document and its undo history stay as they are.
|
||
if others.iter().all(|id| st.doc.node(*id).is_none_or(|l| if lock { l.locked } else { !l.visible })) {
|
||
return Ok(json!({ "count": n }));
|
||
}
|
||
s.edit(if lock { "Lock Others" } else { "Hide Others" }, |d, _| {
|
||
for id in &others {
|
||
if let Some(n) = d.node_mut(*id) {
|
||
if lock {
|
||
n.locked = true;
|
||
} else {
|
||
n.visible = false;
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({ "count": n }))
|
||
}
|
||
|
||
// ---------- Transform Each ----------
|
||
|
||
/// Small deterministic PRNG (xorshift64*) for Transform Each → Random.
|
||
struct Rng(u64);
|
||
impl Rng {
|
||
fn next(&mut self) -> f64 {
|
||
self.0 ^= self.0 >> 12;
|
||
self.0 ^= self.0 << 25;
|
||
self.0 ^= self.0 >> 27;
|
||
(self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64
|
||
}
|
||
}
|
||
|
||
fn about(o: Point, a: Affine) -> Affine {
|
||
Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2())
|
||
}
|
||
|
||
fn transform_each(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let roots = selected_roots(s)?;
|
||
// Bounded so coordinates stay finite (and the document stays serializable).
|
||
let sh = f64_or(p, "scaleH", 100.0).clamp(-100_000.0, 100_000.0);
|
||
let sv = f64_or(p, "scaleV", 100.0).clamp(-100_000.0, 100_000.0);
|
||
let mh = f64_or(p, "moveH", 0.0).clamp(-1.0e7, 1.0e7);
|
||
let mv = f64_or(p, "moveV", 0.0).clamp(-1.0e7, 1.0e7);
|
||
let rot = f64_or(p, "rotate", 0.0);
|
||
let rx = bool_or(p, "reflectX", false);
|
||
let ry = bool_or(p, "reflectY", false);
|
||
let random = bool_or(p, "random", false);
|
||
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize;
|
||
let copy = bool_or(p, "copy", false);
|
||
if sh == 0.0 || sv == 0.0 {
|
||
return Err(bad("object.transformEach", "scale must be non-zero"));
|
||
}
|
||
let mut rng = Rng(p.get("seed").and_then(Value::as_u64).unwrap_or(0x9E37_79B9_7F4A_7C15).max(1));
|
||
let mut sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() };
|
||
sc.patterns = super::object::transform_patterns(s, p, &roots)?;
|
||
// With no object to transform, or without a copy when both scales are 100% and there is no
|
||
// move, rotation or reflection (with or without Random), the document and its undo history
|
||
// stay as they are.
|
||
if roots.is_empty() || (!copy && sh == 100.0 && sv == 100.0 && mh == 0.0 && mv == 0.0 && rot == 0.0 && !rx && !ry) {
|
||
return Ok(ids_json(&roots));
|
||
}
|
||
let ids = s.edit("Transform Each", |d, sel| {
|
||
let targets = if copy { duplicate_in(d, sel, &roots, Affine::IDENTITY)? } else { roots.clone() };
|
||
for id in &targets {
|
||
let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue };
|
||
let (mut k_sh, mut k_sv, mut k_mh, mut k_mv, mut k_rot) = (sh, sv, mh, mv, rot);
|
||
if random {
|
||
k_sh = 100.0 + (sh - 100.0) * rng.next();
|
||
k_sv = 100.0 + (sv - 100.0) * rng.next();
|
||
k_mh = mh * (rng.next() * 2.0 - 1.0);
|
||
k_mv = mv * (rng.next() * 2.0 - 1.0);
|
||
k_rot = rot * (rng.next() * 2.0 - 1.0);
|
||
}
|
||
let o = vectorcraft_geom::reference_point(b, refi);
|
||
let refl = Affine::scale_non_uniform(if ry { -1.0 } else { 1.0 }, if rx { -1.0 } else { 1.0 });
|
||
let m = Affine::translate((k_mh, k_mv))
|
||
* about(o, Affine::rotate(-k_rot.to_radians()) * refl * Affine::scale_non_uniform(k_sh / 100.0, k_sv / 100.0));
|
||
if let Some(n) = d.node_mut(*id) {
|
||
n.transform(m, sc);
|
||
}
|
||
}
|
||
Ok(targets)
|
||
})?;
|
||
Ok(ids_json(&ids))
|
||
}
|
||
|
||
// ---------- Rasterize / Crop ----------
|
||
|
||
pub(crate) const MAX_PIXELS: f64 = 64.0e6;
|
||
|
||
/// A copy of `doc` whose only content is `nodes` (paint order), for offscreen rendering.
|
||
pub(crate) fn isolated_doc(doc: &vectorcraft_doc::Document, nodes: Vec<Node>) -> vectorcraft_doc::Document {
|
||
let mut tmp = doc.clone();
|
||
let mut layer = Node::layer(NodeId(u64::MAX), "raster", vectorcraft_doc::LayerColor::Preset(0));
|
||
if let Some(ch) = layer.children_mut() {
|
||
*ch = nodes.into_iter().map(Arc::new).collect();
|
||
}
|
||
tmp.layers = vec![Arc::new(layer)];
|
||
tmp
|
||
}
|
||
|
||
pub(crate) fn unique_key(d: &vectorcraft_doc::Document, stem: &str) -> String {
|
||
let mut i = d.images.len() + 1;
|
||
loop {
|
||
let k = format!("{stem}-{i}");
|
||
if !d.images.contains_key(&k) {
|
||
return k;
|
||
}
|
||
i += 1;
|
||
}
|
||
}
|
||
|
||
/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`.
|
||
fn render_png(
|
||
doc: &vectorcraft_doc::Document,
|
||
region: Rect,
|
||
scale: f64,
|
||
opts: &vectorcraft_render::RenderOptions,
|
||
color: RasterColorModel,
|
||
) -> Result<(Vec<u8>, u32, u32)> {
|
||
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 {
|
||
return Err(EngineError::Other("image would be too large; lower the resolution".into()));
|
||
}
|
||
let mut r = vectorcraft_render::Renderer::new();
|
||
let mut img = r.render_region_with(doc, region, scale, opts);
|
||
RasterEffectsSettings { color_model: color, ..Default::default() }.finish_pixels(&mut img.pixels);
|
||
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height))
|
||
}
|
||
|
||
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "object.rasterize";
|
||
let roots = selected_roots(s)?;
|
||
let st = s.doc()?;
|
||
let ppi = f64_or(p, "ppi", st.doc.raster_effects_ppi);
|
||
if !(1.0..=2400.0).contains(&ppi) {
|
||
return Err(bad(C, "ppi must be between 1 and 2400"));
|
||
}
|
||
// The document's raster effects settings, changed by the params.
|
||
let mut look = st.doc.raster_effects.clone();
|
||
let mut background = (look.background == vectorcraft_doc::Background::White).then_some([255; 3]);
|
||
let mut anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None };
|
||
for (k, v) in p.as_object().into_iter().flatten().filter(|(_, v)| !v.is_null()) {
|
||
match k.as_str() {
|
||
"background" => background = fileio::background(v).map_err(|e| bad(C, e))?,
|
||
"antiAlias" => {
|
||
anti_alias = match v {
|
||
Value::Bool(b) => {
|
||
if *b {
|
||
AntiAlias::Art
|
||
} else {
|
||
AntiAlias::None
|
||
}
|
||
}
|
||
v => fileio::anti_alias(v.as_str().unwrap_or_default()).map_err(|e| bad(C, e))?,
|
||
}
|
||
}
|
||
"padding" | "addAround" => look.add_around = super::rasterfx::add_around(v, C, k)?,
|
||
"colorModel" => look.color_model = super::rasterfx::color_model(v, st.doc.color_mode, C)?,
|
||
"clippingMask" => look.clipping_mask = v.as_bool().ok_or_else(|| bad(C, "clippingMask must be true or false"))?,
|
||
_ => {}
|
||
}
|
||
}
|
||
let raster = RasterExportOptions { ppi, background, anti_alias, ..Default::default() };
|
||
let pad = look.add_around;
|
||
let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "selection has no bounds"))?.inflate(pad, pad);
|
||
let scale = ppi / 72.0;
|
||
// Snap to whole pixels.
|
||
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
|
||
let nodes: Vec<Node> = roots
|
||
.iter()
|
||
.filter_map(|id| st.doc.node(*id).cloned())
|
||
.map(|mut n| {
|
||
n.visible = true;
|
||
n
|
||
})
|
||
.collect();
|
||
// Create Clipping Mask: the image is clipped to the art's outline.
|
||
let clip = look
|
||
.clipping_mask
|
||
.then(|| vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect())))
|
||
.flatten()
|
||
.map(|(bp, rule)| (PathData::from_bezpath(&bp), rule));
|
||
let tmp = isolated_doc(&st.doc, nodes);
|
||
let (png, w, h) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
|
||
let top = *roots.last().ok_or_else(|| bad(C, "nothing selected"))?;
|
||
let id = s.edit("Rasterize", |d, sel| {
|
||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||
let key = unique_key(d, "raster");
|
||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||
let id = d.alloc_id();
|
||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||
let mut node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }));
|
||
if let Some((path, rule)) = clip {
|
||
node = super::rasterfx::clip_group(d, path, rule, node);
|
||
}
|
||
let id = node.id;
|
||
d.insert(par, idx + 1, node)?;
|
||
for r in &roots {
|
||
d.remove(*r)?;
|
||
}
|
||
sel.set([id]);
|
||
Ok(id)
|
||
})?;
|
||
Ok(json!({ "id": id.0, "width": w, "height": h }))
|
||
}
|
||
|
||
fn has_image(s: &Session) -> std::result::Result<(), String> {
|
||
has_selection(s)?;
|
||
let st = s.active().ok_or("no document open")?;
|
||
if st.selection.objects.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))) {
|
||
Ok(())
|
||
} else {
|
||
Err("select an image".into())
|
||
}
|
||
}
|
||
|
||
fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "object.createObjectMosaic";
|
||
let st = s.doc()?;
|
||
let id = st
|
||
.selection
|
||
.objects
|
||
.iter()
|
||
.copied()
|
||
.find(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_))))
|
||
.ok_or_else(|| bad(C, "select an image"))?;
|
||
let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?;
|
||
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
|
||
let blob = st.doc.images.get(&im.key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| bad(C, "the image has no embedded pixels"))?;
|
||
let raster = vectorcraft_trace::Raster::decode(&blob.bytes).map_err(|e| bad(C, e.to_string()))?;
|
||
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32;
|
||
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32;
|
||
let gray = bool_or(p, "gray", false);
|
||
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
|
||
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
|
||
let b = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
|
||
let (nw, nh) = (f64_or(p, "width", b.width()).max(0.1), f64_or(p, "height", b.height()).max(0.1));
|
||
let resize = Affine::translate(b.origin().to_vec2())
|
||
* Affine::scale_non_uniform(nw / b.width().max(1e-9), nh / b.height().max(1e-9))
|
||
* Affine::translate(-b.origin().to_vec2());
|
||
let xf = resize * im.xf;
|
||
let (tw, th) = (im.width as f64 / cols as f64, im.height as f64 / rows as f64);
|
||
// Spacing is in points; convert to image pixels along each axis.
|
||
let ppx = (xf * Point::new(1.0, 0.0) - xf * Point::ZERO).hypot().max(1e-9);
|
||
let ppy = (xf * Point::new(0.0, 1.0) - xf * Point::ZERO).hypot().max(1e-9);
|
||
let (gx, gy) = ((f64_or(p, "spacingX", 0.0).max(0.0) / ppx).min(tw * 0.9), (f64_or(p, "spacingY", 0.0).max(0.0) / ppy).min(th * 0.9));
|
||
let delete = bool_or(p, "deleteRaster", false);
|
||
let (gid, n) = s.edit("Create Object Mosaic", |d, sel| {
|
||
let mut tiles = vec![];
|
||
for r in 0..rows {
|
||
for c in 0..cols {
|
||
let [cr, cg, cb, ca] = colors[(r * cols + c) as usize];
|
||
if ca == 0 {
|
||
continue;
|
||
}
|
||
let x0 = c as f64 * tw + gx / 2.0;
|
||
let y0 = r as f64 * th + gy / 2.0;
|
||
let quad = [Point::new(x0, y0), Point::new(x0 + tw - gx, y0), Point::new(x0 + tw - gx, y0 + th - gy), Point::new(x0, y0 + th - gy)]
|
||
.map(|q| xf * q);
|
||
let color = if gray {
|
||
let l = (0.299 * cr as f32 + 0.587 * cg as f32 + 0.114 * cb as f32) / 255.0;
|
||
Color::rgb(l, l, l)
|
||
} else {
|
||
Color::rgb(cr as f32 / 255.0, cg as f32 / 255.0, cb as f32 / 255.0)
|
||
};
|
||
let mut tile = Node::path(d.alloc_id(), polygon_path(&quad), Appearance::basic(Paint::solid(color), Paint::None, 0.0));
|
||
if ca < 255 {
|
||
tile.opacity = ca as f32 / 255.0;
|
||
}
|
||
tiles.push(Arc::new(tile));
|
||
}
|
||
}
|
||
let n = tiles.len();
|
||
let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||
let gid = d.alloc_id();
|
||
let mut g = Node::group(gid, tiles);
|
||
g.name = Some("Object Mosaic".into());
|
||
d.insert(par, idx + 1, g)?;
|
||
if delete {
|
||
d.remove(id)?;
|
||
}
|
||
sel.set([gid]);
|
||
Ok((gid, n))
|
||
})?;
|
||
Ok(json!({ "id": gid.0, "tiles": n }))
|
||
}
|
||
|
||
fn polygon_path(pts: &[Point]) -> PathData {
|
||
PathData::single(vectorcraft_geom::SubPath::new(pts.iter().map(|p| Anchor::corner(*p)).collect(), true))
|
||
}
|
||
|
||
fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "object.cropImage";
|
||
let st = s.doc()?;
|
||
let id = st
|
||
.selection
|
||
.objects
|
||
.iter()
|
||
.copied()
|
||
.find(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_))))
|
||
.ok_or_else(|| bad(C, "select an image"))?;
|
||
let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?;
|
||
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
|
||
if p.get("trim").and_then(Value::as_bool) == Some(true) {
|
||
return trim_image(s, id, im.clone());
|
||
}
|
||
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
|
||
let rect = match p.get("rect").and_then(Value::as_array) {
|
||
Some(a) if a.len() == 4 => {
|
||
let v: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
|
||
if v.len() != 4 {
|
||
return Err(bad(C, "rect must be [x, y, width, height]"));
|
||
}
|
||
Rect::new(v[0], v[1], v[0] + v[2], v[1] + v[3]).abs()
|
||
}
|
||
Some(_) => return Err(bad(C, "rect must be [x, y, width, height]")),
|
||
None => {
|
||
let ab = st.doc.artboard_at(ib.center()).and_then(|i| st.doc.artboards.get(i)).map(|a| a.rect);
|
||
match ab {
|
||
Some(r) => r,
|
||
None => return Err(bad(C, "the image is not on an artboard; pass rect")),
|
||
}
|
||
}
|
||
};
|
||
let r = rect.intersect(ib);
|
||
if r.width() <= 0.0 || r.height() <= 0.0 {
|
||
return Err(bad(C, "crop rectangle doesn't overlap the image"));
|
||
}
|
||
if (r.x0 - ib.x0).abs() < 1e-6 && (r.y0 - ib.y0).abs() < 1e-6 && (r.x1 - ib.x1).abs() < 1e-6 && (r.y1 - ib.y1).abs() < 1e-6 {
|
||
return Err(bad(C, "nothing to crop"));
|
||
}
|
||
// Keep the image's pixel density.
|
||
let scale = (im.width as f64 / ib.width().max(1e-9)).max(im.height as f64 / ib.height().max(1e-9));
|
||
let mut bare = node.clone();
|
||
bare.opacity = 1.0;
|
||
bare.blend = Default::default();
|
||
bare.appearance = Appearance::default().into();
|
||
bare.visible = true;
|
||
let tmp = isolated_doc(&st.doc, vec![bare]);
|
||
let region = Rect::new(r.x0, r.y0, r.x0 + (r.width() * scale).round().max(1.0) / scale, r.y0 + (r.height() * scale).round().max(1.0) / scale);
|
||
let (png, w, h) = render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?;
|
||
s.edit("Crop Image", |d, _| {
|
||
let key = unique_key(d, "crop");
|
||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||
if let Some(n) = d.node_mut(id) {
|
||
n.kind = NodeKind::Image(ImageObject {
|
||
key,
|
||
width: w,
|
||
height: h,
|
||
xf: Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale),
|
||
link: None,
|
||
placement: Default::default(),
|
||
});
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({ "id": id.0, "width": w, "height": h }))
|
||
}
|
||
|
||
/// Crop Image's trim: the image cut to the box of its pixels that aren't fully transparent, the
|
||
/// pixels copied as they are (no resampling) and placed where they were.
|
||
fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||
const C: &str = "object.cropImage";
|
||
let st = s.doc()?;
|
||
let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?;
|
||
let px = image::load_from_memory(&blob.bytes).map_err(|_| bad(C, "the image's pixels can't be read"))?.to_rgba8();
|
||
let (w, h) = px.dimensions();
|
||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||
for (x, y, p) in px.enumerate_pixels() {
|
||
if p[3] > 0 {
|
||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
|
||
}
|
||
}
|
||
if x1 <= x0 || y1 <= y0 {
|
||
return Err(bad(C, "the image is fully transparent"));
|
||
}
|
||
if (x0, y0, x1, y1) == (0, 0, w, h) {
|
||
// Already trimmed: not an error, so a script can trim every image it opens.
|
||
return Ok(json!({ "id": id.0, "width": w, "height": h, "trimmed": false }));
|
||
}
|
||
let cut = image::imageops::crop_imm(&px, x0, y0, x1 - x0, y1 - y0).to_image();
|
||
let mut png = Vec::new();
|
||
cut.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| bad(C, e.to_string()))?;
|
||
let (cw, ch) = (x1 - x0, y1 - y0);
|
||
s.edit("Crop Image", |d, _| {
|
||
let key = unique_key(d, "crop");
|
||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||
if let Some(n) = d.node_mut(id) {
|
||
n.kind = NodeKind::Image(ImageObject {
|
||
key,
|
||
width: cw,
|
||
height: ch,
|
||
xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))),
|
||
link: None,
|
||
placement: Default::default(),
|
||
});
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({ "id": id.0, "width": cw, "height": ch, "trimmed": true }))
|
||
}
|
||
|
||
/// The Control bar's Mask for an image: a clip group of the image and a clipping path on its
|
||
/// outline, with that path selected so dragging its handles crops the image.
|
||
fn mask_image(s: &mut Session, _: &Value) -> Result<Value> {
|
||
let st = s.doc()?;
|
||
let (id, outline) = st
|
||
.selection
|
||
.objects
|
||
.iter()
|
||
.find_map(|id| match st.doc.node(*id).map(|n| &n.kind) {
|
||
Some(NodeKind::Image(im)) => {
|
||
Some((*id, vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf)))
|
||
}
|
||
_ => None,
|
||
})
|
||
.ok_or_else(|| bad("object.maskImage", "select an image"))?;
|
||
let (gid, pid) = s.edit("Mask", |d, sel| {
|
||
let mut clip = super::pathops::shape_node(d, outline, None);
|
||
super::object::as_clipping_path(&mut clip)?;
|
||
let pid = clip.id;
|
||
let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||
let gid = d.alloc_id();
|
||
d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![], clip: true }))?;
|
||
d.insert(Some(gid), 0, clip)?;
|
||
d.move_node(id, Some(gid), usize::MAX)?;
|
||
sel.set([pid]);
|
||
Ok((gid, pid))
|
||
})?;
|
||
Ok(json!({ "id": gid.0, "path": pid.0 }))
|
||
}
|
||
|
||
// ---------- Trim marks ----------
|
||
|
||
impl Session {
|
||
/// The trim mark style the preferences ask for (General → Use Japanese Crop Marks).
|
||
pub fn crop_mark_style(&self) -> MarkStyle {
|
||
if self.prefs.japanese_crop_marks { MarkStyle::Japanese } else { MarkStyle::Roman }
|
||
}
|
||
}
|
||
|
||
fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "object.createTrimMarks";
|
||
let style = match str_param(p, "style") {
|
||
Some(v) => MarkStyle::parse(v).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?,
|
||
None => s.crop_mark_style(),
|
||
};
|
||
let d = TrimMarks::default();
|
||
let marks = TrimMarks {
|
||
style,
|
||
offset: f64_or(p, "offset", d.offset).max(0.0),
|
||
length: f64_or(p, "length", d.length).max(0.1),
|
||
bleed: f64_or(p, "bleed", d.bleed).max(0.0),
|
||
weight: f64_or(p, "weight", d.weight).max(0.01),
|
||
};
|
||
let st = s.doc()?;
|
||
let rects: Vec<Rect> = if bool_or(p, "allArtboards", st.selection.is_empty()) {
|
||
st.doc.artboards.iter().map(|a| a.rect).collect()
|
||
} else {
|
||
st.doc.bounds_of(&selected_roots(s)?, true).into_iter().collect()
|
||
};
|
||
if rects.is_empty() {
|
||
return Err(bad(C, "nothing to mark"));
|
||
}
|
||
let parent = s.doc()?.target_parent()?;
|
||
let ids = s.edit("Create Trim Marks", |d, sel| {
|
||
let mut ids = vec![];
|
||
for r in &rects {
|
||
let g = marks.group(*r, &mut || d.alloc_id());
|
||
ids.push(g.id);
|
||
d.insert(parent, usize::MAX, g)?;
|
||
}
|
||
sel.set(ids.iter().copied());
|
||
Ok(ids)
|
||
})?;
|
||
Ok(json!({ "id": ids[0].0, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||
}
|
||
|
||
// ---------- Convert to Shape ----------
|
||
|
||
fn no_handles(a: &Anchor) -> bool {
|
||
!a.has_in() && !a.has_out()
|
||
}
|
||
|
||
/// Recognise a rectangle (any rotation) or an axis-aligned ellipse.
|
||
pub(crate) fn detect_shape(path: &PathData) -> Option<LiveShape> {
|
||
let [sp] = path.subpaths.as_slice() else { return None };
|
||
if !sp.closed || sp.anchors.len() != 4 {
|
||
return None;
|
||
}
|
||
let a = &sp.anchors;
|
||
let size = path.bounds()?.size();
|
||
let tol = 1e-3 * size.width.max(size.height).max(1.0);
|
||
if a.iter().all(no_handles) {
|
||
let e0 = a[1].p - a[0].p;
|
||
let e1 = a[2].p - a[1].p;
|
||
let e2 = a[3].p - a[2].p;
|
||
let (w, h) = (e0.hypot(), e1.hypot());
|
||
if w < 1e-9 || h < 1e-9 {
|
||
return None;
|
||
}
|
||
if e0.dot(e1).abs() > tol * (w + h) || (e2 + e0).hypot() > tol {
|
||
return None;
|
||
}
|
||
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], kinds: Default::default(), xf: Affine::translate(a[0].p.to_vec2()) * rot * flip });
|
||
}
|
||
let b = path.bounds()?;
|
||
let c = b.center();
|
||
let mids = [Point::new(c.x, b.y0), Point::new(b.x1, c.y), Point::new(c.x, b.y1), Point::new(b.x0, c.y)];
|
||
let k = 0.5523;
|
||
for an in a {
|
||
if !mids.iter().any(|m| (an.p - *m).hypot() < tol) {
|
||
return None;
|
||
}
|
||
// Handles tangent to the bounds and ~κ·radius long.
|
||
let horizontal = (an.p.y - b.y0).abs() < tol || (an.p.y - b.y1).abs() < tol;
|
||
let want = if horizontal { k * b.width() / 2.0 } else { k * b.height() / 2.0 };
|
||
for hnd in [an.h_in, an.h_out] {
|
||
let v = hnd - an.p;
|
||
let (along, across) = if horizontal { (v.x.abs(), v.y.abs()) } else { (v.y.abs(), v.x.abs()) };
|
||
if across > tol * 10.0 || (along - want).abs() > want * 0.05 + tol {
|
||
return None;
|
||
}
|
||
}
|
||
}
|
||
Some(LiveShape::Ellipse { w: b.width(), h: b.height(), pie: (0.0, 360.0), xf: Affine::translate(b.origin().to_vec2()) })
|
||
}
|
||
|
||
fn convert_to_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||
let roots = selected_roots(s)?;
|
||
let n = s.edit("Convert to Shape", |d, _| {
|
||
let mut n = 0;
|
||
for id in &roots {
|
||
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||
if live.is_some() {
|
||
continue;
|
||
}
|
||
if let Some(l) = detect_shape(path) {
|
||
*path = l.to_path();
|
||
*live = Some(l);
|
||
n += 1;
|
||
}
|
||
}
|
||
if n == 0 {
|
||
return Err(EngineError::Other("Convert to Shape: no rectangles or ellipses selected".into()));
|
||
}
|
||
Ok(n)
|
||
})?;
|
||
Ok(json!({ "converted": n }))
|
||
}
|
||
|
||
// ---------- Artboards ----------
|
||
|
||
fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||
let roots = selected_roots(s)?;
|
||
let n = s.edit("Convert to Artboards", |d, sel| {
|
||
let mut n = 0;
|
||
for id in &roots {
|
||
let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue };
|
||
if b.width() <= 0.0 || b.height() <= 0.0 {
|
||
continue;
|
||
}
|
||
let aid = d.next_artboard_id();
|
||
d.artboards.push(vectorcraft_doc::Artboard {
|
||
id: aid,
|
||
name: format!("Artboard {aid}"),
|
||
rect: b,
|
||
show_center_mark: false,
|
||
show_cross_hairs: false,
|
||
});
|
||
d.remove(*id)?;
|
||
n += 1;
|
||
}
|
||
if n == 0 {
|
||
return Err(EngineError::Other("Convert to Artboards: selection has no area".into()));
|
||
}
|
||
sel.clear();
|
||
Ok(n)
|
||
})?;
|
||
Ok(json!({ "artboards": n }))
|
||
}
|
||
|
||
fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "artboard.rearrange";
|
||
use super::newdoc::ArtboardLayout as L;
|
||
// A value given (not null) must be one of the names.
|
||
let named = |key: &str| p.get(key).filter(|v| !v.is_null()).map(|v| v.as_str().unwrap_or_default());
|
||
let layout = match named("layout") {
|
||
Some(l) => L::parse(l).ok_or_else(|| bad(CMD, "layout must be gridByRow, gridByColumn, row or column"))?,
|
||
None if bool_or(p, "byColumn", false) => L::GridByColumn,
|
||
None => L::GridByRow,
|
||
};
|
||
let rtl = match named("order") {
|
||
None => false,
|
||
Some(o) if o.eq_ignore_ascii_case("leftToRight") => false,
|
||
Some(o) if o.eq_ignore_ascii_case("rightToLeft") => true,
|
||
Some(_) => return Err(bad(CMD, "order must be leftToRight or rightToLeft")),
|
||
};
|
||
let count = s.doc()?.doc.artboards.len();
|
||
let n_ab = count.max(1);
|
||
// Columns of a grid by row, rows of a grid by column (NaN casts to 0).
|
||
let lines = (f64_or(p, "columns", 2.0).clamp(1.0, n_ab as f64) as usize).max(1);
|
||
let (rows, cols) = match layout {
|
||
L::GridByRow => (n_ab.div_ceil(lines), lines),
|
||
L::GridByColumn => (lines, n_ab.div_ceil(lines)),
|
||
L::Row => (1, n_ab),
|
||
L::Column => (n_ab, 1),
|
||
};
|
||
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
|
||
let move_art = bool_or(p, "moveArtwork", true);
|
||
let locked_and_hidden = s.prefs.move_locked_with_artboard;
|
||
let scale_strokes = false;
|
||
// How far each artboard moves: the grid's top-left is at the first artboard's.
|
||
let rects: Vec<Rect> = s.doc()?.doc.artboards.iter().map(|a| a.rect).collect();
|
||
let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect();
|
||
let origin = rects.first().map_or(Point::ZERO, |r| r.origin());
|
||
let origins = super::newdoc::grid_origins_rc(&sizes, origin, (rows, cols), spacing, layout == L::GridByColumn, rtl);
|
||
let deltas: Vec<Vec2> = rects.iter().zip(origins).map(|(r, o)| o - r.origin()).collect();
|
||
// When no artboard moves more than 1e-9 pt on either axis, or there is none, the document and
|
||
// its undo history stay as they are.
|
||
if deltas.iter().all(|dl| dl.x.abs() <= 1e-9 && dl.y.abs() <= 1e-9) {
|
||
return Ok(json!({ "artboards": count, "rows": rows, "columns": cols }));
|
||
}
|
||
s.edit("Rearrange Artboards", |d, _| {
|
||
if move_art {
|
||
let tops: Vec<(NodeId, Point)> = d
|
||
.layers
|
||
.iter()
|
||
.filter(|l| l.rides_with_artboard(locked_and_hidden))
|
||
.flat_map(|l| l.children().into_iter().flatten())
|
||
.filter(|n| n.rides_with_artboard(locked_and_hidden))
|
||
.filter_map(|n| Some((n.id, n.geometric_bounds()?.center())))
|
||
.collect();
|
||
for (id, c) in tops {
|
||
if let Some(&dl) = rects.iter().position(|r| r.contains(c)).and_then(|i| deltas.get(i))
|
||
&& dl != Vec2::ZERO
|
||
&& let Some(n) = d.node_mut(id)
|
||
{
|
||
n.transform(Affine::translate(dl), scale_strokes);
|
||
}
|
||
}
|
||
}
|
||
let mut moved = vec![];
|
||
for (a, dl) in d.artboards.iter_mut().zip(&deltas) {
|
||
a.rect = a.rect + *dl;
|
||
moved.push((a.id, *dl));
|
||
}
|
||
for (id, dl) in moved {
|
||
d.move_artboard_guides(id, dl);
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({ "artboards": count, "rows": rows, "columns": cols }))
|
||
}
|