G6: image variables swap linked files and keep bounds

Add VariableKind::Image with path values on datasets, makeImageDynamic,
panel/dialog UI, and MCP docs so data merge can drive placed images.
This commit is contained in:
Fabio William Conceição
2026-10-16 04:45:06 +07:00
committed by bflatastic
parent c8310edea1
commit 41c8bd2a2d
9 changed files with 161 additions and 43 deletions
+18 -7
View File
@@ -17,15 +17,18 @@ pub enum VariableKind {
Text,
/// Whether bound objects show.
Visibility,
/// A linked image's file path (the pixels swap; the object's bounds stay).
Image,
}
impl VariableKind {
/// Parse the `kind` of `variable.define`: `text` or `visibility` (`visible` is accepted
/// as the same thing, as the panel and the docs call it both).
/// Parse the `kind` of `variable.define`: `text`, `visibility` (`visible` is accepted
/// as the same thing, as the panel and the docs call it both), or `image`.
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"text" => Some(Self::Text),
"visibility" | "visible" => Some(Self::Visibility),
"image" => Some(Self::Image),
_ => None,
}
}
@@ -35,6 +38,7 @@ impl VariableKind {
match self {
Self::Text => "text",
Self::Visibility => "visibility",
Self::Image => "image",
}
}
}
@@ -52,13 +56,18 @@ pub struct Variable {
pub enum DataValue {
Text(String),
Visible(bool),
/// Absolute path of the image file an image variable should show.
Image(String),
}
impl DataValue {
/// Whether this value fits the variable's kind (a dataset may carry values for
/// variables of either kind; each binding takes what fits it).
pub fn fits(&self, kind: VariableKind) -> bool {
matches!((self, kind), (Self::Text(_), VariableKind::Text) | (Self::Visible(_), VariableKind::Visibility))
matches!(
(self, kind),
(Self::Text(_), VariableKind::Text) | (Self::Visible(_), VariableKind::Visibility) | (Self::Image(_), VariableKind::Image)
)
}
}
@@ -174,9 +183,10 @@ impl Document {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
}
/// What the object `id` holds for the variable of `kind`: its characters, or whether it
/// shows. `None` when the object is gone, is not of that kind, or is locked or on a hidden
/// or locked layer (the document's value is whatever it was left at).
/// What the object `id` holds for the variable of `kind`: its characters, whether it shows,
/// or a linked image's path. `None` when the object is gone, is not of that kind, has no
/// link path yet (an embedded image), or is locked or on a hidden or locked layer (the
/// document's value is whatever it was left at).
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
if !self.variable_target(id) {
return None;
@@ -185,7 +195,8 @@ impl Document {
match (kind, &node.kind) {
(VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())),
(VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)),
(VariableKind::Text, _) => None,
(VariableKind::Image, NodeKind::Image(im)) => im.link.as_ref().map(|l| DataValue::Image(l.path.clone())),
(VariableKind::Text | VariableKind::Image, _) => None,
}
}
+10
View File
@@ -479,6 +479,16 @@ fn clip_image(d: &mut Document, id: NodeId, (frame, room): Clip) -> Result<()> {
Ok(())
}
/// Replace image `id` with the raster at `path`, keeping the object's bounds (what an image
/// variable does on `dataset.select`). The file is linked; a missing or unreadable path is an
/// error so the dataset application can skip that binding.
pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str) -> Result<()> {
let path = absolute_path(path);
let bytes = read_file(&path)?;
let read = (link_info(&path, &bytes), fileio::raster_image(&bytes)?);
install(d, &[id], read, true)
}
/// Image objects `ids` show the file read for `link` (`img`), each placed as its Placement
/// Options say (`force`: as that Preserve choice says, whatever they say).
fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Option<Preserve>) -> Result<()> {
+34 -8
View File
@@ -1,9 +1,10 @@
//! Variables (data merge): named values bound to objects, applied per dataset.
//!
//! One template document produces many variants: bind a type object to a `text`
//! variable and anything to a `visibility` variable, put a row of values in each
//! dataset, and `dataset.select` swaps the whole document to that row in one undo
//! step. Agents reach every command through `run_command`, like all engine commands.
//! variable, anything to a `visibility` variable, and a linked image to an `image`
//! variable, put a row of values in each dataset, and `dataset.select` swaps the
//! whole document to that row in one undo step. Agents reach every command through
//! `run_command`, like all engine commands.
use std::collections::{BTreeMap, BTreeSet};
@@ -50,7 +51,8 @@ fn names_param(p: &Value, cmd: &str, fallback: Option<String>, nothing: &str) ->
}
/// Refuse what a variable of `kind` can't drive: art that is locked or on a hidden or locked
/// layer is not the document's to rewrite, and a text variable only drives type.
/// layer is not the document's to rewrite; a text variable only drives type; an image variable
/// only drives an image object.
fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> {
for id in ids {
match doc.node(*id) {
@@ -59,6 +61,9 @@ fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: Variabl
Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => {
return Err(bad(cmd, format!("node {} is not type", id.0)));
}
Some(n) if kind == VariableKind::Image && !matches!(n.kind, NodeKind::Image(_)) => {
return Err(bad(cmd, format!("node {} is not an image", id.0)));
}
Some(_) => {}
}
}
@@ -83,6 +88,8 @@ fn dataset_value(cmd: &str, var: &str, kind: VariableKind, v: &Value) -> Result<
match kind {
VariableKind::Text => v.as_str().map(|t| DataValue::Text(t.to_string())),
VariableKind::Visibility => v.as_bool().map(DataValue::Visible),
// An image value is a path string; empty is not a file to open.
VariableKind::Image => v.as_str().map(str::trim).filter(|t| !t.is_empty()).map(|t| DataValue::Image(t.to_string())),
}
.ok_or_else(|| bad(cmd, format!("value for `{var}` must match its variable kind")))
}
@@ -104,7 +111,7 @@ fn values_param(vars: &Variables, p: &Value, cmd: &str) -> Result<BTreeMap<Strin
fn define(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "variable.define";
let name = var_name(p, C)?;
let kind = str_param(p, "kind").and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text` or `visibility`"))?;
let kind = str_param(p, "kind").and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text`, `visibility` or `image`"))?;
if s.doc()?.doc.variables.variable(&name).is_some() {
return Err(bad(C, format!("variable `{name}` is already defined")));
}
@@ -147,7 +154,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"from": from, "name": to}))
}
/// Make Text Dynamic / Make Visibility Dynamic: define a variable of that kind and bind the
/// Make Text / Visibility / Image Dynamic: define a variable of that kind and bind the
/// selection to it in one step, naming it after what it drives — the panel's buttons.
fn make_dynamic(s: &mut Session, kind: VariableKind, stem: &str) -> Result<Value> {
// Errors name the command that ran: `variable.make{stem}Dynamic`.
@@ -463,6 +470,8 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
n.visible = *show;
true
}),
// Swap the linked file; keep the object's bounds. A missing path skips.
(VariableKind::Image, DataValue::Image(path)) => links::set_image_from_path(d, id, path).is_ok(),
_ => false,
};
if done {
@@ -502,7 +511,15 @@ fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("variable.define", "New Variable…", ["Window", "Variables"], None, "{name, kind: text|visibility} define a variable", has_doc, define),
cmd!(
"variable.define",
"New Variable…",
["Window", "Variables"],
None,
"{name, kind: text|visibility|image} define a variable",
has_doc,
define
),
cmd!(
"variable.delete",
"Delete Variable",
@@ -540,12 +557,21 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
|s, _| make_dynamic(s, VariableKind::Visibility, "Visibility")
),
cmd!(
"variable.makeImageDynamic",
"Make Image Dynamic",
["Window", "Variables"],
None,
"{} define an image variable over the selection, named after it and bound to it",
has_selection,
|s, _| make_dynamic(s, VariableKind::Image, "Image")
),
cmd!(
"variable.bind",
"Bind Variable",
["Window", "Variables"],
None,
"{variable, ids?} bind objects (default: the selection) to a variable; text needs type, locked art is refused. An object may hold several variables, and binding it to one it already has changes nothing → {bound, already}",
"{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, locked art is refused. An object may hold several variables, and binding it to one it already has changes nothing → {bound, already}",
has_doc,
bind
),
+50
View File
@@ -4,6 +4,7 @@
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use super::tests_links::{BLUE, Folder, RED, bounds, image, place, png, write};
use super::*;
fn session() -> Session {
@@ -827,3 +828,52 @@ fn ids_and_names_from_an_agent_are_checked() {
assert!(s.execute("variable.delete", &json!({"names": []})).is_err());
assert!(s.execute("variable.delete", &json!({"names": [5]})).is_err());
}
/// Image variables swap the linked file and keep the object's bounds; capture records the path.
#[test]
fn image_datasets_swap_pixels_and_keep_bounds() {
let dir = Folder::new("vars-image");
let red_path = dir.file("red.png");
let blue_path = dir.file("blue.png");
write(&red_path, &png(40, 30, RED));
write(&blue_path, &png(80, 60, BLUE));
let mut s = session();
let id = place(&mut s, &red_path);
let before = bounds(&s, id);
s.execute("variable.define", &json!({"name": "Photo", "kind": "image"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Photo", "ids": [id.0]})).unwrap();
// A text object can't take an image variable; a path must be a non-empty string.
let t = text(&mut s, "x");
assert!(s.execute("variable.bind", &json!({"variable": "Photo", "ids": [t.0]})).is_err());
assert!(s.execute("dataset.new", &json!({"name": "B", "values": {"Photo": ""}})).is_err());
assert!(s.execute("dataset.new", &json!({"name": "B", "values": {"Photo": true}})).is_err());
s.execute("dataset.new", &json!({"name": "Blue", "values": {"Photo": blue_path}})).unwrap();
let v = s.execute("dataset.select", &json!({"name": "Blue"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(0)));
assert_eq!(image(&s, id).link.as_ref().map(|l| l.path.as_str()), Some(blue_path.as_str()));
assert_eq!(bounds(&s, id), before, "bounds stay when the file swaps");
// Capture records the path the art holds now; a missing file is skipped, not an abort.
s.execute("dataset.capture", &json!({"name": "Now"})).unwrap();
let sets = s.execute("dataset.list", &json!({})).unwrap();
let now = sets["datasets"].as_array().unwrap().iter().find(|d| d["name"] == "Now").unwrap();
assert_eq!(now["values"]["Photo"], json!({"image": blue_path}));
let gone = dir.file("gone.png");
s.execute("dataset.new", &json!({"name": "Missing", "values": {"Photo": gone}})).unwrap();
let skip = s.execute("dataset.select", &json!({"name": "Missing"})).unwrap();
assert_eq!((skip["applied"].as_u64(), skip["skipped"].as_u64()), (Some(0), Some(1)));
assert_eq!(image(&s, id).link.as_ref().map(|l| l.path.as_str()), Some(blue_path.as_str()));
// Make Image Dynamic: one step from a selection to a bound image variable.
let id2 = place(&mut s, &red_path);
s.execute("select.set", &json!({"ids": [id2.0]})).unwrap();
let made = s.execute("variable.makeImageDynamic", &json!({})).unwrap();
assert_eq!(made["kind"], "image");
assert_eq!(made["ids"], json!([id2.0]));
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
assert!(s.execute("variable.makeImageDynamic", &json!({})).is_err(), "type is not an image");
}
+22 -17
View File
@@ -3,8 +3,9 @@
//!
//! One dialog covers all of them: what it shows is what the command takes. A variable is a
//! name and a kind; a dataset is a name and one field per variable — a text field for a
//! `text` variable, a checkbox for a `visibility` one. `dataset.set` opens with the row's
//! current values, so editing a row is editing what it already says.
//! `text` or `image` variable (path for image), a checkbox for a `visibility` one.
//! `dataset.set` opens with the row's current values, so editing a row is editing what it
//! already says.
use serde_json::{Value, json};
use vectorcraft_doc::{DataValue, VariableKind, Variables};
@@ -19,11 +20,11 @@ use crate::widgets;
pub const KIND: &str = "variables";
/// The kinds a variable can have, as `variable.define` names them.
const KINDS: [&str; 2] = ["text", "visibility"];
const KINDS: [&str; 3] = ["text", "visibility", "image"];
/// What the dialog shows for each of [`KINDS`].
fn kind_labels() -> [&'static str; 2] {
[tl!("Text"), tl!("Visibility")]
fn kind_labels() -> [&'static str; 3] {
[tl!("Text"), tl!("Visibility"), tl!("Image")]
}
pub(super) const SPEC: DialogSpec =
@@ -123,9 +124,10 @@ fn value_fields(vars: &Variables, current: Option<&std::collections::BTreeMap<St
let value = match (current.and_then(|c| c.get(&v.name)), v.kind) {
(Some(DataValue::Text(t)), _) => json!(t),
(Some(DataValue::Visible(b)), _) => json!(b),
// Without a value in the row a text field starts empty (no value in this row) and
// a visibility one starts shown: its false is a value, so it is never "unset".
(None, VariableKind::Text) => json!(""),
(Some(DataValue::Image(p)), _) => json!(p),
// Without a value in the row a text/image field starts empty (no value in this row)
// and a visibility one starts shown: its false is a value, so it is never "unset".
(None, VariableKind::Text | VariableKind::Image) => json!(""),
(None, VariableKind::Visibility) => json!(true),
};
out.insert(value_key(&v.name), value);
@@ -202,7 +204,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
label(ui, &v.name);
let key = value_key(&v.name);
match v.kind {
VariableKind::Text => {
VariableKind::Text | VariableKind::Image => {
text(ui, d, &key, 220.0);
}
VariableKind::Visibility => checkbox(ui, d, &key),
@@ -248,19 +250,22 @@ fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[&str], widt
}
}
/// The `values` object the dialog's fields mean: a text variable whose field is empty carries
/// no value in this row, and a visibility checkbox is always a value — its false, too.
/// The `values` object the dialog's fields mean: a text or image variable whose field is empty
/// carries no value in this row, and a visibility checkbox is always a value — its false, too.
fn values_of(d: &Dialog, vars: &Variables) -> Value {
let mut out = serde_json::Map::new();
for v in &vars.variables {
let key = value_key(&v.name);
if v.kind == VariableKind::Text {
let text = d.str(&key);
if !text.is_empty() {
out.insert(v.name.clone(), json!(text));
match v.kind {
VariableKind::Text | VariableKind::Image => {
let text = d.str(&key);
if !text.is_empty() {
out.insert(v.name.clone(), json!(text));
}
}
VariableKind::Visibility => {
out.insert(v.name.clone(), json!(d.bool(&key)));
}
} else {
out.insert(v.name.clone(), json!(d.bool(&key)));
}
}
Value::Object(out)
+13 -3
View File
@@ -72,13 +72,16 @@ struct Picked {
selection_bound: bool,
/// Make Text Dynamic needs exactly one type object: a text variable only drives type.
single_type: bool,
/// Make Image Dynamic needs exactly one image object.
single_image: bool,
}
fn picked(st: &vectorcraft_engine::DocState) -> Picked {
let selected: Vec<u64> = st.selection.objects.iter().map(|i| i.0).collect();
let selection_bound = selected.iter().any(|id| st.doc.variables.bindings.contains_key(&NodeId(*id)));
let single_type = matches!(selected.as_slice(), [one] if st.doc.node(NodeId(*one)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))));
Picked { has_selection: !selected.is_empty(), selected, selection_bound, single_type }
let single_image = matches!(selected.as_slice(), [one] if st.doc.node(NodeId(*one)).is_some_and(|n| matches!(n.kind, NodeKind::Image(_))));
Picked { has_selection: !selected.is_empty(), selected, selection_bound, single_type, single_image }
}
/// Delete Variable: the highlighted variable only (the command's default); the rest stay.
@@ -128,7 +131,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
})
.collect();
let active = sets.iter().find(|s| s.active);
let Picked { selected, has_selection, selection_bound, single_type } = picked(st);
let Picked { selected, has_selection, selection_bound, single_type, single_image } = picked(st);
let highlighted = highlighted_row(st);
// The current data set across the top: its name, and the arrows that step through them.
@@ -163,6 +166,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let icon = match row.kind {
VariableKind::Text => "type",
VariableKind::Visibility => "eye",
VariableKind::Image => "image",
};
ui.add(egui::Image::new(crate::icons::source(icon)).fit_to_exact_size(egui::vec2(14.0, 14.0)));
// The object a variable drives is named beside it, as the reference panel does.
@@ -205,6 +209,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if widgets::icon_button_enabled(ui, "eye", tl!("Make Visibility Dynamic"), false, has_selection, 24.0).clicked() {
app.run("variable.makeVisibilityDynamic", json!({})).ok();
}
if widgets::icon_button_enabled(ui, "image", tl!("Make Image Dynamic"), false, single_image, 24.0).clicked() {
app.run("variable.makeImageDynamic", json!({})).ok();
}
if widgets::icon_button(ui, "plus", tl!("New Variable…"), false, 24.0).clicked() {
crate::menus::invoke(app, "variable.define", json!({}));
}
@@ -239,7 +246,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let bound: Vec<u64> = st.doc.variables.bindings.keys().map(|i| i.0).collect();
let highlighted = highlighted_row(st);
let highlighted_bound: Vec<u64> = highlighted.as_ref().map(|h| bound_objects(st, h)).unwrap_or_default();
let Picked { selected, has_selection, selection_bound, single_type } = picked(st);
let Picked { selected, has_selection, selection_bound, single_type, single_image } = picked(st);
if widgets::menu_item(ui, tl!("New Variable…"), true, false) {
crate::menus::invoke(app, "variable.define", json!({}));
@@ -255,6 +262,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if widgets::menu_item(ui, tl!("Make Visibility Dynamic"), has_selection, false) {
app.run("variable.makeVisibilityDynamic", json!({})).ok();
}
if widgets::menu_item(ui, tl!("Make Image Dynamic"), single_image, false) {
app.run("variable.makeImageDynamic", json!({})).ok();
}
if widgets::menu_item(ui, tl!("Unbind Variable"), selection_bound, false) {
app.run("variable.unbind", json!({"ids": selected})).ok();
}
+6 -3
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -986,9 +986,9 @@ already walks Image Trace. Short agent entry points:
`symbol.replace`, `symbol.edit`, `symbol.duplicate`, `symbol.selectInstances {name}`.
- Brushes: `brush.list`, `brush.get {name}`, `brush.apply {name, ids?}`, `brush.new {type, name?, params?, ids?}`,
`brush.setCurrent {name}`, `brush.options {name, params?}`, `brush.freehand`, `object.expandBrush {ids?}`.
- Variables: `variable.list`, `variable.define {name, kind: text|visibility}`, `variable.bind {variable, ids?}`,
`variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.unbind`, `dataset.new` / `dataset.set` /
`dataset.select`.
- Variables: `variable.list`, `variable.define {name, kind: text|visibility|image}`, `variable.bind {variable, ids?}`,
`variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.unbind`,
`dataset.new` / `dataset.set` / `dataset.select` (image values are file path strings; apply keeps object bounds).
- Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`),
`imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`.
File diff suppressed because one or more lines are too long