From c8f157cc872aca570df0832febd824286feac4a9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 09:14:17 -0400 Subject: [PATCH] M12.5: Image Trace objects remember their preset and parameters (saved in the file); the panel shows the selected trace's settings; Expand clears them Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/node.rs | 4 ++++ crates/engine/src/cmd/buildcmds.rs | 12 ++++++++++++ crates/engine/src/tests_build.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/panels/image_trace.rs | 23 ++++++++++++++++------- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index e3d3fbd38..12135f434 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -260,6 +260,9 @@ pub struct Node { /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. #[serde(default, skip_serializing_if = "Option::is_none")] pub mask: Option>, + /// Image Trace object: `{preset, params}` it was traced with (the Image Trace panel shows them). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trace: Option>, pub kind: NodeKind, } @@ -301,6 +304,7 @@ impl Node { knockout: false, appearance: Appearance::default(), mask: None, + trace: None, kind, } } diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 6ee45ce59..8ad5c7be4 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -533,6 +533,7 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; n.name = None; + n.trace = None; if let Some(ch) = n.children_mut() { ch.retain(|c| match c.name.as_deref() { Some(SOURCES_NAME) => false, @@ -640,6 +641,13 @@ fn trace_params(p: &Value) -> Result { fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { const C: &str = "imageTrace.make"; let params = trace_params(p)?; + let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?; + // The preset's name if the parameters are exactly that preset's, else "Custom". + let named = str_param(p, "preset").unwrap_or("Default"); + let preset_label = match tr::preset(named) { + Some(b) if serde_json::to_value(&b).ok().as_ref() == Some(¶ms_json) => named.to_string(), + _ => "Custom".to_string(), + }; let roots = root_ids(s, p)?; // The image (inside a trace group, or selected directly). let (target, img) = { @@ -687,6 +695,9 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { let gid = d.alloc_id(); let mut g = Node::group(gid, children); g.name = if expand { None } else { Some(TRACE_NAME.into()) }; + if !expand { + g.trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json }))); + } let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?; d.remove(target)?; d.insert(par, idx, g)?; @@ -727,6 +738,7 @@ fn trace_expand(s: &mut Session, p: &Value) -> Result { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; n.name = None; + n.trace = None; if let Some(ch) = n.children_mut() { ch.retain(|c| !matches!(c.kind, NodeKind::Image(_))); } diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index 9d1228ed9..ef28f23d4 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -416,3 +416,26 @@ fn image_trace_presets_query_and_errors() { assert!(find_command("imageTrace.make").unwrap().menu == ["Object", "Image Trace"]); assert!(find_command("livePaint.make").unwrap().menu == ["Object", "Live Paint"]); } + +#[test] +fn image_trace_object_remembers_its_settings() { + let mut s = session(); + image_doc(&mut s); + let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors"})).unwrap(); + let g = NodeId(r["id"].as_u64().unwrap()); + let t = node(&s, g).trace.clone().expect("settings stored"); + assert_eq!(t["preset"], "6 Colors"); + assert_eq!(t["params"]["colors"], 6); + // Changing a parameter makes it Custom; the settings survive save/open. + let r = s.execute("imageTrace.make", &json!({"id": g.0, "preset": "6 Colors", "params": {"colors": 9}})).unwrap(); + let g = NodeId(r["id"].as_u64().unwrap()); + let d = s.doc().unwrap().doc.clone(); + let back = drawcraft_format::load(&drawcraft_format::save(&d, false)).unwrap(); + let t = back.node(g).unwrap().trace.clone().unwrap(); + assert_eq!((t["preset"].as_str(), t["params"]["colors"].as_u64()), (Some("Custom"), Some(9))); + // Expanded traces are plain groups. + s.execute("imageTrace.expand", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let id = st.selection.in_paint_order(&st.doc)[0]; + assert!(st.doc.node(id).unwrap().trace.is_none()); +} diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index 0bff8ce77..f4f52928d 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -17,6 +17,9 @@ struct TraceUi { preset: String, params: Value, info: Option<(u64, u64, u64)>, + /// The selected Image Trace object's stored settings last adopted (so edits in progress + /// aren't overwritten until the object changes). + synced: Value, } fn presets(app: &mut DrawcraftApp) -> Vec<(String, Value)> { @@ -27,16 +30,17 @@ fn presets(app: &mut DrawcraftApp) -> Vec<(String, Value)> { .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) }; +/// What the selection is: an Image Trace object (with its stored settings), a plain image, or neither. +fn target(app: &DrawcraftApp) -> (bool, bool, Option) { + let Some(n) = first_selected(app) else { return (false, false, None) }; 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(_))) + (trace, matches!(n.kind, drawcraft_doc::NodeKind::Image(_)), n.trace.map(|t| *t)) } fn trace(app: &mut DrawcraftApp, st: &mut TraceUi) { - match app.run("imageTrace.make", json!({ "preset": "Default", "params": st.params })) { + let preset = if st.preset == "Custom" { "Default" } else { st.preset.as_str() }; + match app.run("imageTrace.make", json!({ "preset": preset, "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, } @@ -63,7 +67,12 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { st.preset = "Default".into(); st.params = all.first().map(|p| p.1.clone()).unwrap_or_default(); } - let (is_trace, is_image) = target(app); + let (is_trace, is_image, stored) = target(app); + if let Some(t) = stored.filter(|t| *t != st.synced) { + st.preset = t["preset"].as_str().unwrap_or("Custom").to_string(); + st.params = t["params"].clone(); + st.synced = t; + } let mut retrace = false; ui.horizontal(|ui| { @@ -155,7 +164,7 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { } pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { - let (is_trace, _) = target(app); + let (is_trace, _, _) = target(app); if menu_item(ui, "Release", is_trace, false) { app.run("imageTrace.release", json!({})).ok(); }