vectorcraft/crates/engine/src/cmd/live.rs

1383 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Live Blends (Object → Blend), Envelope Distort (Object → Envelope Distort) and Gradient Mesh
//! (Object → Create Gradient Mesh, Mesh tool edits).
//!
//! The objects are live document nodes (`NodeKind::Blend`, `NodeKind::Envelope`,
//! `NodeKind::Mesh`); evaluation lives in `vectorcraft_doc::live` and the renderer. **Expand**
//! replaces a live object by its evaluated geometry (`vectorcraft_render::expand_live`).
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::live::{
self, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeOptions, GradientMesh, MeshAppearance, PreserveShape, Spine,
};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Affine, PathData, Point};
use super::edit::selected_roots;
use super::expand::gradient_sampler;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
// ---------- Blend ----------
cmd!(
"object.blend.make",
"Make",
["Object", "Blend"],
Some("Cmd+Alt+B"),
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
has_doc,
blend_make
),
cmd!(
"object.blend.release",
"Release",
["Object", "Blend"],
Some("Cmd+Alt+Shift+B"),
"{} release the selected blends: the key objects come back and the spine stays as a path with no fill or stroke, below them → {ids (the keys), spines: [ids]}",
has_blend,
blend_release
),
cmd!(
"object.blend.options",
"Blend Options…",
["Object", "Blend"],
None,
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path} set the options of the selected blends; with no blend selected, the options new blends start with (a tool setting, not an undo step) → {defaults?: true}",
always,
blend_options
),
cmd!(
"object.blend.expand",
"Expand",
["Object", "Blend"],
None,
"{} replace the selected blends by groups of their steps → {ids}",
has_blend,
blend_expand
),
cmd!(
"object.blend.replaceSpine",
"Replace Spine",
["Object", "Blend"],
None,
"{} use the selected path as the spine of the selected blend (the path is consumed)",
has_blend,
blend_replace_spine
),
cmd!(
"object.blend.reverseSpine",
"Reverse Spine",
["Object", "Blend"],
None,
"{} reverse the order of the key objects along the spine",
has_blend,
blend_reverse_spine
),
cmd!(
query "object.blend.info",
"Blend Info",
[],
None,
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
always,
blend_info
),
cmd!(
"object.blend.spine.moveAnchor",
"Move Spine Point",
[],
None,
"{id? (default: the selected blend), anchor: index, x, y, handle?: in|out, independent?: bool} move a point of the blend's spine (its handles along), or with `handle` place that handle's end at (x, y) (a smooth point keeps the other handle in line unless `independent`). A key object sitting on the point moves with it. The first edit turns the straight spine into a path",
has_doc,
spine_move_anchor
),
cmd!(
"object.blend.spine.addAnchor",
"Add Spine Point",
[],
None,
"{id? (default: the selected blend), x, y} add a point to the blend's spine where it passes nearest (x, y) → {anchor}",
has_doc,
spine_add_anchor
),
cmd!(
"object.blend.spine.removeAnchor",
"Delete Spine Point",
[],
None,
"{id? (default: the selected blend), anchor: index} delete a point of the blend's spine that no key object sits on",
has_doc,
spine_remove_anchor
),
cmd!(
"object.blend.reverseFrontToBack",
"Reverse Front to Back",
["Object", "Blend"],
None,
"{} reverse the stacking order of the selected blends",
has_blend,
blend_reverse_stack
),
// ---------- Envelope ----------
cmd!(
"object.envelope.makeWithWarp",
"Make with Warp…",
["Object", "Envelope Distort"],
Some("Cmd+Alt+Shift+W"),
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected) → {id}",
has_selection,
env_make_warp
),
cmd!(
"object.envelope.makeWithMesh",
"Make with Mesh…",
["Object", "Envelope Distort"],
Some("Cmd+Alt+M"),
"{ids?, rows?: 1..50 (4), cols?: 1..50 (4)} envelope the selected objects with a mesh of rows × cols patches → {id}",
has_selection,
env_make_mesh
),
cmd!(
"object.envelope.makeWithTopObject",
"Make with Top Object",
["Object", "Envelope Distort"],
Some("Cmd+Alt+C"),
"{ids?} the topmost selected path becomes the envelope of the others → {id}",
has_multi,
env_make_top
),
cmd!(
"object.envelope.resetWithWarp",
"Reset with Warp…",
["Object", "Envelope Distort"],
None,
"{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %)} make the selected envelopes warp envelopes, keeping their content",
has_envelope,
env_reset_warp
),
cmd!(
"object.envelope.resetWithMesh",
"Reset with Mesh…",
["Object", "Envelope Distort"],
None,
"{rows?: 1..50, cols?: 1..50 (unset: a mesh envelope's own, else 4), maintainShape?: bool (true: the mesh follows the current envelope shape; false: a flat grid over the content)} make the selected envelopes mesh envelopes, keeping their content",
has_envelope,
env_reset_mesh
),
cmd!(
query "object.envelope.info",
"Envelope Info",
[],
None,
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, editing}",
has_doc,
env_info
),
cmd!(
"object.envelope.release",
"Release",
["Object", "Envelope Distort"],
None,
"{} release the selected envelopes into their content and their shape (a grey gradient mesh for warp and mesh envelopes, the path for a top-object envelope; the content keeps the envelope's opacity, blend mode and opacity mask) → {ids}",
has_envelope,
env_release
),
cmd!(
"object.envelope.options",
"Envelope Options…",
["Object", "Envelope Distort"],
None,
"{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance), style?, bend?, h?, v?, horizontal? (warp envelopes)} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
has_doc,
env_options
),
cmd!(
"object.envelope.expand",
"Expand",
["Object", "Envelope Distort"],
None,
"{} replace the selected envelopes by groups of their distorted content (type outlined), keeping name, opacity, blend mode, isolation, knockout and opacity mask → {ids}",
has_envelope,
env_expand
),
cmd!(
"object.envelope.editContents",
"Edit Contents",
["Object", "Envelope Distort"],
None,
"{editing?: bool} toggle Edit Contents / Edit Envelope → {editing}",
has_envelope,
env_edit_contents
),
cmd!(
"object.envelope.setMeshPoint",
"Move Envelope Mesh Point",
[],
None,
"{id, index, x, y} move one point of a mesh envelope",
has_doc,
env_set_mesh_point
),
// ---------- Gradient mesh ----------
cmd!(
"object.mesh.create",
"Create Gradient Mesh…",
["Object"],
None,
"{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y]} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point) → {ids, index?}",
has_selection,
mesh_create
),
cmd!(
"object.mesh.setPointColor",
"Set Mesh Point Color",
[],
None,
"{id, index, color, opacity?: 0..1} colour of one mesh point",
has_doc,
mesh_set_color
),
cmd!(
"object.mesh.movePoint",
"Move Mesh Point",
[],
None,
"{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a mesh point (its handles follow), or with `handle` place that handle end at (x, y)",
has_doc,
mesh_move_point
),
cmd!(
"object.mesh.addLine",
"Add Mesh Line",
[],
None,
"{id, x, y, color?} add a mesh row and column through (x, y) → {index}",
has_doc,
mesh_add_line
),
cmd!(
"object.mesh.deletePoint",
"Delete Mesh Point",
[],
None,
"{id, index} delete the mesh lines through a point",
has_doc,
mesh_delete_point
),
cmd!(
"object.mesh.expand",
"Expand Gradient Mesh",
["Object"],
None,
"{} replace the selected meshes by groups of flat-coloured pieces → {ids}",
has_mesh,
mesh_expand
),
]
}
fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
/// Explicit `ids` (in paint order) or the selected roots.
fn roots_param(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
if let Some(ids) = ids_param(p, "ids") {
let st = s.doc()?;
for id in &ids {
if st.doc.node(*id).is_none() {
return Err(EngineError::NoNode(*id));
}
}
let mut sel = Selection::default();
sel.set(ids.iter().copied().filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer())));
let ordered = sel.in_paint_order(&st.doc);
// Drop ids nested inside other given ids.
return Ok(ordered
.iter()
.copied()
.filter(|id| {
let anc = st.doc.ancestry(*id).unwrap_or_default();
!anc[..anc.len().saturating_sub(1)].iter().any(|a| ordered.contains(a))
})
.collect());
}
selected_roots(s)
}
/// Selected nodes of a kind (the selection itself or an ancestor).
fn selected_of(s: &Session, pred: fn(&Node) -> bool) -> Vec<NodeId> {
let Some(st) = s.active() else { return vec![] };
let mut out = vec![];
for id in &st.selection.objects {
for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() {
if st.doc.node(a).is_some_and(pred) {
if !out.contains(&a) {
out.push(a);
}
break;
}
}
}
out
}
fn is_blend(n: &Node) -> bool {
matches!(n.kind, NodeKind::Blend { .. })
}
fn is_envelope(n: &Node) -> bool {
matches!(n.kind, NodeKind::Envelope { .. })
}
fn is_mesh(n: &Node) -> bool {
matches!(n.kind, NodeKind::Mesh(_))
}
fn has_blend(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
if selected_of(s, is_blend).is_empty() { Err("select a blend".into()) } else { Ok(()) }
}
fn has_envelope(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
if selected_of(s, is_envelope).is_empty() { Err("select an envelope".into()) } else { Ok(()) }
}
fn has_mesh(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
if selected_of(s, is_mesh).is_empty() { Err("select a gradient mesh".into()) } else { Ok(()) }
}
/// Wrap `roots` (paint order) into a new node built by `make`, placed where the topmost root was.
fn wrap(d: &mut Document, sel: &mut Selection, roots: &[NodeId], make: impl FnOnce(NodeId, Vec<Arc<Node>>) -> Node) -> Result<NodeId> {
let top = *roots.last().ok_or_else(|| EngineError::Other("nothing to wrap".into()))?;
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| d.node(*id).cloned()).map(Arc::new).collect();
let id = d.alloc_id();
d.insert(par, idx + 1, make(id, nodes))?;
for r in roots {
d.remove(*r)?;
}
sel.set([id]);
Ok(id)
}
/// Put `nodes` where `id` was (replacing it). Returns their ids.
fn replace_with(d: &mut Document, id: NodeId, nodes: Vec<Node>) -> Result<Vec<NodeId>> {
let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
d.remove(id)?;
let mut out = vec![];
for (k, n) in nodes.into_iter().enumerate() {
out.push(n.id);
d.insert(par, idx + k, n)?;
}
Ok(out)
}
/// Fresh ids for any id-0 (generated) node in `n`'s subtree; keeps real ids.
fn fix_ids(d: &mut Document, n: &mut Node) {
if n.id == NodeId(0) {
n.id = d.alloc_id();
}
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
fix_ids(d, Arc::make_mut(c));
}
}
}
// ---------- Blend ----------
/// The Specified Steps and Specified Distance a spacing mode starts with.
const DEFAULT_STEPS: u32 = 5;
const DEFAULT_DISTANCE: f64 = 10.0;
fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpacing> {
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let mode = str_param(p, "spacing");
let value = num("value");
let sp = match mode {
Some("smooth") | Some("smoothColor") => BlendSpacing::SmoothColor,
Some("steps") => BlendSpacing::Steps(value.or(num("steps")).unwrap_or(DEFAULT_STEPS as f64) as u32),
Some("distance") => BlendSpacing::Distance(value.or(num("distance")).unwrap_or(DEFAULT_DISTANCE)),
Some(o) => return Err(bad(cmd, format!("unknown spacing `{o}` (smooth|steps|distance)"))),
None => {
if let Some(n) = num("steps") {
BlendSpacing::Steps(n as u32)
} else if let Some(d) = num("distance") {
BlendSpacing::Distance(d)
} else if bool_or(p, "smooth", false) {
BlendSpacing::SmoothColor
} else {
current
}
}
};
match sp {
BlendSpacing::Steps(n) if !(1..=1000).contains(&n) => Err(bad(cmd, "steps must be 1..1000")),
BlendSpacing::Distance(d) if !(d.is_finite() && d > 0.0) => Err(bad(cmd, "distance must be > 0")),
s => Ok(s),
}
}
fn orientation_param(p: &Value, current: BlendOrientation) -> BlendOrientation {
match str_param(p, "orientation") {
Some("path") | Some("alignToPath") => BlendOrientation::AlignToPath,
Some("page") | Some("alignToPage") => BlendOrientation::AlignToPage,
_ => current,
}
}
/// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from.
fn starts_param(p: &Value, roots: &[NodeId]) -> Result<Vec<(NodeId, u32)>> {
const C: &str = "object.blend.make";
let Some(v) = p.get("starts").filter(|v| !v.is_null()) else { return Ok(vec![]) };
let list = v.as_array().ok_or_else(|| bad(C, "`starts` must be an array of anchor indices (or nulls)"))?;
let owners = ids_param(p, "ids").unwrap_or_else(|| roots.to_vec());
let mut out = vec![];
for (id, a) in owners.iter().zip(list) {
if a.is_null() {
continue;
}
let i = a.as_u64().and_then(|i| u32::try_from(i).ok()).ok_or_else(|| bad(C, "a start must be an anchor index ≥ 0 or null"))?;
out.push((*id, i));
}
Ok(out)
}
/// The first blend among `roots`.
fn first_blend(d: &Document, roots: &[NodeId]) -> Option<Node> {
roots.iter().filter_map(|r| d.node(*r)).find(|n| is_blend(n)).cloned()
}
/// Key objects of a blend made of `nodes` (paint order) and their start points: a blend among
/// them gives its keys (with theirs), the other objects are keys themselves.
fn merge_keys(nodes: Vec<Arc<Node>>, starts: &[(NodeId, u32)]) -> (Vec<Arc<Node>>, Vec<Option<u32>>) {
let (mut keys, mut st) = (vec![], vec![]);
for n in nodes {
match &n.kind {
NodeKind::Blend { children, spec } => {
st.extend((0..children.len()).map(|i| spec.starts.get(i).copied().flatten()));
keys.extend(children.iter().cloned());
}
_ => {
st.push(starts.iter().find(|(id, _)| *id == n.id).map(|(_, a)| *a));
keys.push(n);
}
}
}
if st.iter().all(Option::is_none) {
st.clear();
}
(keys, st)
}
/// The spine of a blend of `keys` made by giving blend `old` (spec `old_spec`) more keys: its
/// spine pinned, with a straight segment to each new key before or after its own (a closed spine
/// gives way to the straight lines between the keys).
fn extend_spine(spec: &mut BlendSpec, old: &[Arc<Node>], old_spec: &BlendSpec, keys: &[Arc<Node>]) {
if old_spec.spine.is_none() {
return;
}
let Some((mut path, anchors)) = live::pin_spine(old, old_spec) else { return };
let Some(first) = old.first().and_then(|f| keys.iter().position(|k| k.id == f.id)) else { return };
let Some(sp) = path.subpaths.first_mut().filter(|sp| !sp.closed) else { return };
let center = |k: &Arc<Node>| vectorcraft_geom::Anchor::corner(k.geometric_bounds().map(|b| b.center()).unwrap_or_default());
let before: Vec<_> = keys.iter().take(first).map(center).collect();
let after: Vec<_> = keys.iter().skip(first + old.len()).map(center).collect();
let shift = before.len();
sp.anchors.splice(0..0, before);
let n = sp.anchors.len();
sp.anchors.extend(after);
let mut ka: Vec<usize> = (0..shift).collect();
ka.extend(anchors.iter().map(|a| a + shift));
ka.extend(n..sp.anchors.len());
spec.key_anchors = ka.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect();
spec.spine = Some(path);
}
fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.make";
let roots = roots_param(s, p)?;
if roots.len() < 2 {
return Err(bad(C, "select at least two objects"));
}
let starts = starts_param(p, &roots)?;
let base = first_blend(&s.doc()?.doc, &roots);
let cur = match base.as_ref().map(|n| &n.kind) {
Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation },
_ => s.prefs.blend_options.unwrap_or_default(),
};
let spacing = spacing_param(p, C, cur.spacing)?;
let orientation = orientation_param(p, cur.orientation);
let id = s.edit("Make Blend", |d, sel| {
wrap(d, sel, &roots, |id, nodes| {
let (children, starts) = merge_keys(nodes, &starts);
// A blend taking more keys keeps its name, transparency and spine.
let mut n = base.unwrap_or_else(|| Node::new(id, NodeKind::Group { children: vec![], clip: false }));
let mut spec = BlendSpec { spacing, orientation, starts, ..Default::default() };
if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind {
extend_spine(&mut spec, old, old_spec, &children);
}
n.id = id;
n.kind = NodeKind::Blend { children, spec };
n
})
})?;
Ok(json!({ "id": id.0 }))
}
fn blend_release(s: &mut Session, _: &Value) -> Result<Value> {
let blends = selected_of(s, is_blend);
let (ids, spines) = s.edit("Release Blend", |d, sel| {
let (mut keys, mut spines) = (vec![], vec![]);
for b in &blends {
let Some(n) = d.node(*b).cloned() else { continue };
let NodeKind::Blend { children, spec } = &n.kind else { continue };
// The spine stays behind as a path that paints nothing, below the keys.
let mut nodes = vec![];
if let Some((path, _)) = live::blend_spine(children, spec) {
let id = d.alloc_id();
spines.push(id);
nodes.push(Node::path(id, path, Appearance::basic(Paint::None, Paint::None, 0.0)));
}
keys.extend(children.iter().map(|c| c.id));
nodes.extend(children.iter().map(|c| (**c).clone()));
replace_with(d, *b, nodes)?;
}
sel.set(spines.iter().chain(&keys).copied());
Ok((keys, spines))
})?;
let mut r = ids_json(&ids);
r["spines"] = json!(spines.iter().map(|i| i.0).collect::<Vec<_>>());
Ok(r)
}
/// The blend `id` (default: the first selected), its spine pinned to its keys (see
/// [`live::pin_spine`]) and given to `f` with the key anchors and the keys; the result is stored
/// as the blend's spine, one undo step.
fn edit_spine<T>(
s: &mut Session,
p: &Value,
cmd: &str,
label: &str,
f: impl FnOnce(&mut vectorcraft_geom::SubPath, &mut Vec<usize>, &mut [Arc<Node>]) -> Result<T>,
) -> Result<T> {
let id = match id_param(p, "id") {
Some(id) => id,
None => *selected_of(s, is_blend).first().ok_or_else(|| bad(cmd, "select a blend or give `id`"))?,
};
let c = cmd.to_string();
s.edit(label, move |d, _| {
let Some(NodeKind::Blend { children, spec }) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(bad(&c, "not a blend")) };
let (mut path, mut anchors) = live::pin_spine(children, spec).ok_or_else(|| bad(&c, "the blend has no spine"))?;
let sp = path.subpaths.first_mut().ok_or_else(|| bad(&c, "the blend has no spine"))?;
let out = f(sp, &mut anchors, children)?;
spec.key_anchors = anchors.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect();
spec.spine = Some(path);
Ok(out)
})
}
/// A point parameter that must be finite.
fn finite_point(p: &Value, cmd: &str) -> Result<Point> {
let q = Point::new(f64_req(p, "x", cmd)?, f64_req(p, "y", cmd)?);
if q.x.is_finite() && q.y.is_finite() && q.x.abs() < 1e9 && q.y.abs() < 1e9 { Ok(q) } else { Err(bad(cmd, "x and y must be finite")) }
}
fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.moveAnchor";
let index = index_param(p, "anchor", C)?;
let q = finite_point(p, C)?;
let handle = match str_param(p, "handle") {
None => None,
Some("out") => Some(true),
Some("in") => Some(false),
Some(_) => return Err(bad(C, "handle must be in or out")),
};
let independent = bool_or(p, "independent", false);
edit_spine(s, p, C, "Reshape Spine", |sp, anchors, keys| {
let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?;
if let Some(out) = handle {
a.set_handle(out, q, independent);
return Ok(());
}
let delta = q - a.p;
a.translate(delta);
// The key objects on the point move with it.
for (k, _) in anchors.iter().enumerate().filter(|(_, a)| **a == index) {
if let Some(key) = keys.get_mut(k) {
Arc::make_mut(key).transform(Affine::translate(delta), false);
}
}
Ok(())
})?;
ok()
}
fn spine_add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.addAnchor";
let q = finite_point(p, C)?;
let index = edit_spine(s, p, C, "Add Spine Point", |sp, anchors, _| {
let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q).ok_or_else(|| bad(C, "the spine has no segments"))?;
let i = sp.insert_anchor(seg, t.clamp(1e-3, 1.0 - 1e-3));
for a in anchors.iter_mut().filter(|a| **a >= i) {
*a += 1;
}
Ok(i)
})?;
Ok(json!({ "anchor": index }))
}
fn spine_remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.removeAnchor";
let index = index_param(p, "anchor", C)?;
edit_spine(s, p, C, "Delete Spine Point", |sp, anchors, _| {
if index >= sp.anchors.len() {
return Err(bad(C, "no such spine point"));
}
if anchors.contains(&index) {
return Err(bad(C, "a key object sits on that point"));
}
if sp.anchors.len() <= 2 {
return Err(bad(C, "a spine keeps at least two points"));
}
sp.anchors.remove(index);
for a in anchors.iter_mut().filter(|a| **a > index) {
*a -= 1;
}
Ok(())
})?;
ok()
}
fn edit_blends(s: &mut Session, label: &str, f: impl Fn(&mut Vec<Arc<Node>>, &mut BlendSpec)) -> Result<Value> {
let blends = selected_of(s, is_blend);
s.edit(label, |d, _| {
for b in &blends {
if let Some(NodeKind::Blend { children, spec }) = d.node_mut(*b).map(|n| &mut n.kind) {
f(children, spec);
}
}
Ok(())
})?;
ok()
}
/// The first selected blend: its id, spec and key ids.
fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec<NodeId>)> {
let b = *selected_of(s, is_blend).first()?;
match &s.doc().ok()?.doc.node(b)?.kind {
NodeKind::Blend { spec, children } => Some((b, spec.clone(), children.iter().map(|c| c.id).collect())),
_ => None,
}
}
fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.options";
let Some((_, cur, _)) = selected_spec(s) else {
// Nothing to set them on: the options new blends start with.
let d = s.prefs.blend_options.unwrap_or_default();
let spacing = spacing_param(p, C, d.spacing)?;
s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) });
return Ok(json!({ "defaults": true }));
};
let spacing = spacing_param(p, C, cur.spacing)?;
let orientation = orientation_param(p, cur.orientation);
edit_blends(s, "Blend Options", |_, spec| {
spec.spacing = spacing;
spec.orientation = orientation;
})
}
/// Spacing and orientation as `object.blend.options` takes them; the step count and distance
/// not in use show their defaults.
fn blend_options_json(spacing: BlendSpacing, orientation: BlendOrientation) -> Value {
let (mode, steps, distance) = match spacing {
BlendSpacing::SmoothColor => ("smooth", DEFAULT_STEPS, DEFAULT_DISTANCE),
BlendSpacing::Steps(n) => ("steps", n, DEFAULT_DISTANCE),
BlendSpacing::Distance(d) => ("distance", DEFAULT_STEPS, d),
};
let orientation = if orientation == BlendOrientation::AlignToPath { "path" } else { "page" };
json!({ "spacing": mode, "steps": steps, "distance": distance, "orientation": orientation })
}
fn blend_info(s: &mut Session, _: &Value) -> Result<Value> {
Ok(match selected_spec(s) {
Some((id, spec, keys)) => {
let mut v = blend_options_json(spec.spacing, spec.orientation);
v["target"] = json!("blend");
v["id"] = json!(id.0);
v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::<Vec<_>>());
v["keys"] = json!(keys.iter().map(|k| k.0).collect::<Vec<_>>());
if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind)
&& let Some((path, anchors)) = live::blend_spine(children, &spec)
&& let Some(sp) = path.subpaths.first()
{
let pt = |p: Point| json!([p.x, p.y]);
let points: Vec<Value> = sp.anchors.iter().map(|a| json!({"x": a.p.x, "y": a.p.y, "in": pt(a.h_in), "out": pt(a.h_out)})).collect();
v["spine"] = json!({"anchors": points, "closed": sp.closed, "keyAnchors": anchors, "explicit": spec.spine.is_some()});
}
v
}
None => {
let d = s.prefs.blend_options.unwrap_or_default();
let mut v = blend_options_json(d.spacing, d.orientation);
v["target"] = json!("defaults");
v
}
})
}
fn blend_expand(s: &mut Session, _: &Value) -> Result<Value> {
let blends = selected_of(s, is_blend);
let ids = s.edit("Expand Blend", |d, sel| {
let mut out = vec![];
for b in &blends {
let Some(n) = d.node(*b).cloned() else { continue };
let children = vectorcraft_render::expand_live(&n).into_iter().map(Arc::new).collect();
let mut g = Node::new(n.id, NodeKind::Group { children, clip: false });
g.opacity = n.opacity;
g.blend = n.blend;
g.visible = n.visible;
fix_ids(d, &mut g);
out.extend(replace_with(d, *b, vec![g])?);
}
sel.set(out.iter().copied());
Ok(out)
})?;
Ok(ids_json(&ids))
}
/// Move the keys onto their spine positions (so what's stored matches what's drawn): onto their
/// anchors, or spread evenly by arc length along a spine from Replace Spine.
fn keys_to_spine(children: &mut [Arc<Node>], spec: &BlendSpec) {
let Some(path) = &spec.spine else { return };
let k = children.len();
let pinned = spec.key_anchors.len() == k;
let sp = Spine::new(path);
for (i, c) in children.iter_mut().enumerate() {
let pt = if pinned {
spec.key_anchors.get(i).and_then(|a| path.subpaths.first()?.anchors.get(*a as usize)).map(|a| a.p)
} else {
sp.as_ref().map(|sp| sp.at(if k > 1 { i as f64 / (k - 1) as f64 } else { 0.0 }).0)
};
if let (Some(pt), Some(b)) = (pt, c.geometric_bounds()) {
Arc::make_mut(c).transform(Affine::translate(pt - b.center()), false);
}
}
}
/// Store the blend's spine pinned to its keys (see [`live::pin_spine`]); false when it has none.
fn pin(children: &[Arc<Node>], spec: &mut BlendSpec) -> bool {
match live::pin_spine(children, spec) {
Some((path, anchors)) => {
spec.spine = Some(path);
spec.key_anchors = anchors.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect();
true
}
None => false,
}
}
/// `spec`'s spine reversed: the key anchors count from the other end.
fn reverse_spine(spec: &mut BlendSpec) {
if let Some(sp) = &mut spec.spine {
sp.reverse();
let n = sp.subpaths.first().map_or(0, |s| s.anchors.len() as u32);
for a in &mut spec.key_anchors {
*a = n.saturating_sub(1).saturating_sub(*a);
}
}
}
fn blend_replace_spine(s: &mut Session, _: &Value) -> Result<Value> {
let blends = selected_of(s, is_blend);
let st = s.doc()?;
let path = st
.selection
.objects
.iter()
.copied()
.find(|id| {
!blends.contains(id)
&& st.doc.node(*id).is_some_and(|n| n.path_data().is_some())
&& !blends.iter().any(|b| st.doc.ancestry(*id).unwrap_or_default().contains(b))
})
.ok_or_else(|| EngineError::Other("Replace Spine: select a blend and a path".into()))?;
let Some(&b) = blends.first() else { return Err(EngineError::Other("Replace Spine: select a blend and a path".into())) };
s.edit("Replace Spine", |d, sel| {
let spine = d.node(path).and_then(|n| n.path_data().cloned()).ok_or(EngineError::NoNode(path))?;
d.remove(path)?;
if let Some(NodeKind::Blend { children, spec }) = d.node_mut(b).map(|n| &mut n.kind) {
spec.spine = Some(spine);
spec.key_anchors.clear();
keys_to_spine(children, spec);
}
sel.set([b]);
Ok(())
})?;
ok()
}
fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result<Value> {
edit_blends(s, "Reverse Spine", |children, spec| {
if spec.spine.is_some() && pin(children, spec) {
// The keys keep their anchors, which now count from the other end.
if let Some(sp) = &mut spec.spine {
sp.reverse();
}
keys_to_spine(children, spec);
} else {
let centers: Vec<Point> = children.iter().map(|k| k.geometric_bounds().map(|b| b.center()).unwrap_or_default()).collect();
let n = children.len();
for (i, k) in children.iter_mut().enumerate() {
Arc::make_mut(k).transform(Affine::translate(centers[n - 1 - i] - centers[i]), false);
}
}
})
}
fn blend_reverse_stack(s: &mut Session, _: &Value) -> Result<Value> {
edit_blends(s, "Reverse Front to Back", |children, spec| {
children.reverse();
// Each key keeps its start point.
if !spec.starts.is_empty() {
spec.starts.resize(children.len(), None);
spec.starts.reverse();
}
// Each key keeps its place: on its anchor, or (spread evenly) with the spine reversed.
spec.key_anchors.reverse();
reverse_spine(spec);
})
}
// ---------- Envelope ----------
fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<EnvelopeKind> {
let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true);
if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur {
(style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0);
}
if let Some(s) = str_param(p, "style") {
let s = s.strip_prefix("warp.").unwrap_or(s);
if live::WarpStyle::from_id(s).is_none() {
return Err(bad(cmd, format!("unknown warp style `{s}`")));
}
style = s.to_string();
}
bend = f64_or(p, "bend", bend).clamp(-100.0, 100.0);
h = f64_or(p, "h", f64_or(p, "horizontalDistortion", h)).clamp(-100.0, 100.0);
v = f64_or(p, "v", f64_or(p, "verticalDistortion", v)).clamp(-100.0, 100.0);
horizontal = match str_param(p, "orientation") {
Some("vertical") => false,
Some("horizontal") => true,
_ => bool_or(p, "horizontal", horizontal),
};
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal })
}
/// The options and fidelity new envelopes get (Envelope Options with no envelope selected).
fn envelope_defaults(s: &Session) -> (EnvelopeOptions, f64) {
s.envelope_defaults.unwrap_or((EnvelopeOptions::NEW, live::default_fidelity()))
}
fn envelope(id: NodeId, content: Vec<Arc<Node>>, kind: EnvelopeKind, (options, fidelity): (EnvelopeOptions, f64)) -> Node {
Node::new(id, NodeKind::Envelope { content, kind, fidelity, editing: false, options })
}
/// `rows` and `cols` (or `columns`), 1..50 each, defaulting to `default`.
fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> {
let rows = f64_or(p, "rows", default.0 as f64);
let cols = f64_or(p, "cols", f64_or(p, "columns", default.1 as f64));
if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) {
return Err(bad(cmd, "rows and cols must be 1..50"));
}
Ok((rows as u32, cols as u32))
}
fn env_make_warp(s: &mut Session, p: &Value) -> Result<Value> {
let kind = warp_kind(p, "object.envelope.makeWithWarp", None)?;
let roots = roots_param(s, p)?;
if roots.is_empty() {
return Err(EngineError::Other("nothing selected".into()));
}
let defaults = envelope_defaults(s);
let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?;
Ok(json!({ "id": id.0 }))
}
/// A mesh envelope of `rows`×`cols` patches over `content`: following the surface of `shape` (an
/// envelope's current kind), or a flat grid over the content's bounds.
fn mesh_kind(content: &[Arc<Node>], shape: Option<&EnvelopeKind>, rows: u32, cols: u32) -> Option<EnvelopeKind> {
let points = match shape {
Some(k) => live::envelope_surface(content, k, rows, cols, Color::BLACK)?.points.into_iter().map(|m| m.p).collect(),
None => live::grid_points(live::nodes_bounds(content)?, rows, cols),
};
Some(EnvelopeKind::Mesh { rows, cols, points })
}
fn env_make_mesh(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.makeWithMesh";
let (rows, cols) = rows_cols(p, C, (4, 4))?;
let roots = roots_param(s, p)?;
let st = s.doc()?;
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect();
let kind = mesh_kind(&nodes, None, rows, cols).ok_or_else(|| bad(C, "selection has no bounds"))?;
let defaults = envelope_defaults(s);
let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?;
Ok(json!({ "id": id.0 }))
}
/// Path data of a path or compound path.
fn outline_of(n: &Node) -> Option<PathData> {
match &n.kind {
NodeKind::Path { path, .. } => Some(path.clone()),
NodeKind::Compound { children, .. } => {
Some(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.clone()).collect()))
}
_ => None,
}
}
fn env_make_top(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.makeWithTopObject";
let roots = roots_param(s, p)?;
if roots.len() < 2 {
return Err(bad(C, "select the content and a path on top"));
}
let Some((&top, content)) = roots.split_last() else { return Err(bad(C, "select the content and a path on top")) };
let content = content.to_vec();
let path = s.doc()?.doc.node(top).and_then(outline_of).filter(|p| p.bounds().is_some()).ok_or_else(|| bad(C, "the top object must be a path"))?;
let defaults = envelope_defaults(s);
let id = s.edit("Make Envelope", |d, sel| {
let id = wrap(d, sel, &content, |id, content| envelope(id, content, EnvelopeKind::TopObject { path }, defaults))?;
d.remove(top)?;
Ok(id)
})?;
Ok(json!({ "id": id.0 }))
}
/// The kind `f` makes of each selected envelope (from its content and its current kind).
fn reshaped(s: &Session, f: impl Fn(&[Arc<Node>], &EnvelopeKind) -> Result<EnvelopeKind>) -> Result<Vec<(NodeId, EnvelopeKind)>> {
let doc = &s.doc()?.doc;
let mut out = vec![];
for e in selected_of(s, is_envelope) {
if let Some(NodeKind::Envelope { content, kind, .. }) = doc.node(e).map(|n| &n.kind) {
out.push((e, f(content, kind)?));
}
}
Ok(out)
}
/// Give each envelope of `kinds` its new kind (as one undo step `label`).
fn set_kinds(s: &mut Session, label: &str, kinds: Vec<(NodeId, EnvelopeKind)>) -> Result<Value> {
s.edit(label, |d, _| {
for (e, k) in kinds {
if let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(e).map(|n| &mut n.kind) {
*kind = k;
}
}
Ok(())
})?;
ok()
}
fn env_reset_warp(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.resetWithWarp";
let kinds = reshaped(s, |_, k| warp_kind(p, C, Some(k)))?;
set_kinds(s, "Reset with Warp", kinds)
}
fn env_reset_mesh(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.resetWithMesh";
let maintain = bool_or(p, "maintainShape", true);
let kinds = reshaped(s, |content, kind| {
let own = match kind {
EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols),
_ => (4, 4),
};
let (rows, cols) = rows_cols(p, C, own)?;
mesh_kind(content, maintain.then_some(kind), rows, cols).ok_or_else(|| bad(C, "the envelope's content has no bounds"))
})?;
set_kinds(s, "Reset with Mesh", kinds)
}
/// `v` with an envelope's options and fidelity added.
fn envelope_options_json(mut v: Value, o: &EnvelopeOptions, fidelity: f64) -> Value {
v["fidelity"] = json!(fidelity);
v["antiAlias"] = json!(o.anti_alias);
v["preserveShape"] = json!(o.preserve_shape.id());
v["distortAppearance"] = json!(o.distort_appearance);
v["distortLinearGradients"] = json!(o.distort_linear_gradients);
v["distortPatternFills"] = json!(o.distort_pattern_fills);
v
}
/// `cur` with the options `p` sets.
fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result<EnvelopeOptions> {
let preserve_shape = match str_param(p, "preserveShape") {
Some(v) => PreserveShape::parse(v).ok_or_else(|| bad(cmd, "preserveShape must be clippingMask|transparency"))?,
None => cur.preserve_shape,
};
Ok(EnvelopeOptions {
anti_alias: bool_or(p, "antiAlias", cur.anti_alias),
preserve_shape,
distort_appearance: bool_or(p, "distortAppearance", cur.distort_appearance),
distort_linear_gradients: bool_or(p, "distortLinearGradients", cur.distort_linear_gradients),
distort_pattern_fills: bool_or(p, "distortPatternFills", cur.distort_pattern_fills),
})
}
fn env_info(s: &mut Session, _: &Value) -> Result<Value> {
let doc = &s.doc()?.doc;
let env = selected_of(s, is_envelope).into_iter().find_map(|e| Some((e, &doc.node(e)?.kind)));
let Some((id, NodeKind::Envelope { kind, fidelity, editing, options, .. })) = env else {
let (options, fidelity) = envelope_defaults(s);
return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity));
};
let v = match kind {
EnvelopeKind::Warp { style, bend, h, v, horizontal } => {
json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal})
}
EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}),
EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}),
};
let mut v = envelope_options_json(v, options, *fidelity);
v["id"] = json!(id.0);
v["editing"] = json!(editing);
Ok(v)
}
/// What Release gives back as an envelope's shape: a top object's path, or the surface of a warp
/// or mesh envelope as a gradient mesh, painted grey (id 0).
fn envelope_shape(content: &[Arc<Node>], kind: &EnvelopeKind) -> Option<Node> {
let grey = Color::gray(0.25);
let (rows, cols) = match kind {
EnvelopeKind::TopObject { path } => {
return Some(Node::path(NodeId(0), path.clone(), Appearance::basic(Paint::solid(grey), Paint::None, 0.0)));
}
EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols),
EnvelopeKind::Warp { .. } => (4, 4),
};
live::envelope_surface(content, kind, rows, cols, grey).map(|m| Node::new(NodeId(0), NodeKind::Mesh(m)))
}
/// Released content keeps envelope `env`'s opacity, blend mode, isolation, knockout and opacity
/// mask: a single object without its own takes them, else a new group around the content does.
fn carry_transparency(d: &mut Document, env: &Node, mut nodes: Vec<Node>) -> Vec<Node> {
if env.has_default_transparency() {
return nodes;
}
let carry = |n: &mut Node| {
n.opacity = env.opacity;
n.blend = env.blend;
n.isolate = env.isolate;
n.knockout = env.knockout;
n.knockout_shape = env.knockout_shape;
n.mask = env.mask.clone();
};
if let [one] = nodes.as_mut_slice()
&& one.has_default_transparency()
{
carry(one);
return nodes;
}
let mut g = Node::group(d.alloc_id(), nodes.into_iter().map(Arc::new).collect());
carry(&mut g);
vec![g]
}
fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
let envs = selected_of(s, is_envelope);
let ids = s.edit("Release Envelope", |d, sel| {
let mut out = vec![];
for e in &envs {
let Some(n) = d.node(*e).cloned() else { continue };
let NodeKind::Envelope { content, kind, .. } = &n.kind else { continue };
let mut nodes = carry_transparency(d, &n, content.iter().map(|c| (**c).clone()).collect());
if let Some(mut shape) = envelope_shape(content, kind) {
shape.id = d.alloc_id();
nodes.push(shape);
}
out.extend(replace_with(d, *e, nodes)?);
}
sel.set(out.iter().copied());
Ok(out)
})?;
Ok(ids_json(&ids))
}
fn env_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.options";
let fid = p.get("fidelity").and_then(Value::as_f64);
if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) {
return Err(bad(C, "fidelity must be 0..100"));
}
let envs = selected_of(s, is_envelope);
if envs.is_empty() {
// No envelope selected: the options new envelopes get.
let (o, f) = envelope_defaults(s);
s.envelope_defaults = Some((options_param(p, C, o)?, fid.unwrap_or(f)));
return ok();
}
// Validate everything up front.
let doc = &s.doc()?.doc;
let mut updates = vec![];
for e in &envs {
if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = doc.node(*e).map(|n| &n.kind) {
let kind = match kind {
EnvelopeKind::Warp { .. } => warp_kind(p, C, Some(kind))?,
k => k.clone(),
};
updates.push((*e, kind, options_param(p, C, *options)?, fid.unwrap_or(*fidelity)));
}
}
s.edit("Envelope Options", |d, _| {
for (e, k, o, f) in updates {
if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = d.node_mut(e).map(|n| &mut n.kind) {
(*kind, *options, *fidelity) = (k, o, f);
}
}
Ok(())
})?;
ok()
}
/// Replace envelope `id` by a group of its distorted content (type outlined) with its id, name,
/// transparency and opacity mask; the generated pieces get ids of their own.
fn expand_envelope(d: &mut Document, id: NodeId) -> Result<()> {
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let mut g = live::expanded_group(&n, vectorcraft_render::effects::text_outliner());
fix_ids(d, &mut g);
// Outlined type repeats its object's id on its pieces.
vectorcraft_render::effects::fresh_ids(d, &mut g, &mut Default::default());
*d.node_mut(id).ok_or(EngineError::NoNode(id))? = g;
Ok(())
}
/// Object → Expand: every envelope in `root`'s subtree (itself included) expanded
/// ([`expand_envelope`]; envelopes inside envelopes go with them). Returns how many.
pub(super) fn expand_envelopes_under(d: &mut Document, root: NodeId) -> Result<usize> {
fn collect(n: &Node, out: &mut Vec<NodeId>) {
if is_envelope(n) {
out.push(n.id);
} else {
for c in n.children().into_iter().flatten() {
collect(c, out);
}
}
}
let mut envs = vec![];
if let Some(n) = d.node(root) {
collect(n, &mut envs);
}
for e in &envs {
expand_envelope(d, *e)?;
}
Ok(envs.len())
}
fn env_expand(s: &mut Session, _: &Value) -> Result<Value> {
let envs = selected_of(s, is_envelope);
let ids = s.edit("Expand Envelope", |d, sel| {
for e in &envs {
expand_envelope(d, *e)?;
}
sel.set(envs.iter().copied());
Ok(envs.clone())
})?;
Ok(ids_json(&ids))
}
fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
let envs = selected_of(s, is_envelope);
let cur = matches!(envs.first().and_then(|e| s.doc().ok()?.doc.node(*e)).map(|n| &n.kind), Some(NodeKind::Envelope { editing: true, .. }));
let want = bool_or(p, "editing", !cur);
s.edit(if want { "Edit Contents" } else { "Edit Envelope" }, |d, sel| {
let mut content_ids = vec![];
for e in &envs {
if let Some(n) = d.node_mut(*e)
&& let NodeKind::Envelope { editing, content, .. } = &mut n.kind
{
*editing = want;
content_ids.extend(content.iter().map(|c| c.id));
}
}
if want {
sel.set(content_ids);
} else {
sel.set(envs.iter().copied());
}
Ok(())
})?;
Ok(json!({ "editing": want }))
}
fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.setMeshPoint";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let index = f64_req(p, "index", C)? as usize;
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
s.edit("Move Envelope Point", |d, _| {
match d.node_mut(id).map(|n| &mut n.kind) {
Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { points, .. }, .. }) if index < points.len() => points[index] = q,
_ => return Err(bad(C, "not a mesh envelope point")),
}
Ok(())
})?;
ok()
}
// ---------- Gradient mesh ----------
fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.create";
let (rows, cols) = rows_cols(p, C, (4, 4))?;
let app = match str_param(p, "appearance") {
Some(a) => MeshAppearance::parse(a).ok_or_else(|| bad(C, "appearance must be flat|center|edge"))?,
None => MeshAppearance::Flat,
};
let highlight = f64_or(p, "highlight", 100.0).clamp(0.0, 100.0);
let at = point_param(p, "at");
let (rows, cols) = if at.is_some() { (1, 1) } else { (rows, cols) };
let roots = roots_param(s, p)?;
let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| {
let mut out = vec![];
let mut index = None;
for id in &roots {
let Some(n) = d.node(*id).cloned() else { continue };
let Some(path) = outline_of(&n) else { continue };
let m = match n.appearance.fill_paint() {
// The points take the colours the gradient paints there.
Paint::Gradient(g) => {
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| {
m.highlight(app, highlight);
m
})
}
p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight),
};
let Some(mut m) = m else { continue };
if let Some(q) = at {
index = m.add_lines_at(q);
}
let mut mn = Node::new(n.id, NodeKind::Mesh(m));
mn.name = n.name.clone();
mn.opacity = n.opacity;
mn.blend = n.blend;
mn.locked = n.locked;
mn.visible = n.visible;
*d.node_mut(*id).ok_or(EngineError::NoNode(*id))? = mn;
out.push(*id);
}
if out.is_empty() {
return Err(bad(C, "select a path or compound path"));
}
sel.set(out.iter().copied());
Ok((out, index))
})?;
let mut r = ids_json(&ids);
if let Some(i) = index {
r["index"] = json!(i);
}
Ok(r)
}
fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
let c = cmd.to_string();
s.edit(label, move |d, _| match d.node_mut(id).map(|n| &mut n.kind) {
Some(NodeKind::Mesh(m)) => f(m),
Some(_) => Err(bad(&c, "not a gradient mesh")),
None => Err(EngineError::NoNode(id)),
})
}
fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
let i = f64_req(p, key, cmd)?;
if i < 0.0 {
return Err(bad(cmd, "index must be ≥ 0"));
}
Ok(i as usize)
}
fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.setPointColor";
let index = index_param(p, "index", C)?;
let color = p.get("color").and_then(color_value);
let opacity = p.get("opacity").and_then(Value::as_f64);
if color.is_none() && opacity.is_none() {
return Err(bad(C, "missing `color` or `opacity`"));
}
with_mesh(s, p, C, "Mesh Point Color", |m| {
let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?;
if let Some(c) = color {
pt.color = c;
}
if let Some(o) = opacity {
pt.opacity = o.clamp(0.0, 1.0) as f32;
}
Ok(())
})?;
ok()
}
fn mesh_move_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.movePoint";
let index = index_param(p, "index", C)?;
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let handle = p.get("handle").and_then(Value::as_u64).map(|h| h as usize);
if handle.is_some_and(|h| h > 3) {
return Err(bad(C, "handle must be 0..3"));
}
with_mesh(s, p, C, "Move Mesh Point", |m| {
let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?;
match handle {
Some(h) => pt.handles[h] = q - pt.p,
None => pt.p = q,
}
Ok(())
})?;
ok()
}
fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.addLine";
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let color = p.get("color").and_then(color_value);
let index = with_mesh(s, p, C, "Add Mesh Line", |m| {
let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?;
if let Some(c) = color {
m.points[i].color = c;
}
Ok(i)
})?;
Ok(json!({ "index": index }))
}
fn mesh_delete_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.deletePoint";
let index = index_param(p, "index", C)?;
with_mesh(s, p, C, "Delete Mesh Point", |m| {
if m.remove_point_lines(index) { Ok(()) } else { Err(bad(C, "only interior mesh lines can be deleted")) }
})?;
ok()
}
fn mesh_expand(s: &mut Session, _: &Value) -> Result<Value> {
let meshes = selected_of(s, is_mesh);
let ids = s.edit("Expand Gradient Mesh", |d, sel| {
let mut out = vec![];
for m in &meshes {
let Some(n) = d.node(*m).cloned() else { continue };
let mut g = live::expanded_group(&n, None);
fix_ids(d, &mut g);
out.extend(replace_with(d, *m, vec![g])?);
}
sel.set(out.iter().copied());
Ok(out)
})?;
Ok(ids_json(&ids))
}