This commit is contained in:
Brandon Thomas 2026-10-01 08:56:21 -04:00
parent 66855f309d
commit f673491bd2
10 changed files with 484 additions and 106 deletions

View File

@ -23,6 +23,8 @@ pub struct MenuState {
pub new_masks_unclipped: bool, pub new_masks_unclipped: bool,
/// Transparency panel menu: "New Opacity Masks Are Inverted". /// Transparency panel menu: "New Opacity Masks Are Inverted".
pub new_masks_inverted: bool, pub new_masks_inverted: bool,
/// Magic Wand panel settings.
pub wand: super::wand::WandSettings,
} }
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {

View File

@ -28,6 +28,7 @@ mod select;
mod textedit; mod textedit;
mod typecmd; mod typecmd;
mod typemenu; mod typemenu;
pub mod wand;
mod xform; mod xform;
use drawcraft_color::Color; use drawcraft_color::Color;
@ -134,6 +135,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(object::specs()); v.extend(object::specs());
v.extend(path::specs()); v.extend(path::specs());
v.extend(select::specs()); v.extend(select::specs());
v.extend(wand::specs());
v.extend(paint::specs()); v.extend(paint::specs());
v.extend(opacitymask::specs()); v.extend(opacitymask::specs());
v.extend(layer::specs()); v.extend(layer::specs());

View File

@ -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<CommandSpec> {
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<Value> {
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::<f64>().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<NodeId> {
let mut out = vec![];
let mut compounds: Vec<NodeId> = 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<Value> {
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<NodeId> = paintable(&st.doc).into_iter().filter(|i| st.doc.node(*i).is_some_and(|n| w.matches(&reference, n))).collect();
let ids: Vec<NodeId> = 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<u64> {
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]);
}
}

View File

@ -36,7 +36,7 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
"eyedropper" => Box::new(EyedropperTool), "eyedropper" => Box::new(EyedropperTool),
"gradient" => Box::new(GradientTool::default()), "gradient" => Box::new(GradientTool::default()),
"artboard" => Box::new(ArtboardTool::default()), "artboard" => Box::new(ArtboardTool::default()),
"magicWand" => Box::new(MagicWandTool::default()), "magicWand" => Box::new(MagicWandTool),
"lasso" => Box::new(LassoTool::default()), "lasso" => Box::new(LassoTool::default()),
"measure" => Box::new(MeasureTool::default()), "measure" => Box::new(MeasureTool::default()),
_ => return None, _ => return None,

View File

@ -1,70 +1,22 @@
//! Magic Wand (Y) and Lasso (Q). //! Magic Wand (Y) and Lasso (Q).
//! //!
//! Magic Wand: click an object to select every object whose fill colour is within `tolerance` //! Magic Wand: click an object to select every object with similar attributes. The matching and
//! (0..255 RGB distance); Shift adds, Alt subtracts. Lasso: freehand loop selecting anchor points //! its settings (Magic Wand panel) live in the engine's `select.magicWand` command; Shift adds,
//! inside it (direct-selection style); Shift adds, Alt subtracts. //! Alt subtracts. Lasso: freehand loop selecting anchor points inside it (direct-selection
//! style); Shift adds, Alt subtracts.
use std::collections::BTreeSet; use std::collections::BTreeSet;
use drawcraft_color::Paint; use drawcraft_doc::{AnchorRef, NodeId, NodeKind};
use drawcraft_doc::hit::hit_test; use drawcraft_doc::hit::hit_test;
use drawcraft_doc::{AnchorRef, Document, NodeId, NodeKind};
use drawcraft_geom::Point; use drawcraft_geom::Point;
use serde_json::{Value, json}; use serde_json::{Value, json};
use super::paint_owner; 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 { #[derive(Default)]
/// RGB distance tolerance, 0..255. pub struct MagicWandTool;
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::<f64>().sqrt();
d <= tol + 1e-6
}
_ => a == b,
}
}
/// Paintable leaves (paths, compounds, text, images) that are visible and editable.
fn paintable(doc: &Document) -> Vec<NodeId> {
let mut out = vec![];
let mut compounds: Vec<NodeId> = 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
}
impl Tool for MagicWandTool { impl Tool for MagicWandTool {
fn id(&self) -> &'static str { 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 { 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!({}))] }; 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 mode = if ev.mods.alt {
let Some(reference) = cx.doc.node(src).map(fill_of) else { return vec![] }; "subtract"
let hits: Vec<NodeId> = paintable(cx.doc) } else if ev.mods.shift {
.into_iter() "add"
.filter(|id| cx.doc.node(*id).is_some_and(|n| similar_fill(&fill_of(n), &reference, self.tolerance))) } else {
.collect(); "set"
if ev.mods.alt { };
let keep: Vec<NodeId> = cx.selection.objects.iter().copied().filter(|id| !hits.contains(id)).collect(); vec![Action::Exec("select.magicWand".into(), json!({ "id": paint_owner(cx.doc, h.leaf).0, "mode": mode }))]
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) }))]
} }
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair 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. /// Even-odd point-in-polygon.
@ -227,34 +163,22 @@ impl Tool for LassoTool {
mod tests { mod tests {
use super::*; use super::*;
use crate::testutil::*; use crate::testutil::*;
use drawcraft_color::Color; use drawcraft_doc::Selection;
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
}
#[test] #[test]
fn magic_wand_selects_similar_fills() { fn magic_wand_emits_the_engine_command() {
let (mut d, a) = doc_with_rect(); // white fill let (d, a) = doc_with_rect();
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));
let s = Selection::default(); let s = Selection::default();
let p = paint(); let p = paint();
let cx = cx(&d, &s, &p); 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)); 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]}))]); assert_eq!(r, vec![Action::Exec("select.magicWand".into(), json!({"id": a.0, "mode": "set"}))]);
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]}))]);
let shift = Mods { shift: true, ..Default::default() }; let shift = Mods { shift: true, ..Default::default() };
let r = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 420.0, 420.0).with_mods(shift)); let r = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(shift));
assert_eq!(r, vec![Action::Exec("select.add".into(), json!({"ids": [c.0]}))]); 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] #[test]

