diff --git a/ROADMAP.md b/ROADMAP.md index ecb8e2ff8d..6f746b14a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| +| 2026-10-13 | The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Import: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537). Live Corners: a round corner next to a curved side curves smoothly instead of sticking out of the shape, and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533). Guides: Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). Fonts: the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux). | | 2026-10-13 | Image Trace's colour presets trace flat art with its own colours however many are asked for (noise no longer shatters it: 16 Colors 70 → 4 paths on the harness's flat art; no slivers of in-between colour along anti-aliased edges), and the photo presets trace a photo into smooth tones without specks or hairlines (High Fidelity Photo 3,653 → 121 paths, colour error 2.30 → 1.75; 16 megapixels traced where 4 were refused) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). | | 2026-10-13 | Image Trace traces anti-aliased and blurred images to a fraction of a pixel in Black and White, Grayscale and Color, with corners put back, straight edges as lines and round outlines as circles; a harness measures every preset before and after ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)): outlines 0.31 → 0.09 px off on average (Default), 0.27 → 0.06 (Black and White Logo). | | 2026-10-13 | Effect › 3D and Materials: Inflate takes Twist, Taper and Cap (Hollow leaves the back open), as the 3D docs list for every type. | diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 94d316c8da..df105751f9 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -249,7 +249,7 @@ pub(super) fn builtin_rgb_bytes(name: &str) -> Result, CmsError> { /// Encode `p` as an ICC file. The creation date is fixed so exports are reproducible. pub(super) fn encode(p: &ColorProfile) -> Result, CmsError> { - let mut bytes = p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?; + let mut bytes = aligned(p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?); // Header bytes 24..36: year, month, day, hours, minutes, seconds (u16 each). if let Some(date) = bytes.get_mut(24..36) { date.copy_from_slice(&[2026u16, 1, 1, 0, 0, 0].map(u16::to_be_bytes).concat()); @@ -257,6 +257,55 @@ pub(super) fn encode(p: &ColorProfile) -> Result, CmsError> { Ok(bytes) } +/// `icc` with every tagged element starting on a 4-byte boundary, padded with zeros, as ICC.1 +/// §7.1.2 requires (moxcms packs them, so a 62-byte `desc` left the next tag unaligned, #1584). +/// Tags that share data keep sharing it. A profile whose tag table doesn't read is returned as it is. +fn aligned(icc: Vec) -> Vec { + const TABLE: usize = 128; + let be = |b: &[u8], at: usize| b.get(at..at + 4).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]) as usize); + let Some(n) = be(&icc, TABLE).filter(|n| *n <= 1000) else { return icc }; + let data_start = TABLE + 4 + 12 * n; + let mut entries = Vec::with_capacity(n); + for i in 0..n { + let e = TABLE + 4 + 12 * i; + let (Some(off), Some(size)) = (be(&icc, e + 4), be(&icc, e + 8)) else { return icc }; + if off < data_start || off.checked_add(size).is_none_or(|end| end > icc.len()) { + return icc; + } + entries.push((off, size)); + } + if entries.iter().all(|(off, _)| off.is_multiple_of(4)) && icc.len().is_multiple_of(4) { + return icc; + } + let mut out = icc.get(..data_start).map(<[u8]>::to_vec).unwrap_or_default(); + let mut moved: Vec<((usize, usize), usize)> = Vec::new(); + let mut order: Vec = (0..n).collect(); + order.sort_by_key(|&i| entries[i].0); + for i in order { + let (off, size) = entries[i]; + let at = match moved.iter().find(|(k, _)| *k == (off, size)) { + Some((_, at)) => *at, + None => { + out.resize(out.len().next_multiple_of(4), 0); + let at = out.len(); + out.extend_from_slice(&icc[off..off + size]); + moved.push(((off, size), at)); + at + } + }; + let e = TABLE + 4 + 12 * i + 4; + out[e..e + 4].copy_from_slice(&(at as u32).to_be_bytes()); + } + out.resize(out.len().next_multiple_of(4), 0); + let len = out.len() as u32; + out[0..4].copy_from_slice(&len.to_be_bytes()); + // The profile ID (an MD5 of the old bytes) no longer matches: zero is "not computed". + if let Some(id) = out.get_mut(84..100) { + id.fill(0); + } + out +} + fn text_tag(s: &str) -> Option { Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), s.into())])) } diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index 713b65399b..2e5f099c21 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -111,3 +111,26 @@ fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() { let abs = |bpc| icc_cmyk(bpc).cmyk_to_rgb(rich, Intent::AbsoluteColorimetric); assert_eq!(abs(true), abs(false)); } + +/// #1584: the profiles written here (built-in RGB spaces) have every tag on a 4-byte boundary and +/// a size that is a multiple of 4 (ICC.1 §7.1.2), and still read back as the same space. +#[test] +fn written_profiles_align_their_tags() { + for name in [super::SRGB, super::DISPLAY_P3, super::WIDE_GAMUT_RGB, super::PROPHOTO_RGB] { + let icc = super::icc_bytes(name).unwrap(); + let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize; + assert_eq!(be(0), icc.len(), "{name}: header size"); + assert_eq!(icc.len() % 4, 0, "{name}: {} bytes", icc.len()); + let n = be(128); + for i in 0..n { + let (off, size) = (be(132 + 12 * i + 4), be(132 + 12 * i + 8)); + assert_eq!(off % 4, 0, "{name}: tag {i} at {off}"); + assert!(off + size <= icc.len()); + } + let back = moxcms::ColorProfile::new_from_slice(&icc).unwrap(); + let orig = moxcms::ColorProfile::new_srgb(); + if name == super::SRGB { + assert_eq!((back.red_colorant, back.green_colorant, back.blue_colorant), (orig.red_colorant, orig.green_colorant, orig.blue_colorant)); + } + } +} diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index b74d7c2c3d..67ef4c07e2 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -1,5 +1,7 @@ //! Hit testing over the document tree. +use std::collections::HashSet; + use vectorcraft_geom::hit::{fill_contains, stroke_contains}; use vectorcraft_geom::{Point, Rect}; @@ -83,6 +85,77 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option) -> Vec { let opt = HitOptions { scope: scope.or(opt.scope), ..opt }; + // Normally one hit test per *object* suffices: layers and sublayers aren't selectable, + // while a group (including a clipping group) is selected as one object. Repeatedly + // searching the full tree for each hit made a stack of n overlapping paths O(n^2). + // Isolation and Edit Contents change where selection groups begin; keep the original + // traversal for those cases rather than change their Select Behind semantics. + if opt.scope.is_some() { + return objects_at_repeated(doc, p, opt, scope); + } + let mut tops = Vec::new(); + let mut seen = HashSet::new(); + for layer in doc.layers.iter().rev() { + // Malformed files may contain non-layer roots; let the general traversal handle them. + if !layer.is_layer() { + return objects_at_repeated(doc, p, opt, scope); + } + if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) { + continue; + } + if !objects_at_layer(layer, p, opt, &mut tops, &mut seen) { + // An envelope in Edit Contents may expose objects *within* a top-level group. + // The repeated traversal handles that mixed hierarchy precisely. + return objects_at_repeated(doc, p, opt, scope); + } + } + tops +} + +/// One traversal through a layer and its sublayers. Each selectable object's subtree is +/// inspected once (only until its first hit). `false` asks the caller to use the general +/// algorithm for an envelope whose contents are currently being edited. +fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, tops: &mut Vec, seen: &mut HashSet) -> bool { + let Some(children) = layer.children() else { return true }; + if layer.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) { + return true; + } + let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt }; + for (i, c) in children.iter().enumerate().rev() { + if !may_hit(layer, i, c, p, opt, true) || seen.contains(&c.id) { + continue; + } + if c.is_layer() { + if !objects_at_layer(c, p, opt, tops, seen) { + return false; + } + continue; + } + // Editing an envelope selects its contents rather than the containing object. Even + // when nested in a group, the old traversal may select both a member and the group. + if c.is_container() { + let mut editing = false; + c.walk(&mut |n| editing |= edits_contents(n)); + if editing { + return false; + } + } + let skip = |id| seen.contains(&id); + let mut chain = Vec::new(); + let hit = match &c.kind { + NodeKind::Group { .. } => hit_children(c, p, opt, &skip, &mut chain, true).is_some(), + NodeKind::Blend { .. } => hit_children(c, p, opt, &skip, &mut chain, true).is_some() || hit_leaf(c, p, opt).is_some(), + _ => hit_leaf(c, p, opt).is_some(), + }; + if hit && seen.insert(c.id) { + tops.push(c.id); + } + } + true +} + +/// The general selection order also handles isolation and nested Edit Contents. +fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option) -> Vec { let mut tops: Vec = vec![]; // Each round leaves out the objects found so far (and everything inside them). while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id), true).map(|h| h.top_object(scope)) { @@ -150,6 +223,22 @@ fn clip_contains(clip: &Node, p: Point) -> bool { clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p)) } +/// Can a click at `p` hit `c`, the child `i` of `parent`, or something inside it? Not when it is +/// hidden, when it can't be edited (`editable_only`), when it is the part of a Shaper group that +/// isn't shown, or when `p` is outside its reach. +fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, editable_only: bool) -> bool { + let source_isolated = |n: &Node| n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope); + if parent.extras().shaper.is_some() && (i == 0) != source_isolated(parent) { + return false; + } + // Read-only samples can read locked/template art; hidden art never participates. + if !c.visible || editable_only && (c.locked || c.is_template()) { + return false; + } + // (An envelope's content sits where it was, not where the envelope draws it.) + edits_contents(c) || c.extras().shaper.is_some() && source_isolated(c) || c.reach_bounds().is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p)) +} + fn hit_children( parent: &Node, p: Point, @@ -167,22 +256,7 @@ fn hit_children( return None; } for (i, c) in children.iter().enumerate().rev() { - if parent.extras().shaper.is_some() { - let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope); - if (i == 0) != construction { - continue; - } - } - // Read-only samples can read locked/template art; hidden art never participates. - if !c.visible || editable_only && (c.locked || c.is_template()) || skip(c.id) { - continue; - } - // (An envelope's content sits where it was, not where the envelope draws it.) - if !(edits_contents(c) - || c.extras().shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope)) - && let Some(b) = c.reach_bounds() - && !b.inflate(opt.tol, opt.tol).contains(p) - { + if !may_hit(parent, i, c, p, opt, editable_only) || skip(c.id) { continue; } chain.push(c.id); @@ -619,6 +693,146 @@ world", assert!(at(90.0, None).is_empty()); } + /// Many overlapping paths: Select Behind must list each exactly once, front to back. + /// This is quadratic with repeated full-document hit tests, but linear in tree size with + /// the per-object walk. + #[test] + fn objects_at_large_overlapping_stack() { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let mut ids = Vec::new(); + for _ in 0..256 { + let id = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + ids.push(id); + } + ids.reverse(); + assert_eq!(objects_at(&d, Point::new(25.0, 25.0), HitOptions::default(), None), ids); + } + + /// Match the original skip-and-retest algorithm for normal groups, sublayers, clipping, + /// invisibility, and path/outline-only settings. + #[test] + fn objects_at_one_pass_matches_repeated_hits() { + let mut d = Document::new(200.0, 200.0); + let layer = d.layers[0].id; + let add = |d: &mut Document, parent, bounds| { + let id = d.alloc_id(); + d.insert(Some(parent), usize::MAX, Node::path(id, shapes::rectangle(bounds), Appearance::default_art())).unwrap(); + id + }; + add(&mut d, layer, Rect::new(0.0, 0.0, 60.0, 60.0)); + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sublayer", crate::LayerColor::Preset(2))).unwrap(); + add(&mut d, sub, Rect::new(0.0, 0.0, 60.0, 60.0)); + let group = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::group(group, Vec::new())).unwrap(); + add(&mut d, group, Rect::new(0.0, 0.0, 60.0, 60.0)); + add(&mut d, group, Rect::new(5.0, 5.0, 60.0, 60.0)); + let clip = d.alloc_id(); + let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 45.0)), Appearance::default_art()); + let content = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::default_art()); + let mut clipping = Node::group(clip, vec![Arc::new(mask), Arc::new(content)]); + clipping.set_clips(true); + d.insert(Some(layer), usize::MAX, clipping).unwrap(); + for (point, opt) in [ + (Point::new(25.0, 25.0), HitOptions::default()), + (Point::new(3.0, 25.0), HitOptions::default()), + (Point::new(25.0, 25.0), HitOptions { outline: true, ..Default::default() }), + (Point::new(6.0, 25.0), HitOptions { path_only: true, ..Default::default() }), + (Point::new(100.0, 100.0), HitOptions::default()), + ] { + assert_eq!(objects_at(&d, point, opt, None), objects_at_repeated(&d, point, opt, None), "point={point:?}, opt={opt:?}"); + } + if let NodeKind::Layer { preview, .. } = &mut d.node_mut(sub).unwrap().kind { + *preview = false; + } + let p = Point::new(25.0, 25.0); + let opt = HitOptions::default(); + assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None)); + d.node_mut(group).unwrap().locked = true; + assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None)); + } + + /// Documents built at random (nested groups, sublayers, clipping groups, compound paths, blends + /// of nothing, hidden and locked objects, outline layers): Select Behind lists the same objects + /// in the same order as the hit test repeated over what is left. + #[test] + fn objects_at_matches_repeated_hits_on_random_documents() { + let mut seed = 0x9e37_79b9_u32; + let mut next = move |n: u32| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed % n + }; + fn fill(d: &mut Document, parent: NodeId, depth: u32, next: &mut impl FnMut(u32) -> u32) { + for _ in 0..2 + next(4) { + let id = d.alloc_id(); + let (x, y) = (f64::from(next(60)), f64::from(next(60))); + let rect = Rect::new(x, y, x + 10.0 + f64::from(next(50)), y + 10.0 + f64::from(next(50))); + let appearance = if next(4) == 0 { Appearance::default() } else { Appearance::default_art() }; + let mut n = match next(if depth < 3 { 8 } else { 4 }) { + 4 | 5 => Node::group(id, Vec::new()), + 6 => Node::layer(id, "Sublayer", crate::LayerColor::Preset(2)), + 7 => { + let mut g = Node::group(id, Vec::new()); + g.set_clips(true); + g + } + _ => Node::path(id, shapes::rectangle(rect), appearance), + }; + n.visible = next(8) != 0; + n.locked = next(8) == 0; + if let NodeKind::Layer { preview, .. } = &mut n.kind { + *preview = next(3) != 0; + } + let container = n.is_container(); + // A sublayer belongs in a layer; in a group it is a group like another to the hit test. + d.insert(Some(parent), usize::MAX, n).unwrap(); + if container { + fill(d, id, depth + 1, next); + } + } + } + for round in 0..200 { + let mut d = Document::new(120.0, 120.0); + let layer = d.layers[0].id; + fill(&mut d, layer, 0, &mut next); + for _ in 0..12 { + let p = Point::new(f64::from(next(120)), f64::from(next(120))); + let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, ..Default::default() }; + assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}"); + } + } + } + + /// Edit Contents can expose descendants of a top-level group: preserve the original + /// selection order by falling back to the general hit traversal. + #[test] + fn objects_at_edit_contents_uses_general_order() { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let inner = |id| Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())); + let (a, b) = (d.alloc_id(), d.alloc_id()); + let e = Node::new( + d.alloc_id(), + NodeKind::Envelope { + content: vec![inner(a), inner(b)], + kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), + fidelity: 100.0, + editing: true, + options: Default::default(), + frame: Box::new(vectorcraft_geom::Affine::IDENTITY), + }, + ); + d.insert(Some(layer), usize::MAX, e).unwrap(); + let p = Point::new(25.0, 25.0); + let opt = HitOptions::default(); + assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None)); + assert_eq!(objects_at(&d, p, opt, None), vec![b, a]); + } + #[test] fn locked_and_hidden_skip() { let mut d = Document::new(200.0, 200.0); diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index d7d7d1e97f..fc26f394d8 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -318,8 +318,10 @@ impl WarpStyle { } } -/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1). -pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) -> (f64, f64) { +/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1); +/// `aspect` is the box's half-height over its half-width along the warp (Arc bends in real +/// proportions). +pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f64, y: f64) -> (f64, f64) { use WarpStyle::*; let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides @@ -328,14 +330,18 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) -> if b.abs() < 1e-6 { (x, y) } else { - // Bend the centre line into a circular arc of equal length (sweep = |b|·180°). + // Bend the centre line into a circular arc of equal length (sweep = |b|·180°), in + // the box's real proportions (lengths in half-widths, so y counts `aspect` each): + // the edges are concentric arcs, and one that would pass the centre stops there + // instead of folding over (#1578). + let k = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 }; let sweep = b.abs() * PI; let r0 = 2.0 / sweep; - let yy = if b > 0.0 { y } else { -y }; + let yy = if b > 0.0 { y } else { -y } * k; let a = x / r0; - let r = r0 - yy; + let r = (r0 - yy).max(0.0); let (s, c) = a.sin_cos(); - let (nx, ny) = (r * s, r0 - r * c); + let (nx, ny) = (r * s, (r0 - r * c) / k); (nx, if b > 0.0 { ny } else { -ny }) } } @@ -1122,9 +1128,9 @@ fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box]) -> f64 { #[cfg(test)] mod tests { use super::*; + + /// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the + /// aspect), the top and bottom edges are concentric circles about the bend's centre, at their + /// real distance from the centre line; an inner edge that would pass the centre stops there. + #[test] + fn arc_warp_is_round_in_real_proportions() { + for (aspect, b) in [(0.25, 0.1), (0.25, 0.5), (0.25, 1.0), (1.0, 0.5), (2.0, 0.3)] { + let r0 = 2.0 / (b * PI); + for (y, r) in [(-1.0, r0 + aspect), (1.0, (r0 - aspect).max(0.0))] { + for i in 0..=10 { + let x = -1.0 + 0.2 * f64::from(i); + let (nx, ny) = warp_point(WarpStyle::Arc, b, 0.0, 0.0, aspect, x, y); + let d = nx.hypot(ny * aspect - r0); + assert!((d - r).abs() < 1e-9, "aspect {aspect}, bend {b}, y {y}, x {x}: {d} vs {r}"); + } + } + } + // At 100 % on a square the inner edge meets at the centre instead of folding over. + let (nx, _) = warp_point(WarpStyle::Arc, 1.0, 0.0, 0.0, 1.0, 0.8, 1.0); + assert!(nx.abs() < 1e-12); + // A square bent less than that is what it always was. + let (nx, ny) = warp_point(WarpStyle::Arc, 0.5, 0.0, 0.0, 1.0, 0.5, 0.25); + let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI)); + assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12); + } use vectorcraft_geom::shapes; fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 9b520e279c..0c859583b5 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -249,7 +249,7 @@ pub fn effect_catalog() -> Vec { "distort.freeDistort", "Free Distort…", DT, - "{corners: [[x,y]×4] new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates (identity [[0,0],[1,0],[1,1],[0,1]])}", + "{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]])}", json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}), ), g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})), diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index a4021d0fcc..35a67ba237 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -22,10 +22,10 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData { let f = |q: Point| { let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh); let (x2, y2) = if vertical { - let (a, b2) = warp_point(style, bend, dh, dv, y, x); + let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x); (b2, a) } else { - warp_point(style, bend, dh, dv, x, y) + warp_point(style, bend, dh, dv, hh / hw, x, y) }; Point::new(c.x + x2 * hw, c.y + y2 * hh) }; diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index f6b01dcec6..63e7110855 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -120,7 +120,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), @@ -727,6 +727,52 @@ fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveSha live } +/// The gradient fill for a linear gradient the tracer found (in the image's pixels), placed in the +/// document by `xf`. +fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect(); + let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None }); + Paint::Gradient(Box::new(paint)) +} + +/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others +/// inside it (`parents`, as the tracer gives them) in a group with them: the path, then what is +/// inside it. Without parents, the nodes as they are. +fn grouped(d: &mut Document, nodes: Vec, parents: &[Option]) -> Vec> { + let n = nodes.len(); + let mut inside: Vec> = vec![vec![]; n]; + let mut roots = vec![]; + for i in 0..n { + match parents.get(i).copied().flatten().and_then(|p| inside.get_mut(p).filter(|_| p != i)) { + Some(of) => of.push(i), + None => roots.push(i), + } + } + // Outermost first, so that backwards each path comes after those inside it. + let mut order = roots.clone(); + let mut at = 0; + while let Some(&i) = order.get(at) { + order.extend(inside.get(i).into_iter().flatten()); + at += 1; + } + let mut nodes: Vec> = nodes.into_iter().map(Some).collect(); + let mut built: Vec>> = vec![None; n]; + for &i in order.iter().rev() { + let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue }; + let within: Vec> = inside.get(i).into_iter().flatten().filter_map(|&c| built.get_mut(c)?.take()).collect(); + let made = if within.is_empty() { node } else { Node::group(d.alloc_id(), std::iter::once(Arc::new(node)).chain(within).collect()) }; + if let Some(slot) = built.get_mut(i) { + *slot = Some(Arc::new(made)); + } + } + // (Paths that are inside each other in a ring are reached from no root: the tracer makes none, + // and they are kept all the same.) + let ring = nodes.into_iter().flatten().map(Arc::new).collect::>(); + roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect() +} + fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { const C: &str = "imageTrace.make"; let mut params = trace_params(p)?; @@ -787,6 +833,7 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { hidden.visible = false; children.push(Arc::new(hidden)); } + let mut traced = Vec::with_capacity(res.paths.len()); for tp in res.paths { let path = tp.path.transformed(xf); let mut n = shape_node(d, path, None); @@ -798,7 +845,8 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { } // A library colour fills with the swatch's own colour (its model kept). let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1); - let paint = Paint::solid(color); + // Gradients: a path the tracer found to be a linear gradient is filled with it. + let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf)); n.appearance = match tp.stroke { // Traced lines: round ends and corners, as drawn lines have. Some(width) => Appearance { @@ -812,8 +860,9 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { .into(), None => fill_only(paint).into(), }; - children.push(Arc::new(n)); + traced.push(n); } + children.extend(grouped(d, traced, &res.parents)); let gid = d.alloc_id(); let mut g = Node::group(gid, children); g.name = if expand { None } else { Some(TRACE_NAME.into()) }; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 842e69918d..ada48cf891 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -280,6 +280,20 @@ fn move_effect(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&ids), "index": index, "item": item.flatten() })) } +/// The ids of the effects `effect.setParams` with `p` edits, for the check of their parameters +/// (none when `p` doesn't say which: running it reports why). +pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec { + const C: &str = "effect.setParams"; + let (Ok(index), Ok(item), Ok(ids), Ok(st)) = (index_param(p, "index", C), item_target(s, p, C), appearance_targets(s, p), s.doc()) else { + return vec![]; + }; + let effect = |id: &NodeId| { + let ap = &st.doc.node(*id)?.appearance; + Some(ap.effects_at(item.effects_item(ap, C).ok()?)?.get(index)?.id.clone()) + }; + ids.iter().filter_map(effect).collect() +} + fn set_params(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.setParams"; let index = index_param(p, "index", C)?; @@ -566,7 +580,7 @@ mod tests { for other in ["threeD.revolve", "threeD.extrude", "threeD.rotate"] { assert!(s.execute("effect.apply", &json!({"effect": other, "ids": [id.0], "item": null})).is_err(), "{effect} + {other}"); } - s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "depth": 120}, "ids": [id.0], "item": null})).unwrap(); + s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "rotationX": 10}, "ids": [id.0], "item": null})).unwrap(); assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "{effect}: options change the art"); assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"bevel": "nope"}, "ids": [id.0], "item": null})).is_err()); let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index a5433a68a9..7989071f0e 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -10,8 +10,10 @@ //! //! Callers outside the app (the control channel, MCP, the CLI) are answered with a parameter error //! for a key the description doesn't name ([`check_params`]): a misspelt `widht` no longer runs the -//! command with its default. [`Session::execute`] itself takes what it is given, so the journal of -//! an older version still replays. +//! command with its default. So are the `params` of an effect (#1592): `effect.apply` and +//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its +//! default, with a parameter error ([`check_effect_params`]). [`Session::execute`] itself takes +//! what it is given, so the journal of an older version still replays. use serde_json::Value; @@ -150,6 +152,12 @@ fn accepted(doc: &'static str, depth: usize) -> Option> { Some(keys) } +/// Does the description `doc` name `key` as a parameter (its own keys, and those of the commands +/// its `…` entries name)? +pub(crate) fn takes_key(doc: &'static str, key: &str) -> bool { + doc_keys(doc).is_ok_and(|parsed| parsed.keys.contains(&key)) || accepted(doc, 0).is_some_and(|keys| keys.contains(&key)) +} + /// The edits (insert, delete, replace, swap of neighbours) that turn `a` into `b`, ignoring case. fn distance(a: &str, b: &str) -> usize { let (a, b): (Vec, Vec) = (a.chars().map(|c| c.to_ascii_lowercase()).collect(), b.chars().map(|c| c.to_ascii_lowercase()).collect()); @@ -183,15 +191,56 @@ pub fn check_keys(id: &str, doc: &'static str, params: &Value) -> Result<()> { return Ok(()); } let Some(keys) = accepted(doc, 0) else { return Ok(()) }; - let Some(key) = given.keys().find(|k| !keys.contains(&k.as_str())) else { return Ok(()) }; - Err(bad( + match given.keys().find(|k| !keys.contains(&k.as_str())) { + Some(key) => Err(unknown(id, key, &keys)), + None => Ok(()), + } +} + +/// The parameter error of command `id` for `key`, which isn't one of `keys`: with the nearest of +/// them, or all of them. +fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError { + bad( id, - match nearest(key, &keys) { + match nearest(key, keys) { Some(near) => format!("unknown parameter `{key}`; did you mean `{near}`?"), None if keys.is_empty() => format!("unknown parameter `{key}`; the command takes no parameters"), None => format!("unknown parameter `{key}`; the command takes: {}", keys.join(", ")), }, - )) + ) +} + +/// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the +/// effect takes (those of its dialog defaults and of its params description), each of its +/// default's type. Numbers out of range pass: the effect clamps them when it draws. An effect +/// plug-in resolves its own against its manifest, and an unknown effect is the command's to report. +pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> { + let Value::Object(given) = params else { return Ok(()) }; + if effect.starts_with(vectorcraft_plugins::effect::PREFIX) { + return Ok(()); + } + let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) }; + let defaults = info.defaults.as_object(); + let mut keys: Vec<&str> = defaults.into_iter().flat_map(|d| d.keys().map(String::as_str)).collect(); + // A description that doesn't parse names no more keys (the tests rule that out). + for key in doc_keys(info.params).map(|d| d.keys).unwrap_or_default() { + if !keys.contains(&key) { + keys.push(key); + } + } + for (key, v) in given { + if !keys.contains(&key.as_str()) { + return Err(unknown(id, key, &keys)); + } + match defaults.and_then(|d| d.get(key)) { + Some(Value::Number(_)) if !v.is_number() => return Err(bad(id, format!("`{key}` must be a number"))), + Some(Value::Bool(_)) if !v.is_boolean() => return Err(bad(id, format!("`{key}` must be true or false"))), + // (A colour is also taken as `[r, g, b]` or as a colour object.) + Some(Value::String(_)) if v.is_number() || v.is_boolean() => return Err(bad(id, format!("`{key}` must be a string"))), + _ => {} + } + } + Ok(()) } /// Commands whose params aren't checked yet (#1442): they read keys in ways the audit of the @@ -327,10 +376,16 @@ pub const UNCHECKED: &[&str] = &[ "webExport.settings", ]; -/// [`check_keys`] for an engine command, the steps of a `command.batch` included. A command the -/// engine doesn't have passes: running it reports that. +/// [`check_keys`] for an engine command, the steps of a `command.batch` included, and +/// [`check_effect_params`] for the effect `effect.apply` adds. A command the engine doesn't have +/// passes: running it reports that. pub fn check_params(id: &str, params: &Value) -> Result<()> { let Some(spec) = find_command(id) else { return Ok(()) }; + if let ("effect.apply" | "appearance.addEffect", Some(effect), Some(given)) = + (id, params.get("effect").or_else(|| params.get("id")).and_then(Value::as_str), params.get("params")) + { + check_effect_params(id, effect, given)?; + } if UNCHECKED.contains(&id) { return Ok(()); } @@ -344,3 +399,18 @@ pub fn check_params(id: &str, params: &Value) -> Result<()> { } Ok(()) } + +impl crate::Session { + /// [`check_params`], and [`check_effect_params`] for the effects `effect.setParams` edits in + /// this session's document (not as a step of a `command.batch`: what it edits is only there + /// once the steps before it ran). + pub fn check_params(&self, id: &str, params: &Value) -> Result<()> { + check_params(id, params)?; + if let ("effect.setParams", Some(given)) = (id, params.get("params")) { + for effect in super::effectcmd::set_params_effects(self, params) { + check_effect_params(id, &effect, given)?; + } + } + Ok(()) + } +} diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index f94ed93f9d..fe33554f0f 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -383,10 +383,15 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result { let mut styles: Vec