Files
vectorcraft/crates/ui-egui/src/panels/image_trace.rs
T
bflatasticandClaude Opus 5.5 d6f8cc879f Control bar: image controls (Image Trace, Embed, Crop, Mask) when an image is selected
Discord feedback: Image Trace and the other image options were hidden when an image was
selected; copy, paste, Image Trace should work from the Control bar as in Illustrator.

- Control bar, one image selected: Linked File/Embedded, file name, colour mode and PPI, then
  Embed (+ Edit Original) or Unembed…, an Image Trace split button (click: Default preset;
  arrow: trace with any preset), Mask and Crop Image, in place of Fill and Stroke.
- Control bar, one Image Trace object selected: "Image Tracing", its Preset (choosing another
  traces it again), the Image Trace panel and Expand.
- Properties panel: Image Trace (split), Crop Image and Mask in Quick Actions for an image; an
  Image Trace section (Preset, Expand, Release) for an Image Trace object. Embed/Unembed buttons
  are shared with the Control bar (place::link_buttons).
- New engine command object.maskImage ("Mask"): clips the selected image with a clipping path
  on its outline (rotated with it) and selects that path so its handles crop; one undo step.
- widgets::split_button (flat button with a menu arrow).
- Translations for the new strings (es, ja, zh-hans, zh-hant; "Mask" also pt-br, cs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 17:15:48 +07:00

277 lines
12 KiB
Rust

//! 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 vectorcraft_doc::{Node, NodeKind};
use super::{first_selected, pstate, set_pstate};
use crate::VectorcraftApp;
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)>,
/// 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 VectorcraftApp) -> 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()
}
/// Whether `n` is an Image Trace object: a group so named whose first child is the traced image.
pub(crate) fn is_trace(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { clip: false, .. })
&& n.name.as_deref() == Some("Image Trace")
&& n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_))))
}
/// What the selection is: an Image Trace object (with its stored settings), a plain image, or neither.
fn target(app: &VectorcraftApp) -> (bool, bool, Option<Value>) {
let Some(n) = first_selected(app) else { return (false, false, None) };
(is_trace(&n), matches!(n.kind, NodeKind::Image(_)), n.trace.map(|t| *t))
}
/// The preset of the one selected Image Trace object ("Custom" once its settings were changed).
pub(crate) fn selected_preset(app: &VectorcraftApp) -> Option<String> {
let st = app.session.active()?;
let [id] = st.selection.objects[..] else { return None };
let n = st.doc.node(id).filter(|n| is_trace(n))?;
Some(n.trace.as_ref().and_then(|t| t["preset"].as_str()).unwrap_or("Custom").to_string())
}
/// Trace the selected image, or trace the selected Image Trace object again, with built-in `preset`.
fn trace_with(app: &mut VectorcraftApp, preset: &str) {
// A failure is reported in the status bar.
let _ = app.run("imageTrace.make", json!({ "preset": preset }));
}
/// The built-in presets to choose from (`current` highlighted) → the one chosen. Listed only
/// while the menu is open.
fn preset_list(app: &mut VectorcraftApp, ui: &mut Ui, current: &str) -> Option<String> {
let mut chosen = None;
for (name, _) in presets(app) {
if ui.add(egui::Button::selectable(name == current, tl!(&name))).clicked() {
chosen = Some(name);
}
}
chosen
}
/// Width of the Control bar's Image Trace button.
pub(crate) const TRACE_BUTTON_W: f32 = 104.0;
/// The Image Trace button for a selected image (Control bar, Properties): a click traces it with
/// the Default preset, its arrow lists the presets to trace it with.
pub(crate) fn trace_button(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) {
let (main, arrow) = widgets::split_button(ui, tl!("Image Trace"), width);
if main.on_hover_text(tl!("Trace the image with the Default preset")).clicked() {
trace_with(app, "Default");
}
let arrow = arrow.on_hover_text(tl!("Tracing Presets"));
if let Some(p) = egui::Popup::menu(&arrow).show(|ui| preset_list(app, ui, "")).and_then(|r| r.inner) {
trace_with(app, &p);
}
}
/// The selected Image Trace object's preset (Control bar, Properties): choosing another traces it
/// again with that preset.
pub(crate) fn preset_dropdown(app: &mut VectorcraftApp, ui: &mut Ui, current: &str, width: f32) {
if let Some(p) = widgets::combo(ui, "trace-preset", tl!(current), width, false, |ui| preset_list(app, ui, current)) {
trace_with(app, &p);
}
}
/// The Control bar for one selected Image Trace object: its preset, the Image Trace panel and
/// Expand. Whether it is one.
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool {
let Some(preset) = selected_preset(app) else { return false };
widgets::dim_label(ui, tl!("Preset:"));
preset_dropdown(app, ui, &preset, 150.0);
if widgets::icon_button(ui, "image", tl!("Image Trace"), false, 24.0).clicked() {
app.ui.open_panel = Some("imageTrace".into());
}
if widgets::flat_button(ui, tl!("Expand"), 64.0).clicked() {
app.run("imageTrace.expand", json!({})).ok();
}
ui.separator();
true
}
fn trace(app: &mut VectorcraftApp, st: &mut TraceUi) {
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,
}
}
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(tl!(label)).size(12.0)));
// The theme's widget fill matches the panel, which would hide the rail.
let t = crate::theme::Tokens::get(ui.ctx());
ui.visuals_mut().widgets.inactive.bg_fill = t.input_border;
ui.visuals_mut().selection.bg_fill = t.accent;
let r = ui.add(egui::Slider::new(v, range).suffix(suffix).integer().trailing_fill(true));
out = (r.changed(), r.drag_stopped() || (r.changed() && !r.dragged()));
});
out
}
pub fn show(app: &mut VectorcraftApp, 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, 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| {
widgets::dim_label(ui, tl!("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, tl!("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", tl!("Threshold"), 0.0..=255.0)
} else {
("colors", if st.params["mode"] == "grayscale" { tl!("Grays") } else { tl!("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!("{} {}", if open { "▾" } else { "▸" }, tl!("Advanced"))).frame(false)).clicked() {
set_pstate(ui.ctx(), "it-advanced-closed", open);
}
if open {
for (key, label, range, suffix) in
[("paths", tl!("Paths"), 0.0..=100.0, "%"), ("corners", tl!("Corners"), 0.0..=100.0, "%"), ("noise", tl!("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, tl!("Method:"));
for (m, l) in [("abutting", tl!("Abutting")), ("overlapping", tl!("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", tl!("Snap Curves To Lines")), ("ignoreWhite", tl!("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,
&crate::i18n::fmt(
tl!("Paths: {paths} Anchors: {anchors} Colors: {colors}"),
&[("paths", &p.to_string()), ("anchors", &a.to_string()), ("colors", &c.to_string())],
),
);
}
ui.horizontal(|ui| {
let r = ui.add_enabled_ui(is_trace || is_image, |ui| widgets::flat_button(ui, tl!("Trace"), 80.0)).inner;
if r.on_disabled_hover_text(tl!("Select an image to trace")).clicked() {
trace(app, &mut st);
}
if ui.add_enabled_ui(is_trace, |ui| widgets::flat_button(ui, tl!("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 VectorcraftApp, ui: &mut Ui) {
let (is_trace, _, _) = target(app);
if menu_item(ui, tl!("Release"), is_trace, false) {
app.run("imageTrace.release", json!({})).ok();
}
if menu_item(ui, tl!("Expand"), is_trace, false) {
app.run("imageTrace.expand", json!({})).ok();
}
ui.separator();
if menu_item(ui, tl!("Reset to Default"), true, false) {
set_pstate(ui.ctx(), "image-trace", TraceUi::default());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn panel_and_menu_draw_headless() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
for _ in 0..2 {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
show(&mut app, ui);
menu(&mut app, ui);
});
out.textures_delta.clear();
}
}
}