mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 20:29:33 -06:00
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:
co-authored by
Claude Fable 5.1
parent
e65ce42000
commit
2e392e99b1
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, ®ions, |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, ®ions, |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,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, ®ions, |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;
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user