diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs index 0c0a43926..3633bf3c4 100644 --- a/crates/mcp/src/prompts.rs +++ b/crates/mcp/src/prompts.rs @@ -270,7 +270,7 @@ One undo step covers the whole change; gradients and linked swatches stay linked PromptDef { name: "trace-and-style", title: "Trace an image and style it", - description: "Trace a placed raster with Image Trace, then apply a live effect and paint over the result.", + description: "Trace an image with Image Trace and expand the trace, then apply a live effect and recolor the result.", arguments: &[ PromptArg { name: "path", description: "Path to the image file", required: true, default: "", values: Values::Fixed(NONE) }, PromptArg { @@ -293,8 +293,13 @@ Trace the image at {path} in VectorCraft with the \"{preset}\" preset, then styl with the {effect} effect. Method: -1. open_file the image: it becomes a document of its own pixel size. -2. imageTrace.make with the preset name, adjusting `params` — mode blackAndWhite, grayscale, +1. open_file the image: it becomes a document of the image's size at the resolution it declares + (1 px = 1 pt at 72 ppi or when it declares none), with nothing selected. + Then run_command select.all to select the image, or pass the image's `id` (inspect_document + lists it) to imageTrace.make. +2. imageTrace.make with the preset name. The Default preset traces in black and white; a color + preset such as 6 Colors or 16 Colors, or `params` with mode color, keeps the image's colors. + Adjust `params`: mode blackAndWhite, grayscale, color (palette limited, fullTone for photos, automatic, or documentLibrary to trace with the document's swatches or a swatch `library`) or logo (flat-colour logos only; the Flat Logo preset; colours via logoColors), then threshold, colors (colorDetail for @@ -304,11 +309,16 @@ Method: imageTrace.presets to see every preset with its numbers. Lower paths and higher corners give the clean, logo-like result; high fidelity needs paths near 90. imageTrace.setView {view: \"outlinesWithSourceImage\"} draws the traced outlines - over the image on screen to compare them (screenshots show it; exports keep the result). -3. apply_effect {effect} on the traced shapes. Call apply_effect with no `effect` for the + over the image in the app's window to compare them (screenshot {window: true} captures it); + render_preview and screenshot without `window` draw the tracing result, as exports do. +3. imageTrace.expand keeps only the traced paths and removes the source image. + imageTrace.makeAndExpand traces and expands in one command, in place of steps 2 and 3. +4. apply_effect {effect} on the traced shapes. Call apply_effect with no `effect` for the catalogue and each effect's parameters. -4. set_paint over the traced shapes to give the artwork its own colours. -5. screenshot, then remove the placed image if it is still underneath. +5. To change the colors, run recolor.colors on the traced shapes, then recolor.apply with a + `map` from each traced color to its new color. set_paint gives every selected shape the + same fill or stroke. +6. render_preview or screenshot, and compare the result with the source image. The trace is real geometry: every traced shape stays selectable and editable.", }, @@ -546,6 +556,26 @@ mod tests { assert!(e.contains("brief"), "{e}"); } + /// trace-and-style selects the opened image before tracing it, expands the trace, recolors it + /// instead of painting it one color, and says that screenshots draw the tracing result. + #[test] + fn trace_and_style_selects_expands_and_recolors() { + let v = get("trace-and-style", &json!({"path": "art.png"})).unwrap(); + let text = v["messages"][0]["content"]["text"].as_str().unwrap_or_default(); + for step in ["open_file", "select.all", "imageTrace.make", "imageTrace.expand", "apply_effect", "recolor.apply", "render_preview"] { + assert!(text.contains(step), "{step}: {text}"); + } + let at = |s: &str| text.find(s).unwrap_or(usize::MAX); + assert!(at("select.all") < at("imageTrace.make") && at("imageTrace.make") < at("imageTrace.expand"), "{text}"); + assert!(text.contains("black and white"), "{text}"); + assert!(!text.contains("screenshots show it"), "{text}"); + assert!(!text.contains("own pixel size") && text.contains("resolution it declares"), "{text}"); + // No step of its own paints the trace with set_paint. + let steps: Vec<&str> = text.lines().filter(|l| is_item(l)).filter_map(|l| l.split_once(". ").map(|(_, rest)| rest)).collect(); + assert!(!steps.is_empty(), "{text}"); + assert!(steps.iter().all(|s| !s.starts_with("set_paint")), "{steps:?}"); + } + #[test] fn completions_rank_prefix_first_and_cap_at_100() { let mut b = b(); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 3777380ed..3410f4fa5 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -296,6 +296,60 @@ fn headless_end_to_end() { assert_eq!(v["error"]["code"], -32002); } +/// The trace-and-style prompt's steps, run in its order on an image drawn here: each one +/// succeeds, the expanded trace holds no image, and its paths keep the image's colors. +#[test] +fn trace_and_style_prompt_steps_run_in_order() { + let mut s = server(); + // 60 × 40 pixels: a red and a blue block on white. + let png = tmp("trace-and-style.png"); + image::RgbImage::from_fn(60, 40, |x, y| match (x, y) { + (5..25, 5..35) => image::Rgb([220, 30, 30]), + (35..55, 5..35) => image::Rgb([30, 60, 200]), + _ => image::Rgb([255, 255, 255]), + }) + .save(&png) + .unwrap(); + let path = png.to_str().unwrap(); + let v = rpc(&mut s, 1, "prompts/get", json!({"name": "trace-and-style", "arguments": {"path": path}})); + let prompt = v["result"]["messages"][0]["content"]["text"].as_str().unwrap().to_string(); + + let ok = |s: &mut Server, id: u64, name: &str, args: Value| { + let r = call(s, id, name, args); + assert_eq!(r["isError"], false, "{name}: {r}"); + serde_json::from_str::(&text_of(&r)).unwrap_or(Value::Null) + }; + let command = |s: &mut Server, id: u64, command: &str, params: Value| { + assert!(prompt.contains(command), "the prompt names {command}: {prompt}"); + ok(s, id, "run_command", json!({"command": command, "params": params})) + }; + ok(&mut s, 2, "open_file", json!({"path": path})); + command(&mut s, 3, "select.all", json!({})); + let traced = command(&mut s, 4, "imageTrace.make", json!({"preset": "6 Colors"})); + assert!(traced["colors"].as_u64().unwrap() > 1, "{traced}"); + command(&mut s, 5, "imageTrace.expand", json!({})); + ok(&mut s, 6, "apply_effect", json!({"effect": "stylize.scribble"})); + let colors = command(&mut s, 7, "recolor.colors", json!({})); + let from = colors["colors"].as_array().unwrap().iter().filter_map(|c| c["hex"].as_str()).find(|h| *h != "#ffffff").unwrap().to_string(); + command(&mut s, 8, "recolor.apply", json!({"map": {from.as_str(): "#00aa00"}})); + let r = call(&mut s, 9, "render_preview", json!({})); + assert_eq!(r["isError"], false, "{r}"); + + // No image is left, and the paths carry more than one fill color, the new one among them. + let doc = ok(&mut s, 10, "inspect_document", json!({})); + let (mut kinds, mut fills) = (vec![], std::collections::BTreeSet::new()); + let mut stack: Vec<&Value> = doc["layers"].as_array().unwrap().iter().collect(); + while let Some(n) = stack.pop() { + kinds.push(n["kind"].as_str().unwrap_or_default().to_string()); + if let Some(fill) = n["fill"].as_str().filter(|f| f.starts_with('#')) { + fills.insert(fill.to_string()); + } + stack.extend(n["children"].as_array().into_iter().flatten()); + } + assert!(!kinds.iter().any(|k| k == "Image"), "{kinds:?}"); + assert!(fills.len() > 1 && fills.contains("#00aa00") && !fills.contains(&from), "{fills:?}"); +} + #[test] fn headless_path_gesture_undo() { let mut s = server();