From f673491bd234d8c2d2dd7877128cbaa997252707 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 08:56:21 -0400 Subject: [PATCH] progress --- crates/engine/src/cmd/menucmds.rs | 2 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/wand.rs | 223 +++++++++++++++++++++++ crates/tools/src/xform.rs | 2 +- crates/tools/src/xform/wand.rs | 126 +++---------- crates/ui-egui/src/menus.rs | 8 +- crates/ui-egui/src/panels/image_trace.rs | 155 ++++++++++++++++ crates/ui-egui/src/panels/magic_wand.rs | 64 +++++++ crates/ui-egui/src/panels/mod.rs | 6 + crates/ui-egui/src/state.rs | 2 + 10 files changed, 484 insertions(+), 106 deletions(-) create mode 100644 crates/engine/src/cmd/wand.rs create mode 100644 crates/ui-egui/src/panels/image_trace.rs create mode 100644 crates/ui-egui/src/panels/magic_wand.rs diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index e8fc3eef..c9963e19 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -23,6 +23,8 @@ pub struct MenuState { pub new_masks_unclipped: bool, /// Transparency panel menu: "New Opacity Masks Are Inverted". pub new_masks_inverted: bool, + /// Magic Wand panel settings. + pub wand: super::wand::WandSettings, } pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4430a990..daf691cd 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -28,6 +28,7 @@ mod select; mod textedit; mod typecmd; mod typemenu; +pub mod wand; mod xform; use drawcraft_color::Color; @@ -134,6 +135,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(object::specs()); v.extend(path::specs()); v.extend(select::specs()); + v.extend(wand::specs()); v.extend(paint::specs()); v.extend(opacitymask::specs()); v.extend(layer::specs()); diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs new file mode 100644 index 00000000..111d8732 --- /dev/null +++ b/crates/engine/src/cmd/wand.rs @@ -0,0 +1,223 @@ +//! Magic Wand: select objects with attributes similar to a reference object. The settings are the +//! Magic Wand panel's (session state, kept across tool switches); the tool runs +//! `select.magicWand` on click. + +use drawcraft_color::Paint; +use drawcraft_doc::{Document, Node, NodeId, NodeKind}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::*; + +/// Magic Wand panel settings. Tolerances: colours as 0–255 RGB distance, stroke weight in +/// points, opacity in percent. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct WandSettings { + pub fill_color: bool, + pub fill_tolerance: f64, + pub stroke_color: bool, + pub stroke_tolerance: f64, + pub stroke_weight: bool, + pub weight_tolerance: f64, + pub opacity: bool, + pub opacity_tolerance: f64, + pub blending_mode: bool, +} + +impl Default for WandSettings { + fn default() -> Self { + Self { + fill_color: true, + fill_tolerance: 32.0, + stroke_color: false, + stroke_tolerance: 32.0, + stroke_weight: false, + weight_tolerance: 5.0, + opacity: false, + opacity_tolerance: 5.0, + blending_mode: false, + } + } +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "select.magicWand", + "Magic Wand", + [], + None, + "{id, mode?: set|add|subtract} select objects similar to `id` by the Magic Wand settings → {count}", + has_doc, + magic_wand + ), + cmd!( + query "magicWand.set", + "Magic Wand Options", + ["Window", "Magic Wand"], + None, + "{fillColor?, fillTolerance?: 0-255, strokeColor?, strokeTolerance?: 0-255, strokeWeight?, weightTolerance?: pt, opacity?, opacityTolerance?: %, blendingMode?, reset?: bool} → the settings", + always, + set + ), + cmd!(query "magicWand.options", "Magic Wand Settings", [], None, "{} → the Magic Wand settings", always, |s, _| Ok(json!(s.menu.wand))), + ] +} + +fn set(s: &mut Session, p: &Value) -> Result { + if bool_or(p, "reset", false) { + s.menu.wand = WandSettings::default(); + } + let mut v = serde_json::to_value(&s.menu.wand).map_err(|e| EngineError::Other(e.to_string()))?; + if let (Some(o), Some(src)) = (v.as_object_mut(), p.as_object()) { + for (k, val) in src { + if o.contains_key(k) { + o.insert(k.clone(), val.clone()); + } + } + } + let mut w: WandSettings = serde_json::from_value(v).map_err(|e| bad("magicWand.set", e.to_string()))?; + w.fill_tolerance = w.fill_tolerance.clamp(0.0, 255.0); + w.stroke_tolerance = w.stroke_tolerance.clamp(0.0, 255.0); + w.weight_tolerance = w.weight_tolerance.clamp(0.0, 1000.0); + w.opacity_tolerance = w.opacity_tolerance.clamp(0.0, 100.0); + s.menu.wand = w; + Ok(json!(s.menu.wand)) +} + +fn fill_of(n: &Node) -> Paint { + match &n.kind { + NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_default(), + _ => n.appearance.fill_paint(), + } +} + +fn stroke_of(n: &Node) -> (Paint, f64) { + match &n.kind { + NodeKind::Text(t) => t.runs.first().map(|r| (r.style.stroke.clone(), r.style.stroke_width)).unwrap_or_default(), + _ => (n.appearance.stroke_paint(), n.appearance.stroke_width()), + } +} + +/// Are two paints within `tol` (0–255 Euclidean RGB distance)? Non-solid paints must match exactly. +pub fn similar_paint(a: &Paint, b: &Paint, tol: f64) -> bool { + match (a.color(), b.color()) { + (Some(x), Some(y)) => { + let (x, y) = (x.to_rgb(), y.to_rgb()); + let d: f64 = (0..3).map(|i| ((x[i] - y[i]) as f64 * 255.0).powi(2)).sum::().sqrt(); + d <= tol + 1e-6 + } + _ => a == b, + } +} + +impl WandSettings { + /// Does `n` match `reference` on every enabled attribute? + pub fn matches(&self, reference: &Node, n: &Node) -> bool { + let (rs, rw) = stroke_of(reference); + let (ns, nw) = stroke_of(n); + (!self.fill_color || similar_paint(&fill_of(reference), &fill_of(n), self.fill_tolerance)) + && (!self.stroke_color || similar_paint(&rs, &ns, self.stroke_tolerance)) + && (!self.stroke_weight || (rw - nw).abs() <= self.weight_tolerance + 1e-9) + && (!self.opacity || ((reference.opacity - n.opacity).abs() as f64 * 100.0) <= self.opacity_tolerance + 1e-6) + && (!self.blending_mode || reference.blend == n.blend) + } +} + +/// Paintable leaves (paths, compounds, text, images) that are visible and editable. +fn paintable(doc: &Document) -> Vec { + let mut out = vec![]; + let mut compounds: Vec = vec![]; + doc.walk(|n| { + if matches!(n.kind, NodeKind::Compound { .. }) { + compounds.push(n.id); + out.push(n.id); + } else if !n.is_container() { + out.push(n.id); + } + }); + out.retain(|id| { + !doc.parent_of(*id).is_some_and(|p| compounds.contains(&p)) + && doc.is_editable(*id) + && doc.is_visible(*id) + && doc.node(*id).is_some_and(|n| n.visible) + }); + out +} + +fn magic_wand(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("select.magicWand", "missing id"))?; + let st = s.doc()?; + let reference = st.doc.node(id).ok_or(EngineError::NoNode(id))?.clone(); + let w = s.menu.wand.clone(); + let hits: Vec = paintable(&st.doc).into_iter().filter(|i| st.doc.node(*i).is_some_and(|n| w.matches(&reference, n))).collect(); + let ids: Vec = match str_param(p, "mode").unwrap_or("set") { + "add" => st.selection.objects.iter().copied().chain(hits.iter().copied().filter(|h| !st.selection.objects.contains(h))).collect(), + "subtract" => st.selection.objects.iter().copied().filter(|i| !hits.contains(i)).collect(), + "set" => hits, + other => return Err(bad("select.magicWand", format!("unknown mode `{other}` (set, add, subtract)"))), + }; + let n = ids.len(); + s.doc_mut()?.selection.set(ids); + Ok(json!({ "count": n })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rect(s: &mut Session, x: f64, fill: &str, stroke_w: f64) -> u64 { + let id = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"color": fill})).unwrap(); + s.execute("stroke.set", &json!({"weight": stroke_w})).unwrap(); + id + } + + fn selected(s: &Session) -> Vec { + let st = s.doc().unwrap(); + st.selection.in_paint_order(&st.doc).iter().map(|i| i.0).collect() + } + + #[test] + fn fill_tolerance_stroke_weight_and_modes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap(); + let a = rect(&mut s, 0.0, "#ffffff", 1.0); + let b = rect(&mut s, 20.0, "#fafafa", 4.0); + let c = rect(&mut s, 40.0, "#ff0000", 1.0); + s.execute("select.magicWand", &json!({"id": a})).unwrap(); + assert_eq!(selected(&s), [a, b]); + s.execute("magicWand.set", &json!({"fillTolerance": 0})).unwrap(); + s.execute("select.magicWand", &json!({"id": a})).unwrap(); + assert_eq!(selected(&s), [a]); + s.execute("select.magicWand", &json!({"id": c, "mode": "add"})).unwrap(); + assert_eq!(selected(&s), [a, c]); + // Stroke weight only (fill off): a and c share 1 pt. + s.execute("magicWand.set", &json!({"fillColor": false, "strokeWeight": true, "weightTolerance": 0.5})).unwrap(); + s.execute("select.magicWand", &json!({"id": a})).unwrap(); + assert_eq!(selected(&s), [a, c]); + s.execute("select.magicWand", &json!({"id": a, "mode": "subtract"})).unwrap(); + assert!(selected(&s).is_empty()); + // Settings survive tool switches and reset to Illustrator's defaults. + s.select_tool("pen", Default::default()).unwrap(); + assert_eq!(s.execute("magicWand.options", &json!({})).unwrap()["strokeWeight"], true); + assert_eq!(s.execute("magicWand.set", &json!({"reset": true})).unwrap(), json!(WandSettings::default())); + } + + #[test] + fn opacity_and_blending_mode() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap(); + let a = rect(&mut s, 0.0, "#000000", 1.0); + let b = rect(&mut s, 20.0, "#000000", 1.0); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 50, "blend": "Multiply"})).unwrap(); + s.execute("magicWand.set", &json!({"opacity": true})).unwrap(); + s.execute("select.magicWand", &json!({"id": a})).unwrap(); + assert_eq!(selected(&s), [a]); + s.execute("magicWand.set", &json!({"opacity": false, "blendingMode": true})).unwrap(); + s.execute("select.magicWand", &json!({"id": b})).unwrap(); + assert_eq!(selected(&s), [b]); + } +} diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index 81ecf463..16bff465 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -36,7 +36,7 @@ pub fn create(id: &str) -> Option> { "eyedropper" => Box::new(EyedropperTool), "gradient" => Box::new(GradientTool::default()), "artboard" => Box::new(ArtboardTool::default()), - "magicWand" => Box::new(MagicWandTool::default()), + "magicWand" => Box::new(MagicWandTool), "lasso" => Box::new(LassoTool::default()), "measure" => Box::new(MeasureTool::default()), _ => return None, diff --git a/crates/tools/src/xform/wand.rs b/crates/tools/src/xform/wand.rs index 6489e932..2d2a7e8f 100644 --- a/crates/tools/src/xform/wand.rs +++ b/crates/tools/src/xform/wand.rs @@ -1,70 +1,22 @@ //! Magic Wand (Y) and Lasso (Q). //! -//! Magic Wand: click an object to select every object whose fill colour is within `tolerance` -//! (0..255 RGB distance); Shift adds, Alt subtracts. Lasso: freehand loop selecting anchor points -//! inside it (direct-selection style); Shift adds, Alt subtracts. +//! Magic Wand: click an object to select every object with similar attributes. The matching and +//! its settings (Magic Wand panel) live in the engine's `select.magicWand` command; Shift adds, +//! Alt subtracts. Lasso: freehand loop selecting anchor points inside it (direct-selection +//! style); Shift adds, Alt subtracts. use std::collections::BTreeSet; -use drawcraft_color::Paint; +use drawcraft_doc::{AnchorRef, NodeId, NodeKind}; use drawcraft_doc::hit::hit_test; -use drawcraft_doc::{AnchorRef, Document, NodeId, NodeKind}; use drawcraft_geom::Point; use serde_json::{Value, json}; use super::paint_owner; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; -pub struct MagicWandTool { - /// RGB distance tolerance, 0..255. - pub tolerance: f64, -} - -impl Default for MagicWandTool { - fn default() -> Self { - Self { tolerance: 32.0 } - } -} - -fn fill_of(n: &drawcraft_doc::Node) -> Paint { - match &n.kind { - NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_default(), - _ => n.appearance.fill_paint(), - } -} - -/// Are two fills "similar" within `tol` (0..255 Euclidean RGB distance)? Non-solid paints must match exactly. -pub fn similar_fill(a: &Paint, b: &Paint, tol: f64) -> bool { - match (a.color(), b.color()) { - (Some(x), Some(y)) => { - let (x, y) = (x.to_rgb(), y.to_rgb()); - let d: f64 = (0..3).map(|i| ((x[i] - y[i]) as f64 * 255.0).powi(2)).sum::().sqrt(); - d <= tol + 1e-6 - } - _ => a == b, - } -} - -/// Paintable leaves (paths, compounds, text, images) that are visible and editable. -fn paintable(doc: &Document) -> Vec { - let mut out = vec![]; - let mut compounds: Vec = vec![]; - doc.walk(|n| { - if matches!(n.kind, NodeKind::Compound { .. }) { - compounds.push(n.id); - out.push(n.id); - } else if !n.is_container() { - out.push(n.id); - } - }); - out.retain(|id| { - !doc.parent_of(*id).is_some_and(|p| compounds.contains(&p)) - && doc.is_editable(*id) - && doc.is_visible(*id) - && doc.node(*id).is_some_and(|n| n.visible) - }); - out -} +#[derive(Default)] +pub struct MagicWandTool; impl Tool for MagicWandTool { fn id(&self) -> &'static str { @@ -78,35 +30,19 @@ impl Tool for MagicWandTool { let Some(h) = hit_test(cx.doc, ev.pos, cx.hit_options()) else { return if ev.mods.shift || ev.mods.alt { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; }; - let src = paint_owner(cx.doc, h.leaf); - let Some(reference) = cx.doc.node(src).map(fill_of) else { return vec![] }; - let hits: Vec = paintable(cx.doc) - .into_iter() - .filter(|id| cx.doc.node(*id).is_some_and(|n| similar_fill(&fill_of(n), &reference, self.tolerance))) - .collect(); - if ev.mods.alt { - let keep: Vec = cx.selection.objects.iter().copied().filter(|id| !hits.contains(id)).collect(); - return vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&keep) }))]; - } - let cmd = if ev.mods.shift { "select.add" } else { "select.set" }; - vec![Action::Exec(cmd.into(), json!({ "ids": json_ids(&hits) }))] + let mode = if ev.mods.alt { + "subtract" + } else if ev.mods.shift { + "add" + } else { + "set" + }; + vec![Action::Exec("select.magicWand".into(), json!({ "id": paint_owner(cx.doc, h.leaf).0, "mode": mode }))] } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { Cursor::Crosshair } - - fn options(&self) -> Value { - json!({ "tolerance": self.tolerance }) - } - - fn set_option(&mut self, key: &str, value: &Value) { - if key == "tolerance" - && let Some(v) = value.as_f64() - { - self.tolerance = v.clamp(0.0, 255.0); - } - } } /// Even-odd point-in-polygon. @@ -227,34 +163,22 @@ impl Tool for LassoTool { mod tests { use super::*; use crate::testutil::*; - use drawcraft_color::Color; - use drawcraft_doc::{Appearance, Node, Selection}; - use drawcraft_geom::{Rect, shapes}; - - fn add_rect(d: &mut Document, r: Rect, fill: Color) -> NodeId { - let l = d.layers[0].id; - let id = d.alloc_id(); - d.insert(Some(l), usize::MAX, Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 1.0))).unwrap(); - id - } + use drawcraft_doc::Selection; #[test] - fn magic_wand_selects_similar_fills() { - let (mut d, a) = doc_with_rect(); // white fill - let b = add_rect(&mut d, Rect::new(300.0, 300.0, 350.0, 350.0), Color::rgb8(250, 250, 250)); - let c = add_rect(&mut d, Rect::new(400.0, 400.0, 450.0, 450.0), Color::rgb8(255, 0, 0)); + fn magic_wand_emits_the_engine_command() { + let (d, a) = doc_with_rect(); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); - let mut t = MagicWandTool::default(); + let mut t = MagicWandTool; let r = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); - assert_eq!(r, vec![Action::Exec("select.set".into(), json!({"ids": [a.0, b.0]}))]); - t.set_option("tolerance", &json!(0)); - let r = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); - assert_eq!(r, vec![Action::Exec("select.set".into(), json!({"ids": [a.0]}))]); + assert_eq!(r, vec![Action::Exec("select.magicWand".into(), json!({"id": a.0, "mode": "set"}))]); let shift = Mods { shift: true, ..Default::default() }; - let r = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 420.0, 420.0).with_mods(shift)); - assert_eq!(r, vec![Action::Exec("select.add".into(), json!({"ids": [c.0]}))]); + let r = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(shift)); + assert_eq!(r, vec![Action::Exec("select.magicWand".into(), json!({"id": a.0, "mode": "add"}))]); + let miss = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 900.0, 900.0)); + assert_eq!(miss, vec![Action::Exec("select.none".into(), json!({}))]); } #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ed0e2efd..f7d8cd0f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -744,7 +744,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { todo("Recent Fonts"), sub("Size", TYPE_SIZES.iter().map(|(l, n)| cp(l, "text.setStyle", json!({ "size": n }))).collect()), Sep, - todo("Glyphs"), + panel("Glyphs", "glyphs"), sub("Insert Special Character", insert_items(INSERT_SPECIAL)), sub("Insert Whitespace Character", insert_items(INSERT_WHITESPACE)), sub("Insert Break Character", insert_items(INSERT_BREAK)), @@ -955,12 +955,12 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { panel("Gradient", "gradient"), panel("Graphic Styles", "graphicStyles"), panel("History", "history"), - todo("Image Trace"), + panel("Image Trace", "imageTrace"), panel("Info", "info"), panel("Layers", "layers"), panel("Libraries", "libraries"), todo("Links"), - todo("Magic Wand"), + panel("Magic Wand", "magicWand"), panel("Navigator", "navigator"), panel("Pathfinder", "pathfinder"), panel("Pattern Options", "patternOptions"), @@ -977,7 +977,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { vec![ panel("Character", "character"), todo("Character Styles"), - todo("Glyphs"), + panel("Glyphs", "glyphs"), todo("OpenType"), panel("Paragraph", "paragraph"), todo("Paragraph Styles"), diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs new file mode 100644 index 00000000..6b031406 --- /dev/null +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -0,0 +1,155 @@ +//! Image Trace panel: preset, mode, threshold or colour count, and the advanced fidelity options. +//! With an Image Trace object selected, changing a setting re-traces it (one undo step per change; +//! sliders apply when released). With an image selected, Trace makes a new Image Trace object. + +use egui::Ui; +use serde_json::{Value, json}; + +use super::{first_selected, pstate, set_pstate}; +use crate::DrawcraftApp; +use crate::widgets::{self, menu_item}; + +const MODES: [(&str, &str); 3] = [("blackAndWhite", "Black and White"), ("grayscale", "Grayscale"), ("color", "Color")]; + +/// Panel state: the preset name, the current parameters and the last result's counts. +#[derive(Clone, Default)] +struct TraceUi { + preset: String, + params: Value, + info: Option<(u64, u64, u64)>, +} + +fn presets(app: &mut DrawcraftApp) -> Vec<(String, Value)> { + let v = app.session.execute("imageTrace.presets", &json!({})).unwrap_or_default(); + v["presets"].as_array().map(|a| a.iter().map(|p| (p["name"].as_str().unwrap_or("").to_string(), p["params"].clone())).collect()).unwrap_or_default() +} + +/// What the selection is: an Image Trace object, a plain image, or neither. +fn target(app: &DrawcraftApp) -> (bool, bool) { + let Some(n) = first_selected(app) else { return (false, false) }; + let trace = n.name.as_deref() == Some("Image Trace") && n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, drawcraft_doc::NodeKind::Image(_)))); + (trace, matches!(n.kind, drawcraft_doc::NodeKind::Image(_))) +} + +fn trace(app: &mut DrawcraftApp, st: &mut TraceUi) { + match app.run("imageTrace.make", json!({ "preset": "Default", "params": st.params })) { + Ok(r) => st.info = Some((r["paths"].as_u64().unwrap_or(0), r["anchors"].as_u64().unwrap_or(0), r["colors"].as_u64().unwrap_or(0))), + Err(e) => app.ui.status = e, + } +} + +fn slider(ui: &mut Ui, label: &str, v: &mut f64, range: std::ops::RangeInclusive, suffix: &str) -> (bool, bool) { + let mut out = (false, false); + ui.horizontal(|ui| { + ui.add_sized([74.0, 22.0], egui::Label::new(egui::RichText::new(label).size(12.0))); + let r = ui.add(egui::Slider::new(v, range).suffix(suffix).integer()); + out = (r.changed(), r.drag_stopped() || (r.changed() && !r.dragged())); + }); + out +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let all = presets(app); + let mut st: TraceUi = pstate(ui.ctx(), "image-trace"); + if st.params.is_null() { + st.preset = "Default".into(); + st.params = all.first().map(|p| p.1.clone()).unwrap_or_default(); + } + let (is_trace, is_image) = target(app); + let mut retrace = false; + + ui.horizontal(|ui| { + widgets::dim_label(ui, "Preset:"); + let names: Vec<&str> = all.iter().map(|p| p.0.as_str()).collect(); + if let Some(i) = widgets::dropdown(ui, "it-preset", &st.preset, &names, 170.0) { + st.preset = all[i].0.clone(); + st.params = all[i].1.clone(); + retrace = true; + } + }); + ui.horizontal(|ui| { + widgets::dim_label(ui, "Mode:"); + let mode = st.params["mode"].as_str().unwrap_or("blackAndWhite").to_string(); + let label = MODES.iter().find(|m| m.0 == mode).map_or("Black and White", |m| m.1); + let labels: Vec<&str> = MODES.iter().map(|m| m.1).collect(); + if let Some(i) = widgets::dropdown(ui, "it-mode", label, &labels, 170.0) { + st.params["mode"] = json!(MODES[i].0); + st.preset = "Custom".into(); + retrace = true; + } + }); + let bw = st.params["mode"].as_str() == Some("blackAndWhite"); + let (key, label, range) = if bw { ("threshold", "Threshold", 0.0..=255.0) } else { ("colors", if st.params["mode"] == "grayscale" { "Grays" } else { "Colors" }, 2.0..=256.0) }; + let mut v = st.params[key].as_f64().unwrap_or(0.0); + let (changed, release) = slider(ui, label, &mut v, range, ""); + if changed { + st.params[key] = json!(v.round() as u64); + st.preset = "Custom".into(); + } + retrace |= release; + + let open: bool = !pstate::(ui.ctx(), "it-advanced-closed"); + if ui.add(egui::Button::new(format!("{} Advanced", if open { "▾" } else { "▸" })).frame(false)).clicked() { + set_pstate(ui.ctx(), "it-advanced-closed", open); + } + if open { + for (key, label, range, suffix) in [("paths", "Paths", 0.0..=100.0, "%"), ("corners", "Corners", 0.0..=100.0, "%"), ("noise", "Noise", 1.0..=100.0, " px")] { + let mut v = st.params[key].as_f64().unwrap_or(0.0); + let (changed, release) = slider(ui, label, &mut v, range, suffix); + if changed { + st.params[key] = if key == "noise" { json!(v.round() as u64) } else { json!(v.round()) }; + st.preset = "Custom".into(); + } + retrace |= release; + } + ui.horizontal(|ui| { + widgets::dim_label(ui, "Method:"); + for (m, l) in [("abutting", "Abutting"), ("overlapping", "Overlapping")] { + if ui.selectable_label(st.params["method"] == m, l).clicked() && st.params["method"] != m { + st.params["method"] = json!(m); + st.preset = "Custom".into(); + retrace = true; + } + } + }); + for (key, label) in [("snapCurvesToLines", "Snap Curves To Lines"), ("ignoreWhite", "Ignore White")] { + let on = st.params[key].as_bool().unwrap_or(false); + if widgets::check(ui, label, on, true) { + st.params[key] = json!(!on); + st.preset = "Custom".into(); + retrace = true; + } + } + } + widgets::divider(ui); + if let Some((p, a, c)) = st.info { + widgets::dim_label(ui, &format!("Paths: {p} Anchors: {a} Colors: {c}")); + } + ui.horizontal(|ui| { + let r = ui.add_enabled_ui(is_trace || is_image, |ui| widgets::flat_button(ui, "Trace", 80.0)).inner; + if r.on_disabled_hover_text("Select an image to trace").clicked() { + trace(app, &mut st); + } + if ui.add_enabled_ui(is_trace, |ui| widgets::flat_button(ui, "Expand", 80.0)).inner.clicked() { + app.run("imageTrace.expand", json!({})).ok(); + } + }); + if retrace && is_trace { + trace(app, &mut st); + } + set_pstate(ui.ctx(), "image-trace", st); +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let (is_trace, _) = target(app); + if menu_item(ui, "Release", is_trace, false) { + app.run("imageTrace.release", json!({})).ok(); + } + if menu_item(ui, "Expand", is_trace, false) { + app.run("imageTrace.expand", json!({})).ok(); + } + ui.separator(); + if menu_item(ui, "Reset to Default", true, false) { + set_pstate(ui.ctx(), "image-trace", TraceUi::default()); + } +} diff --git a/crates/ui-egui/src/panels/magic_wand.rs b/crates/ui-egui/src/panels/magic_wand.rs new file mode 100644 index 00000000..e3a3db1f --- /dev/null +++ b/crates/ui-egui/src/panels/magic_wand.rs @@ -0,0 +1,64 @@ +//! Magic Wand panel: which attributes the Magic Wand tool compares, with a tolerance for each. +//! The settings live in the engine (`magicWand.set` / `magicWand.options`), so they survive tool +//! switches and agents can set them too. + +use egui::Ui; +use serde_json::{Value, json}; + +use super::{pstate, set_pstate}; +use crate::DrawcraftApp; +use crate::widgets::{self, menu_item}; + +fn options(app: &mut DrawcraftApp) -> Value { + app.session.execute("magicWand.options", &json!({})).unwrap_or_default() +} + +/// One row: checkbox + tolerance field. +fn row(app: &mut DrawcraftApp, ui: &mut Ui, o: &Value, label: &str, flag: &str, tol: Option<(&str, &str, f64)>) { + let on = o[flag].as_bool().unwrap_or(false); + ui.horizontal(|ui| { + ui.set_min_height(26.0); + if widgets::check(ui, label, on, true) { + app.run("magicWand.set", json!({ flag: !on })).ok(); + } + if let Some((key, unit, max)) = tol { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_enabled_ui(on, |ui| { + let v = o[key].as_f64().unwrap_or(0.0); + if let Some(n) = widgets::spin_plain(ui, ("wand", key), v, unit, 0, 64.0, 1.0, 0.0, &[]) { + app.run("magicWand.set", json!({ key: n.clamp(0.0, max) })).ok(); + } + }); + widgets::dim_label(ui, "Tolerance:"); + }); + } + }); +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let o = options(app); + row(app, ui, &o, "Fill Color", "fillColor", Some(("fillTolerance", "", 255.0))); + if !pstate::(ui.ctx(), "wand-hide-stroke") { + widgets::divider(ui); + row(app, ui, &o, "Stroke Color", "strokeColor", Some(("strokeTolerance", "", 255.0))); + row(app, ui, &o, "Stroke Weight", "strokeWeight", Some(("weightTolerance", " pt", 1000.0))); + } + if !pstate::(ui.ctx(), "wand-hide-transparency") { + widgets::divider(ui); + row(app, ui, &o, "Opacity", "opacity", Some(("opacityTolerance", "%", 100.0))); + row(app, ui, &o, "Blending Mode", "blendingMode", None); + } +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + for (label, key) in [("Stroke Options", "wand-hide-stroke"), ("Transparency Options", "wand-hide-transparency")] { + let hidden: bool = pstate(ui.ctx(), key); + if menu_item(ui, &format!("{} {label}", if hidden { "Show" } else { "Hide" }), true, false) { + set_pstate(ui.ctx(), key, !hidden); + } + } + ui.separator(); + if menu_item(ui, "Reset", true, false) { + app.run("magicWand.set", json!({ "reset": true })).ok(); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 6053f784..cd7aee23 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -15,8 +15,10 @@ pub mod glyphs; pub mod gradient; pub mod graphic_styles; pub mod history; +pub mod image_trace; pub mod info; pub mod layers; +pub mod magic_wand; pub mod navigator; pub mod paragraph; pub mod pathfinder; @@ -74,6 +76,8 @@ pub fn show_icon_panel(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { "brushes" => brushes::show(app, ui), "symbols" => symbols::show(app, ui), "patternOptions" => pattern_options::show(app, ui), + "imageTrace" => image_trace::show(app, ui), + "magicWand" => magic_wand::show(app, ui), _ => { dim_label(ui, "This panel is on the roadmap (see the parity plan)."); } @@ -105,6 +109,8 @@ pub fn panel_menu_items(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { "brushes" => brushes::menu(app, ui), "symbols" => symbols::menu(app, ui), "patternOptions" => pattern_options::menu(app, ui), + "imageTrace" => image_trace::menu(app, ui), + "magicWand" => magic_wand::menu(app, ui), _ => { ui.add_enabled(false, egui::Button::new("No options").frame(false)); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ab581a8a..6c556250 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -81,6 +81,8 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("info", "Info", "info"), ("navigator", "Navigator", "map"), ("separations", "Separations Preview", "printer"), + ("imageTrace", "Image Trace", "image"), + ("magicWand", "Magic Wand", "wand-sparkles"), ]; /// Groups of icon panels separated by dividers in the collapsed column.