From c662e76b845b07244437e105ac69d984aa362516 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:00:02 +0000 Subject: [PATCH] refactor(engine): move cmd/live.rs items to cmd/live/{blend,commands,common,envelope,mesh}.rs --- crates/engine/src/cmd/live.rs | 1647 +----------------------- crates/engine/src/cmd/live/blend.rs | 525 ++++++++ crates/engine/src/cmd/live/commands.rs | 289 +++++ crates/engine/src/cmd/live/common.rs | 202 +++ crates/engine/src/cmd/live/envelope.rs | 423 ++++++ crates/engine/src/cmd/live/mesh.rs | 229 ++++ 6 files changed, 1678 insertions(+), 1637 deletions(-) create mode 100644 crates/engine/src/cmd/live/blend.rs create mode 100644 crates/engine/src/cmd/live/commands.rs create mode 100644 crates/engine/src/cmd/live/common.rs create mode 100644 crates/engine/src/cmd/live/envelope.rs create mode 100644 crates/engine/src/cmd/live/mesh.rs diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index d228c7d81..1276bdc2a 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -21,1640 +21,13 @@ use super::expand::gradient_sampler; use super::pathops::Beside; use super::*; -pub fn specs() -> Vec { - 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), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} 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; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [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, smooth?: bool, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct} set the options of the selected blends; easingType chooses the distribution, ramp sets its strength (0..100; 0 = even), colorShift (-100..100; 0 = even) shifts colours independently of shape/position easing (omitted ones keep their value; out of range is an error); with no blend selected, the options new blends start with (spacing and orientation only; 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 keys and steps, each keeping the blend's id, name, transparency (knockout and isolation included), opacity mask and appearance → {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, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, 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.convertAnchor", - "Convert Anchor Point", - [], - None, - "{id? (default: the selected blend), anchor: index, to: smooth|corner, x?, y?} convert a blend spine point: dragging a corner creates symmetric smooth handles, clicking a point with handles retracts both. Without x/y, smooth uses the neighbouring spine points", - has_doc, - spine_convert_anchor - ), - cmd!( - "object.blend.spine.reshapeSegment", - "Reshape Spine Segment", - [], - None, - "{id? (default: the selected blend), segment: index, t: 0..1, dx, dy, perpendicular?: bool} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's (perpendicular too): a straight spine turns into a curve (#1111)", - has_doc, - spine_reshape_segment - ), - 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, horizontalDistortion?: as h, verticalDistortion?: as v} 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), columns?: as cols} 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 %), horizontalDistortion?: as h, verticalDistortion?: as v} 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), columns?: as cols} 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), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} 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, handle?: 0 right|1 left|2 down|3 up} move a point of a mesh envelope (its handles follow), or with `handle` place that handle end at (x, y); object.mesh.movePoint, addLine and deletePoint edit mesh envelopes too", - 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], columns?: as cols} 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); an embedded image becomes a mesh over its rectangle (its transform kept) whose points take the average colour and opacity of the picture around them (a linked image is refused: embed it first) → {ids, index?}", - has_selection, - mesh_create - ), - cmd!( - "object.mesh.setPointColor", - "Set Mesh Point Color", - [], - None, - "{id, index, color, keepModel?: bool (as paint.setFill), 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 point of a gradient mesh or mesh envelope (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?, keepModel?: bool (as paint.setFill)} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope → {index}", - has_doc, - mesh_add_line - ), - cmd!( - "object.mesh.deletePoint", - "Delete Mesh Point", - [], - None, - "{id, index} delete the mesh lines through a point of a gradient mesh or mesh envelope", - 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::>() }) -} - -/// Explicit `ids` (in paint order) or the selected roots. -fn roots_param(s: &Session, p: &Value) -> Result> { - 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 { - 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>) -> Node) -> Result { - if roots.is_empty() { - return Err(EngineError::Other("nothing to wrap".into())); - } - let id = d.alloc_id(); - d.wrap(roots, |nodes| make(id, nodes))?; - 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) -> Result> { - 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. -pub(super) 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 { - check_numbers(p, &["value", "steps", "distance"], cmd)?; - let smooth = switch_or(p, "smooth", false, cmd)?; - let num = |k: &str| p.get(k).and_then(Value::as_f64); - let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32)); - let mode = str_param(p, "spacing"); - let value = num("value"); - let sp = match mode { - Some("smooth") | Some("smoothColor") => BlendSpacing::SmoothColor, - Some("steps") => match value { - Some(v) => steps("value", v)?, - None => steps("steps", num("steps").unwrap_or(DEFAULT_STEPS as f64))?, - }, - 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") { - steps("steps", n)? - } else if let Some(d) = num("distance") { - BlendSpacing::Distance(d) - } else if smooth { - 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, - } -} - -/// Blend panel Type and Ramp, validated before any document mutation. -fn ease_param(p: &Value, cmd: &str, current: BlendEase) -> Result { - let kind = match p.get("easingType").filter(|v| !v.is_null()) { - None => current.kind, - Some(v) => match v.as_str() { - Some("easeIn") => BlendEaseType::In, - Some("easeOut") => BlendEaseType::Out, - Some("easeInOut") => BlendEaseType::InOut, - Some("easeOutIn") => BlendEaseType::OutIn, - _ => return Err(bad(cmd, "easingType must be easeIn, easeOut, easeInOut or easeOutIn")), - }, - }; - Ok(BlendEase { kind, ramp: percent_param(p, cmd, "ramp", current.ramp * 100.0, 0.0)? / 100.0 }) -} - -/// Bounded percentage input; missing or null preserves the current value. -fn percent_param(p: &Value, cmd: &str, key: &str, current: f64, min: f64) -> Result { - match p.get(key).filter(|v| !v.is_null()) { - None => Ok(current), - Some(v) => v - .as_f64() - .filter(|n| n.is_finite() && (min..=100.0).contains(n)) - .ok_or_else(|| bad(cmd, format!("{key} must be a percentage {min}..100"))), - } -} - -/// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from. -fn starts_param(p: &Value, roots: &[NodeId]) -> Result> { - 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 { - 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>, starts: &[(NodeId, u32)]) -> (Vec>, Vec>) { - 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], old_spec: &BlendSpec, keys: &[Arc]) { - 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| 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 = (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 { - 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 (ease0, color0) = match base.as_ref().map(|n| &n.kind) { - Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift), - _ => Default::default(), - }; - let ease = ease_param(p, C, ease0)?; - let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?; - 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; a new one is a - // knockout group, so translucent steps don't show through each other. - let mut n = base.unwrap_or_else(|| { - let mut n = Node::new(id, NodeKind::Group { children: vec![], clip: false }); - n.knockout = vectorcraft_doc::Knockout::On; - n - }); - let mut spec = BlendSpec { spacing, orientation, ease, color_shift, 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: Box::new(spec) }; - n - }) - })?; - Ok(json!({ "id": id.0 })) -} - -fn blend_release(s: &mut Session, _: &Value) -> Result { - let blends = selected_of(s, is_blend); - let (ids, spines, groups) = s.edit("Release Blend", |d, sel| { - let (mut keys, mut spines, mut groups) = (vec![], vec![], vec![]); - let mut released = Beside::replacing(d, &blends); - 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())); - // What the blend itself carries stays on a group around what it gave back. - if n.name.is_some() - || n.opacity < 1.0 - || n.blend != vectorcraft_color::BlendMode::Normal - || n.isolate - || n.extras().mask.is_some() - || !n.appearance.items.is_empty() - || !n.appearance.effects.is_empty() - { - let mut g = n.clone(); - g.kind = NodeKind::Group { children: nodes.into_iter().map(Arc::new).collect(), clip: false }; - groups.push(g.id); - nodes = vec![g]; - } - released.put(d, *b, nodes)?; - } - released.finish(d)?; - if groups.is_empty() { - sel.set(spines.iter().chain(&keys).copied()); - } else { - sel.set(groups.iter().copied()); - } - Ok((keys, spines, groups)) - })?; - let mut r = ids_json(&ids); - r["spines"] = json!(spines.iter().map(|i| i.0).collect::>()); - r["groups"] = json!(groups.iter().map(|i| i.0).collect::>()); - 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( - s: &mut Session, - p: &Value, - cmd: &str, - label: &str, - f: impl FnOnce(&mut vectorcraft_geom::SubPath, &mut Vec, &mut [Arc]) -> Result, -) -> Result { - 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 { - 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 { - 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 = switch_or(p, "independent", false, C)?; - 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_convert_anchor(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.blend.spine.convertAnchor"; - let index = index_param(p, "anchor", C)?; - let smooth = match str_param(p, "to") { - Some("smooth") => true, - Some("corner") => false, - _ => return Err(bad(C, "to must be smooth or corner")), - }; - let target = match (p.get("x"), p.get("y")) { - (None, None) => None, - (Some(_), Some(_)) => Some(finite_point(p, C)?), - _ => return Err(bad(C, "x and y must be given together")), - }; - edit_spine(s, p, C, "Convert Anchor Point", |sp, _, _| { - if smooth && target.is_none() { - if index >= sp.anchors.len() { - return Err(bad(C, "no such spine point")); - } - vectorcraft_tools::draw2::curvature_smooth(sp, index); - return Ok(()); - } - let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?; - match target { - Some(q) if smooth && q.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, q), - _ => a.retract(), - } - Ok(()) - })?; - ok() -} - -fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.blend.spine.reshapeSegment"; - let seg = index_param(p, "segment", C)?; - let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95); - let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?); - if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) { - return Err(bad(C, "t, dx and dy must be finite")); - } - let perpendicular = switch_or(p, "perpendicular", false, C)?; - edit_spine(s, p, C, "Reshape Spine", |sp, _, _| { - let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) }; - if bent { Ok(()) } else { Err(bad(C, "no such spine segment")) } - })?; - ok() -} - -fn spine_add_anchor(s: &mut Session, p: &Value) -> Result { - 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 { - 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>, &mut BlendSpec)) -> Result { - 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)> { - 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 { - const C: &str = "object.blend.options"; - let Some((_, cur, _)) = selected_spec(s) else { - if ["easingType", "ramp", "colorShift"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) { - return Err(bad(C, "select a blend to set easing or color shift")); - } - // Nothing to set them on: the options new blends start with (easing is per blend). - 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); - let ease = ease_param(p, C, cur.ease)?; - let color_shift = percent_param(p, C, "colorShift", cur.color_shift, -100.0)?; - edit_blends(s, "Blend Options", |_, spec| { - spec.spacing = spacing; - spec.orientation = orientation; - spec.ease = ease; - spec.color_shift = color_shift; - }) -} - -/// 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 { - 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["easingType"] = json!(spec.ease.kind.id()); - v["ramp"] = json!(spec.ease.ramp * 100.0); - v["colorShift"] = json!(spec.color_shift); - v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::>()); - v["keys"] = json!(keys.iter().map(|k| k.0).collect::>()); - 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 = 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 { - let blends = selected_of(s, is_blend); - let ids = s.edit("Expand Blend", |d, sel| { - for b in &blends { - expand_blend(d, *b)?; - } - sel.set(blends.iter().copied()); - Ok(blends.clone()) - })?; - Ok(ids_json(&ids)) -} - -/// Replace blend `id` by a group of its keys and steps in place, keeping everything about the -/// blend itself: id, name, visibility, lock, transparency (knockout and isolation included), -/// opacity mask and appearance. -fn expand_blend(d: &mut Document, id: NodeId) -> Result<()> { - let Some(n) = d.node(id).filter(|n| is_blend(n)).cloned() else { return Ok(()) }; - let mut g = n.clone(); - g.kind = NodeKind::Group { children: vectorcraft_render::expand_live(&n).into_iter().map(Arc::new).collect(), clip: false }; - fix_ids(d, &mut g); - *d.node_mut(id).ok_or(EngineError::NoNode(id))? = g; - Ok(()) -} - -/// Object › Expand: every blend in the subtrees of `roots` (outer ones first) becomes a group of -/// its steps ([`expand_blend`]). Returns how many. -pub(crate) fn expand_blends(d: &mut Document, roots: &[NodeId]) -> Result { - let mut blends = vec![]; - for r in roots { - if let Some(n) = d.node(*r) { - n.walk(&mut |c| { - if is_blend(c) { - blends.push(c.id); - } - }); - } - } - for b in &blends { - expand_blend(d, *b)?; - } - Ok(blends.len()) -} - -/// 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], 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], 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 { - 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 { - 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 = 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 { - 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 ---------- - -/// The number keys of a warp: `bend`, and the two distortions by their short and long names. -const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verticalDistortion"]; - -fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result { - check_numbers(p, WARP_NUMBERS, cmd)?; - 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); - let given = switch_or(p, "horizontal", horizontal, cmd)?; - horizontal = match str_param(p, "orientation") { - Some("vertical") => false, - Some("horizontal") => true, - _ => given, - }; - 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>, kind: EnvelopeKind, (options, fidelity): (EnvelopeOptions, f64)) -> Node { - Node::new(id, NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing: false, options, frame: Box::new(Affine::IDENTITY) }) -} - -/// `rows` and `cols` (or `columns`), whole numbers 1..50 each, defaulting to `default`. -fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> { - check_numbers(p, &["rows", "cols", "columns"], cmd)?; - let rows = f64_or(p, "rows", default.0 as f64); - let cols_key = if p.get("cols").and_then(Value::as_f64).is_some() { "cols" } else { "columns" }; - let cols = f64_or(p, cols_key, 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((whole(rows, "rows", cmd)? as u32, whole(cols, cols_key, cmd)? as u32)) -} - -fn env_make_warp(s: &mut Session, p: &Value) -> Result { - 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 for an envelope with map `map`: following its -/// current surface (`maintain`), or a flat grid over its content. -fn mesh_kind(map: &EnvelopeMap, maintain: bool, rows: u32, cols: u32) -> EnvelopeKind { - if !maintain { - return EnvelopeKind::Mesh { rows, cols, points: map.grid(rows, cols), handles: vec![] }; - } - // The surface's points with handles along it: the grid lines keep the current shape. - let m = map.surface_mesh(rows, cols, Color::BLACK); - EnvelopeKind::Mesh { rows, cols, points: m.points.iter().map(|q| q.p).collect(), handles: m.points.iter().map(|q| q.handles).collect() } -} - -fn env_make_mesh(s: &mut Session, p: &Value) -> Result { - 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> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect(); - let src = live::nodes_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?; - let kind = EnvelopeKind::Mesh { rows, cols, points: live::grid_points(src, rows, cols), handles: vec![] }; - 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 { - 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 { - 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 the envelope and its current kind). -fn reshaped(s: &Session, f: impl Fn(&Node, &EnvelopeKind) -> Result) -> Result> { - let doc = &s.doc()?.doc; - let mut out = vec![]; - for e in selected_of(s, is_envelope) { - if let Some(n) = doc.node(e) - && let NodeKind::Envelope { kind, .. } = &n.kind - { - out.push((e, f(n, 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 { - 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 { - 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 { - const C: &str = "object.envelope.resetWithMesh"; - let maintain = switch_or(p, "maintainShape", true, C)?; - let kinds = reshaped(s, |env, kind| { - let own = match kind { - EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols), - _ => (4, 4), - }; - let (rows, cols) = rows_cols(p, C, own)?; - let map = EnvelopeMap::of(env).ok_or_else(|| bad(C, "the envelope's content has no bounds"))?; - Ok(mesh_kind(&map, maintain, rows, cols)) - })?; - 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 { - 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: switch_or(p, "antiAlias", cur.anti_alias, cmd)?, - preserve_shape, - distort_appearance: switch_or(p, "distortAppearance", cur.distort_appearance, cmd)?, - distort_linear_gradients: switch_or(p, "distortLinearGradients", cur.distort_linear_gradients, cmd)?, - distort_pattern_fills: switch_or(p, "distortPatternFills", cur.distort_pattern_fills, cmd)?, - }) -} - -fn env_info(s: &mut Session, _: &Value) -> Result { - 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 the shape of envelope `env` (of `kind`): 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(env: &Node, kind: &EnvelopeKind) -> Option { - 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))); - } - // The mesh itself, handles and all. - EnvelopeKind::Mesh { rows, cols, points, handles } => { - let mut m = live::envelope_grid(*rows, *cols, points, handles)?; - m.points.iter_mut().for_each(|q| q.color = grey); - return Some(Node::new(NodeId(0), NodeKind::Mesh(m))); - } - EnvelopeKind::Warp { .. } => (4, 4), - }; - EnvelopeMap::of(env).map(|map| Node::new(NodeId(0), NodeKind::Mesh(map.surface_mesh(rows, cols, grey)))) -} - -/// 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) -> Vec { - 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.extras_mut().mask = env.extras().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 { - let envs = selected_of(s, is_envelope); - let ids = s.edit("Release Envelope", |d, sel| { - let mut out = vec![]; - let mut released = Beside::replacing(d, &envs); - 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(&n, kind) { - shape.id = d.alloc_id(); - nodes.push(shape); - } - out.extend(nodes.iter().map(|c| c.id)); - released.put(d, *e, nodes)?; - } - released.finish(d)?; - sel.set(out.iter().copied()); - Ok(out) - })?; - Ok(ids_json(&ids)) -} - -fn env_options(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.envelope.options"; - check_numbers(p, &["fidelity"], C)?; - check_numbers(p, WARP_NUMBERS, C)?; - check_switches(p, &["horizontal"], C)?; - 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 { - k @ EnvelopeKind::Warp { .. } => warp_kind(p, C, Some(k))?, - 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 = vectorcraft_render::effects::expanded_live_group(Some(d), &n); - fix_ids(d, &mut g); - // Outlined type's emoji take their pixels into the document. - d.adopt_pixels(&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 { - fn collect(n: &Node, out: &mut Vec) { - 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 { - 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 { - const C: &str = "object.envelope.editContents"; - 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 = switch_or(p, "editing", !cur, C)?; - 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 { - const C: &str = "object.envelope.setMeshPoint"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; - let index = index_param(p, "index", C)?; - let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); - let handle = handle_param(p, C)?; - s.edit("Move Envelope Point", |d, _| { - let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { - return Err(bad(C, "not a mesh envelope point")); - }; - let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { - return Err(bad(C, "not a mesh envelope point")); - }; - let at = *points.get(index).ok_or_else(|| bad(C, "not a mesh envelope point"))?; - match handle { - // The handles are offsets: they follow. - None => points.get_mut(index).into_iter().for_each(|p| *p = q), - Some(h) => { - // The first handle edited fills in the smooth mesh's handles. - if handles.len() != points.len() { - *handles = live::smooth_handles(*rows, *cols, points); - } - if let Some(hs) = handles.get_mut(index) { - hs[h] = q - at; - } - } - } - Ok(()) - })?; - ok() -} - -// ---------- Gradient mesh ---------- - -fn mesh_create(s: &mut Session, p: &Value) -> Result { - 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, - }; - check_numbers(p, &["highlight"], C)?; - let highlight = f64_or(p, "highlight", 100.0).clamp(0.0, 100.0); - let at = match p.get("at") { - None | Some(Value::Null) => None, - Some(v) => Some(finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?), - }; - 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; - let mut refused = None; - let model = d.color_mode.model(); - for id in &roots { - let Some(n) = d.node(*id).cloned() else { continue }; - let m = if let NodeKind::Image(im) = &n.kind { - // An embedded image: the points take the picture's colours there. - match image_mesh(d, im, rows, cols) { - Ok(mut m) => { - m.highlight(app, highlight); - Some(m) - } - Err(why) => { - refused.get_or_insert(why); - continue; - } - } - } else { - let Some(path) = outline_of(&n) else { continue }; - 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, model))).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, refused.unwrap_or("select a path, a compound path or an embedded image"))); - } - 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) -} - -/// Create Gradient Mesh on an embedded image: a `rows` × `cols` mesh over the picture's rectangle -/// (placed with the image's transform), each point taking the average colour and opacity of the -/// area around it. Linked images are refused: the documentation allows embedded ones only. -fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: u32) -> std::result::Result { - if im.link.is_some() { - return Err("a linked image can't become a gradient mesh: embed it first"); - } - let xf = im.xf.as_coeffs(); - if im.width == 0 || im.height == 0 || !xf.iter().all(|k| k.is_finite()) { - return Err("the image has no usable size or position"); - } - let samples = d.images.get(&im.key).and_then(|b| b.grid_samples(rows, cols)).ok_or("the image's pixels can't be read")?; - let (w, h) = (im.width as f64, im.height as f64); - let at = |u: f64, v: f64| im.xf * Point::new(u * w, v * h); - if ![at(0.0, 0.0), at(1.0, 0.0), at(0.0, 1.0), at(1.0, 1.0)].iter().all(|p| p.x.abs() < 1e15 && p.y.abs() < 1e15) { - return Err("the image has no usable size or position"); - } - let cols_n = cols as usize + 1; - let steps = |n: u32| (0..=n).map(|i| i as f64 / n as f64).collect::>(); - Ok(GradientMesh::from_grid(&steps(cols), &steps(rows), &at, &|r, c, _| { - let [pr, pg, pb, pa] = samples.get(r * cols_n + c).copied().unwrap_or([0; 4]); - (Color::rgb8(pr, pg, pb), pa as f32 / 255.0) - })) -} - -/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh -/// envelope's grid too (its points and handles are written back: editing fills in the handles). -fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { - 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(NodeKind::Envelope { kind, .. }) if envelopes && matches!(**kind, EnvelopeKind::Mesh { .. }) => { - let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { - return Err(bad(&c, "not a gradient mesh or mesh envelope")); - }; - let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(&c, "the envelope's mesh is malformed"))?; - let r = f(&mut m)?; - (*rows, *cols) = (m.rows, m.cols); - *points = m.points.iter().map(|q| q.p).collect(); - *handles = m.points.iter().map(|q| q.handles).collect(); - Ok(r) - } - Some(_) => Err(bad(&c, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })), - None => Err(EngineError::NoNode(id)), - }) -} - -/// The `handle` parameter (0 right, 1 left, 2 down, 3 up), if any. -fn handle_param(p: &Value, cmd: &str) -> Result> { - match p.get("handle") { - None | Some(Value::Null) => Ok(None), - Some(v) => match v.as_u64() { - Some(h) if h <= 3 => Ok(Some(h as usize)), - _ => Err(bad(cmd, "handle must be 0..3")), - }, - } -} - -fn index_param(p: &Value, key: &str, cmd: &str) -> Result { - let i = f64_req(p, key, cmd)?; - if i < 0.0 { - return Err(bad(cmd, "index must be ≥ 0")); - } - Ok(whole(i, key, cmd)? as usize) -} - -/// `x`, the value of `key`, when it is a whole number, and an error otherwise. -fn whole(x: f64, key: &str, cmd: &str) -> Result { - if x.fract() == 0.0 { Ok(x) } else { Err(bad(cmd, format!("`{key}` must be a whole number"))) } -} - -fn mesh_set_color(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.mesh.setPointColor"; - check_numbers(p, &["opacity"], C)?; - super::paint::check_keep_model(p, C)?; - let index = index_param(p, "index", C)?; - let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c)); - 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", false, |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 { - 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 = handle_param(p, C)?; - with_mesh(s, p, C, "Move Mesh Point", true, |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 { - const C: &str = "object.mesh.addLine"; - let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); - super::paint::check_keep_model(p, C)?; - let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c)); - let index = with_mesh(s, p, C, "Add Mesh Line", true, |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 { - const C: &str = "object.mesh.deletePoint"; - let index = index_param(p, "index", C)?; - with_mesh(s, p, C, "Delete Mesh Point", true, |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 { - 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)) -} +mod blend; +mod commands; +mod common; +mod envelope; +mod mesh; + +pub(crate) use blend::expand_blends; +pub use commands::specs; +pub(super) use common::fix_ids; +pub(super) use envelope::expand_envelopes_under; diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs new file mode 100644 index 000000000..1ab018a8f --- /dev/null +++ b/crates/engine/src/cmd/live/blend.rs @@ -0,0 +1,525 @@ +use super::common::{ + DEFAULT_DISTANCE, DEFAULT_STEPS, ease_param, fix_ids, ids_json, is_blend, orientation_param, percent_param, roots_param, selected_of, + spacing_param, starts_param, wrap, +}; +use super::mesh::index_param; +use super::*; + +/// The first blend among `roots`. +fn first_blend(d: &Document, roots: &[NodeId]) -> Option { + 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>, starts: &[(NodeId, u32)]) -> (Vec>, Vec>) { + 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], old_spec: &BlendSpec, keys: &[Arc]) { + 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| 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 = (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); +} + +pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result { + 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 (ease0, color0) = match base.as_ref().map(|n| &n.kind) { + Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift), + _ => Default::default(), + }; + let ease = ease_param(p, C, ease0)?; + let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?; + 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; a new one is a + // knockout group, so translucent steps don't show through each other. + let mut n = base.unwrap_or_else(|| { + let mut n = Node::new(id, NodeKind::Group { children: vec![], clip: false }); + n.knockout = vectorcraft_doc::Knockout::On; + n + }); + let mut spec = BlendSpec { spacing, orientation, ease, color_shift, 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: Box::new(spec) }; + n + }) + })?; + Ok(json!({ "id": id.0 })) +} + +pub(super) fn blend_release(s: &mut Session, _: &Value) -> Result { + let blends = selected_of(s, is_blend); + let (ids, spines, groups) = s.edit("Release Blend", |d, sel| { + let (mut keys, mut spines, mut groups) = (vec![], vec![], vec![]); + let mut released = Beside::replacing(d, &blends); + 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())); + // What the blend itself carries stays on a group around what it gave back. + if n.name.is_some() + || n.opacity < 1.0 + || n.blend != vectorcraft_color::BlendMode::Normal + || n.isolate + || n.extras().mask.is_some() + || !n.appearance.items.is_empty() + || !n.appearance.effects.is_empty() + { + let mut g = n.clone(); + g.kind = NodeKind::Group { children: nodes.into_iter().map(Arc::new).collect(), clip: false }; + groups.push(g.id); + nodes = vec![g]; + } + released.put(d, *b, nodes)?; + } + released.finish(d)?; + if groups.is_empty() { + sel.set(spines.iter().chain(&keys).copied()); + } else { + sel.set(groups.iter().copied()); + } + Ok((keys, spines, groups)) + })?; + let mut r = ids_json(&ids); + r["spines"] = json!(spines.iter().map(|i| i.0).collect::>()); + r["groups"] = json!(groups.iter().map(|i| i.0).collect::>()); + 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( + s: &mut Session, + p: &Value, + cmd: &str, + label: &str, + f: impl FnOnce(&mut vectorcraft_geom::SubPath, &mut Vec, &mut [Arc]) -> Result, +) -> Result { + 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 { + 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")) } +} + +pub(super) fn spine_move_anchor(s: &mut Session, p: &Value) -> Result { + 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 = switch_or(p, "independent", false, C)?; + 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() +} + +pub(super) fn spine_convert_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.spine.convertAnchor"; + let index = index_param(p, "anchor", C)?; + let smooth = match str_param(p, "to") { + Some("smooth") => true, + Some("corner") => false, + _ => return Err(bad(C, "to must be smooth or corner")), + }; + let target = match (p.get("x"), p.get("y")) { + (None, None) => None, + (Some(_), Some(_)) => Some(finite_point(p, C)?), + _ => return Err(bad(C, "x and y must be given together")), + }; + edit_spine(s, p, C, "Convert Anchor Point", |sp, _, _| { + if smooth && target.is_none() { + if index >= sp.anchors.len() { + return Err(bad(C, "no such spine point")); + } + vectorcraft_tools::draw2::curvature_smooth(sp, index); + return Ok(()); + } + let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?; + match target { + Some(q) if smooth && q.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, q), + _ => a.retract(), + } + Ok(()) + })?; + ok() +} + +pub(super) fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.spine.reshapeSegment"; + let seg = index_param(p, "segment", C)?; + let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95); + let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?); + if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) { + return Err(bad(C, "t, dx and dy must be finite")); + } + let perpendicular = switch_or(p, "perpendicular", false, C)?; + edit_spine(s, p, C, "Reshape Spine", |sp, _, _| { + let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) }; + if bent { Ok(()) } else { Err(bad(C, "no such spine segment")) } + })?; + ok() +} + +pub(super) fn spine_add_anchor(s: &mut Session, p: &Value) -> Result { + 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 })) +} + +pub(super) fn spine_remove_anchor(s: &mut Session, p: &Value) -> Result { + 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>, &mut BlendSpec)) -> Result { + 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)> { + 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, + } +} + +pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.options"; + let Some((_, cur, _)) = selected_spec(s) else { + if ["easingType", "ramp", "colorShift"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) { + return Err(bad(C, "select a blend to set easing or color shift")); + } + // Nothing to set them on: the options new blends start with (easing is per blend). + 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); + let ease = ease_param(p, C, cur.ease)?; + let color_shift = percent_param(p, C, "colorShift", cur.color_shift, -100.0)?; + edit_blends(s, "Blend Options", |_, spec| { + spec.spacing = spacing; + spec.orientation = orientation; + spec.ease = ease; + spec.color_shift = color_shift; + }) +} + +/// 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 }) +} + +pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result { + 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["easingType"] = json!(spec.ease.kind.id()); + v["ramp"] = json!(spec.ease.ramp * 100.0); + v["colorShift"] = json!(spec.color_shift); + v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::>()); + v["keys"] = json!(keys.iter().map(|k| k.0).collect::>()); + 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 = 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 + } + }) +} + +pub(super) fn blend_expand(s: &mut Session, _: &Value) -> Result { + let blends = selected_of(s, is_blend); + let ids = s.edit("Expand Blend", |d, sel| { + for b in &blends { + expand_blend(d, *b)?; + } + sel.set(blends.iter().copied()); + Ok(blends.clone()) + })?; + Ok(ids_json(&ids)) +} + +/// Replace blend `id` by a group of its keys and steps in place, keeping everything about the +/// blend itself: id, name, visibility, lock, transparency (knockout and isolation included), +/// opacity mask and appearance. +fn expand_blend(d: &mut Document, id: NodeId) -> Result<()> { + let Some(n) = d.node(id).filter(|n| is_blend(n)).cloned() else { return Ok(()) }; + let mut g = n.clone(); + g.kind = NodeKind::Group { children: vectorcraft_render::expand_live(&n).into_iter().map(Arc::new).collect(), clip: false }; + fix_ids(d, &mut g); + *d.node_mut(id).ok_or(EngineError::NoNode(id))? = g; + Ok(()) +} + +/// Object › Expand: every blend in the subtrees of `roots` (outer ones first) becomes a group of +/// its steps ([`expand_blend`]). Returns how many. +pub(crate) fn expand_blends(d: &mut Document, roots: &[NodeId]) -> Result { + let mut blends = vec![]; + for r in roots { + if let Some(n) = d.node(*r) { + n.walk(&mut |c| { + if is_blend(c) { + blends.push(c.id); + } + }); + } + } + for b in &blends { + expand_blend(d, *b)?; + } + Ok(blends.len()) +} + +/// 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], 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], 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); + } + } +} + +pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { + 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() +} + +pub(super) fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result { + 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 = 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); + } + } + }) +} + +pub(super) fn blend_reverse_stack(s: &mut Session, _: &Value) -> Result { + 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); + }) +} diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs new file mode 100644 index 000000000..d46e198e3 --- /dev/null +++ b/crates/engine/src/cmd/live/commands.rs @@ -0,0 +1,289 @@ +use super::blend::{ + blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, blend_reverse_stack, + spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, +}; +use super::common::{has_blend, has_envelope, has_mesh}; +use super::envelope::{ + env_edit_contents, env_expand, env_info, env_make_mesh, env_make_top, env_make_warp, env_options, env_release, env_reset_mesh, env_reset_warp, + env_set_mesh_point, +}; +use super::mesh::{mesh_add_line, mesh_create, mesh_delete_point, mesh_expand, mesh_move_point, mesh_set_color}; +use super::*; + +pub fn specs() -> Vec { + 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), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} 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; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [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, smooth?: bool, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct} set the options of the selected blends; easingType chooses the distribution, ramp sets its strength (0..100; 0 = even), colorShift (-100..100; 0 = even) shifts colours independently of shape/position easing (omitted ones keep their value; out of range is an error); with no blend selected, the options new blends start with (spacing and orientation only; 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 keys and steps, each keeping the blend's id, name, transparency (knockout and isolation included), opacity mask and appearance → {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, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, 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.convertAnchor", + "Convert Anchor Point", + [], + None, + "{id? (default: the selected blend), anchor: index, to: smooth|corner, x?, y?} convert a blend spine point: dragging a corner creates symmetric smooth handles, clicking a point with handles retracts both. Without x/y, smooth uses the neighbouring spine points", + has_doc, + spine_convert_anchor + ), + cmd!( + "object.blend.spine.reshapeSegment", + "Reshape Spine Segment", + [], + None, + "{id? (default: the selected blend), segment: index, t: 0..1, dx, dy, perpendicular?: bool} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's (perpendicular too): a straight spine turns into a curve (#1111)", + has_doc, + spine_reshape_segment + ), + 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, horizontalDistortion?: as h, verticalDistortion?: as v} 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), columns?: as cols} 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 %), horizontalDistortion?: as h, verticalDistortion?: as v} 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), columns?: as cols} 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), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} 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, handle?: 0 right|1 left|2 down|3 up} move a point of a mesh envelope (its handles follow), or with `handle` place that handle end at (x, y); object.mesh.movePoint, addLine and deletePoint edit mesh envelopes too", + 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], columns?: as cols} 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); an embedded image becomes a mesh over its rectangle (its transform kept) whose points take the average colour and opacity of the picture around them (a linked image is refused: embed it first) → {ids, index?}", + has_selection, + mesh_create + ), + cmd!( + "object.mesh.setPointColor", + "Set Mesh Point Color", + [], + None, + "{id, index, color, keepModel?: bool (as paint.setFill), 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 point of a gradient mesh or mesh envelope (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?, keepModel?: bool (as paint.setFill)} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope → {index}", + has_doc, + mesh_add_line + ), + cmd!( + "object.mesh.deletePoint", + "Delete Mesh Point", + [], + None, + "{id, index} delete the mesh lines through a point of a gradient mesh or mesh envelope", + 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 + ), + ] +} diff --git a/crates/engine/src/cmd/live/common.rs b/crates/engine/src/cmd/live/common.rs new file mode 100644 index 000000000..4237f48d3 --- /dev/null +++ b/crates/engine/src/cmd/live/common.rs @@ -0,0 +1,202 @@ +use super::mesh::whole; +use super::*; + +pub(super) fn ids_json(ids: &[NodeId]) -> Value { + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) +} + +/// Explicit `ids` (in paint order) or the selected roots. +pub(super) fn roots_param(s: &Session, p: &Value) -> Result> { + 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). +pub(super) fn selected_of(s: &Session, pred: fn(&Node) -> bool) -> Vec { + 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 +} + +pub(super) fn is_blend(n: &Node) -> bool { + matches!(n.kind, NodeKind::Blend { .. }) +} + +pub(super) fn is_envelope(n: &Node) -> bool { + matches!(n.kind, NodeKind::Envelope { .. }) +} + +pub(super) fn is_mesh(n: &Node) -> bool { + matches!(n.kind, NodeKind::Mesh(_)) +} + +pub(super) 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(()) } +} + +pub(super) 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(()) } +} + +pub(super) 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. +pub(super) fn wrap(d: &mut Document, sel: &mut Selection, roots: &[NodeId], make: impl FnOnce(NodeId, Vec>) -> Node) -> Result { + if roots.is_empty() { + return Err(EngineError::Other("nothing to wrap".into())); + } + let id = d.alloc_id(); + d.wrap(roots, |nodes| make(id, nodes))?; + sel.set([id]); + Ok(id) +} + +/// Put `nodes` where `id` was (replacing it). Returns their ids. +pub(super) fn replace_with(d: &mut Document, id: NodeId, nodes: Vec) -> Result> { + 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. +pub(in super::super) 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. +pub(super) const DEFAULT_STEPS: u32 = 5; + +pub(super) const DEFAULT_DISTANCE: f64 = 10.0; + +pub(super) fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result { + check_numbers(p, &["value", "steps", "distance"], cmd)?; + let smooth = switch_or(p, "smooth", false, cmd)?; + let num = |k: &str| p.get(k).and_then(Value::as_f64); + let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32)); + let mode = str_param(p, "spacing"); + let value = num("value"); + let sp = match mode { + Some("smooth") | Some("smoothColor") => BlendSpacing::SmoothColor, + Some("steps") => match value { + Some(v) => steps("value", v)?, + None => steps("steps", num("steps").unwrap_or(DEFAULT_STEPS as f64))?, + }, + 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") { + steps("steps", n)? + } else if let Some(d) = num("distance") { + BlendSpacing::Distance(d) + } else if smooth { + 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), + } +} + +pub(super) 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, + } +} + +/// Blend panel Type and Ramp, validated before any document mutation. +pub(super) fn ease_param(p: &Value, cmd: &str, current: BlendEase) -> Result { + let kind = match p.get("easingType").filter(|v| !v.is_null()) { + None => current.kind, + Some(v) => match v.as_str() { + Some("easeIn") => BlendEaseType::In, + Some("easeOut") => BlendEaseType::Out, + Some("easeInOut") => BlendEaseType::InOut, + Some("easeOutIn") => BlendEaseType::OutIn, + _ => return Err(bad(cmd, "easingType must be easeIn, easeOut, easeInOut or easeOutIn")), + }, + }; + Ok(BlendEase { kind, ramp: percent_param(p, cmd, "ramp", current.ramp * 100.0, 0.0)? / 100.0 }) +} + +/// Bounded percentage input; missing or null preserves the current value. +pub(super) fn percent_param(p: &Value, cmd: &str, key: &str, current: f64, min: f64) -> Result { + match p.get(key).filter(|v| !v.is_null()) { + None => Ok(current), + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && (min..=100.0).contains(n)) + .ok_or_else(|| bad(cmd, format!("{key} must be a percentage {min}..100"))), + } +} + +/// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from. +pub(super) fn starts_param(p: &Value, roots: &[NodeId]) -> Result> { + 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) +} diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs new file mode 100644 index 000000000..27e41509a --- /dev/null +++ b/crates/engine/src/cmd/live/envelope.rs @@ -0,0 +1,423 @@ +use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wrap}; +use super::mesh::{handle_param, index_param, whole}; +use super::*; + +// ---------- Envelope ---------- + +/// The number keys of a warp: `bend`, and the two distortions by their short and long names. +const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verticalDistortion"]; + +fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result { + check_numbers(p, WARP_NUMBERS, cmd)?; + 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); + let given = switch_or(p, "horizontal", horizontal, cmd)?; + horizontal = match str_param(p, "orientation") { + Some("vertical") => false, + Some("horizontal") => true, + _ => given, + }; + 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>, kind: EnvelopeKind, (options, fidelity): (EnvelopeOptions, f64)) -> Node { + Node::new(id, NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing: false, options, frame: Box::new(Affine::IDENTITY) }) +} + +/// `rows` and `cols` (or `columns`), whole numbers 1..50 each, defaulting to `default`. +pub(super) fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> { + check_numbers(p, &["rows", "cols", "columns"], cmd)?; + let rows = f64_or(p, "rows", default.0 as f64); + let cols_key = if p.get("cols").and_then(Value::as_f64).is_some() { "cols" } else { "columns" }; + let cols = f64_or(p, cols_key, 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((whole(rows, "rows", cmd)? as u32, whole(cols, cols_key, cmd)? as u32)) +} + +pub(super) fn env_make_warp(s: &mut Session, p: &Value) -> Result { + 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 for an envelope with map `map`: following its +/// current surface (`maintain`), or a flat grid over its content. +fn mesh_kind(map: &EnvelopeMap, maintain: bool, rows: u32, cols: u32) -> EnvelopeKind { + if !maintain { + return EnvelopeKind::Mesh { rows, cols, points: map.grid(rows, cols), handles: vec![] }; + } + // The surface's points with handles along it: the grid lines keep the current shape. + let m = map.surface_mesh(rows, cols, Color::BLACK); + EnvelopeKind::Mesh { rows, cols, points: m.points.iter().map(|q| q.p).collect(), handles: m.points.iter().map(|q| q.handles).collect() } +} + +pub(super) fn env_make_mesh(s: &mut Session, p: &Value) -> Result { + 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> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect(); + let src = live::nodes_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?; + let kind = EnvelopeKind::Mesh { rows, cols, points: live::grid_points(src, rows, cols), handles: vec![] }; + 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. +pub(super) fn outline_of(n: &Node) -> Option { + 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, + } +} + +pub(super) fn env_make_top(s: &mut Session, p: &Value) -> Result { + 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 the envelope and its current kind). +fn reshaped(s: &Session, f: impl Fn(&Node, &EnvelopeKind) -> Result) -> Result> { + let doc = &s.doc()?.doc; + let mut out = vec![]; + for e in selected_of(s, is_envelope) { + if let Some(n) = doc.node(e) + && let NodeKind::Envelope { kind, .. } = &n.kind + { + out.push((e, f(n, 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 { + 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() +} + +pub(super) fn env_reset_warp(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.resetWithWarp"; + let kinds = reshaped(s, |_, k| warp_kind(p, C, Some(k)))?; + set_kinds(s, "Reset with Warp", kinds) +} + +pub(super) fn env_reset_mesh(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.resetWithMesh"; + let maintain = switch_or(p, "maintainShape", true, C)?; + let kinds = reshaped(s, |env, kind| { + let own = match kind { + EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols), + _ => (4, 4), + }; + let (rows, cols) = rows_cols(p, C, own)?; + let map = EnvelopeMap::of(env).ok_or_else(|| bad(C, "the envelope's content has no bounds"))?; + Ok(mesh_kind(&map, maintain, rows, cols)) + })?; + 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 { + 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: switch_or(p, "antiAlias", cur.anti_alias, cmd)?, + preserve_shape, + distort_appearance: switch_or(p, "distortAppearance", cur.distort_appearance, cmd)?, + distort_linear_gradients: switch_or(p, "distortLinearGradients", cur.distort_linear_gradients, cmd)?, + distort_pattern_fills: switch_or(p, "distortPatternFills", cur.distort_pattern_fills, cmd)?, + }) +} + +pub(super) fn env_info(s: &mut Session, _: &Value) -> Result { + 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 the shape of envelope `env` (of `kind`): 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(env: &Node, kind: &EnvelopeKind) -> Option { + 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))); + } + // The mesh itself, handles and all. + EnvelopeKind::Mesh { rows, cols, points, handles } => { + let mut m = live::envelope_grid(*rows, *cols, points, handles)?; + m.points.iter_mut().for_each(|q| q.color = grey); + return Some(Node::new(NodeId(0), NodeKind::Mesh(m))); + } + EnvelopeKind::Warp { .. } => (4, 4), + }; + EnvelopeMap::of(env).map(|map| Node::new(NodeId(0), NodeKind::Mesh(map.surface_mesh(rows, cols, grey)))) +} + +/// 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) -> Vec { + 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.extras_mut().mask = env.extras().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] +} + +pub(super) fn env_release(s: &mut Session, _: &Value) -> Result { + let envs = selected_of(s, is_envelope); + let ids = s.edit("Release Envelope", |d, sel| { + let mut out = vec![]; + let mut released = Beside::replacing(d, &envs); + 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(&n, kind) { + shape.id = d.alloc_id(); + nodes.push(shape); + } + out.extend(nodes.iter().map(|c| c.id)); + released.put(d, *e, nodes)?; + } + released.finish(d)?; + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(ids_json(&ids)) +} + +pub(super) fn env_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.options"; + check_numbers(p, &["fidelity"], C)?; + check_numbers(p, WARP_NUMBERS, C)?; + check_switches(p, &["horizontal"], C)?; + 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 { + k @ EnvelopeKind::Warp { .. } => warp_kind(p, C, Some(k))?, + 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 = vectorcraft_render::effects::expanded_live_group(Some(d), &n); + fix_ids(d, &mut g); + // Outlined type's emoji take their pixels into the document. + d.adopt_pixels(&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(in super::super) fn expand_envelopes_under(d: &mut Document, root: NodeId) -> Result { + fn collect(n: &Node, out: &mut Vec) { + 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()) +} + +pub(super) fn env_expand(s: &mut Session, _: &Value) -> Result { + 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)) +} + +pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.editContents"; + 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 = switch_or(p, "editing", !cur, C)?; + 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 })) +} + +pub(super) fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.setMeshPoint"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let index = index_param(p, "index", C)?; + let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); + let handle = handle_param(p, C)?; + s.edit("Move Envelope Point", |d, _| { + let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { + return Err(bad(C, "not a mesh envelope point")); + }; + let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { + return Err(bad(C, "not a mesh envelope point")); + }; + let at = *points.get(index).ok_or_else(|| bad(C, "not a mesh envelope point"))?; + match handle { + // The handles are offsets: they follow. + None => points.get_mut(index).into_iter().for_each(|p| *p = q), + Some(h) => { + // The first handle edited fills in the smooth mesh's handles. + if handles.len() != points.len() { + *handles = live::smooth_handles(*rows, *cols, points); + } + if let Some(hs) = handles.get_mut(index) { + hs[h] = q - at; + } + } + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs new file mode 100644 index 000000000..570ef6c92 --- /dev/null +++ b/crates/engine/src/cmd/live/mesh.rs @@ -0,0 +1,229 @@ +use super::common::{fix_ids, ids_json, is_mesh, replace_with, roots_param, selected_of}; +use super::envelope::{outline_of, rows_cols}; +use super::*; + +// ---------- Gradient mesh ---------- + +pub(super) fn mesh_create(s: &mut Session, p: &Value) -> Result { + 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, + }; + check_numbers(p, &["highlight"], C)?; + let highlight = f64_or(p, "highlight", 100.0).clamp(0.0, 100.0); + let at = match p.get("at") { + None | Some(Value::Null) => None, + Some(v) => Some(finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?), + }; + 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; + let mut refused = None; + let model = d.color_mode.model(); + for id in &roots { + let Some(n) = d.node(*id).cloned() else { continue }; + let m = if let NodeKind::Image(im) = &n.kind { + // An embedded image: the points take the picture's colours there. + match image_mesh(d, im, rows, cols) { + Ok(mut m) => { + m.highlight(app, highlight); + Some(m) + } + Err(why) => { + refused.get_or_insert(why); + continue; + } + } + } else { + let Some(path) = outline_of(&n) else { continue }; + 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, model))).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, refused.unwrap_or("select a path, a compound path or an embedded image"))); + } + 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) +} + +/// Create Gradient Mesh on an embedded image: a `rows` × `cols` mesh over the picture's rectangle +/// (placed with the image's transform), each point taking the average colour and opacity of the +/// area around it. Linked images are refused: the documentation allows embedded ones only. +fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: u32) -> std::result::Result { + if im.link.is_some() { + return Err("a linked image can't become a gradient mesh: embed it first"); + } + let xf = im.xf.as_coeffs(); + if im.width == 0 || im.height == 0 || !xf.iter().all(|k| k.is_finite()) { + return Err("the image has no usable size or position"); + } + let samples = d.images.get(&im.key).and_then(|b| b.grid_samples(rows, cols)).ok_or("the image's pixels can't be read")?; + let (w, h) = (im.width as f64, im.height as f64); + let at = |u: f64, v: f64| im.xf * Point::new(u * w, v * h); + if ![at(0.0, 0.0), at(1.0, 0.0), at(0.0, 1.0), at(1.0, 1.0)].iter().all(|p| p.x.abs() < 1e15 && p.y.abs() < 1e15) { + return Err("the image has no usable size or position"); + } + let cols_n = cols as usize + 1; + let steps = |n: u32| (0..=n).map(|i| i as f64 / n as f64).collect::>(); + Ok(GradientMesh::from_grid(&steps(cols), &steps(rows), &at, &|r, c, _| { + let [pr, pg, pb, pa] = samples.get(r * cols_n + c).copied().unwrap_or([0; 4]); + (Color::rgb8(pr, pg, pb), pa as f32 / 255.0) + })) +} + +/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh +/// envelope's grid too (its points and handles are written back: editing fills in the handles). +fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { + 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(NodeKind::Envelope { kind, .. }) if envelopes && matches!(**kind, EnvelopeKind::Mesh { .. }) => { + let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { + return Err(bad(&c, "not a gradient mesh or mesh envelope")); + }; + let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(&c, "the envelope's mesh is malformed"))?; + let r = f(&mut m)?; + (*rows, *cols) = (m.rows, m.cols); + *points = m.points.iter().map(|q| q.p).collect(); + *handles = m.points.iter().map(|q| q.handles).collect(); + Ok(r) + } + Some(_) => Err(bad(&c, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })), + None => Err(EngineError::NoNode(id)), + }) +} + +/// The `handle` parameter (0 right, 1 left, 2 down, 3 up), if any. +pub(super) fn handle_param(p: &Value, cmd: &str) -> Result> { + match p.get("handle") { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_u64() { + Some(h) if h <= 3 => Ok(Some(h as usize)), + _ => Err(bad(cmd, "handle must be 0..3")), + }, + } +} + +pub(super) fn index_param(p: &Value, key: &str, cmd: &str) -> Result { + let i = f64_req(p, key, cmd)?; + if i < 0.0 { + return Err(bad(cmd, "index must be ≥ 0")); + } + Ok(whole(i, key, cmd)? as usize) +} + +/// `x`, the value of `key`, when it is a whole number, and an error otherwise. +pub(super) fn whole(x: f64, key: &str, cmd: &str) -> Result { + if x.fract() == 0.0 { Ok(x) } else { Err(bad(cmd, format!("`{key}` must be a whole number"))) } +} + +pub(super) fn mesh_set_color(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.mesh.setPointColor"; + check_numbers(p, &["opacity"], C)?; + crate::cmd::paint::check_keep_model(p, C)?; + let index = index_param(p, "index", C)?; + let color = p.get("color").and_then(color_value).map(|c| crate::cmd::paint::new_color(s, p, c)); + 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", false, |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() +} + +pub(super) fn mesh_move_point(s: &mut Session, p: &Value) -> Result { + 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 = handle_param(p, C)?; + with_mesh(s, p, C, "Move Mesh Point", true, |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() +} + +pub(super) fn mesh_add_line(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.mesh.addLine"; + let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); + crate::cmd::paint::check_keep_model(p, C)?; + let color = p.get("color").and_then(color_value).map(|c| crate::cmd::paint::new_color(s, p, c)); + let index = with_mesh(s, p, C, "Add Mesh Line", true, |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 })) +} + +pub(super) fn mesh_delete_point(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.mesh.deletePoint"; + let index = index_param(p, "index", C)?; + with_mesh(s, p, C, "Delete Mesh Point", true, |m| { + if m.remove_point_lines(index) { Ok(()) } else { Err(bad(C, "only interior mesh lines can be deleted")) } + })?; + ok() +} + +pub(super) fn mesh_expand(s: &mut Session, _: &Value) -> Result { + 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)) +}