View File

@ -744,7 +744,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
todo("Recent Fonts"), todo("Recent Fonts"),
sub("Size", TYPE_SIZES.iter().map(|(l, n)| cp(l, "text.setStyle", json!({ "size": n }))).collect()), sub("Size", TYPE_SIZES.iter().map(|(l, n)| cp(l, "text.setStyle", json!({ "size": n }))).collect()),
Sep, Sep,
todo("Glyphs"), panel("Glyphs", "glyphs"),
sub("Insert Special Character", insert_items(INSERT_SPECIAL)), sub("Insert Special Character", insert_items(INSERT_SPECIAL)),
sub("Insert Whitespace Character", insert_items(INSERT_WHITESPACE)), sub("Insert Whitespace Character", insert_items(INSERT_WHITESPACE)),
sub("Insert Break Character", insert_items(INSERT_BREAK)), sub("Insert Break Character", insert_items(INSERT_BREAK)),
@ -955,12 +955,12 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
panel("Gradient", "gradient"), panel("Gradient", "gradient"),
panel("Graphic Styles", "graphicStyles"), panel("Graphic Styles", "graphicStyles"),
panel("History", "history"), panel("History", "history"),
todo("Image Trace"), panel("Image Trace", "imageTrace"),
panel("Info", "info"), panel("Info", "info"),
panel("Layers", "layers"), panel("Layers", "layers"),
panel("Libraries", "libraries"), panel("Libraries", "libraries"),
todo("Links"), todo("Links"),
todo("Magic Wand"), panel("Magic Wand", "magicWand"),
panel("Navigator", "navigator"), panel("Navigator", "navigator"),
panel("Pathfinder", "pathfinder"), panel("Pathfinder", "pathfinder"),
panel("Pattern Options", "patternOptions"), panel("Pattern Options", "patternOptions"),
@ -977,7 +977,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
vec![ vec![
panel("Character", "character"), panel("Character", "character"),
todo("Character Styles"), todo("Character Styles"),
todo("Glyphs"), panel("Glyphs", "glyphs"),
todo("OpenType"), todo("OpenType"),
panel("Paragraph", "paragraph"), panel("Paragraph", "paragraph"),
todo("Paragraph Styles"), todo("Paragraph Styles"),

View File

@ -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<f64>, 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::<bool>(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());
}
}

View File

@ -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::<bool>(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::<bool>(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();
}
}

View File

@ -15,8 +15,10 @@ pub mod glyphs;
pub mod gradient; pub mod gradient;
pub mod graphic_styles; pub mod graphic_styles;
pub mod history; pub mod history;
pub mod image_trace;
pub mod info; pub mod info;
pub mod layers; pub mod layers;
pub mod magic_wand;
pub mod navigator; pub mod navigator;
pub mod paragraph; pub mod paragraph;
pub mod pathfinder; 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), "brushes" => brushes::show(app, ui),
"symbols" => symbols::show(app, ui), "symbols" => symbols::show(app, ui),
"patternOptions" => pattern_options::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)."); 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), "brushes" => brushes::menu(app, ui),
"symbols" => symbols::menu(app, ui), "symbols" => symbols::menu(app, ui),
"patternOptions" => pattern_options::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)); ui.add_enabled(false, egui::Button::new("No options").frame(false));
} }

View File

@ -81,6 +81,8 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
("info", "Info", "info"), ("info", "Info", "info"),
("navigator", "Navigator", "map"), ("navigator", "Navigator", "map"),
("separations", "Separations Preview", "printer"), ("separations", "Separations Preview", "printer"),
("imageTrace", "Image Trace", "image"),
("magicWand", "Magic Wand", "wand-sparkles"),
]; ];
/// Groups of icon panels separated by dividers in the collapsed column. /// Groups of icon panels separated by dividers in the collapsed column.