M8: a fill per region: the Fill section and the fill commands edit the regions selected in vector edit mode (#1875)

fill.set/add/remove/move/paste on the edited layer go to the selected regions' own fill stacks (or those named by regions), the layer's fills untouched; the Fill section and the colour picker list them. Test regions_tests::the_fill_commands_edit_the_selected_regions; docs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Trevor Brown
2026-11-02 07:55:27 -05:00
co-authored by Claude Fable 5.1
parent e65ce42000
commit 2e392e99b1
9 changed files with 219 additions and 20 deletions
+15
View File
@@ -1245,6 +1245,21 @@ pub fn paint_all(doc: &mut Document, id: NodeId, pts: &[Point]) -> Result<bool>
Ok(filled)
}
/// The fills the Fill section shows for layer `id` while regions of it are selected in edit
/// mode: the stack those regions share (their own, else the layer's), `Some(None)` when they
/// differ (mixed), `None` when no region of that layer is selected (the layer's fills show). The
/// fill commands edit the same regions then ("you can adjust the fill for each region
/// individually", Edit vector layers, #1875).
pub fn selected_region_fills(s: &Session, id: NodeId) -> Option<Option<Vec<figcraft_color::Paint>>> {
let ve = s.ds().ok()?.vector_edit.as_ref().filter(|ve| ve.id == id && !ve.regions.is_empty())?;
let doc = s.doc().ok()?;
let node = doc.node(id)?;
let net = network(doc, id)?;
let mut stacks = ve.regions.iter().filter_map(|r| net.regions.get(*r)).map(|r| r.fills.clone().unwrap_or_else(|| node.fills.clone()));
let first = stacks.next()?;
Some(stacks.all(|st| st == first).then_some(first))
}
/// Add points `vs` of edited layer `id` to its selection.
pub(crate) fn add_points(ve: &mut VectorEdit, id: NodeId, vs: &[usize]) {
let list = if id == ve.id { Some(&mut ve.vertices) } else { ve.others.iter_mut().find(|o| o.id == id).map(|o| &mut o.vertices) };
@@ -6,6 +6,7 @@ use figcraft_geom::{Affine, Point, Rect, Vec2};
use serde_json::json;
use super::regions::*;
use super::selected_region_fills;
use super::tests::{ex, net, ve};
use crate::Session;
use crate::tools::{PointerEvent, PointerKind};
@@ -540,3 +541,49 @@ fn hovered_and_selected_is_hatched_once_and_regions_share_one_pattern() {
assert!(!part.is_empty() && part.len() < all.len());
assert!(part.iter().all(|x| all.iter().any(|y| (x - y).abs() < 1e-6)));
}
/// "If there are multiple closed regions within a vector network, you can adjust the fill for each
/// region individually" (Edit vector layers, #1875): with regions selected in edit mode, the fill
/// commands edit those regions' own fills and leave the layer's; `regions` names them outright.
#[test]
fn the_fill_commands_edit_the_selected_regions() {
let mut s = Session::new();
let id = editing_vector(&mut s, overlapping());
let fills_of = |s: &Session, r: usize| net(s, id).regions[r].fills.clone();
let layer = |s: &Session| s.doc().unwrap().node(id).unwrap().fills.clone();
let colour = |s: &Session, r: usize, i: usize| fills_of(s, r).unwrap()[i].solid_color().unwrap().to_css_hex().to_ascii_uppercase();
let before = layer(&s);
ex(&mut s, "vector.edit.select", json!({"regions": [1]}));
ex(&mut s, "fill.set", json!({"ids": [id], "color": "#FF0000"}));
assert_eq!(fills_of(&s, 1).map(|f| f.len()), Some(1));
assert_eq!(colour(&s, 1, 0), "#FF0000");
assert_eq!(fills_of(&s, 0), None, "the other region still inherits");
assert_eq!(layer(&s), before, "the layer's fills are untouched");
// The section's list for the layer is the region's own stack.
assert_eq!(selected_region_fills(&s, id).and_then(|l| l).map(|l| l.len()), Some(1));
ex(&mut s, "fill.add", json!({"ids": [id], "color": "#00FF00"}));
assert_eq!(fills_of(&s, 1).unwrap().len(), 2);
ex(&mut s, "fill.move", json!({"ids": [id], "from": 0, "to": 1}));
assert_eq!((colour(&s, 1, 0), colour(&s, 1, 1)), ("#00FF00".to_string(), "#FF0000".to_string()));
ex(&mut s, "fill.remove", json!({"ids": [id], "index": 0}));
assert_eq!(fills_of(&s, 1).unwrap().len(), 1);
assert_eq!(colour(&s, 1, 0), "#FF0000");
assert_eq!(layer(&s), before);
// Both regions selected with different fills: the section shows mixed content.
ex(&mut s, "vector.edit.select", json!({"regions": [0, 1]}));
assert_eq!(selected_region_fills(&s, id), Some(None));
// Named outright, with no selection; junk is refused.
ex(&mut s, "vector.edit.select", json!({}));
ex(&mut s, "fill.set", json!({"ids": [id], "regions": [0], "color": "#0000FF"}));
assert_eq!(colour(&s, 0, 0), "#0000FF");
assert!(s.execute("fill.set", &json!({"ids": [id], "regions": [9], "color": "#0000FF"})).is_err());
assert!(s.execute("fill.set", &json!({"ids": [id], "regions": "all", "color": "#0000FF"})).is_err());
assert!(s.execute("fill.set", &json!({"ids": [id], "regions": [], "color": "#0000FF"})).is_err());
// Nothing selected: the layer's fills, as before; the regions keep their own.
ex(&mut s, "fill.set", json!({"ids": [id], "color": "#123456"}));
assert_ne!(layer(&s), before);
assert_eq!(colour(&s, 1, 0), "#FF0000");
assert_eq!(selected_region_fills(&s, id), None);
s.undo().unwrap();
assert_eq!(layer(&s), before, "one undo step per command");
}
+19 -4
View File
@@ -15,6 +15,7 @@ use crate::{BatchStep, EngineError, Result, Session, bad, spec};
mod clipboard;
mod props;
pub(crate) use props::{edit_region_fills, fill_regions};
mod select;
pub use clipboard::{CLIP_CLOSE, CLIP_OPEN, copy_to_clipboard, design_clipboard_buffer, paste_into};
@@ -330,14 +331,28 @@ pub fn specs() -> Vec<CommandSpec> {
s.doc_mut()?.reorder(i, idx)?;
ok()
}),
spec!("fill.add", "Add fill", "{ids?, paint? | color? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)),
spec!(
"fill.add",
"Add fill",
"{ids?, paint? | color? (default #D9D9D9 or white for frames), regions? (a vector's regions by index: their own fills, as the regions selected in edit mode)}",
true,
|s, p| paint_list(s, p, true, true)
),
spec!("stroke.add", "Add stroke", "{ids?, paint? | color? (default black)}", true, |s, p| paint_list(s, p, false, true)),
spec!("fill.remove", "Remove fill", "{ids?, index}", true, |s, p| paint_list(s, p, true, false)),
spec!("fill.remove", "Remove fill", "{ids?, index, regions?}", true, |s, p| paint_list(s, p, true, false)),
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
spec!(
"fill.set",
"Set fill",
"{ids?, index?, paint | color, opacity?, visible?, blend?, regions? (a vector's regions by index: their own fills, as the regions selected in edit mode)}",
true,
|s, p| paint_set(s, p, true)
),
spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)),
spec!("fill.copy", "Copy fill", "{id?, index? (default the top fill)}", false, |s, p| paint_copy(s, p, true)),
spec!("fill.paste", "Paste fill", "{ids?, index? (replace the fill there; without: add one)}", true, |s, p| paint_paste(s, p, true)),
spec!("fill.paste", "Paste fill", "{ids?, index? (replace the fill there; without: add one), regions?}", true, |s, p| paint_paste(
s, p, true
)),
spec!(
"stroke.copy",
"Copy stroke",
+102 -10
View File
@@ -260,10 +260,83 @@ pub(super) fn reparent(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// The regions of a vector whose own fill stacks a fill command edits instead of the layer's: the
/// `regions` named (with one target, a vector; indices into its network's regions), else the
/// regions selected in vector edit mode when the one target is the layer being edited ("you can
/// adjust the fill for each region individually", Edit vector layers, #1875). None: the layer's.
pub(crate) fn fill_regions(s: &Session, cmd: &str, p: &Value, ids: &[NodeId]) -> Result<Option<(NodeId, Vec<usize>)>> {
let doc = s.doc()?;
let region_count = |id: NodeId| match doc.node(id).map(|n| &n.kind) {
Some(NodeKind::Vector { network }) => Some(network.regions.len()),
_ => None,
};
if let Some(list) = get(p, "regions") {
let [id] = ids else { return Err(bad(cmd, "`regions` goes with one vector layer")) };
let Some(count) = region_count(*id) else { return Err(bad(cmd, "`regions` goes with a vector layer")) };
let regions = list
.as_array()
.ok_or_else(|| bad(cmd, "regions: [index, ...]"))?
.iter()
.map(|v| {
v.as_u64()
.and_then(|i| usize::try_from(i).ok())
.filter(|i| *i < count)
.ok_or_else(|| bad(cmd, format!("regions: indices below {count}")))
})
.collect::<Result<Vec<usize>>>()?;
if regions.is_empty() {
return Err(bad(cmd, "regions: at least one"));
}
return Ok(Some((*id, regions)));
}
let [id] = ids else { return Ok(None) };
let Some(ve) = s.ds()?.vector_edit.as_ref().filter(|ve| ve.id == *id && !ve.regions.is_empty()) else { return Ok(None) };
let count = region_count(*id).unwrap_or(0);
let regions: Vec<usize> = ve.regions.iter().copied().filter(|r| *r < count).collect();
Ok((!regions.is_empty()).then_some((*id, regions)))
}
/// Runs `f` on the own fill stack of each of `regions` of vector `id` (a region without one
/// starts from the layer's fills, which stay as they are).
pub(crate) fn edit_region_fills(
doc: &mut figcraft_doc::Document,
id: NodeId,
regions: &[usize],
mut f: impl FnMut(&mut Vec<figcraft_color::Paint>) -> Result<()>,
) -> Result<()> {
let inherited = doc.get(id)?.fills.clone();
let NodeKind::Vector { network } = &mut doc.get_mut(id)?.kind else { return Err(bad("fill", "not a vector layer")) };
for &r in regions {
let Some(region) = network.regions.get_mut(r) else { continue };
let mut list = region.fills.take().unwrap_or_else(|| inherited.clone());
f(&mut list)?;
region.fills = Some(list);
}
Ok(())
}
pub(super) fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result<Value> {
let cmd = if fills { "fill" } else { "stroke" };
let ids = req_targets(cmd, s, p)?;
let index = if add || get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) };
if let Some((id, regions)) = fill_regions(s, cmd, p, &ids)?.filter(|_| fills) {
let paint = match get(p, "paint").or_else(|| get(p, "color")) {
Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?),
None => None,
};
edit_region_fills(s.doc_mut()?, id, &regions, |list| {
if add {
list.push(paint.clone().unwrap_or_else(|| figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)));
} else {
let idx = index.unwrap_or(list.len().saturating_sub(1));
if idx < list.len() {
let _ = list.remove(idx);
}
}
Ok(())
})?;
return ok();
}
let doc = s.doc_mut()?;
for i in ids {
let n = doc.get_mut(i)?;
@@ -295,16 +368,8 @@ pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value
None => None,
};
let col = color(p, "color");
let doc = s.doc_mut()?;
for i in ids {
let n = doc.get_mut(i)?;
let list = if fills { &mut n.fills } else { &mut n.strokes };
let at = idx.unwrap_or(list.len().saturating_sub(1));
if list.is_empty() {
list.push(figcraft_color::Paint::solid(if fills { figcraft_color::Color::DEFAULT_SHAPE } else { figcraft_color::Color::BLACK }));
}
let Some(slot) = list.get_mut(at) else { return Err(bad(cmd, format!("no paint at index {at}"))) };
let old = slot.clone();
// The edit of one paint slot, the same for a layer's list and a region's.
let edit_slot = |slot: &mut figcraft_color::Paint| {
if let Some(np) = &new_paint {
*slot = np.clone();
}
@@ -325,6 +390,30 @@ pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value
if let Some(b) = blend(p, "blend") {
slot.blend = b;
}
};
if let Some((id, regions)) = fill_regions(s, cmd, p, &ids)?.filter(|_| fills) {
edit_region_fills(s.doc_mut()?, id, &regions, |list| {
if list.is_empty() {
list.push(figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE));
}
let at = idx.unwrap_or(list.len().saturating_sub(1));
let Some(slot) = list.get_mut(at) else { return Err(bad(cmd, format!("no paint at index {at}"))) };
edit_slot(slot);
Ok(())
})?;
return ok();
}
let doc = s.doc_mut()?;
for i in ids {
let n = doc.get_mut(i)?;
let list = if fills { &mut n.fills } else { &mut n.strokes };
let at = idx.unwrap_or(list.len().saturating_sub(1));
if list.is_empty() {
list.push(figcraft_color::Paint::solid(if fills { figcraft_color::Color::DEFAULT_SHAPE } else { figcraft_color::Color::BLACK }));
}
let Some(slot) = list.get_mut(at) else { return Err(bad(cmd, format!("no paint at index {at}"))) };
let old = slot.clone();
edit_slot(slot);
crate::cmd::lists::rebind_edited_paint(n, fills, at, &old);
if fills {
n.styles.fill = None;
@@ -413,6 +502,9 @@ pub(super) fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result<Val
}
}
let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?});
if let Some(regions) = get(p, "regions") {
q["regions"] = regions.clone();
}
let out = match opt_index(cmd, p, "index")? {
Some(at) => {
q["index"] = json!(at);
+15 -2
View File
@@ -15,7 +15,7 @@ use crate::{Result, Session, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("fill.move", "Move fill", "{ids?, from, to} (indices in the fill list, bottom first)", true, |s, p| {
spec!("fill.move", "Move fill", "{ids?, from, to, regions?} (indices in the fill list, bottom first)", true, |s, p| {
move_item(s, p, "fill.move", BoundList::Fills)
}),
spec!("stroke.move", "Move stroke", "{ids?, from, to} (indices in the stroke list, bottom first)", true, |s, p| {
@@ -249,7 +249,20 @@ fn reorder<T>(v: &mut Vec<T>, from: usize, to: usize) -> bool {
fn move_item(s: &mut Session, p: &Value, cmd: &str, list: BoundList) -> Result<Value> {
let from = req_index(cmd, p, "from")?;
let to = req_index(cmd, p, "to")?;
for id in req_targets(cmd, s, p)? {
let ids = req_targets(cmd, s, p)?;
// Fills of the regions selected (or named) in a vector: their own stacks move (#1875).
if matches!(list, BoundList::Fills)
&& let Some((id, regions)) = crate::cmd::edit::fill_regions(s, cmd, p, &ids)?
{
crate::cmd::edit::edit_region_fills(s.doc_mut()?, id, &regions, |fills| {
if from < fills.len() && to < fills.len() {
reorder(fills, from, to);
}
Ok(())
})?;
return ok();
}
for id in ids {
let len = s.doc()?.node(id).map(|n| list.len(n)).unwrap_or(0);
if from >= len || to >= len || from == to {
continue;
+7 -1
View File
@@ -293,7 +293,13 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
}
let t = Tokens::get(ui.ctx());
let ids = sel.to_vec();
let list = common(doc, sel, |n| if fills { n.fills.clone() } else { n.strokes.clone() });
// Regions selected in vector edit mode: their own fills ("you can adjust the fill for each
// region individually", Edit vector layers, #1875); the fill commands edit those too.
let region_list = match sel {
[id] if fills => figcraft_engine::vectoredit::selected_region_fills(&app.session, *id),
_ => None,
};
let list = region_list.unwrap_or_else(|| common(doc, sel, |n| if fills { n.fills.clone() } else { n.strokes.clone() }));
let title = if fills { tr("Fill") } else { tr("Stroke") };
let empty = list.as_ref().is_none_or(|l| l.is_empty());
let slot = if fills { StyleSlot::Fill } else { StyleSlot::Stroke };
@@ -92,6 +92,13 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
},
PickerTarget::Paint { fills, index, ids } => {
let n = doc.node(*ids.first()?)?;
// Regions selected in vector edit mode: the picker edits their own fills (#1875).
if *fills
&& ids.len() == 1
&& let Some(Some(list)) = figcraft_engine::vectoredit::selected_region_fills(&app.session, n.id)
{
return list.get(*index).cloned();
}
let list = if *fills { &n.fills } else { &n.strokes };
list.get(*index).cloned()
}
+4 -1
View File
@@ -723,7 +723,10 @@ made active), add?}`, `vector.edit.selectAll` (every edited layer's points), `ve
`vector.edit.addVertex`, `vector.edit.connect {a, b}`, `vector.edit.pen {x, y, dx?, dy?}` (continues
from the pen's open end; clicking a point connects or branches), `vector.edit.penEnd`,
`vector.edit.paint {x, y} | {points}` (paint bucket: fills or unfills the closed area under a page
point; with `points`, the areas under the rest go the way the first went, like a drag),
point; with `points`, the areas under the rest go the way the first went, like a drag), `fill.set`,
`fill.add`, `fill.remove`, `fill.move` and `fill.paste` on the edited layer while regions are
selected (`vector.edit.select {regions}`), which edit those regions' own fills and leave the
layer's (`regions: [index, ...]` names them on any vector layer),
`vector.edit.cut {segment, t} | {vertex} | {from: [x, y], to: [x, y]}` (the last cuts every path the
page line crosses; parts that come apart move to layers of their own above, listed in `layers`),
`vector.edit.erase {points, radius?}` (the eraser),
+3 -2
View File
File diff suppressed because one or more lines are too long