mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
706 lines
27 KiB
Rust
706 lines
27 KiB
Rust
//! Object menu long tail: Lock/Hide All Artwork Above & Other Layers, Transform Each, Reset
|
|
//! Bounding Box, Expand, Rasterize, Crop Image, Create Trim Marks, Convert to Shape and
|
|
//! Artboards → Convert / Rearrange. (Live blends are in `live.rs`.)
|
|
|
|
use std::sync::Arc;
|
|
|
|
use drawcraft_color::{Color, Paint};
|
|
use drawcraft_doc::{Appearance, ImageObject, LiveShape, Node, NodeId, NodeKind};
|
|
use drawcraft_geom::{Affine, Anchor, PathData, Point, Rect, Vec2, shapes};
|
|
use serde_json::{Value, json};
|
|
|
|
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,
|
|
}
|
|
|
|
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} transform every selected object about its own reference point → {ids}",
|
|
has_selection,
|
|
transform_each
|
|
),
|
|
cmd!(
|
|
"object.resetBoundingBox",
|
|
"Reset Bounding Box",
|
|
["Object", "Transform"],
|
|
None,
|
|
"{} no-op: DrawCraft bounding boxes are always axis-aligned to the document → {changed: 0}",
|
|
has_selection,
|
|
|_, _| Ok(json!({ "changed": 0 }))
|
|
),
|
|
cmd!(
|
|
"object.expand",
|
|
"Expand…",
|
|
["Object"],
|
|
None,
|
|
"{object?: true (text → outlines, live shapes → paths, effects baked), fill?: true, stroke?: false (strokes → filled outlines)} one undo step → {ids}",
|
|
has_selection,
|
|
expand
|
|
),
|
|
cmd!(
|
|
"object.rasterize",
|
|
"Rasterize…",
|
|
["Object"],
|
|
None,
|
|
"{ppi?: (document raster effects resolution), background?: \"transparent\"|\"white\", padding?: pt} replace the selection with an embedded PNG image → {id, width, height}",
|
|
has_selection,
|
|
rasterize
|
|
),
|
|
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,
|
|
"{offset?: pt (9), length?: pt (18), weight?: pt (0.3)} trim marks around the selection (or the first artboard) as a group → {id}",
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- 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 scale_strokes = s.prefs.scale_strokes;
|
|
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 = drawcraft_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, scale_strokes);
|
|
}
|
|
}
|
|
Ok(targets)
|
|
})?;
|
|
Ok(ids_json(&ids))
|
|
}
|
|
|
|
// ---------- Expand ----------
|
|
|
|
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let object = bool_or(p, "object", true);
|
|
let fill = bool_or(p, "fill", true);
|
|
let stroke = bool_or(p, "stroke", false);
|
|
let from = s.doc()?.history.undo.len();
|
|
let rev0 = s.doc()?.revision;
|
|
let roots = selected_roots(s)?;
|
|
let d = &s.doc()?.doc;
|
|
let (mut has_text, mut has_fx, mut has_live, mut has_stroke) = (false, false, false, false);
|
|
for r in &roots {
|
|
if let Some(n) = d.node(*r) {
|
|
n.walk(&mut |c| {
|
|
has_text |= matches!(c.kind, NodeKind::Text(_));
|
|
has_live |= matches!(c.kind, NodeKind::Path { live: Some(_), .. });
|
|
has_fx |= !c.appearance.effects.is_empty();
|
|
has_stroke |= matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !c.appearance.stroke_paint().is_none();
|
|
});
|
|
}
|
|
}
|
|
if object && has_fx {
|
|
let _ = run_raw(s, "effect.expandAppearance", &json!({}));
|
|
}
|
|
if object && has_live {
|
|
let roots = selected_roots(s)?;
|
|
s.edit("Expand", |d, _| {
|
|
for r in &roots {
|
|
if let Some(n) = d.node_mut(*r) {
|
|
drop_live(n);
|
|
}
|
|
}
|
|
Ok(())
|
|
})?;
|
|
}
|
|
if object && has_text {
|
|
let _ = run_raw(s, "type.createOutlines", &json!({}));
|
|
}
|
|
if stroke && has_stroke {
|
|
let _ = run_raw(s, "object.path.outlineStroke", &json!({}));
|
|
}
|
|
if !fill {
|
|
// Fill unchecked: strokes only survive (Illustrator expands just the selected attributes).
|
|
}
|
|
if s.doc()?.revision == rev0 {
|
|
return Err(EngineError::Other("Expand: nothing to expand".into()));
|
|
}
|
|
squash(s, from, "Expand");
|
|
let ids = s.doc()?.selection.objects.clone();
|
|
Ok(ids_json(&ids))
|
|
}
|
|
|
|
fn drop_live(n: &mut Node) {
|
|
if let NodeKind::Path { live, .. } = &mut n.kind {
|
|
*live = None;
|
|
}
|
|
if let Some(ch) = n.children_mut() {
|
|
for c in ch.iter_mut() {
|
|
drop_live(Arc::make_mut(c));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- Rasterize / Crop ----------
|
|
|
|
const MAX_PIXELS: f64 = 64.0e6;
|
|
|
|
/// A copy of `doc` whose only content is `nodes` (paint order), for offscreen rendering.
|
|
fn isolated_doc(doc: &drawcraft_doc::Document, nodes: Vec<Node>) -> drawcraft_doc::Document {
|
|
let mut tmp = doc.clone();
|
|
let mut layer = Node::layer(NodeId(u64::MAX), "raster", drawcraft_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
|
|
}
|
|
|
|
fn unique_key(d: &drawcraft_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;
|
|
}
|
|
}
|
|
|
|
fn render_png(doc: &drawcraft_doc::Document, region: Rect, scale: f64, white: bool) -> 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 = drawcraft_render::Renderer::new();
|
|
let img = r.render_region(doc, region, scale, white);
|
|
Ok((img.to_png(), 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"));
|
|
}
|
|
let pad = f64_or(p, "padding", 0.0).max(0.0);
|
|
let white = str_param(p, "background") == Some("white");
|
|
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();
|
|
let tmp = isolated_doc(&st.doc, nodes);
|
|
let (png, w, h) = render_png(&tmp, region, scale, white)?;
|
|
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(), drawcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
|
|
let id = d.alloc_id();
|
|
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
|
let node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None }));
|
|
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().unwrap();
|
|
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 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 { unreachable!() };
|
|
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, false)?;
|
|
s.edit("Crop Image", |d, _| {
|
|
let key = unique_key(d, "crop");
|
|
d.images.insert(key.clone(), drawcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(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,
|
|
});
|
|
}
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({ "id": id.0, "width": w, "height": h }))
|
|
}
|
|
|
|
// ---------- Trim marks ----------
|
|
|
|
fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let st = s.doc()?;
|
|
let off = f64_or(p, "offset", 9.0).max(0.0);
|
|
let len = f64_or(p, "length", 18.0).max(0.1);
|
|
let weight = f64_or(p, "weight", 0.3).max(0.01);
|
|
let r = if st.selection.is_empty() {
|
|
st.doc.artboards.first().map(|a| a.rect)
|
|
} else {
|
|
let roots = selected_roots(s)?;
|
|
s.doc()?.doc.bounds_of(&roots, true)
|
|
}
|
|
.ok_or_else(|| EngineError::Other("nothing to mark".into()))?;
|
|
let parent = s.doc()?.insertion_parent();
|
|
let lines = [
|
|
// Top-left.
|
|
(Point::new(r.x0 - off - len, r.y0), Point::new(r.x0 - off, r.y0)),
|
|
(Point::new(r.x0, r.y0 - off - len), Point::new(r.x0, r.y0 - off)),
|
|
// Top-right.
|
|
(Point::new(r.x1 + off, r.y0), Point::new(r.x1 + off + len, r.y0)),
|
|
(Point::new(r.x1, r.y0 - off - len), Point::new(r.x1, r.y0 - off)),
|
|
// Bottom-right.
|
|
(Point::new(r.x1 + off, r.y1), Point::new(r.x1 + off + len, r.y1)),
|
|
(Point::new(r.x1, r.y1 + off), Point::new(r.x1, r.y1 + off + len)),
|
|
// Bottom-left.
|
|
(Point::new(r.x0 - off - len, r.y1), Point::new(r.x0 - off, r.y1)),
|
|
(Point::new(r.x0, r.y1 + off), Point::new(r.x0, r.y1 + off + len)),
|
|
];
|
|
let id = s.edit("Create Trim Marks", |d, sel| {
|
|
let mut children = vec![];
|
|
for (a, b) in lines {
|
|
let id = d.alloc_id();
|
|
children.push(Arc::new(Node::path(id, shapes::line(a, b), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), weight))));
|
|
}
|
|
let gid = d.alloc_id();
|
|
let mut g = Node::group(gid, children);
|
|
g.name = Some("Trim Marks".into());
|
|
d.insert(parent, usize::MAX, g)?;
|
|
sel.set([gid]);
|
|
Ok(gid)
|
|
})?;
|
|
Ok(json!({ "id": id.0 }))
|
|
}
|
|
|
|
// ---------- 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(drawcraft_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 n = rects.len();
|
|
let rows = n.div_ceil(cols);
|
|
// Grid cell (row, col) of artboard i.
|
|
let cell = |i: usize| if by_col { (i % rows, i / rows) } else { (i / cols, i % cols) };
|
|
let mut col_w = vec![0.0f64; cols];
|
|
let mut row_h = vec![0.0f64; rows];
|
|
for (i, r) in rects.iter().enumerate() {
|
|
let (ro, co) = cell(i);
|
|
col_w[co] = col_w[co].max(r.width());
|
|
row_h[ro] = row_h[ro].max(r.height());
|
|
}
|
|
let x_of = |c: usize| first.x0 + col_w[..c].iter().map(|w| w + spacing).sum::<f64>();
|
|
let y_of = |r: usize| first.y0 + row_h[..r].iter().map(|h| h + spacing).sum::<f64>();
|
|
let deltas: Vec<Vec2> = rects
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, r)| {
|
|
let (ro, co) = cell(i);
|
|
Point::new(x_of(co), y_of(ro)) - 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()
|
|
}
|