diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 42051b8f7..e3055da0b 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -86,6 +86,7 @@ pub use solids::{ EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid, validate_three_d, view_options, }; +pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use trap::TRAP; pub use warp::{WarpStyle, warp_point}; diff --git a/crates/effects/src/stylize.rs b/crates/effects/src/stylize.rs index bdb5f8623..98bf2dd67 100644 --- a/crates/effects/src/stylize.rs +++ b/crates/effects/src/stylize.rs @@ -1,6 +1,6 @@ //! Convert to Shape, Round Corners and Scribble. -use serde_json::Value; +use serde_json::{Value, json}; use vectorcraft_geom::kurbo; use vectorcraft_geom::shapes::{self, KAPPA}; use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath, corners}; @@ -144,6 +144,54 @@ fn crosses_outline(p: Point, q: Point, polys: &[Vec]) -> bool { }) } +/// One named Settings entry in Effect › Stylize › Scribble (public feature names; parameter sets +/// are chosen for VectorCraft). +pub struct ScribbleSetting { + pub id: &'static str, + pub label: &'static str, + pub params: Value, +} + +/// The Scribble dialog's Settings presets, in menu order. +pub fn scribble_settings() -> &'static [ScribbleSetting] { + static PRESETS: std::sync::OnceLock> = std::sync::OnceLock::new(); + PRESETS.get_or_init(|| { + [ + ("default", "Default", 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})), + ("childlike", "Childlike", json!({"angle": 45.0, "overlap": 2.0, "overlapVariation": 2.0, "strokeWidth": 2.0, "curviness": 15.0, "curvinessVariation": 30.0, "spacing": 4.0, "spacingVariation": 1.5})), + ("dense", "Dense", json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.0, "strokeWidth": 3.0, "curviness": 0.0, "curvinessVariation": 0.0, "spacing": 2.0, "spacingVariation": 0.0})), + ("loose", "Loose", json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 1.0, "strokeWidth": 2.0, "curviness": 20.0, "curvinessVariation": 40.0, "spacing": 12.0, "spacingVariation": 3.0})), + ("moire", "Moire", json!({"angle": 0.0, "overlap": 0.0, "overlapVariation": 0.0, "strokeWidth": 1.0, "curviness": 80.0, "curvinessVariation": 10.0, "spacing": 3.0, "spacingVariation": 0.5})), + ("sharp", "Sharp", json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.0, "strokeWidth": 2.0, "curviness": 0.0, "curvinessVariation": 0.0, "spacing": 3.0, "spacingVariation": 0.0})), + ("sketch", "Sketch", json!({"angle": 30.0, "overlap": -1.0, "overlapVariation": 0.5, "strokeWidth": 2.0, "curviness": 10.0, "curvinessVariation": 15.0, "spacing": 6.0, "spacingVariation": 1.0})), + ("snarl", "Snarl", json!({"angle": 15.0, "overlap": 3.0, "overlapVariation": 3.0, "strokeWidth": 4.0, "curviness": 40.0, "curvinessVariation": 50.0, "spacing": 8.0, "spacingVariation": 4.0})), + ("swash", "Swash", json!({"angle": 60.0, "overlap": 5.0, "overlapVariation": 2.0, "strokeWidth": 5.0, "curviness": 30.0, "curvinessVariation": 20.0, "spacing": 10.0, "spacingVariation": 2.0})), + ("tight", "Tight", json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.0, "strokeWidth": 3.0, "curviness": 5.0, "curvinessVariation": 0.0, "spacing": 2.0, "spacingVariation": 0.2})), + ("zigzag", "Zigzag", json!({"angle": 90.0, "overlap": 0.0, "overlapVariation": 0.0, "strokeWidth": 3.0, "curviness": 0.0, "curvinessVariation": 0.0, "spacing": 8.0, "spacingVariation": 0.0})), + ] + .into_iter() + .map(|(id, label, params)| ScribbleSetting { id, label, params }) + .collect() + }) +} + +/// Which Settings preset matches `params` (seed ignored), if any. +pub fn scribble_setting_id(params: &Value) -> Option<&'static str> { + let obj = params.as_object()?; + scribble_settings().iter().find(|p| p.params.as_object().is_some_and(|want| scribble_params_match(obj, want))).map(|p| p.id) +} + +fn scribble_params_match(have: &serde_json::Map, want: &serde_json::Map) -> bool { + want.iter().all(|(k, v)| have.get(k).is_some_and(|a| scribble_param_eq(a, v))) +} + +fn scribble_param_eq(a: &Value, b: &Value) -> bool { + match (a.as_f64(), b.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() < 1e-6, + _ => a == b, + } +} + /// 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. `overlapVariation` moves each /// line's ends along it by up to that many points, `spacingVariation` moves them across it, and diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index c5b5b47d2..3996794ac 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -925,6 +925,17 @@ fn scribble_reads_its_earlier_single_variation() { assert!(default_params("stylize.scribble").unwrap().get("variation").is_none()); } +#[test] +fn scribble_settings_presets_are_distinct_and_recognized() { + let presets = crate::scribble_settings(); + assert_eq!(presets.len(), 11); + assert_eq!(presets[0].id, "default"); + assert_eq!(crate::scribble_setting_id(&presets[0].params), Some("default")); + assert_eq!(crate::scribble_setting_id(&presets[9].params), Some("tight")); + let custom = json!({"angle": 30.0, "overlap": 0.0, "overlapVariation": 0.5, "strokeWidth": 3.0, "curviness": 5.0, "curvinessVariation": 0.0, "spacing": 4.0, "spacingVariation": 0.5}); + assert_eq!(crate::scribble_setting_id(&custom), 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)] { diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 695c3d454..4bc5bde5b 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -161,6 +161,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { None if id == GLASS => glass_fields(app, ui, d, GLASS), None if id == TEXTURIZER => glass_fields(app, ui, d, TEXTURIZER), None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()), + None if id == SCRIBBLE => scribble_fields(ui, d, app.session.general_unit()), None if id == ZIG_ZAG => zig_zag_fields(ui, d, app.session.general_unit()), None if id == DROP_SHADOW => drop_shadow_fields(ui, d, app.session.general_unit()), None if id == "artistic.filmGrain" => film_grain_fields(ui, d), @@ -478,6 +479,53 @@ fn values_changed(d: &mut Dialog) -> bool { /// The Transform effect (Effect › Distort & Transform › Transform…). const TRANSFORM: &str = "distort.transform"; +/// Scribble (Effect › Stylize › Scribble…). +const SCRIBBLE: &str = "stylize.scribble"; + +/// Dialog-only: which Settings preset is selected (`custom` when the values match none). +const SCRIBBLE_SETTING: &str = "__scribbleSetting"; + +/// Scribble's dialog: a Settings preset menu, then its parameters. Returns whether the values differ +/// from those last previewed. +fn scribble_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool { + if !d.fields.contains_key(SCRIBBLE_SETTING) { + let id = vectorcraft_effects::scribble_setting_id(&form::params(d)).unwrap_or("custom"); + d.fields.insert(SCRIBBLE_SETTING.into(), json!(id)); + } + let presets = vectorcraft_effects::scribble_settings(); + let cur = d.fields.get(SCRIBBLE_SETTING).and_then(Value::as_str).unwrap_or("default"); + let labels: Vec<&str> = presets.iter().map(|p| p.label).chain(["Custom"]).collect(); + let ids: Vec<&str> = presets.iter().map(|p| p.id).chain(["custom"]).collect(); + let current = if cur == "custom" { "Custom" } else { presets.iter().find(|p| p.id == cur).map(|p| p.label).unwrap_or(labels[0]) }; + widgets::subheader(ui, tl!("Settings")); + let mut settings_changed = false; + ui.horizontal(|ui| { + if let Some(i) = widgets::dropdown(ui, "fx-scribble-setting", current, &labels, 160.0) { + let picked = ids[i]; + d.fields.insert(SCRIBBLE_SETTING.into(), json!(picked)); + if picked != "custom" + && let Some(preset) = presets.iter().find(|p| p.id == picked) + && let Some(m) = preset.params.as_object() + { + for (k, v) in m { + d.fields.insert(k.clone(), v.clone()); + } + } + settings_changed = true; + } + }); + ui.add_space(4.0); + let doc = vectorcraft_effects::effect_info(SCRIBBLE).map(|e| e.params).unwrap_or_default(); + let fields_changed = + form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(SCRIBBLE, k, false), &|k| choices(SCRIBBLE, k), &|k| doc_rank(doc, k), unit); + if let Value::Object(m) = vectorcraft_effects::upgrade_params(SCRIBBLE, &Value::Object(d.fields.clone())) { + d.fields = m; + } + let id = vectorcraft_effects::scribble_setting_id(&form::params(d)).unwrap_or("custom"); + d.fields.insert(SCRIBBLE_SETTING.into(), json!(id)); + settings_changed || fields_changed +} + /// The Transform effect's dialog: Transform Each's Scale and Move sliders and Rotate dial, then /// the options Transform Objects, Transform Patterns and Scale Strokes & Effects (#885), Copies, /// the reflections, the reference point and Random. Returns whether the values differ from those diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index a0ada2902..5be090dc6 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -276,6 +276,25 @@ 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_settings_presets_load_parameters() { + 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(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); + assert!(text.lines().any(|l| l == "Settings"), "{text}"); + let tight = vectorcraft_effects::scribble_settings().iter().find(|p| p.id == "tight").unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("__scribbleSetting".into(), json!("tight")); + for (k, v) in tight.params.as_object().unwrap() { + app.ui.dialog.as_mut().unwrap().fields.insert(k.clone(), v.clone()); + } + frame(&mut app, Default::default()); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("spacing", 0.0), 2.0); + confirm(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["spacing"], json!(2.0)); +} + #[test] fn scribble_dialog_shows_the_angle_as_stored_and_each_variation() { let mut app = app();