Recolor Artwork: a weighed color recolors with the row it moved to

- recolor.weigh drops another row's entry for a color that moved, so
  recolor.apply recolors it with the row it moved to and the shares
  describe the recolor. A color that the first color of another row
  with a new color also lists stays where it is.
- Linked swatch colors are grouped through a map, so a row with many
  unlinked colors no longer compares each one with every group.
- The Prominent Colors bar reads the shares again when a row gains or
  loses a new color, since that changes which row takes a shared color.
This commit is contained in:
dotCooCoo
2026-11-13 04:58:39 -08:00
parent fafd06c959
commit 3aa7ea2f78
4 changed files with 113 additions and 27 deletions
+57 -23
View File
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Prominent Colors",
[],
None,
"{map: rows [{from, to, …}] or {current: new} as recolor.apply, weights?: [numbers ≥ 0, one per row; an excluded row's is ignored, and at least one other must be above 0], ids?: as recolor.colors} → {map: the rows as given, each with its current colours (from) moved as below, shares: [one per row: its percent of the uses (recolor.colors' count) of the current colours of the rows that aren't excluded; 0 for an excluded row]} for Recolor Artwork's Prominent Colors (VectorCraft's own rule): without weights the rows stay as they are. With weights, current colours move one at a time between the rows that aren't excluded and have a new colour (to), while a move brings each row's share of the uses nearer its share of the weights, first the colour nearest in Lab to the first colour of the row it goes to compared with the row it leaves; a row's first colour stays in it, the tints of one global swatch in a row move together, and a use counts for the row recolor.apply recolors it with (rows with a new colour first, an exact colour before a hex one, the first row that lists it), else for the first other row that lists it. The colours that can move are compared with the rows at most 50,000,000 times in all, and when they times the rows that aren't excluded pass that, none move. Changes nothing",
"{map: rows [{from, to, …}] or {current: new} as recolor.apply, weights?: [numbers ≥ 0, one per row; an excluded row's is ignored, and at least one other must be above 0], ids?: as recolor.colors} → {map: the rows as given, each with its current colours (from) moved as below, shares: [one per row: its percent of the uses (recolor.colors' count) of the current colours of the rows that aren't excluded; 0 for an excluded row]} for Recolor Artwork's Prominent Colors (VectorCraft's own rule): without weights the rows stay as they are. With weights, current colours move one at a time between the rows that aren't excluded and have a new colour (to), while a move brings each row's share of the uses nearer its share of the weights, first the colour nearest in Lab to the first colour of the row it goes to compared with the row it leaves; a row's first colour stays in it, the tints of one global swatch in a row move together, a colour that another row with a new colour lists first stays where it is, the other rows with a new colour drop their entries for a colour that moves, and a use counts for the row recolor.apply recolors it with (rows with a new colour first, an exact colour before a hex one, the first row that lists it), else for the first other row that lists it. The colours that can move are compared with the rows at most 50,000,000 times in all, and when they times the rows that aren't excluded pass that, none move. Changes nothing",
has_doc,
weigh
),
@@ -1126,25 +1126,35 @@ fn weigh(s: &mut Session, p: &Value) -> Result<Value> {
}
// Each current colour's uses and global swatch. A use counts where recolor.apply recolors it:
// rows with a new colour first, exact colours before hex colours, the first row that lists it;
// then the other rows, in the same way.
let mut take = |m: &Match| -> (usize, Option<String>) {
let hits: Vec<usize> = match m {
Match::Key(k) => by_key.get(k).copied().into_iter().collect(),
Match::Hex(h) => by_hex.get(h).cloned().unwrap_or_default(),
};
let (mut uses, mut link) = (0, None);
for u in hits.into_iter().filter_map(|i| used[i].take()) {
uses += u.count;
link = link.or(u.swatch);
}
(uses, link)
};
// then the other rows, in the same way. `owner` holds the row and entry that took each colour,
// and `shadows` the entries of rows with a new colour whose colours other entries took.
let mut owner: Vec<Option<(usize, usize)>> = vec![None; used.len()];
let mut shadows: Vec<(usize, usize, Vec<usize>)> = vec![];
let mut found: Vec<Vec<(usize, Option<String>)>> = rows.iter().map(|r| vec![(0, None); r.from.len()]).collect();
for recolors in [true, false] {
for hex in [false, true] {
for (r, row) in rows.iter().enumerate().filter(|(_, r)| r.new_color().is_some() == recolors) {
for (e, m) in row.from.iter().enumerate().filter(|(_, m)| matches!(m, Match::Hex(_)) == hex) {
found[r][e] = take(m);
let hits: Vec<usize> = match m {
Match::Key(k) => by_key.get(k).copied().into_iter().collect(),
Match::Hex(h) => by_hex.get(h).cloned().unwrap_or_default(),
};
let (mut uses, mut link, mut took, mut taken) = (0, None, false, vec![]);
for i in hits {
match used[i].take() {
Some(u) => {
uses += u.count;
link = link.or(u.swatch);
took = true;
owner[i] = Some((r, e));
}
None => taken.push(i),
}
}
found[r][e] = (uses, link);
if recolors && !took && !taken.is_empty() {
shadows.push((r, e, taken));
}
}
}
}
@@ -1152,22 +1162,41 @@ fn weigh(s: &mut Session, p: &Value) -> Result<Value> {
// The units that move: in each row that isn't excluded, the colours linked to one global
// swatch go together, each other colour on its own, and the unit of the first colour stays.
let (mut units, mut members): (Vec<WeighUnit>, Vec<(usize, Vec<usize>)>) = (vec![], vec![]);
let mut unit_of: HashMap<(usize, usize), usize> = HashMap::new();
for (n, &r) in active.iter().enumerate() {
let mut groups: Vec<(Option<&String>, Vec<usize>, usize)> = vec![];
let mut by_link: HashMap<&String, usize> = HashMap::new();
for (e, (uses, link)) in found[r].iter().enumerate() {
match groups.iter_mut().find(|g| link.is_some() && g.0 == link.as_ref()) {
Some(g) => {
g.1.push(e);
g.2 += uses;
let g = match link {
Some(l) => *by_link.entry(l).or_insert_with(|| {
groups.push((Some(l), vec![], 0));
groups.len() - 1
}),
None => {
groups.push((None, vec![], 0));
groups.len() - 1
}
None => groups.push((link.as_ref(), vec![e], *uses)),
}
};
groups[g].1.push(e);
groups[g].2 += uses;
}
for (k, (_, entries, uses)) in groups.into_iter().enumerate() {
for &e in &entries {
unit_of.insert((r, e), units.len());
}
units.push(WeighUnit { color: rows[r].from[entries[0]].color(), uses: uses as f64, row: n, fixed: k == 0 });
members.push((r, entries));
}
}
// recolor.apply matches a colour to the first row with a new colour that lists it. A colour
// that another such row's first colour also lists stays where it is; another row's other
// entries for a colour that moves are dropped from that row below.
let owners = |taken: &[usize]| -> Vec<usize> { taken.iter().filter_map(|&i| unit_of.get(&owner[i]?).copied()).collect() };
for (_, _, taken) in shadows.iter().filter(|(_, e, _)| *e == 0) {
for u in owners(taken) {
units[u].fixed = true;
}
}
if let Some(w) = &weights {
// Only rows with a new colour recolor what they hold, so only they give and take colours.
let movers: Vec<usize> = (0..active.len()).filter(|&n| rows[active[n]].new_color().is_some()).collect();
@@ -1187,14 +1216,19 @@ fn weigh(s: &mut Session, p: &Value) -> Result<Value> {
let mut out = given_rows(p);
if weights.is_some() {
// Each row's colours: those that stayed, in their order, then those that came to it.
// A row's entry for a colour that moved to another row would take it back, so it goes.
let moved = |u: usize| active[units[u].row] != members[u].0;
let dropped: std::collections::HashSet<(usize, usize)> =
shadows.iter().filter(|(_, e, taken)| *e != 0 && owners(taken).into_iter().any(moved)).map(|(r, e, _)| (*r, *e)).collect();
let old: Vec<Vec<Value>> = out.iter().map(|r| r["from"].as_array().cloned().unwrap_or_default()).collect();
let mut stayed: Vec<Vec<usize>> = active.iter().map(|_| vec![]).collect();
let mut came: Vec<Vec<Value>> = active.iter().map(|_| vec![]).collect();
for (u, (r, entries)) in units.iter().zip(&members) {
let kept = entries.iter().copied().filter(|&e| !dropped.contains(&(*r, e)));
if active[u.row] == *r {
stayed[u.row].extend(entries);
stayed[u.row].extend(kept);
} else {
came[u.row].extend(entries.iter().filter_map(|&e| old[*r].get(e).cloned()));
came[u.row].extend(kept.filter_map(|e| old[*r].get(e).cloned()));
}
}
for (n, &r) in active.iter().enumerate() {
+32
View File
@@ -1418,6 +1418,38 @@ fn weigh_moves_colors_to_the_rows_weights_and_changes_nothing() {
assert_eq!(r["map"][2]["from"], json!(["#00ff00", PURPLE]));
}
/// A colour that two rows list moves only where recolor.apply recolors it with the row it moved
/// to, so the shares describe the recolor.
#[test]
fn weigh_keeps_a_moved_color_from_rows_that_also_list_it() {
const GREEN: &str = "rgb 0 255 0";
let mut s = session();
let [_, _, purple, _] = prominent_art(&mut s);
rect(&mut s, json!("#00ff00"));
select_all(&mut s);
// The purple moves from the first row to the third; the second row's entry for it goes.
let map = json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [BLUE, PURPLE], "to": "#ffffff"}, {"from": [GREEN], "to": "#ffff00"}]);
let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [2, 1, 2]}));
assert_eq!(r["map"][0]["from"], json!([RED]));
assert_eq!(r["map"][1]["from"], json!([BLUE]));
assert_eq!(r["map"][2]["from"], json!([GREEN, PURPLE]));
assert_eq!(r["shares"], json!([40.0, 20.0, 40.0]));
run(&mut s, "recolor.apply", json!({"map": r["map"], "method": "exact"}));
assert_eq!(fill(&s, purple).to_hex(), "#ffff00", "the purple takes the row it moved to");
// A colour that another row lists first stays: that row would take it back.
let mut s = session();
let [_, _, purple, _] = prominent_art(&mut s);
rect(&mut s, json!("#00ff00"));
select_all(&mut s);
let map = json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [PURPLE, BLUE], "to": "#ffffff"}, {"from": [GREEN], "to": "#ffff00"}]);
let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [2, 1, 2]}));
assert_eq!(r["map"][0]["from"], json!([RED, PURPLE]));
assert_eq!(r["map"][1]["from"], json!([PURPLE, BLUE]));
assert_eq!(r["shares"], json!([60.0, 20.0, 20.0]));
run(&mut s, "recolor.apply", json!({"map": r["map"], "method": "exact"}));
assert_eq!(fill(&s, purple).to_hex(), "#000000", "the purple keeps the first row's colour");
}
#[test]
fn weigh_moves_the_tints_of_a_global_swatch_together() {
let mut s = session();
+21 -3
View File
@@ -228,8 +228,8 @@ fn sync(app: &mut VectorcraftApp, d: &mut Dialog) {
/// The fields of Prominent Colors: the weights the rows are to take (`recolor.weigh`'s `weights`,
/// one per row; a new list moves the rows' current colors), the weights they last took, each row's
/// share of the art's uses (`recolor.weigh`'s `shares`) and the rows' current colors those were
/// read for; a drag of a part's edge (each part's share of the bar while it lasts); and a request
/// share of the art's uses (`recolor.weigh`'s `shares`) and the rows' current colors, exclusions
/// and whether each has a new color, which those were read for; a drag of a part's edge (each part's share of the bar while it lasts); and a request
/// from the New Color Group menu (`all` or `prominent`).
const WEIGHTS: &str = "weights";
const WEIGHED: &str = "__weighed";
@@ -273,7 +273,8 @@ fn sync_weights(app: &mut VectorcraftApp, d: &mut Dialog) {
}
}
let rs = rows(d);
let of = Value::Array(rs.iter().map(|r| json!([r["from"], excluded(r)])).collect());
// A row with a new color takes a color before one without, so that changes the shares too.
let of = Value::Array(rs.iter().map(|r| json!([r["from"], excluded(r), !r["to"].is_null()])).collect());
if d.fields.get(SHARES_OF) == Some(&of) {
return;
}
@@ -1875,6 +1876,23 @@ mod tests {
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
}
/// A row that takes a new color takes the colors it shares with other rows, so the bar reads
/// the shares again.
#[test]
fn a_new_color_for_a_row_reads_the_shares_again() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let ctx = egui::Context::default();
prominent_setup(&mut app, &ctx);
let rows = json!([{"from": [RED, PURPLE], "to": null}, {"from": [RED, BLUE], "to": "rgb 255 255 0"}]);
app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), rows);
frame(&mut app, &ctx, 0.1, vec![], 0);
assert_eq!(app.ui.dialog.as_ref().unwrap().fields[SHARES], json!([25.0, 75.0]));
let rows = json!([{"from": [RED, PURPLE], "to": "rgb 0 255 0"}, {"from": [RED, BLUE], "to": "rgb 255 255 0"}]);
app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), rows);
frame(&mut app, &ctx, 0.2, vec![], 0);
assert_eq!(app.ui.dialog.as_ref().unwrap().fields[SHARES], json!([75.0, 25.0]));
}
/// Dragging the edge between two parts of the bar to the left gives the right-hand part more
/// weight; when the drag ends, the rows' current colors move.
#[test]
+3 -1
View File
@@ -3729,7 +3729,9 @@ rows come back unchanged. `weights` holds one number of 0 or more per row; an ex
at least one other row needs a weight above 0. As VectorCraft's own rule, current colors then move one at a time
between the rows that are not excluded and have a new color (`to`), as long as a move brings each row's share of the uses nearer its share of the
weights. Of those moves, it takes first the color nearest in Lab to the first color of the row it goes to, compared
with the row it leaves. A row keeps its first color, and the tints of one global swatch in a row move together. The
with the row it leaves. A row keeps its first color, and the tints of one global swatch in a row move together. A
color that the first color of another row with a new color also lists stays where it is. When a color moves, other
rows with a new color drop their own entries for it, so `recolor.apply` recolors it with the row it moved to. The
colors that can move are compared with the rows at most 50,000,000 times in all; when those colors times the rows
that are not excluded pass that, no color moves. Colors that stay in a row keep their order, and the colors that come
to it follow them. The command changes nothing; `recolor.apply` with the returned `map` recolors the art. In the Recolor Artwork dialog the