Commands: ids leave out an object listed with a group that holds it, as the selection does

Commands that read the selection through edit::selected_roots leave
out an object whose group is also selected (edit::roots_of). Fourteen
places that read `ids` kept an object listed together with its group.
Given ids that list an object and a group that holds it, Make Repeat
failed with "no such object", New Symbol failed or stored the object
twice in the symbol, and object.transform moved it twice (20 pt for a
10 pt move).

These places now pass explicit ids through edit::outermost, which
drops an id whose ancestor is also listed, with the filter that
roots_of runs on the selection (now edit::without_inner). Nothing else
is dropped or replaced: unlike roots_of, it keeps layers and
compound-path members. A list without nesting gives the same result as
before. Commands that take both ids and the selection as listed, such
as paint.setFill without item, are unchanged.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-16 06:16:35 +07:00
committed by bflatastic
parent a6f56caa0e
commit 67a278b053
19 changed files with 208 additions and 41 deletions
+6 -6
View File
@@ -22,7 +22,7 @@ use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer};
use vectorcraft_geom::Rect;
use super::edit::selected_roots;
use super::edit::{outermost, selected_roots};
use super::opacitymask::percent;
use super::paint::paint_from;
use super::*;
@@ -294,15 +294,15 @@ pub(crate) fn subject_roots(s: &Session) -> Result<Vec<NodeId>> {
}
}
/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too), else the
/// [`subject_roots`]; with `target: "contents"` the painted objects inside the groups and layers
/// among them ([`leaf_targets`]).
/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too; an id inside
/// another given one is dropped), else the [`subject_roots`]; with `target: "contents"` the painted
/// objects inside the groups and layers among them ([`leaf_targets`]).
pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let roots = if p.get("ids").is_none() && p.get("id").is_none() {
subject_roots(s)?
} else {
let d = &s.doc()?.doc;
targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some()).collect()
outermost(d, targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some()).collect())
};
if targets_contents(p)? { leaf_targets(s, &roots) } else { Ok(roots) }
}
@@ -745,7 +745,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false));
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
let selected = match ids_param(p, "ids") {
Some(v) => v,
Some(v) => outermost(&s.doc()?.doc, v),
None => selected_roots(s)?,
};
// Reversed, the first selected object's attributes go onto the clicked object.
+3 -3
View File
@@ -14,7 +14,7 @@ use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolRegistration};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::edit::selected_roots;
use super::edit::{outermost, selected_roots};
use super::pathops::{remove_each, separate};
use super::*;
use crate::EngineError;
@@ -198,11 +198,11 @@ fn ids_json(ids: &[NodeId]) -> Value {
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(mut v) => {
// Explicit ids: only existing, non-layer objects.
// Explicit ids: only existing, non-layer objects, none inside another of them.
let d = &s.doc()?.doc;
v.retain(|id| d.node(*id).is_some_and(|n| !n.is_layer()));
v.dedup();
Ok(v)
Ok(outermost(d, v))
}
None => selected_roots(s),
}
+2 -2
View File
@@ -24,7 +24,7 @@ use vectorcraft_tools::builder::{
};
use vectorcraft_trace as tr;
use super::edit::selected_roots;
use super::edit::{outermost, selected_roots};
use super::paint::paint_from;
use super::pathops::{Beside, shape_node};
use super::*;
@@ -192,7 +192,7 @@ fn root_ids(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
if ids.is_empty() {
return Err(EngineError::Other("select objects, not layers".into()));
}
Ok(sorted_roots(d, &ids))
Ok(outermost(d, sorted_roots(d, &ids)))
}
fn points_param(p: &Value, key: &str) -> Option<Vec<Point>> {
+4 -3
View File
@@ -13,7 +13,7 @@ use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sam
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with};
use super::edit::{duplicate_in, selected_roots};
use super::edit::{duplicate_in, outermost, selected_roots};
use super::*;
use crate::EngineError;
@@ -706,7 +706,8 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) {
return Err(bad(C, "pins must be finite"));
}
let ids = if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { puppet_ids(p, || selected_roots(s))? };
let ids =
if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) };
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
let angles: Vec<Option<f64>> = match p.get("angles") {
None | Some(Value::Null) => vec![None; pins.len()],
@@ -846,7 +847,7 @@ fn plane_param(g: &PerspectiveGrid, p: &Value, cmd: &str) -> Result<Plane> {
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(v) => Ok(v),
Some(v) => Ok(outermost(&s.doc()?.doc, v)),
None => selected_roots(s),
}
}
+2 -1
View File
@@ -372,7 +372,8 @@ fn snap_xf(b: Rect, half: bool) -> Affine {
}
fn pixel_perfect(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<NodeId> = if p.get("ids").is_some() { targets(s, p)? } else { super::edit::selected_roots(s)? };
let ids: Vec<NodeId> =
if p.get("ids").is_some() { super::edit::outermost(&s.doc()?.doc, targets(s, p)?) } else { super::edit::selected_roots(s)? };
let n = ids.len();
s.edit("Make Pixel Perfect", |d, _| {
for id in &ids {
+21 -7
View File
@@ -342,17 +342,31 @@ pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
})
.collect();
sel.dedup_by_key(|(id, _)| *id);
let listed: std::collections::HashSet<NodeId> = sel.iter().map(|(id, _)| *id).collect();
sel.iter()
.filter(|(_, path)| {
let anc = path.as_deref().and_then(|p| doc.ancestry_at(p)).unwrap_or_default();
!anc[..anc.len().saturating_sub(1)].iter().any(|a| listed.contains(a))
})
without_inner(doc, sel)
.filter(|(_, path)| path.as_deref().and_then(|p| doc.node_at(p)).is_some_and(|n| !n.is_layer()))
.map(|(id, _)| *id)
.map(|(id, _)| id)
.collect()
}
/// `ids` in the order given, less those whose ancestor is also listed: an object listed with a
/// group that holds it is dropped, as [`roots_of`] drops it from a selection. Nothing else is
/// dropped or replaced: unlike `roots_of`, it keeps layers, ids that aren't in the document and
/// compound-path members.
pub(crate) fn outermost(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
let paths = doc.index_paths(ids.iter().copied());
let placed = ids.into_iter().map(|id| (id, paths.get(&id).cloned())).collect();
without_inner(doc, placed).map(|(id, _)| id).collect()
}
/// The entries of `placed` (ids with their index paths) whose id has no ancestor among its ids.
fn without_inner(doc: &Document, placed: Vec<(NodeId, Option<Vec<usize>>)>) -> impl Iterator<Item = (NodeId, Option<Vec<usize>>)> + '_ {
let listed: std::collections::HashSet<NodeId> = placed.iter().map(|(id, _)| *id).collect();
placed.into_iter().filter(move |(_, path)| {
let anc = path.as_deref().and_then(|p| doc.ancestry_at(p)).unwrap_or_default();
!anc[..anc.len().saturating_sub(1)].iter().any(|a| listed.contains(a))
})
}
fn copy(s: &mut Session, _: &Value) -> Result<Value> {
// The Artboard tool copies its artboard (and the art on it).
if s.tool_id() == "artboard" {
+2 -2
View File
@@ -8,7 +8,7 @@ use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_tools::params::color_json;
use super::appearance::{ItemTarget, edit_items, edits_stroke, item_target};
use super::edit::selected_roots;
use super::edit::{outermost, selected_roots};
use super::paint::swatch_solid;
use super::*;
use crate::EngineError;
@@ -512,7 +512,7 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
let item = item.of_kind(s, !stroke);
let targets = match item {
ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), Ok)?)?,
ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), |ids| Ok(outermost(&s.doc()?.doc, ids)))?)?,
_ => item.targets(s, p)?,
};
if targets.is_empty() {
+3 -2
View File
@@ -340,9 +340,10 @@ pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str
values.iter().map(|value| checked_id(s, value, command)).collect()
}
/// The objects a command acts on: `ids` when given (every one checked), else the selected objects.
/// The objects a command acts on: `ids` when given (every one checked, and those inside another
/// given one dropped), else the selected objects.
pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> Result<Vec<NodeId>> {
if p.get("ids").is_some() { checked_ids_param(s, p, "ids", command) } else { edit::selected_roots(s) }
if p.get("ids").is_some() { Ok(edit::outermost(&s.doc()?.doc, checked_ids_param(s, p, "ids", command)?)) } else { edit::selected_roots(s) }
}
pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result<NodeId> {
+2 -2
View File
@@ -11,7 +11,7 @@ use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, No
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2};
use super::edit::{duplicate_in, selected_roots};
use super::edit::{duplicate_in, outermost, selected_roots};
use super::opacitymask::percent;
use super::*;
use crate::{EngineError, LastTransform};
@@ -492,7 +492,7 @@ fn resize_type_area(n: &mut Node, xf: Affine) -> bool {
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
let (ids, anchors) = match ids_param(p, "ids") {
Some(v) => (v, false),
Some(v) => (outermost(&s.doc()?.doc, v), false),
None => (selected_roots(s)?, bool_or(p, "anchors", false)),
};
apply_transform(s, "Transform", ids, m, p, anchors)
+1 -1
View File
@@ -173,7 +173,7 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(id) = ids.iter().find(|id| d.node(**id).is_none()) {
return Err(EngineError::NoNode(*id));
}
Selection { objects: ids, ..Default::default() }.in_paint_order(d)
super::edit::outermost(d, Selection { objects: ids, ..Default::default() }.in_paint_order(d))
}
None => super::appearance::subject_roots(s)?,
};
+2 -2
View File
@@ -17,7 +17,7 @@ use vectorcraft_doc::pattern::{self, Overlap, PATTERN_EDIT_LAYER, PatternDef, Pa
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::Rect;
use super::edit::selected_roots;
use super::edit::{outermost, selected_roots};
use super::pathops::Beside;
use super::*;
@@ -203,7 +203,7 @@ fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let st = s.doc()?;
let mut sel = Selection::default();
sel.set(ids.iter().copied().filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer())));
Ok(sel.in_paint_order(&st.doc))
Ok(outermost(&st.doc, sel.in_paint_order(&st.doc)))
}
None => selected_roots(s),
}
+1 -1
View File
@@ -280,7 +280,7 @@ pub(crate) fn options_json(o: &SliceOptions) -> Value {
fn make(s: &mut Session, p: &Value) -> Result<Value> {
let ids = match ids_param(p, "ids") {
Some(ids) => ids,
Some(ids) => super::edit::outermost(&s.doc()?.doc, ids),
None => super::edit::selected_roots(s)?,
};
let d = &s.doc()?.doc;
+3 -2
View File
@@ -7,7 +7,7 @@ use vectorcraft_color::Paint;
use vectorcraft_doc::{Node, NodeKind, Scaling};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use super::edit::selected_roots;
use super::edit::{outermost, selected_roots};
use super::*;
use crate::EngineError;
@@ -140,7 +140,8 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
.filter(|a| a.len() == 4)
.and_then(|a| a.iter().map(|c| finite_numbers(c).map(|[x, y]| Point::new(x, y))).collect());
let Some(&[tl, tr, br, bl]) = corners.as_deref() else { return Err(bad(C, "corners must be 4 [x, y] points (TL, TR, BR, BL)")) };
let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)? } else { selected_roots(s)? };
let ids =
if p.get("ids").is_some_and(|v| !v.is_null()) { outermost(&s.doc()?.doc, checked_ids_param(s, p, "ids", C)?) } else { selected_roots(s)? };
if ids.is_empty() {
return Err(bad(C, "nothing to distort"));
}