777 lines
32 KiB
Rust
777 lines
32 KiB
Rust
//! Object menu long tail: Lock/Hide All Artwork Above & Other Layers, Transform Each, Reset
|
||
//! Bounding Box, Rasterize, Crop 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 {
|
||
/// Saved selections: (document title, name, object ids).
|
||
pub saved_selections: Vec<(String, String, Vec<NodeId>)>,
|
||
/// 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 → {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 → {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)} 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]} crop the selected image (default: to the artboard it sits on) → {id, width, height}",
|
||
has_image,
|
||
crop_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,
|
||
"{columns?: n (2), spacing?: pt (20), byColumn?: false, moveArtwork?: true} lay artboards out in a grid",
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
let label = if lock { "Lock" } else { "Hide" };
|
||
let n = hits.len();
|
||
s.edit(label, |d, _| {
|
||
for id in &hits {
|
||
if let Some(n) = d.node_mut(*id) {
|
||
if lock {
|
||
n.locked = true;
|
||
} else {
|
||
n.visible = false;
|
||
}
|
||
}
|
||
}
|
||
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();
|
||
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 sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() };
|
||
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")) };
|
||
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();
|
||
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 }))
|
||
}
|
||
|
||
// ---------- 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()?.insertion_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], 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> {
|
||
let n_ab = s.doc()?.doc.artboards.len().max(1);
|
||
let cols = (f64_or(p, "columns", 2.0).clamp(1.0, n_ab as f64)) as usize;
|
||
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
|
||
let by_col = bool_or(p, "byColumn", false);
|
||
let move_art = bool_or(p, "moveArtwork", true);
|
||
let scale_strokes = false;
|
||
s.edit("Rearrange Artboards", |d, _| {
|
||
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
|
||
let Some(first) = rects.first().copied() else { return Ok(()) };
|
||
let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect();
|
||
let origins = super::newdoc::grid_origins(&sizes, first.origin(), cols, spacing, by_col, false);
|
||
let deltas: Vec<Vec2> = rects.iter().zip(origins).map(|(r, o)| o - r.origin()).collect();
|
||
if move_art {
|
||
let tops: Vec<(NodeId, Point)> = d
|
||
.layers
|
||
.iter()
|
||
.flat_map(|l| l.children().into_iter().flatten())
|
||
.filter_map(|n| Some((n.id, n.geometric_bounds()?.center())))
|
||
.collect();
|
||
for (id, c) in tops {
|
||
if let Some(i) = rects.iter().position(|r| r.contains(c))
|
||
&& deltas[i] != Vec2::ZERO
|
||
&& let Some(n) = d.node_mut(id)
|
||
{
|
||
n.transform(Affine::translate(deltas[i]), scale_strokes);
|
||
}
|
||
}
|
||
}
|
||
for (a, dl) in d.artboards.iter_mut().zip(&deltas) {
|
||
a.rect = a.rect + *dl;
|
||
}
|
||
Ok(())
|
||
})?;
|
||
ok()
|
||
}
|