diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index e11eee5d8..fb6b28099 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -134,7 +134,7 @@ fn lengths_of(id: &str) -> Lengths { "threeD.inflate" => always(&["depth"]), "path.outlineStroke" => always(&["width"]), "stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]), - "stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]), + "stylize.scribble" => always(&["overlap", "overlapVariation", "strokeWidth", "spacing", "spacingVariation"]), "stylize.dropShadow" => always(&["x", "y", "blur"]), "stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]), "stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]), @@ -301,8 +301,8 @@ pub fn effect_catalog() -> Vec { "stylize.scribble", "Scribble…", STYLIZE, - "{angle: deg (30), overlap: pt (0), strokeWidth: pt (3), curviness: % (5), spacing: pt (5), variation: pt (0.5), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)", - json!({"angle": 30.0, "overlap": 0.0, "strokeWidth": 3.0, "curviness": 5.0, "spacing": 5.0, "variation": 0.5, "seed": 0}), + "{angle: deg (30; stored above -180 and up to 180: 270 becomes -90, -180 becomes 180), overlap: pt (0; Path Overlap: a positive value extends each line beyond the path, a negative one ends it inside), overlapVariation: pt (0..100, 0.5; how far each line's ends move along it), strokeWidth: pt (3), curviness: % (5), curvinessVariation: % (0..100, 0; how far each turn's curviness moves from curviness), spacing: pt (5), spacingVariation: pt (0..100, 0.5; how far each line's ends move across it), variation: pt (the earlier form, read as overlapVariation and spacingVariation unless those are given), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)", + json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.5, "strokeWidth": 3.0, "curviness": 5.0, "curvinessVariation": 0.0, "spacing": 5.0, "spacingVariation": 0.5, "seed": 0}), ), r( "stylize.dropShadow", @@ -642,8 +642,9 @@ pub fn default_params(id: &str) -> Option { } } -/// `params` layered over the defaults of `id` (unknown keys are kept), with the 3D effects' -/// earlier `perspective` read as `perspectiveAngle` (see [`upgrade_params`]). +/// `params` layered over the defaults of `id` (unknown keys are kept), in their current form (see +/// [`upgrade_params`]). The 3D effects' earlier `perspective` is read as `perspectiveAngle`, and +/// Scribble's earlier `variation` is read as `overlapVariation` and `spacingVariation`. pub fn merged_params(id: &str, params: &Value) -> Value { let mut out = match catalog_index().get(id).map(|e| &e.defaults) { Some(Value::Object(m)) => m.clone(), @@ -660,14 +661,17 @@ pub fn merged_params(id: &str, params: &Value) -> Value { /// `params` for effect `id` in their current form. The 3D effects took Perspective as a 0–100 % /// strength, `perspective`, before the documented 0°–160° angle, `perspectiveAngle`, and the light /// as `lightAzimuth` and `lightElevation` before the documented Rotation and Height -/// (`lightRotation`, `lightHeight`, `lightBehind`). Earlier values (from a file or a script of +/// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its +/// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of /// then) become the current ones that show exactly the same, unless those are given too, which win. +/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°). pub fn upgrade_params(id: &str, params: &Value) -> Value { match params { Value::Object(m) => { let mut m = m.clone(); upgrade_perspective(id, &mut m); upgrade_light(id, &mut m); + upgrade_scribble(id, &mut m); Value::Object(m) } other => other.clone(), @@ -700,6 +704,21 @@ fn upgrade_perspective(id: &str, m: &mut Map) { } } +fn upgrade_scribble(id: &str, m: &mut Map) { + if id != "stylize.scribble" { + return; + } + if let Some(variation) = m.remove("variation") { + for key in ["overlapVariation", "spacingVariation"] { + m.entry(key).or_insert_with(|| variation.clone()); + } + } + if let Some(angle) = m.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite() && (*a <= -180.0 || *a > 180.0)) { + let wrapped = angle.rem_euclid(360.0); + m.insert("angle".into(), json!(if wrapped > 180.0 { wrapped - 360.0 } else { wrapped })); + } +} + /// A new effect record with default parameters (overridden by `params`). pub fn new_effect(id: &str, params: &Value) -> Option { if !catalog_index().contains_key(id) { diff --git a/crates/effects/src/stylize.rs b/crates/effects/src/stylize.rs index 2a82d1de4..b5f01f06d 100644 --- a/crates/effects/src/stylize.rs +++ b/crates/effects/src/stylize.rs @@ -112,14 +112,19 @@ fn crosses_outline(p: Point, q: Point, polys: &[Vec]) -> bool { } /// Scribble (simplified): a zig-zag hatching across the filled area at `angle`, one per region -/// of the shape, outlined to `strokeWidth` so the fill paints it. +/// of the shape, outlined to `strokeWidth` so the fill paints it. `overlapVariation` moves each +/// line's ends along it by up to that many points, `spacingVariation` moves them across it, and +/// each turn's curviness moves from `curviness` by up to `curvinessVariation` percent. pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData { let angle = num(p, "angle", 30.0).to_radians(); let overlap = num(p, "overlap", 0.0); let width = num(p, "strokeWidth", 3.0).clamp(0.01, 1000.0); - let curvy = num(p, "curviness", 5.0).clamp(0.0, 100.0) / 100.0; + let curviness = num(p, "curviness", 5.0).clamp(0.0, 100.0); + let curvy = curviness / 100.0; + let curvy_var = num(p, "curvinessVariation", 0.0).clamp(0.0, 100.0); let spacing = num(p, "spacing", 5.0).max(0.1); - let variation = num(p, "variation", 0.5).clamp(0.0, 100.0); + let along = num(p, "overlapVariation", 0.5).clamp(0.0, 100.0); + let across = num(p, "spacingVariation", 0.5).clamp(0.0, 100.0); let seed = seed(p); let c = b.center(); let to_local = Affine::translate(c.to_vec2()) * Affine::rotate(angle) * Affine::translate(-c.to_vec2()); @@ -140,6 +145,8 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData { // stays inside: one chain, as before. struct Chain { pts: Vec, + // Each point's curviness noise in [-1, 1]. + bends: Vec, end: Point, row: u64, } @@ -160,10 +167,12 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData { let spans: Vec<(f64, f64)> = xs.as_chunks::<2>().0.iter().map(|s| (s[0] - overlap, s[1] + overlap)).collect(); let open = chains.len(); for (a, bx) in spans { - let jitter = |k: u64| variation * noise(seed, row, k, 3); - let (l, r) = (Point::new(a + jitter(0), y + jitter(1)), Point::new(bx + jitter(2), y + jitter(3))); + let jitter = |k: u64, v: f64| v * noise(seed, row, k, 3); + let (l, r) = (Point::new(a + jitter(0, along), y + jitter(1, across)), Point::new(bx + jitter(2, along), y + jitter(3, across))); let even = row.is_multiple_of(2); let pair = if even { [l, r] } else { [r, l] }; + let bend = [noise(seed, row, 4, 3), noise(seed, row, 5, 3)]; + let bend = if even { bend } else { [bend[1], bend[0]] }; // Un-jittered ends of the span in drawing order. let (start, end) = if even { (Point::new(a, y), Point::new(bx, y)) } else { (Point::new(bx, y), Point::new(a, y)) }; // The nearest chain of the previous row that this span can join without leaving @@ -176,9 +185,10 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData { Some(k) => { let c = &mut chains[k]; c.pts.extend(pair); + c.bends.extend(bend); (c.end, c.row) = (end, row); } - None => chains.push(Chain { pts: pair.to_vec(), end, row }), + None => chains.push(Chain { pts: pair.to_vec(), bends: bend.to_vec(), end, row }), } } row += 1; @@ -187,7 +197,15 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData { let lines: Vec = chains .into_iter() .filter(|c| c.pts.len() >= 2) - .map(|c| if curvy > 0.0 { catmull_rom(&c.pts, false, curvy * 2.0) } else { SubPath::polyline(&c.pts, false) }) + .map(|c| { + if curvy_var > 0.0 { + varied_curves(&c.pts, &c.bends, curviness, curvy_var) + } else if curvy > 0.0 { + catmull_rom(&c.pts, false, curvy * 2.0) + } else { + SubPath::polyline(&c.pts, false) + } + }) .collect(); if lines.is_empty() { return PathData::default(); @@ -198,3 +216,17 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData { let outline = kurbo::stroke(center.to_bezpath(), &stroke, &kurbo::StrokeOpts::default(), 0.05); PathData::from_bezpath(&outline) } + +/// A scribble line through `pts` whose curviness at each point is `curviness` percent plus +/// `variation` percent times that point's `bends` noise (in -1..1), kept within 0 to 100 percent. +fn varied_curves(pts: &[Point], bends: &[f64], curviness: f64, variation: f64) -> SubPath { + // A tension of 6 makes each handle the whole Catmull-Rom tangent; each point then scales its + // own to the tension `catmull_rom` takes for that curviness (curviness / 100 × 2). + let mut line = catmull_rom(pts, false, 6.0); + for (a, bend) in line.anchors.iter_mut().zip(bends) { + let k = (curviness + variation * bend).clamp(0.0, 100.0) / 300.0; + a.h_in = a.p + (a.h_in - a.p) * k; + a.h_out = a.p + (a.h_out - a.p) * k; + } + line +} diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index bd0517f0e..7b238c496 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -539,6 +539,59 @@ fn scribble_produces_filled_outline_inside_bounds() { assert_eq!(out, run("stylize.scribble", json!({}), &square())); } +#[test] +fn scribble_variations_are_separate() { + // At 0° the lines run along x: the Path Overlap variation moves their ends in x only, the + // Spacing variation moves them in y only, and the Curviness variation curves some of the turns. + let base = + json!({"angle": 0, "curviness": 0, "strokeWidth": 0.5, "spacing": 10, "overlapVariation": 0, "spacingVariation": 0, "curvinessVariation": 0}); + let with = |key: &str, v: f64| { + let mut p = base.clone(); + p[key] = json!(v); + run("stylize.scribble", p, &square()) + }; + let plain = run("stylize.scribble", base.clone(), &square()); + let b = plain.bounds().unwrap(); + let moved = |a: f64, b: f64| (a - b).abs() > 0.5; + let along = with("overlapVariation", 4.0).bounds().unwrap(); + assert!(close(along.y0, b.y0, 0.06) && close(along.y1, b.y1, 0.06), "{along:?} {b:?}"); + assert!(moved(along.x0, b.x0) || moved(along.x1, b.x1), "{along:?} {b:?}"); + let across = with("spacingVariation", 4.0).bounds().unwrap(); + assert!(close(across.x0, b.x0, 0.06) && close(across.x1, b.x1, 0.06), "{across:?} {b:?}"); + assert!(moved(across.y0, b.y0) || moved(across.y1, b.y1), "{across:?} {b:?}"); + assert_ne!(with("curvinessVariation", 50.0), plain); + // On a curviness of its own, the variation changes how much each turn curves. + let mut p = base.clone(); + p["curviness"] = json!(20); + let even = run("stylize.scribble", p.clone(), &square()); + p["curvinessVariation"] = json!(20); + assert_ne!(run("stylize.scribble", p, &square()), even); +} + +#[test] +fn scribble_reads_its_earlier_single_variation() { + // Before the separate variations, one `variation` moved the ends both along and across. + let old = run("stylize.scribble", json!({"variation": 3}), &square()); + assert_eq!(old, run("stylize.scribble", json!({"overlapVariation": 3, "spacingVariation": 3}), &square())); + assert_eq!(upgrade_params("stylize.scribble", &json!({"variation": 3})), json!({"overlapVariation": 3, "spacingVariation": 3})); + // A current key given with it wins. + assert_eq!( + upgrade_params("stylize.scribble", &json!({"variation": 3, "spacingVariation": 1})), + json!({"overlapVariation": 3, "spacingVariation": 1}) + ); + assert!(default_params("stylize.scribble").unwrap().get("variation").is_none()); +} + +#[test] +fn scribble_angle_is_stored_above_minus_180_and_up_to_180() { + for (given, stored) in [(270.0, -90.0), (-180.0, 180.0), (180.0, 180.0), (540.0, 180.0), (-270.0, 90.0), (-200.0, 160.0)] { + assert_eq!(new_effect("stylize.scribble", &json!({"angle": given})).unwrap().params["angle"].as_f64(), Some(stored), "{given}"); + } + // An angle in range is kept as given; other effects' angles are not changed. + assert_eq!(upgrade_params("stylize.scribble", &json!({"angle": 45})), json!({"angle": 45})); + assert_eq!(upgrade_params("distort.twist", &json!({"angle": 270})), json!({"angle": 270})); +} + #[test] fn effects_chain_in_order_and_skip_hidden_and_raster() { let chain = [fx("distort.transform", json!({"moveH": 10})), fx("distort.transform", json!({"scaleH": 200}))]; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 4d7108ad7..775935f0d 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -647,6 +647,24 @@ mod tests { assert_eq!(params(&s), json!({"lightRotation": 0.0, "lightHeight": 60, "lightBehind": false})); } + /// Scripts from before Scribble's separate variations give one `variation`, which is stored as the + /// Path Overlap and Spacing variations. An angle is stored above -180° and up to 180°. + #[test] + fn scribble_stores_an_earlier_variation_as_two_and_its_angle_within_180() { + let (mut s, id) = session_with_rect(); + let p = json!({"effect": "stylize.scribble", "ids": [id.0], "item": null, "params": {"angle": 270, "variation": 2}}); + s.execute("effect.apply", &p).unwrap(); + let params = |s: &Session| node(s, id).appearance.effects[0].params.clone(); + assert_eq!((params(&s)["angle"].as_f64(), params(&s).get("variation")), (Some(-90.0), None), "{}", params(&s)); + assert_eq!((¶ms(&s)["overlapVariation"], ¶ms(&s)["spacingVariation"]), (&json!(2), &json!(2))); + s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": -200}, "ids": [id.0], "item": null})).unwrap(); + assert_eq!(params(&s)["angle"].as_f64(), Some(160.0)); + // A file of then stores its variation; setting one of the two keeps it for the other. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params = json!({"variation": 3}); + s.execute("effect.setParams", &json!({"index": 0, "params": {"spacingVariation": 1}, "ids": [id.0], "item": null})).unwrap(); + assert_eq!(params(&s), json!({"overlapVariation": 3, "spacingVariation": 1})); + } + fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node { effects::revolve_art(&node(s, id)).unwrap() } diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index b62941116..7e9dc7518 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -148,7 +148,13 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { None => { let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default(); let unit = app.session.general_unit(); - form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), &|k| choices(&id, k), &|k| doc_rank(doc, k), unit) + let changed = + form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), &|k| choices(&id, k), &|k| doc_rank(doc, k), unit); + // The fields show what the effect stores: Scribble's 270° angle reads -90°. + if let Value::Object(m) = vectorcraft_effects::upgrade_params(&id, &Value::Object(d.fields.clone())) { + d.fields = m; + } + changed } }; ui.add_space(6.0); diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 3d422351f..796dadc0c 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -432,6 +432,7 @@ pub(super) fn humanize(k: &str) -> String { "Channel4" => "Channel 4:".into(), "Include Cmy Blacks" => "Include Blacks with CMY:".into(), "Align To Path" => "Align to Path:".into(), + "Overlap Variation" | "Curviness Variation" | "Spacing Variation" => "Variation:".into(), "Create" => "Create New Fields by:".into(), _ => format!("{s}:"), } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 469543d8c..8770bf8c5 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -238,6 +238,28 @@ fn effect_dialog_applies_to_its_appearance_item() { assert!(n.appearance.effects.is_empty() && n.appearance.items[0].effects().is_empty()); } +#[test] +fn scribble_dialog_shows_the_angle_as_stored_and_each_variation() { + let mut app = app(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("effect.dialog", json!({"effect": "stylize.scribble"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("angle".into(), json!(270)); + frame(&mut app, Default::default()); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["angle"].as_f64(), Some(-90.0), "the field reads what is stored"); + confirm(&mut app).unwrap(); + let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + assert_eq!(n.appearance.effects[0].params["angle"].as_f64(), Some(-90.0)); + // Each variation follows the option it varies, as in the reference app's Scribble Options. + let doc = vectorcraft_effects::effect_info("stylize.scribble").unwrap().params; + let order = ["angle", "overlap", "overlapVariation", "strokeWidth", "curviness", "curvinessVariation", "spacing", "spacingVariation", "seed"]; + let ranks: Vec = order.iter().map(|k| super::effect::doc_rank(doc, k)).collect(); + assert!(ranks.windows(2).all(|w| w[0] < w[1]) && ranks[8] < usize::MAX, "{ranks:?}"); + for key in ["overlapVariation", "curvinessVariation", "spacingVariation"] { + assert_eq!(super::form::humanize(key), "Variation:"); + } +} + #[test] fn revolve_dialog_preview_cancel_confirm_and_undo() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 77420c453..d19589964 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -728,13 +728,7 @@ fn has_options(id: &str) -> bool { /// Drop Shadow's Color or Darkness as radio buttons); edits go through `effect.setParams`. fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usize, e: &Effect) { let t = Tokens::get(ui.ctx()); - let defaults = vectorcraft_effects::default_params(&e.id).unwrap_or(Value::Null); - let mut params = defaults.as_object().cloned().unwrap_or_default(); - if let Some(cur) = e.params.as_object() { - for (key, v) in cur { - params.insert(key.clone(), v.clone()); - } - } + let params = editor_params(e); let mut change: Option<(String, Value)> = None; let mut edit = false; let built_in = built_in_effect(&e.id); @@ -808,6 +802,15 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: } } +/// The values [`effect_editor`] lists: the effect's own values over its defaults, with earlier keys +/// in their current form (Scribble's one `variation` as its Path Overlap and Spacing variations). +fn editor_params(e: &Effect) -> serde_json::Map { + match vectorcraft_effects::merged_params(&e.id, &e.params) { + Value::Object(m) => m, + _ => serde_json::Map::new(), + } +} + /// "offsetX" → "Offset X". pub fn humanize(key: &str) -> String { let mut out = String::new(); @@ -1037,6 +1040,19 @@ mod tests { assert_eq!(n.appearance.effects[0].params["shade"], json!("darkness")); } + #[test] + fn the_effect_editor_lists_earlier_parameters_in_their_current_form() { + // A Scribble saved with one `variation` lists it as its Path Overlap and Spacing variations. + let e = Effect { id: "stylize.scribble".into(), params: json!({"variation": 2, "angle": 270}), visible: true }; + let p = editor_params(&e); + assert!(!p.contains_key("variation"), "{p:?}"); + assert_eq!((&p["overlapVariation"], &p["spacingVariation"], &p["curvinessVariation"]), (&json!(2), &json!(2), &json!(0.0))); + assert_eq!(p["angle"].as_f64(), Some(-90.0)); + // Another effect lists its values over its defaults, as before. + let e = Effect { id: "distort.twist".into(), params: json!({"angle": 270}), visible: true }; + assert_eq!(editor_params(&e).get("angle"), Some(&json!(270))); + } + #[test] fn drops_pick_the_command() { use vectorcraft_doc::{Appearance, FillLayer}; diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 346447e79..601276e91 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -26,7 +26,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Path | Offset Path, Outline Object, Outline Stroke | 2/3 | Outline Object | | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 10/13 | Hard Mix, Soft Mix, Trap | | Rasterize | Rasterize… (as a live effect) | 0/1 | the live effect (Object › Rasterize exists) | -| Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble draws one zig-zag per region, with no lines across holes and notches (#1651) | +| Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets | | SVG Filters | Apply SVG Filter… and the filter list | 0/1 | the whole submenu (SVG filters on import round-trip already) | | Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; Arc is a circular arc in the object's own proportions (#1578) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 75fe45d49..c70d3aee8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -510,6 +510,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). On the canvas, in Expand Appearance and in exports, Effect → Pathfinder uses the glyph outlines of type in the group, and results painted like the type keep its opacity and blend mode. - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). + - Scribble (Effect › Stylize › Scribble…) has a Variation for each of Path Overlap, Curviness and Spacing ([Scribble options](https://helpx.adobe.com/illustrator/using/creating-sketches-mosaics.html)) (`overlapVariation`, `curvinessVariation`, `spacingVariation`), shown in its dialog and in the Appearance panel. A Scribble saved with the earlier single `variation` reads it as the Path Overlap and Spacing variations and draws as before. The angle is stored above -180° and up to 180°, so 270° is stored as -90°. - **Simplify feedback and component selection (#1272):** the existing tolerance dialog previews from the original interaction snapshot and shows original/new anchor counts. Preview off and Cancel restore the document and selection; OK is one Undo. A refit or Add Anchor Points clears component indexes only on paths whose geometry changes, keeping their objects selected whole and unchanged paths' components intact. Engine and real-egui frame regressions cover document/selection restoration and grouped paths. Public references: [Simplify benefits](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/simplify-path-benefits.html) and [advanced options](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/simplify-paths-advanced-options-overview.html). Nearly unchanged appearance is expected when reducing points. Percentage/auto-simplify controls, the corner-angle UI and Show Original are not implemented; the checklist is partial, without raising scores.