Merge main into #1879

This commit is contained in:
bflatastic
2026-11-07 11:31:30 +07:00
33 changed files with 805 additions and 88 deletions
+5 -1
View File
@@ -949,7 +949,11 @@ impl Node {
// Right-angled or smooth outlines (boxes, whole ellipses) end their mitres on the grown
// box; an arc's corners don't.
let miter = if self.outline_mitres_end_on_its_box() { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * miter
// A dash's square end stands out along the path as far as the band does across it:
// its corners reach √2 times as far from the path, past the box round a curve.
let square_dashes = !self.stroke.dashes.is_empty() && self.stroke.band_caps().contains(&StrokeCap::Square);
let corners = if square_dashes { miter.max(std::f64::consts::SQRT_2) } else { miter };
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * corners
} else {
0.0
};
+11 -4
View File
@@ -146,10 +146,16 @@ impl Brush {
}
}
/// How much wider than the stroke weight the brush can paint (larger copies, strays).
pub fn reach_factor(&self) -> f64 {
/// The farthest a built-in brush shape reaches from the path, in stroke weights: the ink
/// brush's ragged edges stand a little past half the weight.
pub const SHAPE_REACH: f64 = 0.54;
/// How much wider than the stroke weight the brush can paint where a width profile scales it
/// at most `scale`: its shapes' edges and larger copies, which the profile scales, and strays,
/// which it doesn't.
pub fn reach_factor(&self, scale: f64) -> f64 {
let b = self.sanitized();
1.0 + b.size_jitter + 2.0 * b.scatter
2.0 * Self::SHAPE_REACH * (1.0 + b.size_jitter) * scale + 2.0 * b.scatter
}
}
@@ -188,7 +194,8 @@ impl StrokeProps {
/// The widest the stroke gets anywhere along the path (the weight, scaled by a width profile
/// and widened by a brush's reach).
pub fn max_weight(&self) -> f64 {
self.profile().map_or(self.weight, |p| self.weight * p.max_scale()) * self.brush.as_ref().map_or(1.0, Brush::reach_factor)
let scale = self.profile().map_or(1.0, WidthProfile::max_scale);
self.weight * self.brush.as_ref().map_or(scale, |b| b.reach_factor(scale))
}
/// The miter limit as the angle (degrees) below which joins bevel: limit = 1 / sin(angle / 2),
+42
View File
@@ -107,3 +107,45 @@ fn the_pencil_records_pressure_and_its_brush() {
let last = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Vector { .. })).max_by_key(|n| n.id).unwrap();
assert!(last.stroke.profile().is_none() && last.stroke.brush.is_none());
}
/// The Pencil draws black on a light page and white on a dark page or inside a dark frame, the
/// rule new text follows (#1741; Figma: "in black, unless you're sketching on a dark canvas or
/// frame"), and the stroke in progress previews in that colour.
#[test]
fn the_pencil_draws_white_on_a_dark_background() {
use figcraft_color::Color;
let mut s = Session::new();
s.execute("tool.set", &json!({"tool": "pencil"})).unwrap();
// A stroke from (x, y); its colour, and the preview colour the overlay carried while drawing.
let stroke = |s: &mut Session, x: f64, y: f64| {
s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, x + 40.0, y + 10.0, 1.0)).unwrap();
let preview = s.tool.overlay.pen_color;
s.pointer(&PointerEvent::at(PointerKind::Move, x + 80.0, y, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, x + 80.0, y, 1.0)).unwrap();
assert!(s.tool.overlay.pen_color.is_none(), "no preview colour after the stroke");
let doc = s.doc().unwrap();
let last = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Vector { .. })).max_by_key(|n| n.id).unwrap();
(last.strokes.first().and_then(|p| p.solid_color()).unwrap(), preview)
};
assert_eq!(stroke(&mut s, 0.0, 0.0), (Color::BLACK, Some(Color::BLACK)), "a light page");
s.execute("page.setBackground", &json!({"color": "#1E1E1E"})).unwrap();
assert_eq!(stroke(&mut s, 0.0, 100.0), (Color::WHITE, Some(Color::WHITE)), "a dark page");
// A dark frame on a light page, and a light frame on a dark page.
s.execute("page.setBackground", &json!({"color": "#F5F5F5"})).unwrap();
s.execute("node.create", &json!({"type": "frame", "x": 300, "y": 300, "width": 200, "height": 200, "props": {"fills": ["#202020"]}})).unwrap();
assert_eq!(stroke(&mut s, 320.0, 320.0), (Color::WHITE, Some(Color::WHITE)), "inside a dark frame");
s.execute("page.setBackground", &json!({"color": "#1E1E1E"})).unwrap();
s.execute("node.create", &json!({"type": "frame", "x": 600, "y": 300, "width": 200, "height": 200, "props": {"fills": ["#FFFFFF"]}})).unwrap();
assert_eq!(stroke(&mut s, 620.0, 320.0), (Color::BLACK, Some(Color::BLACK)), "inside a light frame on a dark page");
// A frame without a fill, or with a see-through one, is looked through to what is behind it.
s.execute("node.create", &json!({"type": "frame", "x": 900, "y": 300, "width": 200, "height": 200, "props": {"fills": []}})).unwrap();
assert_eq!(stroke(&mut s, 920.0, 320.0), (Color::WHITE, Some(Color::WHITE)), "a frame without a fill on a dark page");
s.execute("node.create", &json!({"type": "frame", "x": 1200, "y": 300, "width": 200, "height": 200, "props": {"fills": ["#FFFFFF40"]}})).unwrap();
assert_eq!(stroke(&mut s, 1220.0, 320.0), (Color::WHITE, Some(Color::WHITE)), "a mostly transparent light frame on a dark page");
// The threshold is Color::is_dark: white reads better than black from #757575 down.
for (bg, want) in [("#767676", Color::BLACK), ("#757575", Color::WHITE)] {
s.execute("page.setBackground", &json!({"color": bg})).unwrap();
assert_eq!(stroke(&mut s, 0.0, 600.0).0, want, "on {bg}");
}
}
+3
View File
@@ -328,6 +328,9 @@ pub struct Overlay {
pub cursor: Cursor,
/// Pen tool: the in-progress path's anchor points and the rubber band to the pointer.
pub pen: Vec<[f64; 2]>,
/// Pencil: the colour of the stroke in progress, for its preview (white over a dark page or
/// frame); None outside a Pencil stroke.
pub pen_color: Option<figcraft_color::Color>,
pub rubber: Option<[f64; 4]>,
/// Pen tool: the handles shaping the segments at the pen (anchor x, y, handle x, y): the last
/// anchor's in and out handles and the one before's out handle.
+1
View File
@@ -237,6 +237,7 @@ impl Session {
}
Gesture::Pencil { mut points, mut pressure } => {
self.tool.overlay.pen.clear();
self.tool.overlay.pen_color = None;
// The stroke ends where the button came up, even without a move to get there.
if points.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) {
points.push(ev.p());
+25 -8
View File
@@ -36,16 +36,33 @@ pub fn anchor_name(a: (u8, u8)) -> &'static str {
ANCHORS.get(usize::from(a.1.min(2)) * 3 + usize::from(a.0.min(2))).copied().unwrap_or("tl")
}
/// Whether what is drawn in `parent` sits on a dark background: the nearest layer from `parent`
/// out whose top visible fill is a mostly opaque (alpha 0.5 or more) solid colour, else the page
/// (#362); a frame without such a fill is looked through to what is behind it. Dark means white
/// reads better on it than black (`Color::is_dark`: `#757575` is dark, `#767676` is light).
pub(crate) fn on_dark_background(doc: &Document, parent: NodeId) -> bool {
std::iter::once(parent)
.chain(doc.ancestors(parent))
.filter_map(|id| doc.node(id))
.find_map(|n| match &n.kind {
NodeKind::Page { background, .. } => Some(background.is_dark()),
_ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()),
})
.unwrap_or(false)
}
/// The fill new text takes to be readable where it is drawn, in `parent`: white on a dark
/// background (None: the model's default, black). The background is the nearest layer from `parent`
/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). For text made
/// in the app: by the Text tool, or by a command that names no fill while the interface is up.
/// background (None: the model's default, black). For text made in the app: by the Text tool, or
/// by a command that names no fill while the interface is up.
pub(crate) fn readable_text_fills(doc: &Document, parent: NodeId) -> Option<Vec<figcraft_color::Paint>> {
let dark = std::iter::once(parent).chain(doc.ancestors(parent)).filter_map(|id| doc.node(id)).find_map(|n| match &n.kind {
NodeKind::Page { background, .. } => Some(background.is_dark()),
_ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()),
});
dark.unwrap_or(false).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)])
on_dark_background(doc, parent).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)])
}
/// The colour the Pencil draws with in `parent`: [`PENCIL_STROKE`]'s black, or white on a dark
/// background by the rule text follows (#1741; "in black, unless you're sketching on a dark canvas
/// or frame").
pub(crate) fn pencil_color(doc: &Document, parent: NodeId) -> figcraft_color::Color {
if on_dark_background(doc, parent) { figcraft_color::Color::WHITE } else { PENCIL_STROKE.0 }
}
const ANCHORS: [&str; 9] = ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"];
+12 -1
View File
@@ -333,7 +333,9 @@ impl Session {
let mut local = fitted.clone();
local.apply_affine(pw.inverse());
let _ = d;
let (color, weight) = PENCIL_STROKE;
// Black, or white on a dark page or frame (#1741).
let (_, weight) = PENCIL_STROKE;
let color = super::handles::pencil_color(self.doc()?, parent);
let mut props = json!({"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"});
// Pen pressure becomes the stroke's width profile (brushes follow it too).
if let Some(points) = pressure_profile(points, pressure) {
@@ -348,4 +350,13 @@ impl Session {
)?;
Ok(())
}
/// The colour a Pencil stroke starting at `p` will have: it goes into the frame under `p`
/// (else the page), and is white when that is dark (`pencil_color`).
pub(super) fn pencil_color_at(&self, p: figcraft_geom::Point) -> Result<figcraft_color::Color> {
let page = self.page()?;
let doc = self.doc()?;
let parent = frame_at(doc, page, p, &[]).unwrap_or(page);
Ok(super::handles::pencil_color(doc, parent))
}
}
@@ -160,6 +160,8 @@ impl Session {
ToolId::TextOnPath => self.text_on_path_down(ev),
ToolId::Pen => self.pen_down(ev),
ToolId::Pencil => {
// The preview draws in the colour the stroke will have (#1741).
self.tool.overlay.pen_color = self.pencil_color_at(ev.p()).ok();
self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] };
Ok(())
}
+14
View File
@@ -1261,6 +1261,20 @@ 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 (#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,85 @@ 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)));
}
/// Each closed region of a vector network has its own fill (#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");
}
/// Region fill edits from review: a region named twice is edited once; an edit that changes
/// nothing leaves an inheriting region inheriting; Copy fill reads the selected region's fill.
#[test]
fn region_fill_edits_apply_once_skip_no_ops_and_copy_the_region() {
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();
ex(&mut s, "vector.edit.select", json!({"regions": [1]}));
ex(&mut s, "fill.move", json!({"ids": [id], "from": 0, "to": 0}));
assert_eq!(fills_of(&s, 1), None, "moving a paint onto itself leaves the region inheriting");
ex(&mut s, "fill.remove", json!({"ids": [id], "index": 5}));
assert_eq!(fills_of(&s, 1), None, "removing a paint that isn't there leaves it inheriting");
ex(&mut s, "fill.add", json!({"ids": [id], "regions": [0, 0], "color": "#00FF00"}));
assert_eq!(fills_of(&s, 0).map(|f| f.len()), Some(2), "the layer's fill and one added, once");
// Copy fill takes the selected region's own paint, not the layer's.
ex(&mut s, "fill.set", json!({"ids": [id], "color": "#FF0000"}));
ex(&mut s, "fill.copy", json!({"ids": [id], "index": 0}));
let copied = s.clipboard.paint.clone().map(|(_, p)| p.solid_color().unwrap().to_css_hex().to_ascii_uppercase());
assert_eq!(copied.as_deref(), Some("#FF0000"));
// Regions whose fills differ have no one paint to copy.
ex(&mut s, "vector.edit.select", json!({"regions": [0, 1]}));
assert!(s.execute("fill.copy", &json!({"ids": [id], "index": 0})).is_err());
}
/// `fill.pattern` sets the layer's fill, as its spec says, also while regions are selected.
#[test]
fn fill_pattern_sets_the_layers_fill_with_regions_selected() {
let mut s = Session::new();
let src = crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": 400, "y": 0, "width": 20, "height": 20}))["id"]).unwrap();
let id = editing_vector(&mut s, overlapping());
ex(&mut s, "vector.edit.select", json!({"regions": [1]}));
ex(&mut s, "fill.pattern", json!({"ids": [id], "source": src}));
assert_eq!(net(&s, id).regions[1].fills, None, "the region still inherits");
let top = s.doc().unwrap().node(id).unwrap().fills.last().cloned().unwrap();
assert!(matches!(top.kind, figcraft_color::PaintKind::Pattern(_)), "{top:?}");
}
+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(crate) use clipboard::apply_copied_props;
@@ -345,14 +346,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",
+130 -16
View File
@@ -260,10 +260,92 @@ 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 (#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 mut 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>>>()?;
// A region named twice is edited once.
regions.sort_unstable();
regions.dedup();
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 mut regions: Vec<usize> = ve.regions.iter().copied().filter(|r| *r < count).collect();
regions.sort_unstable();
regions.dedup();
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). A region that inherits the layer's
/// fills keeps inheriting when `f` leaves the stack as it was, so later edits of the layer's
/// fills still reach it; a region's own stack is only replaced once `f` succeeds.
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.clone().unwrap_or_else(|| inherited.clone());
f(&mut list)?;
if region.fills.is_some() || list != inherited {
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)?;
@@ -299,6 +381,12 @@ pub(super) fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) ->
}
pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
paint_set_in(s, p, fills, true)
}
/// [`paint_set`]; `to_regions`: whether the regions selected (or named) on a vector take the
/// edit in their own fills (#1875), as for `fill.set`; `fill.pattern` always sets the layer's.
fn paint_set_in(s: &mut Session, p: &Value, fills: bool, to_regions: bool) -> Result<Value> {
let cmd = if fills { "fill.set" } else { "stroke.set" };
let ids = req_targets(cmd, s, p)?;
let idx = opt_index(cmd, p, "index")?;
@@ -307,19 +395,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)?;
if fills {
crate::cmd::lists::take_character_fills(n);
}
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();
}
@@ -340,6 +417,33 @@ 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 && to_regions) {
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)?;
if fills {
crate::cmd::lists::take_character_fills(n);
}
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;
@@ -359,8 +463,15 @@ pub(super) fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result<Valu
let (paint, stroke) = {
let doc = s.doc()?;
let n = doc.get(id)?;
// A text's fills are what its characters show (the list the Design panel's rows are).
let list = if fills { crate::cmd::lists::character_fills(n).unwrap_or_default() } else { n.strokes.clone() };
// Regions selected in vector edit mode: their own fills, as the Fill section lists them
// (#1875); a text's fills are what its characters show (#1856).
let regions = if fills { crate::vectoredit::selected_region_fills(s, id) } else { None };
let list: Vec<figcraft_color::Paint> = match regions {
Some(Some(list)) => list,
Some(None) => return Err(bad(cmd, "the selected regions' fills differ")),
None if fills => crate::cmd::lists::character_fills(n).unwrap_or_default(),
None => n.strokes.clone(),
};
let at = at.unwrap_or(list.len().saturating_sub(1));
let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?;
(paint, (!fills).then(|| n.stroke.clone()))
@@ -429,6 +540,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);
@@ -464,7 +578,7 @@ pub(super) fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
q["paint"] =
serde_json::to_value(figcraft_color::Paint { kind: figcraft_color::PaintKind::Pattern(pt), ..figcraft_color::Paint::pattern(src.0) })
.map_err(|e| bad(CMD, e.to_string()))?;
paint_set(s, &q, true)
paint_set_in(s, &q, true, false)
}
pub(super) fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
+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| {
@@ -295,7 +295,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 {
// A text's characters' shared fills are the list the Design panel shows and reorders.
let len = s
.doc()?
+1
View File
@@ -3,6 +3,7 @@ name = "figcraft-pathops"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "FigCraft path operations: booleans (union, subtract, intersect, exclude), outline stroke with inside/center/outside alignment, arrowheads"
[lints]
+16 -5
View File
@@ -21,14 +21,25 @@ fn props(weight: f64, b: &Brush) -> StrokeProps {
StrokeProps { weight, align: StrokeAlign::Center, brush: Some(b.clone()), ..Default::default() }
}
/// What a brush paints stays within half [`StrokeProps::max_weight`] of the path, which the
/// stroke's bounds go by: plain, with copies grown, turned and strayed all they can, and with a
/// width profile thinning or widening it (strays keep their distance, whatever the profile).
#[test]
fn every_preset_draws_inside_its_reach() {
for p in PRESETS {
let b = brush(p.id);
let out = outline(&line(200.0), 10.0, &props(10.0, &b), &b).unwrap();
let bb = out.bounding_box();
assert!(!out.elements().is_empty(), "{}", p.id);
assert!(bb.y0 >= -5.5 && bb.y1 <= 5.5 && bb.x0 >= -5.5 && bb.x1 <= 205.5, "{}: {bb:?}", p.id);
let wild = Brush { size_jitter: 1.0, rotation_jitter: 1.0, scatter: 4.0, ..brush(p.id) };
for (b, profile) in [(brush(p.id), None), (wild.clone(), None), (wild.clone(), Some(0.2)), (wild, Some(2.0))] {
let k = StrokeProps { width_profile: profile.map(|s| WidthProfile::new([(0.0, s), (1.0, s)])), ..props(10.0, &b) };
let out = outline(&line(200.0), 10.0, &k, &b).unwrap();
let bb = out.bounding_box();
let reach = k.max_weight() / 2.0;
assert!(!out.elements().is_empty(), "{}", p.id);
assert!(
bb.y0 >= -reach && bb.y1 <= reach && bb.x0 >= -reach && bb.x1 <= 200.0 + reach,
"{} {b:?} {profile:?}: {bb:?}, reach {reach}",
p.id
);
}
}
assert!(outline(&line(10.0), 1.0, &props(1.0, &brush("nope")), &brush("nope")).is_none());
}
+2 -2
View File
@@ -112,7 +112,7 @@ impl Fenwick {
let mut i = rank + 1;
while let Some(c) = self.0.get_mut(i) {
*c += by;
i += i.isolate_lowest_one();
i += i & i.wrapping_neg();
}
}
@@ -123,7 +123,7 @@ impl Fenwick {
&& let Some(c) = self.0.get(i)
{
n += c;
i -= i.isolate_lowest_one();
i -= i & i.wrapping_neg();
}
u64::try_from(n).unwrap_or(0)
}
+6
View File
@@ -93,3 +93,9 @@ fn a_fold_stops_once_it_has_swept_its_budget() {
assert_eq!(steps, MAX_FOLD_SEGMENTS / 100_004);
assert_eq!(budget.spend(&BezPath::new(), &BezPath::new()), Err(PathOpsError::TooComplex), "spent stays spent");
}
#[test]
fn overlapping_rectangle_bounds_count_self_and_cross_pairs() {
let shapes = rects([Rect::new(0.0, 0.0, 2.0, 2.0), Rect::new(1.0, 1.0, 3.0, 3.0), Rect::new(4.0, 4.0, 5.0, 5.0)]);
assert_eq!(complexity([&shapes]), Some(Complexity { segments: 12, crossings: 14 }));
}
+2
View File
@@ -570,6 +570,8 @@ mod solo_tests;
#[cfg(test)]
mod spread_tests;
#[cfg(test)]
mod stroke_reach_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod width_tests;
+54
View File
@@ -0,0 +1,54 @@
//! What a stroke paints stays within the bounds an export and a redraw go by: square dash ends
//! that stand out round a curve, brush shapes past half the weight and brush copies that stray
//! further than a width profile thins them included.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Brush, Document, Node, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, WidthProfile};
use super::*;
/// How many pixels `kind` (at (200, 200), `size` square) with a 40-unit stroke styled by `style`
/// paints more than a pixel outside its export bounds, on a 600 × 600 view.
fn painted_outside(kind: &NodeKind, size: f64, align: StrokeAlign, miter_limit: f64, style: fn(&mut Node)) -> usize {
let mut d = Document::new("reach");
let p = d.first_page().unwrap();
let n = d.create(p, kind.clone(), "S", 200.0, 200.0, size, size).unwrap();
let node = d.get_mut(n).unwrap();
node.fills.clear();
node.corners.radii = [30.0; 4];
node.strokes = vec![Paint::solid(Color::WHITE)];
(node.stroke.weight, node.stroke.align, node.stroke.join, node.stroke.miter_limit) = (40.0, align, StrokeJoin::Miter, miter_limit);
style(node);
let b = crate::export_bounds(&d, n).unwrap();
let mut pix = Pixmap::new(600, 600).unwrap();
render_nodes_into(&d, &[n], &View { origin: (0.0, 0.0), scale: 1.0, width: 600, height: 600 }, &mut pix);
let (xs, ys) = (b.x0 - 1.0..=b.x1 + 1.0, b.y0 - 1.0..=b.y1 + 1.0);
let inside = |i: usize| xs.contains(&((i % 600) as f64 + 0.5)) && ys.contains(&((i / 600) as f64 + 0.5));
pix.data().chunks(4).enumerate().filter(|(i, p)| p[3] > 0 && !inside(*i)).count()
}
#[test]
fn a_stroke_with_corners_paints_within_its_bounds() {
type Style = fn(&mut Node);
let strokes: [(&str, Style); 4] = [
("square dash ends", |n| (n.stroke.dashes, n.stroke.dash_cap) = (vec![30.0, 30.0], Some(StrokeCap::Square))),
("short square dashes", |n| (n.stroke.dashes, n.stroke.dash_cap) = (vec![1.0, 7.0], Some(StrokeCap::Square))),
("the ink brush", |n| n.stroke.brush = Some(Brush::new("ink"))),
("a thinned scatter brush", |n| {
n.stroke.brush = Some(Brush { scatter: 1.0, ..Brush::new("dots") });
n.stroke.width_profile = Some(WidthProfile::new([(0.0, 0.5), (1.0, 0.5)]));
}),
];
let circle = NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 };
let shapes = [(&circle, 200.0), (&circle, 30.0), (&NodeKind::Rectangle, 200.0), (&NodeKind::Star { count: 5, inner_ratio: 0.4 }, 200.0)];
for (kind, size) in shapes {
for (name, style) in strokes {
for align in [StrokeAlign::Center, StrokeAlign::Outside] {
for miter_limit in [4.0, 1.0] {
let outside = painted_outside(kind, size, align, miter_limit, style);
assert_eq!(outside, 0, "{kind:?} {size}, {name}, {align:?}, mitre limit {miter_limit}");
}
}
}
}
}
+1 -1
View File
@@ -1355,7 +1355,7 @@ fn index_font_file(path: &std::path::Path, meta: &std::fs::Metadata, out: &mut B
return;
}
let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default();
if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") && !(path.extension().is_none() && starts_like_a_font(path)) {
if !(matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") || (path.extension().is_none() && starts_like_a_font(path))) {
return;
}
// Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the
+17 -10
View File
@@ -41,11 +41,16 @@ enum Drag {
type UnitRect = (f64, f64, f64, f64);
/// The paint list a picker paint at an index belongs to on layer `n`, as the Fill section and the
/// colour picker list it: for fills, a text's characters' shared fills (`character_fills`: the
/// layer's own when the characters have none), else the layer's strokes. None when a text's
/// characters have different fills. Writes go through `fill.set`, which moves the characters'
/// list to the layer first, so the handles read and write the same paint (#1856).
fn edited_list(n: &figcraft_doc::Node, fills: bool) -> Option<Vec<Paint>> {
/// colour picker list it: the selected regions' own fills while regions of it are selected in
/// vector edit mode (#1875); else, for fills, a text's characters' shared fills (`character_fills`:
/// the layer's own when the characters have none, #1856), or the layer's strokes. None when those
/// regions' or characters' fills differ. Writes go through `fill.set`, which edits the selected
/// regions or moves the characters' list to the layer first, so the handles read and write the
/// same paint.
fn edited_list(app: &FigApp, n: &figcraft_doc::Node, fills: bool) -> Option<Vec<Paint>> {
if fills && let Some(regions) = figcraft_engine::vectoredit::selected_region_fills(&app.session, n.id) {
return regions;
}
if fills { figcraft_engine::cmd::lists::character_fills(n) } else { Some(n.strokes.clone()) }
}
@@ -62,7 +67,7 @@ fn crop_target(app: &FigApp) -> Option<(NodeId, bool, usize, ImagePaint, UnitRec
};
let doc = app.session.doc().ok()?;
let n = doc.node(id)?;
let list = edited_list(n, fills)?;
let list = edited_list(app, n, fills)?;
let PaintKind::Image(img) = &list.get(index)?.kind else { return None };
if img.scale_mode != ImageScaleMode::Crop {
return None;
@@ -229,7 +234,7 @@ fn gradient_target(app: &FigApp) -> Option<(NodeId, bool, usize, figcraft_color:
return None;
}
let n = app.session.doc().ok()?.node(id)?;
let list = edited_list(n, *fills)?;
let list = edited_list(app, n, *fills)?;
match &list.get(*index)?.kind {
PaintKind::Gradient(g) => Some((id, *fills, *index, g.clone())),
_ => None,
@@ -269,7 +274,7 @@ fn line_param(a: Pos2, b: Pos2, p: Pos2) -> Option<(f64, f32)> {
fn set_gradient(app: &mut FigApp, id: NodeId, fills: bool, index: usize, g: figcraft_color::Gradient) {
let Some(n) = app.session.doc().ok().and_then(|d| d.node(id)).cloned() else { return };
let Some(list) = edited_list(&n, fills) else { return };
let Some(list) = edited_list(app, &n, fills) else { return };
let Some(mut paint) = list.get(index).cloned() else { return };
paint.kind = PaintKind::Gradient(g);
let cmd = if fills { "fill.set" } else { "stroke.set" };
@@ -693,7 +698,7 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) {
Drag::Gradient { id, fills, index, which } => {
let Some(u) = screen_to_unit(app, *id, pos) else { return };
let Some(n) = app.session.doc().ok().and_then(|d| d.node(*id)).cloned() else { return };
let Some(list) = edited_list(&n, *fills) else { return };
let Some(list) = edited_list(app, &n, *fills) else { return };
let Some(mut paint) = list.get(*index).cloned() else { return };
if let PaintKind::Gradient(g) = &mut paint.kind {
match which {
@@ -768,7 +773,7 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) {
(ax.min(ax + nw), ay.min(ay + nh), ax.max(ax + nw), ay.max(ay + nh))
};
let Some(n) = app.session.doc().ok().and_then(|d| d.node(*id)).cloned() else { return };
let Some(list) = edited_list(&n, *fills) else { return };
let Some(list) = edited_list(app, &n, *fills) else { return };
let Some(mut paint) = list.get(*index).cloned() else { return };
if let PaintKind::Image(img) = &mut paint.kind {
img.crop = Some(ImagePaint::crop_from_rect(r.0, r.1, r.2, r.3));
@@ -793,3 +798,5 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) {
#[cfg(test)]
mod character_fill_tests;
#[cfg(test)]
mod region_tests;
@@ -0,0 +1,35 @@
//! The canvas paint handles with a region of a vector selected in edit mode (#1875): they read
//! and write the paint the colour picker shows, the region's own, not the layer's.
use figcraft_color::{Color, Gradient, GradientKind, Paint, PaintKind};
use serde_json::json;
use super::{gradient_target, set_gradient};
use crate::colorpicker::PickerTarget;
use crate::layers_drag_tests::Ui;
#[test]
fn the_gradient_handles_edit_a_selected_regions_own_paint() {
let mut s = figcraft_engine::Session::new();
let id = crate::region_fill_rows_tests::two_region_vector(&mut s);
s.execute("vector.edit.begin", &json!({"id": id.0})).unwrap();
s.execute("vector.edit.select", &json!({"regions": [1]})).unwrap();
// The region's own paint: a gradient at half opacity; the layer keeps its solid grey.
let mut paint = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK));
paint.opacity = 0.5;
s.execute("fill.set", &json!({"ids": [id.0], "paint": paint})).unwrap();
let mut u = Ui::new(s);
u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![id] });
let (at, fills, index, g) = gradient_target(&u.app).expect("the handles show the region's gradient");
assert_eq!((at, fills, index), (id, true, 0));
let mut moved = g.clone();
moved.from = (0.1, 0.2);
set_gradient(&mut u.app, id, true, 0, moved);
let doc = u.app.session.doc().unwrap();
let n = doc.node(id).unwrap();
let figcraft_doc::NodeKind::Vector { network } = &n.kind else { panic!("not a vector") };
let region = network.regions[1].fills.clone().unwrap();
assert!(matches!(&region[0].kind, PaintKind::Gradient(x) if x.from == (0.1, 0.2)), "{region:?}");
assert_eq!(region[0].opacity, 0.5, "the region's paint keeps its own opacity");
assert!(matches!(n.fills[0].kind, PaintKind::Solid { .. }), "the layer's fill is untouched: {:?}", n.fills);
}
+4 -2
View File
@@ -435,8 +435,10 @@ fn pen_overlay(app: &FigApp, painter: &egui::Painter, zoom: f64) {
if !ov.pen.is_empty() {
let pts: Vec<Pos2> = ov.pen.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
if app.session.tool.tool == ToolId::Pencil {
// The stroke it will make, in its colour and weight (at least a point when zoomed out).
let (color, weight) = figcraft_engine::tools::PENCIL_STROKE;
// The stroke it will make, in its colour and weight (at least a point when zoomed out):
// white over a dark page or frame, as the stroke will be (#1741).
let (default, weight) = figcraft_engine::tools::PENCIL_STROKE;
let color = ov.pen_color.unwrap_or(default);
painter.add(Shape::line(pts, stroke(doc_c32(painter.ctx(), color), ((weight * zoom) as f32).max(1.0))));
} else {
for [x0, y0, x1, y1] in &ov.pen_handles {
+17
View File
@@ -186,6 +186,23 @@ fn the_pencil_previews_its_stroke_in_its_colour_and_weight() {
assert!((w - 1.0).abs() < 0.01, "at least a point: {w}");
}
/// Over a dark page the Pencil's preview is white, as the stroke it makes will be (#1741).
#[test]
fn the_pencil_previews_white_over_a_dark_page() {
let mut u = Ui::new(Session::new());
u.app.run("page.setBackground", json!({"color": "#1E1E1E"}));
u.app.run("tool.set", json!({"tool": "pencil"}));
let white = egui::epaint::ColorMode::Solid(crate::theme::c32(figcraft_color::Color::WHITE));
let at = |u: &Ui, x: f64, y: f64| u.app.to_screen(x, y);
u.button(at(&u, 10.0, 10.0), true);
for i in 1..=6 {
let p = at(&u, 10.0 + 12.0 * f64::from(i), 10.0 + 6.0 * f64::from(i % 2));
u.frame(vec![egui::Event::PointerMoved(p)]);
}
let shapes = flat(&u.frame_output(vec![]).shapes);
assert!(shapes.iter().any(|s| matches!(s, Shape::Path(p) if !p.closed && p.points.len() >= 3 && p.stroke.color == white)), "a white preview");
}
#[test]
fn a_selected_layer_shows_its_constraint_guides_in_its_frame() {
// A child pinned left (the default) in a frame without auto layout: a dashed guide runs from its
+56
View File
@@ -1215,6 +1215,62 @@ fn sidebar_field_text_stays_in_place_on_focus() {
}
}
/// The Page section's opacity field edits the page background's alpha (#1748; it was a label),
/// one undo step per commit, and the hex field keeps that opacity unless the typed colour
/// carries its own.
#[test]
fn the_page_background_opacity_field_sets_the_page_colour_alpha() {
let mut u = Ui::new(Session::new());
u.ctx.enable_accesskit();
u.frame(vec![]);
let page_bg = |u: &Ui| {
let page = u.app.session.page().unwrap();
match &u.app.session.doc().unwrap().node(page).unwrap().kind {
figcraft_doc::NodeKind::Page { background, .. } => *background,
_ => panic!("the page"),
}
};
// Within an 8-bit step: the colour travels as RRGGBBAA.
let near = |a: f32, b: f32| (a - b).abs() < 1.0 / 255.0;
assert!(near(page_bg(&u).a, 1.0));
let steps = undo_steps(&u);
type_into(&mut u, "Opacity", "50", Key::Enter);
assert!(near(page_bg(&u).a, 0.5), "{:?}", page_bg(&u));
assert_eq!(undo_steps(&u), steps + 1, "one undo step for the commit");
// No label is drawn, so the field's leading strip scrubs it: a percent a point, live, and one
// undo step for the whole drag.
let edge = lead(&u, Id::unique("pagebg_a"));
drag(&mut u, edge, -25.0, Modifiers::NONE);
assert!(near(page_bg(&u).a, 0.25), "{:?}", page_bg(&u));
assert_eq!(undo_steps(&u), steps + 2, "one undo step for the whole scrub");
u.app.run("edit.undo", json!({}));
assert!(near(page_bg(&u).a, 0.5), "undo takes back the scrub alone");
// The hex field keeps the opacity when the typed colour has none, and takes one it carries.
let hex = |u: &mut Ui| u.ctx.read_response(Id::unique("pagebg").with("click")).expect("the hex field").rect.center();
let at = hex(&mut u);
click(&mut u, at);
u.frame(vec![Event::Text("FF0000".into())]);
key(&mut u, Key::Enter, Modifiers::NONE);
let c = page_bg(&u);
assert!(near(c.r, 1.0) && near(c.g, 0.0) && near(c.a, 0.5), "{c:?}");
let at = hex(&mut u);
click(&mut u, at);
u.frame(vec![Event::Text("00FF0080".into())]);
key(&mut u, Key::Enter, Modifiers::NONE);
let c = page_bg(&u);
assert!(near(c.g, 1.0) && near(c.a, 128.0 / 255.0), "{c:?}");
// Eight digits asking for opaque set 100 %: an alpha of 1 in the text is not "no alpha".
let at = hex(&mut u);
click(&mut u, at);
u.frame(vec![Event::Text("0000FFFF".into())]);
key(&mut u, Key::Enter, Modifiers::NONE);
let c = page_bg(&u);
assert!(near(c.b, 1.0) && near(c.a, 1.0), "{c:?}");
assert_eq!(undo_steps(&u), steps + 4);
u.app.run("edit.undo", json!({}));
assert!(near(page_bg(&u).g, 1.0) && near(page_bg(&u).a, 128.0 / 255.0), "undo takes back the last colour alone");
}
/// "You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option
/// Windows: Alt" to scrub it (Adjust alignment, rotation, position, and dimensions). Alt is what
/// makes the drag a scrub there, not the fine rate: a point moves a step, as on the label. Without
+2
View File
@@ -225,6 +225,8 @@ mod proto_reorder_tests;
#[cfg(test)]
mod proto_responsive_tests;
#[cfg(test)]
mod region_fill_rows_tests;
#[cfg(test)]
mod resources_tests;
#[cfg(test)]
mod sampled_color_tests;
+32 -9
View File
@@ -317,27 +317,50 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
/// Draw the page section: background and colour profile, local variables/styles, and the
/// page export section.
fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
let t = Tokens::get(ui.ctx());
let Ok(page) = app.session.page() else { return };
let bg = match doc.node(page).map(|n| &n.kind) {
Some(NodeKind::Page { background, .. }) => *background,
_ => figcraft_color::Color::DEFAULT_PAGE,
};
widgets::section_header(ui, tr("Page"), &[], false);
let mut new_hex = None;
// The new colour and, from a scrub, whether the scrub is over (live edits stay one undo step).
let mut new_color: Option<(figcraft_color::Color, Option<bool>)> = None;
row(ui, 32.0, |ui| {
let p = Paint::solid(bg);
let sw = widgets::swatch(ui, &p, 16.0);
app.picker_control(crate::colorpicker::PickerTarget::PageBackground, &sw);
if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") {
new_hex = Some(h);
// A typed colour keeps the page's opacity unless it carries one: 4 or 8 hex digits
// (`FF000080`; `FF0000FF` asks for opaque).
if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "")
&& let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
{
let digits = h.trim().trim_start_matches('#');
let carries_alpha = matches!(digits.len(), 4 | 8) && digits.chars().all(|ch| ch.is_ascii_hexdigit());
new_color = Some((if carries_alpha { c } else { c.with_alpha(bg.a) }, None));
}
// The opacity: a field like every other opacity field, not a label (#1748).
let alpha = |v: f64| bg.with_alpha((v / 100.0).clamp(0.0, 1.0) as f32);
let shown = Some(f64::from((bg.a * 100.0).round()));
match widgets::number_field(ui, Id::unique("pagebg_a"), Lead::Named(tr("Opacity")), shown, "%", 56.0, 1.0) {
Some(FieldEdit::Set(v)) => new_color = Some((alpha(v), None)),
Some(FieldEdit::Scrub(v, done)) => new_color = Some((alpha(v), Some(done))),
None => {}
}
widgets::label(ui, "100 %", t.text);
});
if let Some(h) = new_hex
&& let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
{
app.run("page.setBackground", json!({"color": c.to_css_hex()}));
if let Some((c, scrub)) = new_color {
let p = json!({"color": c.to_css_hex()});
match scrub {
None => {
app.run("page.setBackground", p);
}
Some(done) => {
let _ = app.session.begin_interaction("Page background");
let _ = app.session.execute("page.setBackground", &p);
if done {
let _ = app.session.commit_interaction();
}
}
}
}
widgets::separator(ui);
widgets::section_header(ui, tr("Color profile"), &[], false);
+25 -14
View File
@@ -293,21 +293,28 @@ 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();
// A text's fills are what its characters show: one list they share, or mixed (#1856).
let list = common(doc, sel, |n| if fills { figcraft_engine::cmd::lists::character_fills(n) } else { Some(n.strokes.clone()) }).flatten();
// Regions selected in vector edit mode: their own fills (#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,
};
// The fill style, a variable bound to the layer's fill and the image crop are the layer's:
// they stay off a region's rows.
let region = region_list.is_some();
// Else a text's fills are what its characters show: one list they share, or mixed (#1856).
let list = region_list.unwrap_or_else(|| {
common(doc, sel, |n| if fills { figcraft_engine::cmd::lists::character_fills(n) } else { Some(n.strokes.clone()) }).flatten()
});
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 };
// The section's right-click menu adds the copied paint (`fill.paste` / `stroke.paste`).
let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") };
let (clicked, header) = widgets::section_header_resp(
ui,
title,
&[(Icon::Styles, tr("Styles")), (Icon::Plus, if fills { tr("Add fill") } else { tr("Add stroke") })],
empty,
);
let add = (Icon::Plus, if fills { tr("Add fill") } else { tr("Add stroke") });
let buttons = if region { vec![add] } else { vec![(Icon::Styles, tr("Styles")), add] };
let (clicked, header) = widgets::section_header_resp(ui, title, &buttons, empty);
if let Some(i) = clicked {
if i == 1 {
if buttons.get(i).is_some_and(|(icon, _)| *icon == Icon::Plus) {
// The new paint goes on top; open its picker. A mixed selection has no one index to edit.
if app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})).is_some()
&& let Some(l) = &list
@@ -324,14 +331,16 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
ui.close();
}
});
style_picker::chip(app, ui, doc, &ids, slot);
if !region {
style_picker::chip(app, ui, doc, &ids, slot);
}
match &list {
None => widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary),
Some(l) => {
// The last paint is on top: shown first. Rows drag to reorder.
let mut drag = ListDrag::new(ui, if fills { "fills" } else { "strokes" });
for (i, p) in l.iter().enumerate().rev() {
let r = paint_row(app, ui, &ids, i, p, fills);
let r = paint_row(app, ui, &ids, i, p, fills, region);
drag.row(ui, i, r, r, || paint_label(p));
}
if let Some((from, to)) = drag.finish(ui) {
@@ -441,7 +450,9 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
}
/// Paint `i`'s row (swatch, colour or kind, opacity, visibility, remove); returns its area.
fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, fills: bool) -> Rect {
/// One paint's row; `region`: a row of the regions selected in vector edit mode (#1875), which
/// leaves out what belongs to the layer (its variable binding, the image crop).
fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, fills: bool, region: bool) -> Rect {
let t = Tokens::get(ui.ctx());
let ids = ids.to_vec();
let cmd = if fills { "fill.set" } else { "stroke.set" };
@@ -450,7 +461,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
let (sresp, e) = widgets::paint_field(ui, Id::unique(("swatch", fills, i)), Id::unique(("pop", fills, i)), p, w, |child, text_r| {
let r = child.max_rect();
// The variable bound to this paint's colour (its key on the layers: `fill` or `fills/<i>/color`).
let bound = app.session.doc().ok().and_then(|d| {
let bound = app.session.doc().ok().filter(|_| !region).and_then(|d| {
let key = ids.first().and_then(|id| d.node(*id)).and_then(|n| figcraft_engine::cmd::lists::paint_binding_key(n, fills, i))?;
Some((key.to_string(), crate::variable_ui::bound_name(d, &ids, key)?))
});
@@ -491,7 +502,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
_ => {
child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, paint_label(p), regular(11.0), t.text);
// One layer's image fill: crop it on the canvas.
if fills && ids.len() == 1 && matches!(p.kind, PaintKind::Image(_)) {
if fills && ids.len() == 1 && !region && matches!(p.kind, PaintKind::Image(_)) {
let cr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0));
let cresp = child.interact(cr, Id::unique(("crop_image", i)), Sense::click()).tip(tr("Crop image"));
icons::paint(child.painter(), cr.shrink(3.0), Icon::Crop, if cresp.hovered() { t.icon } else { t.icon_secondary });
@@ -0,0 +1,105 @@
//! The Fill section with a region of a vector selected in edit mode (#1875), driven through egui
//! headless: the region's own fill shows, and what belongs to the layer (its fill style, a
//! variable bound to its fill) stays off the region's rows.
use egui::accesskit::Role;
use egui::{Pos2, pos2};
use figcraft_doc::{NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex};
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
/// Four points and four straight segments of a square, appended to `n`.
fn square(n: &mut VectorNetwork, x0: f64, y0: f64, size: f64) {
let v0 = n.vertices.len();
for (x, y) in [(x0, y0), (x0 + size, y0), (x0 + size, y0 + size), (x0, y0 + size)] {
n.vertices.push(Vertex::new(x, y));
}
for k in 0..4 {
n.segments.push(Segment::line(v0 + k, v0 + (k + 1) % 4));
}
}
/// A vector of two squares on the page, the outer one's region then the inner one's, both
/// inheriting the layer's default grey fill (also used by the canvas handle and picker tests).
pub(crate) fn two_region_vector(s: &mut Session) -> NodeId {
let r = s.execute("vector.create", &json!({"points": [[0, 0], [10, 0], [10, 10]], "closed": true})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
{
let doc = s.doc_mut().unwrap();
let node = doc.get_mut(id).unwrap();
let mut n = VectorNetwork::default();
square(&mut n, 0.0, 0.0, 200.0);
square(&mut n, 60.0, 60.0, 80.0);
n.regions =
vec![Region { loops: vec![vec![0, 1, 2, 3]], ..Default::default() }, Region { loops: vec![vec![4, 5, 6, 7]], ..Default::default() }];
node.kind = NodeKind::Vector { network: n };
node.transform = figcraft_geom::Affine::IDENTITY;
node.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)];
figcraft_engine::vectoredit::refit(doc, id).unwrap();
}
id
}
/// [`two_region_vector`] with its fill bound to a blue colour variable; in edit mode with the
/// inner region selected and given a red fill of its own.
fn ui() -> Ui {
let mut s = Session::new();
let id = two_region_vector(&mut s);
s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
s.execute("variable.create", &json!({"collection": "Theme", "name": "brand", "type": "color", "value": "#0000FF"})).unwrap();
s.execute("variable.bind", &json!({"ids": [id.0], "field": "fill", "variable": "brand"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("vector.edit.begin", &json!({"id": id.0})).unwrap();
s.execute("vector.edit.select", &json!({"regions": [1]})).unwrap();
s.execute("fill.set", &json!({"ids": [id.0], "color": "#FF0000"})).unwrap();
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
u.frame(vec![]);
u
}
/// The accessible controls after a frame: role, label, value and centre.
fn controls(u: &mut Ui) -> Vec<(Role, String, String, Pos2)> {
u.frame(vec![]);
let out = u.frame_output(vec![]);
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
nodes
.iter()
.map(|(_, n)| {
let at = n.bounds().map_or(pos2(0.0, 0.0), |b| pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32));
(n.role(), n.label().unwrap_or_default().to_string(), n.value().unwrap_or_default().to_string(), at)
})
.collect()
}
fn button(c: &[(Role, String, String, Pos2)], name: &str) -> Pos2 {
c.iter().find(|(r, l, _, _)| *r == Role::Button && l == name).map(|(_, _, _, p)| *p).unwrap_or_else(|| panic!("no {name} button: {c:?}"))
}
/// What the Fill section shows, between its + and the Stroke section's: its hex rows, whether a
/// Detach variable button is there, and whether its header has a Styles button.
fn fill_section(c: &[(Role, String, String, Pos2)]) -> (Vec<String>, bool, bool) {
let (top, bottom) = (button(c, "Add fill"), button(c, "Add stroke"));
let inside = |p: &Pos2| p.y > top.y + 2.0 && p.y < bottom.y - 2.0;
let rows = c.iter().filter(|(r, _, _, p)| *r == Role::TextInput && inside(p)).map(|(_, _, v, _)| v.clone()).collect();
let detach = c.iter().any(|(_, l, _, p)| l == "Detach variable" && inside(p));
let styles = c.iter().any(|(r, l, _, p)| *r == Role::Button && l == "Styles" && (p.y - top.y).abs() < 2.0);
(rows, detach, styles)
}
#[test]
fn a_selected_regions_rows_leave_out_the_layers_style_and_variable() {
let mut u = ui();
let red = figcraft_color::Color::from_hex("#FF0000").unwrap().to_hex();
let (rows, detach, styles) = fill_section(&controls(&mut u));
assert_eq!(rows, [red], "the region's own red, as a hex field");
assert!(!detach, "the layer's variable binding is not the region's");
assert!(!styles, "no fill style for a region's fills");
// No region selected: the layer's row again, with its bound variable and the Styles button.
u.app.run("vector.edit.select", json!({}));
let (rows, detach, styles) = fill_section(&controls(&mut u));
assert!(rows.is_empty(), "the layer's paint shows as its variable, not a hex field: {rows:?}");
assert!(detach && styles);
}
+31 -5
View File
@@ -92,7 +92,14 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
},
PickerTarget::Paint { fills, index, ids } => {
let n = doc.node(*ids.first()?)?;
// A text's fills are its characters' (none to pick while they differ).
// 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();
}
// A text's fills are its characters' (none to pick while they differ, #1856).
let list = if *fills { figcraft_engine::cmd::lists::character_fills(n)? } else { n.strokes.clone() };
list.get(*index).cloned()
}
@@ -575,6 +582,18 @@ fn set_image_pending(ctx: &egui::Context, target: Option<&PickerTarget>) {
}
/// `image.replace`'s parameters for layers' paint `index`.
/// Whether `target` is a paint of the regions selected in vector edit mode (#1875): the picker
/// shows the regions' own fills, which the image commands (layer paints only) don't reach, so
/// their image edits are written as paints like any other.
fn region_backed(app: &FigApp, target: &PickerTarget) -> bool {
match target {
PickerTarget::Paint { fills: true, ids, .. } => {
matches!(ids.as_slice(), [id] if figcraft_engine::vectoredit::selected_region_fills(&app.session, *id).is_some())
}
_ => false,
}
}
fn image_params(fills: bool, index: usize, ids: &[NodeId]) -> serde_json::Value {
json!({"ids": ids, "target": if fills { "fill" } else { "stroke" }, "index": index})
}
@@ -585,6 +604,11 @@ fn image_params(fills: bool, index: usize, ids: &[NodeId]) -> serde_json::Value
pub(crate) fn image_drop_target(app: &FigApp, ctx: &egui::Context) -> Option<(Rect, serde_json::Value)> {
let target = app.ui.picker.as_ref()?;
let PickerTarget::Paint { fills, index, ids } = target else { return None };
// A region's own paint takes no drop (`image.replace` edits layer paints); the file goes to
// the canvas as it would without the picker.
if region_backed(app, target) {
return None;
}
if !image_pending(ctx, target) && !matches!(current_paint(app, target)?.kind, PaintKind::Image(_)) {
return None;
}
@@ -605,7 +629,9 @@ fn picked_bytes(file: &serde_json::Value) -> Option<Vec<u8>> {
fn upload_image(app: &mut FigApp, target: &PickerTarget) {
let target = target.clone();
app.pick_file(tr("Choose image"), &IMAGE_FILES, true, move |app, file| {
if let PickerTarget::Paint { fills, index, ids } = &target {
if let PickerTarget::Paint { fills, index, ids } = &target
&& !region_backed(app, &target)
{
let mut params = image_params(*fills, *index, ids);
for key in ["path", "data"] {
if let Some(v) = file.get(key) {
@@ -1401,9 +1427,9 @@ fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &m
app.run("image.deletePreset", json!({"name": name}));
}
Some(PresetAction::Apply(name)) => match target {
// Layer paints go through the command; other targets (a text range, a style) get the
// preset's values written like any edit.
PickerTarget::Paint { fills, index, ids } => {
// Layer paints go through the command; other targets (a text range, a style, a
// region's own paint) get the preset's values written like any edit.
PickerTarget::Paint { fills, index, ids } if !region_backed(app, target) => {
app.run("image.adjust", json!({"ids": ids, "target": if *fills { "fill" } else { "stroke" }, "index": index, "preset": name}));
}
_ => {
@@ -284,3 +284,31 @@ fn cancelling_the_upload_changes_nothing() {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A region of a vector selected in edit mode (#1875): Upload from computer puts the image in the
/// region's own fill the picker shows, the layer's fills untouched; a file dropped on the picker
/// isn't taken for a region's paint (it goes to the canvas, as without the picker).
#[test]
fn upload_from_computer_on_a_regions_fill_puts_the_image_in_the_region() {
let dir = temp_dir("region");
let mut s = Session::new();
let id = crate::region_fill_rows_tests::two_region_vector(&mut s);
s.execute("vector.edit.begin", &json!({"id": id.0})).unwrap();
s.execute("vector.edit.select", &json!({"regions": [1]})).unwrap();
s.execute("fill.set", &json!({"ids": [id.0], "color": "#FF0000"})).unwrap();
let layer_fills = s.doc().unwrap().node(id).unwrap().fills.clone();
let mut u = Ui::new(s);
open_picker(&mut u, id, 0);
let asked = fake_picker(&mut u, Some(png(5)), false, &dir);
click_image_tab(&mut u);
assert!(super::image_drop_target(&u.app, &u.ctx).is_none(), "a drop isn't taken for a region's paint");
click_upload(&mut u);
assert_eq!(asked.load(Ordering::SeqCst), 1);
let doc = u.app.session.doc().unwrap();
let n = doc.node(id).unwrap();
assert_eq!(n.fills, layer_fills, "the layer's fills are untouched");
let figcraft_doc::NodeKind::Vector { network } = &n.kind else { panic!("not a vector") };
let region = network.regions[1].fills.clone().unwrap();
assert!(matches!(region.last().map(|p| &p.kind), Some(PaintKind::Image(_))), "{region:?}");
let _ = std::fs::remove_dir_all(&dir);
}
+5 -1
View File
@@ -730,7 +730,11 @@ 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; `fill.copy` copies the selected
regions' paint),
`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),
+4 -3
View File
File diff suppressed because one or more lines are too long