progress
This commit is contained in:
parent
66855f309d
commit
f673491bd2
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue