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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 09:14:17 -04:00
co-authored by Claude Opus 5.5
parent e5959de6cc
commit c8f157cc87
4 changed files with 55 additions and 7 deletions
+4
View File
@@ -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<Box<OpacityMask>>,
/// 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<Box<serde_json::Value>>,
pub kind: NodeKind,
}
@@ -301,6 +304,7 @@ impl Node {
knockout: false,
appearance: Appearance::default(),
mask: None,
trace: None,
kind,
}
}
+12
View File
@@ -533,6 +533,7 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
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<tr::TraceParams> {
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
const C: &str = "imageTrace.make";
let params = trace_params(p)?;
let params_json = serde_json::to_value(&params).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(&params_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<Value> {
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<Value> {
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(_)));
}
+23
View File
@@ -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());
}
+16 -7
View File
@@ -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<Value>) {
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();
}