Brushes: each Pattern Brush Options tile can be set to a pattern swatch or None

A menu under each of the five tiles in Pattern Brush Options lists None,
Original and the document's pattern swatches; the corner menus keep the
four automatic corners. A swatch choice copies the swatch's whole art and
its tile frame into the brush, so the brush keeps drawing after the swatch
is deleted, and the menu then shows that art as Original. Side cannot be
None: OK shows "You must choose a side pattern." and keeps the dialog open.

brush.options takes side_tile, outer_corner_tile, inner_corner_tile,
start_tile and end_tile (a pattern name, none, original, the corner auto
ids, or {"pattern": name} for a swatch named like one of those words), and
rejects side_tile "none" and unknown names before editing.
This commit is contained in:
dotCooCoo
2026-11-01 01:22:03 +07:00
committed by bflatastic
parent 34d58c1bfb
commit 012af4fd49
39 changed files with 837 additions and 48 deletions
+43
View File
@@ -386,10 +386,22 @@ impl CornerSource {
#[serde(default)]
pub struct PatternBrush {
pub side: Node,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub side_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub side_swatch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outer_corner: Option<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outer_corner_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outer_corner_swatch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inner_corner: Option<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inner_corner_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inner_corner_swatch: Option<String>,
/// What the outer corners take when there is no `outer_corner` art. Without art or a source,
/// they take the `inner_corner` art.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -400,8 +412,16 @@ pub struct PatternBrush {
pub inner_source: Option<CornerSource>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start: Option<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_swatch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_swatch: Option<String>,
/// Scale in percent.
pub scale: f64,
/// Space between tiles in percent of the tile width.
@@ -422,12 +442,22 @@ impl Default for PatternBrush {
fn default() -> Self {
Self {
side: defaults::stitch_tile(),
side_frame: None,
side_swatch: None,
outer_corner: None,
outer_corner_frame: None,
outer_corner_swatch: None,
inner_corner: None,
inner_corner_frame: None,
inner_corner_swatch: None,
outer_source: None,
inner_source: None,
start: None,
start_frame: None,
start_swatch: None,
end: None,
end_frame: None,
end_swatch: None,
scale: 100.0,
spacing: 0.0,
fit: PatternFit::Stretch,
@@ -500,6 +530,14 @@ fn within_pair((a, b): (f64, f64), range: (f64, f64), fallback: f64) -> (f64, f6
(within(a, range, fallback), within(b, range, fallback))
}
fn valid_tile_frame(frame: Rect) -> bool {
[frame.x0, frame.y0, frame.x1, frame.y1].into_iter().all(f64::is_finite)
&& frame.width().is_finite()
&& frame.height().is_finite()
&& frame.width() > 1e-9
&& frame.height() > 1e-9
}
impl Colorization {
/// A Hue Shift key colour that isn't a colour (components out of reach) becomes black.
fn sanitize(&mut self) {
@@ -600,6 +638,11 @@ impl Brush {
BrushKind::Pattern(p) => {
p.scale = within(p.scale, PatternBrush::SCALE_RANGE, 100.0);
p.spacing = within(p.spacing, PatternBrush::SPACING_RANGE, 0.0);
for frame in [&mut p.side_frame, &mut p.outer_corner_frame, &mut p.inner_corner_frame, &mut p.start_frame, &mut p.end_frame] {
if frame.is_some_and(|f| !valid_tile_frame(f)) {
*frame = None;
}
}
p.colorization.sanitize();
}
BrushKind::Bristle(b) => {
+37
View File
@@ -561,6 +561,43 @@ fn pattern_corner_source_none_leaves_that_kind_of_corner_empty() {
assert!((outer == plain && inner == saved) || (inner == plain && outer == saved), "only the corners set to None are empty");
}
#[test]
fn pattern_without_start_tile_runs_side_to_path_start() {
let path = line(0.0, 0.0, 100.0, 0.0);
let doc = Document::new(1.0, 1.0);
let mut b = pattern_brush(PatternFit::Stretch, false);
if let BrushKind::Pattern(p) = &mut b.kind {
p.start = Some(rect_art(20.0, 4.0));
}
let with_start = stroke_pieces(&doc, &b, &path, &stroke(1.0));
assert_eq!(with_start[0].geometric_bounds().unwrap().width(), 20.0);
if let BrushKind::Pattern(p) = &mut b.kind {
p.start = None;
}
let without_start = stroke_pieces(&doc, &b, &path, &stroke(1.0));
assert_eq!(without_start.len(), 10);
assert!(near(without_start[0].geometric_bounds().unwrap().x0, 0.0, 1e-6));
assert!(near(without_start[0].geometric_bounds().unwrap().width(), 10.0, 1e-6));
}
#[test]
fn pattern_without_end_tile_runs_side_to_path_end() {
let path = line(0.0, 0.0, 100.0, 0.0);
let doc = Document::new(1.0, 1.0);
let mut b = pattern_brush(PatternFit::Stretch, false);
if let BrushKind::Pattern(p) = &mut b.kind {
p.end = Some(rect_art(20.0, 4.0));
}
let with_end = stroke_pieces(&doc, &b, &path, &stroke(1.0));
assert!(with_end.iter().any(|n| near(n.geometric_bounds().unwrap().width(), 20.0, 1e-6)));
if let BrushKind::Pattern(p) = &mut b.kind {
p.end = None;
}
let without_end = stroke_pieces(&doc, &b, &path, &stroke(1.0));
assert_eq!(without_end.len(), 10);
assert!(near(without_end.last().unwrap().geometric_bounds().unwrap().x1, 100.0, 1e-6));
}
#[test]
fn pattern_corner_sources_round_trip_and_stay_out_of_brushes_without_them() {
let saved = serde_json::to_value(pattern_brush(PatternFit::Stretch, true)).unwrap();
+16 -6
View File
@@ -247,6 +247,16 @@ fn oriented(art: &Node, dir: Direction, flip_along: bool, flip_across: bool) ->
(b.width() > 1e-9).then_some((a, b))
}
/// Orient a tile's repeat frame with its art. The frame can be smaller than the art.
fn oriented_tile(art: &Node, frame: Option<Rect>, flip_along: bool, flip_across: bool) -> Option<(Node, Rect)> {
let (node, bounds) = oriented(art, Direction::LeftToRight, flip_along, flip_across)?;
let Some(frame) = frame else { return Some((node, bounds)) };
let x = if flip_along { (-frame.x1, -frame.x0) } else { (frame.x0, frame.x1) };
let y = if flip_across { (-frame.y1, -frame.y0) } else { (frame.y0, frame.y1) };
let frame = Rect::new(x.0, y.0, x.1, y.1);
(frame.width() > 1e-9 && frame.height() > 1e-9).then_some((node, frame))
}
/// Piecewise-linear map from art x (offset from the art's left edge) to arc length.
#[derive(Clone, Copy, Debug)]
pub(crate) struct AlongMap {
@@ -800,13 +810,13 @@ fn masked(n: Node, quad: &[Point], p: Point, towards: Vec2) -> Option<Node> {
}
pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
let orient = |n: &Node| oriented(n, Direction::LeftToRight, pb.flip_along, pb.flip_across);
let Some(side) = orient(&pb.side) else { return vec![] };
let orient = |n: &Node, frame| oriented_tile(n, frame, pb.flip_along, pb.flip_across);
let Some(side) = orient(&pb.side, pb.side_frame) else { return vec![] };
let k = weight * pb.scale.max(1.0) / 100.0;
let outer = pb.outer_corner.as_ref().and_then(orient);
let inner = pb.inner_corner.as_ref().and_then(orient);
let start = pb.start.as_ref().and_then(orient);
let end = pb.end.as_ref().and_then(orient);
let outer = pb.outer_corner.as_ref().and_then(|n| orient(n, pb.outer_corner_frame));
let inner = pb.inner_corner.as_ref().and_then(|n| orient(n, pb.inner_corner_frame));
let start = pb.start.as_ref().and_then(|n| orient(n, pb.start_frame));
let end = pb.end.as_ref().and_then(|n| orient(n, pb.end_frame));
let tw = side.1.width() * k;
let gap = tw * pb.spacing.max(0.0) / 100.0;
// Each kind of corner takes its own art, else its source, else the other kind's art.
+1 -1
View File
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Brush Options…",
[],
None,
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art). scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
has_doc,
brush_options
),
+70
View File
@@ -390,7 +390,77 @@ pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
let i = lib.iter().position(|b| b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
let mut v = serde_json::to_value(&lib[i]).unwrap_or_default();
if let Some(params) = p.get("params") {
let original = v.clone();
merge(&mut v, params);
if lib[i].kind.type_id() == "pattern" {
let tiles = [
("side_tile", "side"),
("outer_corner_tile", "outer_corner"),
("inner_corner_tile", "inner_corner"),
("start_tile", "start"),
("end_tile", "end"),
];
// Resolve all Original choices against the brush before any tile can clear the art
// another corner borrows. Keep the matching frame with each copied tile.
let originals: Vec<_> = tiles
.iter()
.map(|(_, art)| {
let from = if art.ends_with("corner")
&& original.get(art).is_none_or(Value::is_null)
&& original.get(if *art == "outer_corner" { "outer_source" } else { "inner_source" }).is_none_or(Value::is_null)
{
if *art == "outer_corner" { "inner_corner" } else { "outer_corner" }
} else {
*art
};
(original.get(from).cloned().unwrap_or(Value::Null), original.get(format!("{from}_frame")).cloned().unwrap_or(Value::Null))
})
.collect();
for ((tile, art), (original_art, original_frame)) in tiles.into_iter().zip(originals) {
let frame = format!("{art}_frame");
let swatch = format!("{art}_swatch");
let Some(choice) = params.get(tile) else {
if params.get(art).is_some() {
if params.get(frame.as_str()).is_none() {
v[frame.as_str()] = Value::Null;
}
if params.get(swatch.as_str()).is_none() {
v[swatch.as_str()] = Value::Null;
}
}
continue;
};
let (choice, swatch_only) = if let Some(name) = choice.as_object() {
(name.get("pattern").and_then(Value::as_str).ok_or_else(|| bad(C, "invalid pattern tile choice"))?, true)
} else {
(choice.as_str().ok_or_else(|| bad(C, "invalid pattern tile choice"))?, false)
};
if art == "side" && choice == "none" && !swatch_only {
return Err(bad(C, "You must choose a side pattern."));
}
if choice == "original" && !swatch_only {
v[art] = original_art;
v[frame.as_str()] = original_frame;
v[swatch.as_str()] = Value::Null;
} else if !swatch_only && (choice == "none" || (art.ends_with("corner") && brush::CornerSource::parse(choice).is_some())) {
v[art] = Value::Null;
v[frame.as_str()] = Value::Null;
v[swatch.as_str()] = Value::Null;
if art.ends_with("corner") {
v[if art == "outer_corner" { "outer_source" } else { "inner_source" }] = json!(choice);
}
} else {
let pattern = s.doc()?.doc.pattern(choice).ok_or_else(|| bad(C, format!("no pattern named `{choice}`")))?;
let copy = Node::new(NodeId(0), NodeKind::Group { children: pattern.art.clone(), clip: false });
v[art] = serde_json::to_value(copy).unwrap_or_default();
v[frame.as_str()] = serde_json::to_value(pattern.tile).unwrap_or_default();
v[swatch.as_str()] = json!(choice);
if art.ends_with("corner") {
v[if art == "outer_corner" { "outer_source" } else { "inner_source" }] = Value::Null;
}
}
}
}
}
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
if let Some(nn) = &new_name {
+188
View File
@@ -832,6 +832,194 @@ fn art_brush_options_keep_the_scale_fields_an_edit_leaves_out() {
assert_eq!(s.execute("brush.get", &json!({"name": "Bar"})).unwrap()["scale"], json!({"mode": "stretch"}));
}
#[test]
fn pattern_tile_choices_copy_whole_swatch_art_and_reject_invalid_side() {
use std::sync::Arc;
use vectorcraft_doc::{PatternDef, StrokeLayer};
use vectorcraft_geom::BezPath;
let mut s = session();
s.edit("Pattern", |d, _| {
let art =
Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 4.0)), vectorcraft_doc::Appearance::default_art());
d.patterns.push(PatternDef { tile: Rect::new(0.0, 0.0, 10.0, 4.0), ..PatternDef::new("Wide", vec![Arc::new(art)]) });
Ok(())
})
.unwrap();
let side = s.execute("brush.get", &json!({"name":"Diamonds"})).unwrap()["side"].clone();
s.execute("brush.new", &json!({"type":"pattern", "name":"Plain Tiles", "params":{"side":side}})).unwrap();
let original = s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap();
let steps = s.doc().unwrap().history.undo.len();
for choice in ["none", "Missing"] {
let error = s.execute("brush.options", &json!({"name":"Plain Tiles", "params":{"side_tile":choice}})).unwrap_err().to_string();
assert!(error.contains(if choice == "none" { "You must choose a side pattern." } else { "no pattern named" }), "{error}");
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
assert_eq!(s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap(), original);
}
s.execute("brush.options", &json!({"name":"Plain Tiles", "params":{"side_tile":"Wide", "fit":"addSpace"}})).unwrap();
let saved = s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap();
assert_eq!(saved["side_frame"], json!(Rect::new(0.0, 0.0, 10.0, 4.0)));
assert_eq!(saved["side_swatch"], "Wide");
let brush: vectorcraft_brush::Brush = serde_json::from_value(saved).unwrap();
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((100.0, 0.0));
let pieces = vectorcraft_brush::stroke_pieces(
&s.doc().unwrap().doc,
&brush,
&path,
&StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0),
);
assert_eq!(pieces.len(), 10, "10 pt tile frame repeats ten times along 100 pt");
assert!(pieces[0].geometric_bounds().unwrap().width() >= 29.9, "whole 30 pt art is copied");
s.execute("brush.options", &json!({"name":"Plain Tiles", "params":{"inner_corner_tile":"Wide", "outer_corner_tile":"none"}})).unwrap();
let corners: vectorcraft_brush::Brush = serde_json::from_value(s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap()).unwrap();
let mut fallback = corners.clone();
if let (vectorcraft_brush::BrushKind::Pattern(actual), vectorcraft_brush::BrushKind::Pattern(legacy)) = (&corners.kind, &mut fallback.kind) {
assert_eq!(actual.outer_source, Some(vectorcraft_brush::CornerSource::None));
assert!(actual.inner_corner.is_some());
legacy.outer_source = None;
}
let square = |clockwise: bool| {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
if clockwise {
for point in [(100.0, 0.0), (100.0, 100.0), (0.0, 100.0)] {
p.line_to(point);
}
} else {
for point in [(0.0, 100.0), (100.0, 100.0), (100.0, 0.0)] {
p.line_to(point);
}
}
p.close_path();
p
};
let stroke = StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0);
assert!(
[false, true].into_iter().any(|clockwise| {
let path = square(clockwise);
vectorcraft_brush::stroke_pieces(&s.doc().unwrap().doc, &corners, &path, &stroke)
!= vectorcraft_brush::stroke_pieces(&s.doc().unwrap().doc, &fallback, &path, &stroke)
}),
"explicit outer None omits the inner corner art on outer turns"
);
let borrowed_art = s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap()["inner_corner"].clone();
s.execute("brush.new", &json!({"type":"pattern", "name":"Borrowed Corner", "params":{"side":original["side"], "inner_corner":borrowed_art}}))
.unwrap();
s.execute("brush.options", &json!({"name":"Borrowed Corner", "params":{"outer_corner_tile":"original", "inner_corner_tile":"none"}})).unwrap();
let borrowed = s.execute("brush.get", &json!({"name":"Borrowed Corner"})).unwrap();
assert!(borrowed["outer_corner"].is_object());
assert_eq!(borrowed["inner_source"], "none");
s.edit("Delete pattern", |d, _| {
d.patterns.clear();
Ok(())
})
.unwrap();
let after: vectorcraft_brush::Brush = serde_json::from_value(s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap()).unwrap();
assert_eq!(
vectorcraft_brush::stroke_pieces(
&s.doc().unwrap().doc,
&after,
&path,
&StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0)
)
.len(),
10
);
let copied = s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap()["side"].clone();
s.execute("brush.options", &json!({"name":"Plain Tiles", "params":{"side_tile":"original"}})).unwrap();
let original_choice = s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap();
assert_eq!(original_choice["side"], copied);
assert!(original_choice.get("side_swatch").is_none());
}
#[test]
fn pattern_original_corner_keeps_borrowed_art_and_frame_when_source_is_cleared() {
let mut s = session();
let chain = s.execute("brush.get", &json!({"name": "Chain"})).unwrap();
let art = chain["outer_corner"].clone();
let frame = json!(Rect::new(1.0, 2.0, 15.0, 18.0));
for (name, source, borrowed) in [("Borrow Outer", "inner", "outer"), ("Borrow Inner", "outer", "inner")] {
let source_art = format!("{source}_corner");
let source_frame = format!("{source_art}_frame");
let borrowed_art = format!("{borrowed}_corner");
let borrowed_frame = format!("{borrowed_art}_frame");
let source_tile = format!("{source_art}_tile");
let borrowed_tile = format!("{borrowed_art}_tile");
s.execute("brush.new", &json!({"type":"pattern", "name":name, "params":{"side":chain["side"], source_art:art, source_frame:frame}})).unwrap();
s.execute("brush.options", &json!({"name":name, "params":{source_tile:"none", borrowed_tile:"original"}})).unwrap();
let saved = s.execute("brush.get", &json!({"name":name})).unwrap();
assert_eq!(saved[&borrowed_art], chain["outer_corner"], "{borrowed} art");
assert_eq!(saved[&borrowed_frame], frame, "{borrowed} frame");
assert_eq!(saved[format!("{source}_source")], "none");
}
}
#[test]
fn pattern_tile_object_selects_reserved_swatch_names() {
use std::sync::Arc;
use vectorcraft_doc::PatternDef;
let mut s = session();
let names = ["none", "original", "autoCentered", "autoBetween", "autoSliced", "autoOverlap"];
s.edit("Patterns", |doc, _| {
for name in names {
let art = Node::path(
doc.alloc_id(),
vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 4.0)),
vectorcraft_doc::Appearance::default_art(),
);
doc.patterns.push(PatternDef { tile: Rect::new(0.0, 0.0, 10.0, 4.0), ..PatternDef::new(name, vec![Arc::new(art)]) });
}
Ok(())
})
.unwrap();
let before = s.execute("brush.get", &json!({"name":"Diamonds"})).unwrap();
for invalid in [json!({}), json!({"pattern": null}), json!({"pattern": 1})] {
let error = s.execute("brush.options", &json!({"name":"Diamonds", "params":{"side_tile":invalid}})).unwrap_err().to_string();
assert!(error.contains("invalid pattern tile choice"), "{error}");
assert_eq!(s.execute("brush.get", &json!({"name":"Diamonds"})).unwrap(), before);
}
let slots = ["side_tile", "outer_corner_tile", "inner_corner_tile", "start_tile", "end_tile"];
for (slot, name) in slots.into_iter().zip(names) {
s.execute("brush.options", &json!({"name":"Diamonds", "params":{(slot):{"pattern":name}}})).unwrap();
}
let saved = s.execute("brush.get", &json!({"name":"Diamonds"})).unwrap();
for (art, name) in ["side", "outer_corner", "inner_corner", "start", "end"].into_iter().zip(names) {
assert_eq!(saved[format!("{art}_swatch")], name);
assert_eq!(saved[format!("{art}_frame")], json!(Rect::new(0.0, 0.0, 10.0, 4.0)));
}
s.execute("brush.options", &json!({"name":"Diamonds", "params":{"outer_corner_tile":{"pattern":"autoOverlap"}}})).unwrap();
assert_eq!(s.execute("brush.get", &json!({"name":"Diamonds"})).unwrap()["outer_corner_swatch"], "autoOverlap");
}
#[test]
fn pattern_end_tile_none_choices_leave_side_art_at_both_path_ends() {
use vectorcraft_doc::StrokeLayer;
use vectorcraft_geom::BezPath;
let mut s = session();
let side = s.execute("brush.get", &json!({"name":"Diamonds"})).unwrap()["side"].clone();
let cap = s.execute("brush.get", &json!({"name":"Chain"})).unwrap()["outer_corner"].clone();
s.execute("brush.new", &json!({"type":"pattern", "name":"Capped", "params":{"side":side, "start":cap, "end":cap}})).unwrap();
s.execute("brush.options", &json!({"name":"Capped", "params":{"start_tile":"none", "end_tile":"none"}})).unwrap();
let def = s.execute("brush.get", &json!({"name":"Capped"})).unwrap();
assert!(def.get("start").is_none() && def.get("end").is_none());
let brush: vectorcraft_brush::Brush = serde_json::from_value(def).unwrap();
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((100.0, 0.0));
let pieces = vectorcraft_brush::stroke_pieces(
&s.doc().unwrap().doc,
&brush,
&path,
&StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0),
);
let bounds = pieces.iter().fold(None, |bounds, node| vectorcraft_geom::union_opt(bounds, node.geometric_bounds())).unwrap();
assert!((bounds.x0 - 0.0).abs() < 1e-6 && (bounds.x1 - 100.0).abs() < 1e-6, "{bounds:?}");
}
// ---------- symbols ----------
fn make_symbol(s: &mut Session) -> (NodeId, String, Rect) {
+153 -31
View File
@@ -26,7 +26,7 @@ use egui::{Color32, Sense, Stroke, pos2, vec2};
use serde_json::{Map, Value, json};
use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Colorization, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, PatternDef};
use super::brush_options::{clamped, mode_key, stroke_preview};
use crate::VectorcraftApp;
@@ -44,6 +44,55 @@ const DIRECTIONS: [Direction; 4] = [Direction::LeftToRight, Direction::RightToLe
const TILES: [(&str, &str); 5] =
[("outer_corner", "outerCorner"), ("side", "side"), ("inner_corner", "innerCorner"), ("start", "start"), ("end", "end")];
fn tile_key(field: &str) -> String {
match field {
"outer_corner" => "outerCornerTile".into(),
"inner_corner" => "innerCornerTile".into(),
_ => format!("{field}Tile"),
}
}
pub(super) fn pattern_names(d: &Dialog) -> Vec<String> {
d.fields
.get("__patterns")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|v| v.get("name").and_then(Value::as_str).map(str::to_string))
.collect()
}
fn pattern_choice(d: &Dialog, choice: &str) -> Option<String> {
let name = choice.strip_prefix("pattern:").unwrap_or(choice);
let reserved = choice == "none" || choice == "original" || CornerSource::parse(choice).is_some();
(!reserved && pattern_names(d).iter().any(|candidate| candidate == name)).then(|| name.to_string())
}
pub(super) fn original_available(d: &Dialog, field: &str) -> bool {
let choice = d.str(&tile_key(field));
let drawn = if field.ends_with("corner") { corner_art_field(d.fields.get("__def"), field) } else { Some(field) };
drawn.is_some() && pattern_choice(d, &choice).is_none()
}
pub(super) fn reconcile_pattern_choices(f: &mut Map<String, Value>) {
let names: Vec<String> = f
.get("__patterns")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|v| v.get("name").and_then(Value::as_str).map(str::to_string))
.collect();
let def = f.get("__def").cloned().unwrap_or_default();
for (art, _) in TILES {
let swatch = def.get(format!("{art}_swatch")).and_then(Value::as_str);
if let Some(name) = swatch.filter(|name| names.iter().any(|n| n == name)) {
f.insert(tile_key(art), json!(format!("pattern:{name}")));
} else if !art.ends_with("corner") {
f.insert(tile_key(art), json!(if def.get(art).is_some_and(|v| !v.is_null()) { "original" } else { "none" }));
}
}
}
/// Pattern brush corner tiles: (dialog field, art field of the definition, source field, art field
/// of the other kind of corner).
const CORNER_TILES: [(&str, &str, &str, &str); 2] =
@@ -63,20 +112,20 @@ fn corner_art_field<'a>(def: Option<&Value>, field: &'a str) -> Option<&'a str>
}
}
/// The dialog field that records that corner tile `key`'s menu was set.
/// The dialog field that records that tile `key`'s menu was set.
fn corner_set_key(key: &str) -> String {
format!("__{key}Set")
}
/// Sets corner tile `key` to `choice` from its menu.
/// Sets tile `key` to `choice` from its menu.
pub(super) fn choose_corner(d: &mut Dialog, key: &str, choice: &str) {
d.fields.insert(key.into(), json!(choice));
note_set(d, key);
}
/// Records that field `field` was set, from its menu or `ui.dialog.set`, when it is a corner tile.
/// Records that a tile field was set from its menu or `ui.dialog.set`.
pub(super) fn note_set(d: &mut Dialog, field: &str) {
if CORNER_TILES.iter().any(|(key, ..)| *key == field) {
if CORNER_TILES.iter().any(|(key, ..)| *key == field) || TILES.iter().any(|(art, _)| tile_key(art) == field) {
d.fields.insert(corner_set_key(field), json!(true));
}
}
@@ -259,18 +308,34 @@ pub(super) fn params(d: &Dialog) -> Value {
let def = d.fields.get("__def");
for (key, art, source, other) in CORNER_TILES {
let choice = d.str(key);
if let Some(name) = pattern_choice(d, &choice) {
if d.bool(&corner_set_key(key)) {
p[format!("{art}_tile")] = json!({"pattern": name});
}
continue;
}
let unset = |f: &str| def.and_then(|d| d.get(f)).is_none_or(Value::is_null);
if choice == CornerSource::None.id() && !d.bool(&corner_set_key(key)) && unset(source) && corner_art_field(def, art).is_none() {
continue;
}
if let Some(s) = CornerSource::parse(&choice) {
p[art] = Value::Null;
p[source] = json!(s.id());
p[format!("{art}_tile")] = json!(s.id());
} else if choice == "original"
&& let Some(from) = corner_art_field(def, art).filter(|f| *f != art)
&& CORNER_TILES.iter().find(|(_, a, _, _)| *a == other).is_some_and(|(k, ..)| d.str(k) != "original")
{
p[art] = def.and_then(|d| d.get(from)).cloned().unwrap_or_default();
p[format!("{art}_frame")] = def.and_then(|d| d.get(format!("{from}_frame"))).cloned().unwrap_or(Value::Null);
}
if choice == "original" && d.bool(&corner_set_key(key)) {
p[format!("{art}_tile")] = json!("original");
}
}
for art in ["side", "start", "end"] {
let key = tile_key(art);
if d.bool(&corner_set_key(&key)) {
let choice = d.str(&key);
p[format!("{art}_tile")] = pattern_choice(d, &choice).map_or_else(|| json!(choice), |name| json!({"pattern": name}));
}
}
p
@@ -302,6 +367,35 @@ pub(super) fn edited_def(d: &Dialog) -> Value {
if let (Some(o), Value::Object(p)) = (def.as_object_mut(), params(d)) {
o.extend(p);
}
if d.str("__type") == "pattern" {
for (art, _) in TILES {
let key = tile_key(art);
if !d.bool(&corner_set_key(&key)) {
continue;
}
let choice = d.str(&key);
if choice == "none" || (art.ends_with("corner") && CornerSource::parse(&choice).is_some()) {
def[art] = Value::Null;
def[format!("{art}_frame")] = Value::Null;
def[format!("{art}_swatch")] = Value::Null;
if art.ends_with("corner") {
def[if art == "outer_corner" { "outer_source" } else { "inner_source" }] = json!(choice);
}
} else if choice == "original" {
def[format!("{art}_swatch")] = Value::Null;
} else if let Some(name) = pattern_choice(d, &choice)
&& let Some(pattern) = d.fields.get("__patterns").and_then(Value::as_array).and_then(|a| a.iter().find(|p| p["name"] == name))
&& let Ok(pattern) = serde_json::from_value::<PatternDef>(pattern.clone())
{
def[art] = serde_json::to_value(Node::new(NodeId(0), NodeKind::Group { children: pattern.art, clip: false })).unwrap_or_default();
def[format!("{art}_frame")] = json!(pattern.tile);
def[format!("{art}_swatch")] = json!(name);
if art.ends_with("corner") {
def[if art == "outer_corner" { "outer_source" } else { "inner_source" }] = Value::Null;
}
}
}
}
def
}
@@ -447,12 +541,12 @@ fn pattern_body(ui: &mut egui::Ui, d: &mut Dialog) {
radios(ui, d, "fit", &fits);
}
/// What a corner tile can be: (choice, label). Original (the corner art the brush draws there) is
/// offered only when the brush draws corner art there.
/// Built-in corner choices. Original is disabled when the slot has no retained art or a listed
/// swatch was selected.
fn corner_choices() -> [(&'static str, &'static str); 6] {
[
("original", tl!("Original")),
(CornerSource::None.id(), tl!("None")),
("original", tl!("Original")),
(CornerSource::AutoCentered.id(), tl!("Auto-Centered")),
(CornerSource::AutoBetween.id(), tl!("Auto-Between")),
(CornerSource::AutoSliced.id(), tl!("Auto-Sliced")),
@@ -460,31 +554,47 @@ fn corner_choices() -> [(&'static str, &'static str); 6] {
]
}
/// The pattern's five tiles, each with its art (or None when the brush has no such tile). Below
/// each corner tile, a menu sets it to the brush's art, None or a tile made from the side tile.
pub(super) fn tile_choices(d: &Dialog, corner: bool) -> Vec<(String, String)> {
let mut choices: Vec<(String, String)> = if corner {
corner_choices().into_iter().map(|(id, label)| (id.into(), label.into())).collect()
} else {
vec![("none".into(), tl!("None").into()), ("original".into(), tl!("Original").into())]
};
choices.extend(pattern_names(d).into_iter().map(|name| (format!("pattern:{name}"), name)));
choices
}
/// The pattern's five tiles, each with its art (or None when the brush has no such tile). A menu
/// below each preview selects retained art, no art, or a document pattern; corners also offer
/// generated choices.
fn tiles(ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
let names = [tl!("Outer Corner Tile"), tl!("Side Tile"), tl!("Inner Corner Tile"), tl!("Start Tile"), tl!("End Tile")];
let keys = d.fields.get("__tiles").cloned();
let preview_def = edited_def(d);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for ((field, slot), name) in TILES.into_iter().zip(names) {
for ((field, _slot), name) in TILES.into_iter().zip(names) {
let corner = CORNER_TILES.iter().find(|(_, art, _, _)| *art == field).map(|(key, ..)| *key);
// The art the tile draws with: its own, or a corner's that falls back on the other's.
let drawn = if corner.is_some() { corner_art_field(d.fields.get("__def"), field) } else { Some(field) };
let drawn_slot = drawn.and_then(|f| TILES.iter().find(|(tf, _)| *tf == f)).map_or(slot, |(_, s)| *s);
let choices: Vec<(&str, &str)> = corner_choices().into_iter().filter(|(c, _)| drawn.is_some() || *c != "original").collect();
let choice = corner.map(|key| d.str(key));
let drawn = if corner.is_some() { corner_art_field(Some(&preview_def), field) } else { Some(field) };
let choice = d.str(&tile_key(field));
let original_enabled = original_available(d, field);
let choices = tile_choices(d, corner.is_some());
// The label of the corner's choice when it shows no art.
let shown = choice.as_deref().filter(|c| *c != "original");
let label = shown.and_then(|c| choices.iter().find(|(id, _)| *id == c)).map_or(tl!("None"), |(_, l)| *l);
let shown = (choice != "original" && pattern_choice(d, &choice).is_none()).then_some(choice.as_str());
let label = shown.and_then(|c| choices.iter().find(|(id, _)| id == c)).map_or(tl!("None"), |(_, l)| l.as_str());
ui.vertical(|ui| {
let (r, resp) = ui.allocate_exact_size(vec2(76.0, 44.0), Sense::hover());
ui.painter().rect_filled(r, 0.0, Color32::WHITE);
let key = keys.as_ref().filter(|_| shown.is_none()).and_then(|k| k.get(drawn_slot)).and_then(Value::as_str);
let tex = key.and_then(|key| {
let art_value = drawn.and_then(|f| preview_def.get(f)).filter(|_| shown.is_none());
let key = art_value.map(|art| {
let mut h = std::collections::hash_map::DefaultHasher::new();
art.to_string().hash(&mut h);
format!("brush-tile:{:x}", h.finish())
});
let tex = key.as_deref().and_then(|key| {
widgets::doc_preview(ui, key, r.size(), |w, h| {
let art: Node = serde_json::from_value(d.fields.get("__def")?.get(drawn?)?.clone()).ok()?;
let art: Node = serde_json::from_value(art_value?.clone()).ok()?;
tile_doc(&art, w, h)
})
});
@@ -499,14 +609,26 @@ fn tiles(ui: &mut egui::Ui, d: &mut Dialog) {
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
resp.on_hover_text(name);
ui.add_sized(vec2(76.0, 14.0), egui::Label::new(egui::RichText::new(name).size(10.5).color(t.text_dim)).truncate());
if let (Some(key), Some(cur)) = (corner, &choice) {
let current = choices.iter().find(|(id, _)| id == cur).map_or(tl!("None"), |(_, l)| *l);
let labels: Vec<&str> = choices.iter().map(|(_, l)| *l).collect();
if let Some((id, _)) =
widgets::dropdown_named(ui, ("brush-pattern-corner", key), Some(name), current, &labels, 76.0).and_then(|i| choices.get(i))
{
choose_corner(d, key, id);
}
let current = choices.iter().find(|(id, _)| id == &choice).map_or(tl!("None"), |(_, l)| l.as_str());
if let Some(id) = widgets::combo_named(ui, ("brush-pattern-tile", field), Some(name), current, 76.0, |ui| {
choices.iter().enumerate().find_map(|(i, (id, label))| {
ui.add_enabled(id != "original" || original_enabled, egui::Button::selectable(id == &choice, label.as_str()))
.clicked()
.then_some(i)
})
})
.and_then(|i| choices.get(i).map(|(id, _)| id.clone()))
{
let key = corner.unwrap_or_else(|| {
if field == "side" {
"sideTile"
} else if field == "start" {
"startTile"
} else {
"endTile"
}
});
choose_corner(d, key, &id);
}
});
}
+37 -1
View File
@@ -97,6 +97,10 @@ pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result<Value, Strin
let b: Brush = serde_json::from_value(def.clone()).map_err(|e| e.to_string())?;
let mut f = fields(&b, def)?;
f.insert("__brush".into(), json!(b.name));
if let Some(st) = app.session.active() {
f.insert("__patterns".into(), serde_json::to_value(&st.doc.patterns).unwrap_or_default());
}
brush_art_options::reconcile_pattern_choices(&mut f);
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields: f });
Ok(Value::Null)
}
@@ -149,6 +153,7 @@ fn target(app: &VectorcraftApp) -> Option<String> {
/// opens.
pub(super) fn open_new(app: &mut VectorcraftApp, ty: &str, ids: Option<Value>) -> Result<Value, String> {
let st = app.session.active().ok_or("no document open")?;
let patterns = serde_json::to_value(&st.doc.patterns).unwrap_or_default();
let title: String = ty.chars().take(1).flat_map(char::to_uppercase).chain(ty.chars().skip(1)).collect();
let name = vectorcraft_brush::unique_name(&vectorcraft_brush::library(&st.doc), &format!("New {title} Brush"));
let mut def = json!({ "name": name, "type": ty });
@@ -169,6 +174,8 @@ pub(super) fn open_new(app: &mut VectorcraftApp, ty: &str, ids: Option<Value>) -
b.sanitize();
let def = serde_json::to_value(&b).map_err(|e| e.to_string())?;
let mut f = fields(&b, def)?;
f.insert("__patterns".into(), patterns);
brush_art_options::reconcile_pattern_choices(&mut f);
f.insert(CREATE.into(), json!(true));
if let Some(ids) = ids {
f.insert(IDS.into(), ids);
@@ -183,9 +190,38 @@ pub(super) fn open_new(app: &mut VectorcraftApp, ty: &str, ids: Option<Value>) -
/// dialog closes once the brush is made.
fn create(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut brush_params = params(d);
if d.str("__type") == "pattern" {
if d.str("sideTile") == "none" {
return Err("You must choose a side pattern.".into());
}
let preview = brush_art_options::edited_def(d);
for key in [
"side",
"side_frame",
"side_swatch",
"outer_corner",
"outer_corner_frame",
"outer_corner_swatch",
"outer_source",
"inner_corner",
"inner_corner_frame",
"inner_corner_swatch",
"inner_source",
"start",
"start_frame",
"start_swatch",
"end",
"end_frame",
"end_swatch",
] {
if let Some(value) = preview.get(key) {
brush_params[key] = value.clone();
}
}
}
if let Some(def) = d.fields.get("__def") {
for key in ["art", "side"] {
if let Some(art) = def.get(key).filter(|v| !v.is_null()) {
if let Some(art) = def.get(key).filter(|v| !v.is_null() && brush_params.get(key).is_none_or(Value::is_null)) {
brush_params[key] = art.clone();
}
}
@@ -458,7 +458,7 @@ fn pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile()
assert!(roles.contains(&egui::accesskit::Role::ComboBox), "a menu named {name}: {roles:?}");
}
for name in ["Side Tile", "Start Tile", "End Tile"] {
assert!(!shown_tree.iter().any(|n| n.label() == Some(name) && n.role() == egui::accesskit::Role::ComboBox), "no menu for {name}");
assert!(shown_tree.iter().any(|n| n.label() == Some(name) && n.role() == egui::accesskit::Role::ComboBox), "a menu for {name}");
}
set(&mut app, json!({"outerCornerTile": "autoBetween", "innerCornerTile": "autoOverlap"}));
let text = shown(&mut app);
@@ -492,6 +492,98 @@ fn pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile()
assert_eq!(app.ui.dialog.as_ref().unwrap().str("outerCornerTile"), "autoSliced");
}
#[test]
fn pattern_tile_menus_use_document_patterns_and_side_none_keeps_dialog_open() {
use std::sync::Arc;
use vectorcraft_doc::{Node, PatternDef};
use vectorcraft_geom::Rect;
let mut app = app();
app.session
.edit("Pattern", |doc, _| {
let art = Node::path(
doc.alloc_id(),
vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 4.0)),
vectorcraft_doc::Appearance::default_art(),
);
doc.patterns.push(PatternDef { tile: Rect::new(0.0, 0.0, 10.0, 4.0), ..PatternDef::new("Wide", vec![Arc::new(art)]) });
Ok(())
})
.unwrap();
let before = app.run("brush.get", json!({"name":"Diamonds"})).unwrap();
app.run("ui.brushOptions", json!({"name":"Diamonds"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(crate::dialogs::brush_art_options::pattern_names(d), vec!["Wide".to_string()]);
for key in ["sideTile", "outerCornerTile", "innerCornerTile", "startTile", "endTile"] {
assert!(d.fields.contains_key(key), "{key}");
}
for corner in [false, true] {
let ids: Vec<_> = crate::dialogs::brush_art_options::tile_choices(d, corner).into_iter().map(|(id, _)| id).collect();
assert!(ids.contains(&"pattern:Wide".to_string()));
if corner {
for auto in ["autoCentered", "autoBetween", "autoSliced", "autoOverlap"] {
assert!(ids.contains(&auto.to_string()));
}
} else {
assert_eq!(ids.first().map(String::as_str), Some("none"));
assert_eq!(ids.get(1).map(String::as_str), Some("original"));
}
}
let d = app.ui.dialog.as_mut().unwrap();
crate::dialogs::brush_art_options::choose_corner(d, "sideTile", "Wide");
assert!(!crate::dialogs::brush_art_options::original_available(d, "side"));
let preview = crate::dialogs::brush_art_options::edited_def(d);
assert_eq!(preview["side_frame"], json!(Rect::new(0.0, 0.0, 10.0, 4.0)));
assert_ne!(preview["side"], before["side"]);
super::super::confirm(&mut app).unwrap();
app.session
.edit("Delete pattern", |doc, _| {
doc.patterns.clear();
Ok(())
})
.unwrap();
app.run("ui.brushOptions", json!({"name":"Diamonds"})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("sideTile"), "original");
let saved = app.run("brush.get", json!({"name":"Diamonds"})).unwrap();
let undo = app.session.doc().unwrap().history.undo.len();
let d = app.ui.dialog.as_mut().unwrap();
crate::dialogs::brush_art_options::choose_corner(d, "sideTile", "none");
let error = super::super::confirm(&mut app).unwrap_err();
assert!(error.contains("You must choose a side pattern."), "{error}");
assert!(app.ui.dialog.is_some());
assert_eq!(app.run("brush.get", json!({"name":"Diamonds"})).unwrap(), saved);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo);
}
#[test]
fn new_pattern_brush_keeps_a_tile_swatch_choice() {
use std::sync::Arc;
use vectorcraft_doc::{Node, PatternDef};
use vectorcraft_geom::Rect;
let mut app = app();
app.run("shape.rectangle", json!({"x":0,"y":0,"width":20,"height":4})).unwrap();
app.session
.edit("Pattern", |doc, _| {
let art = Node::path(
doc.alloc_id(),
vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 4.0)),
vectorcraft_doc::Appearance::default_art(),
);
doc.patterns.push(PatternDef { tile: Rect::new(0.0, 0.0, 10.0, 4.0), ..PatternDef::new("Wide", vec![Arc::new(art)]) });
Ok(())
})
.unwrap();
super::open_new(&mut app, "pattern", None).unwrap();
let name = app.ui.dialog.as_ref().unwrap().str("name");
let d = app.ui.dialog.as_mut().unwrap();
crate::dialogs::brush_art_options::choose_corner(d, "sideTile", "Wide");
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name":name})).unwrap();
assert_eq!(def["side_swatch"], "Wide");
assert_eq!(def["side_frame"], json!(Rect::new(0.0, 0.0, 10.0, 4.0)));
}
/// A pattern brush with art for one kind of corner draws it at both kinds. Its other corner tile
/// shows that art as Original, and OK keeps it there when the first corner tile changes.
#[test]
@@ -509,6 +601,115 @@ fn pattern_options_keep_the_corner_art_a_corner_borrows() {
assert_eq!(def["outer_source"], json!("none"));
}
#[test]
fn pattern_options_preview_and_save_borrowed_corner_before_clearing_source() {
use vectorcraft_geom::Rect;
let mut app = app();
let chain = app.run("brush.get", json!({"name": "Chain"})).unwrap();
let art = chain["outer_corner"].clone();
let frame = json!(Rect::new(1.0, 2.0, 15.0, 18.0));
for (name, source, borrowed) in [("Borrow Outer", "inner", "outer"), ("Borrow Inner", "outer", "inner")] {
let source_art = format!("{source}_corner");
let source_frame = format!("{source_art}_frame");
let borrowed_art = format!("{borrowed}_corner");
let borrowed_frame = format!("{borrowed_art}_frame");
let source_tile = format!("{source}CornerTile");
let borrowed_tile = format!("{borrowed}CornerTile");
app.run("brush.new", json!({"type":"pattern", "name":name, "params":{"side":chain["side"], source_art:art, source_frame:frame}})).unwrap();
app.run("ui.brushOptions", json!({"name":name})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
crate::dialogs::brush_art_options::choose_corner(d, &source_tile, "none");
crate::dialogs::brush_art_options::choose_corner(d, &borrowed_tile, "original");
let preview = crate::dialogs::brush_art_options::edited_def(d);
assert_eq!(preview[&borrowed_art], chain["outer_corner"], "{borrowed} preview art");
assert_eq!(preview[&borrowed_frame], frame, "{borrowed} preview frame");
super::super::confirm(&mut app).unwrap();
let saved = app.run("brush.get", json!({"name":name})).unwrap();
assert_eq!(saved[&borrowed_art], chain["outer_corner"], "{borrowed} saved art");
assert_eq!(saved[&borrowed_frame], frame, "{borrowed} saved frame");
}
}
#[test]
fn pattern_options_borrowed_swatch_corner_keeps_its_frame() {
use std::sync::Arc;
use vectorcraft_doc::{Node, PatternDef};
use vectorcraft_geom::Rect;
let mut app = app();
app.session
.edit("Pattern", |doc, _| {
let art = Node::path(
doc.alloc_id(),
vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 4.0)),
vectorcraft_doc::Appearance::default_art(),
);
doc.patterns.push(PatternDef { tile: Rect::new(0.0, 0.0, 10.0, 4.0), ..PatternDef::new("Corner", vec![Arc::new(art)]) });
Ok(())
})
.unwrap();
let side = app.run("brush.get", json!({"name":"Diamonds"})).unwrap()["side"].clone();
app.run("brush.new", json!({"type":"pattern", "name":"Borrow", "params":{"side":side}})).unwrap();
app.run("brush.options", json!({"name":"Borrow", "params":{"outer_corner_tile":{"pattern":"Corner"}}})).unwrap();
let before = app.run("brush.get", json!({"name":"Borrow"})).unwrap();
app.run("ui.brushOptions", json!({"name":"Borrow"})).unwrap();
set(&mut app, json!({"outerCornerTile":"none"}));
super::super::confirm(&mut app).unwrap();
let after = app.run("brush.get", json!({"name":"Borrow"})).unwrap();
assert_eq!(after["inner_corner"], before["outer_corner"]);
assert_eq!(after["inner_corner_frame"], before["outer_corner_frame"]);
assert_eq!(after["outer_source"], json!("none"));
}
#[test]
fn pattern_options_selects_swatches_with_reserved_names() {
use std::sync::Arc;
use vectorcraft_doc::{Node, PatternDef};
use vectorcraft_geom::Rect;
let mut app = app();
let names = ["none", "original", "autoCentered", "autoBetween", "autoSliced", "autoOverlap"];
app.session
.edit("Patterns", |doc, _| {
for name in names {
let art = Node::path(
doc.alloc_id(),
vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 4.0)),
vectorcraft_doc::Appearance::default_art(),
);
doc.patterns.push(PatternDef { tile: Rect::new(0.0, 0.0, 10.0, 4.0), ..PatternDef::new(name, vec![Arc::new(art)]) });
}
Ok(())
})
.unwrap();
app.run("ui.brushOptions", json!({"name":"Diamonds"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
for name in names {
let choice = (format!("pattern:{name}"), name.to_string());
assert!(crate::dialogs::brush_art_options::tile_choices(d, true).contains(&choice));
}
for (field, name) in [
("sideTile", "none"),
("outerCornerTile", "original"),
("innerCornerTile", "autoCentered"),
("startTile", "autoBetween"),
("endTile", "autoSliced"),
] {
crate::dialogs::brush_art_options::choose_corner(app.ui.dialog.as_mut().unwrap(), field, &format!("pattern:{name}"));
}
let preview = crate::dialogs::brush_art_options::edited_def(app.ui.dialog.as_ref().unwrap());
assert_eq!(preview["side_swatch"], "none");
super::super::confirm(&mut app).unwrap();
let saved = app.run("brush.get", json!({"name":"Diamonds"})).unwrap();
for (art, name) in
[("side", "none"), ("outer_corner", "original"), ("inner_corner", "autoCentered"), ("start", "autoBetween"), ("end", "autoSliced")]
{
assert_eq!(saved[format!("{art}_swatch")], name);
assert_eq!(saved[format!("{art}_frame")], json!(Rect::new(0.0, 0.0, 10.0, 4.0)));
}
}
/// OK leaves the corner fields of a corner tile the menus were not set to as they were: a corner
/// without art keeps borrowing the other kind's art when it gets some, and a corner that borrows
/// art still borrows it. A None picked from the menu is saved, after another choice too.
+3
View File
@@ -5899,6 +5899,9 @@ breakText Glyphs Глифи
Gap 4 Разстояние 4
Dash 5 Тире 5
Gap 5 Разстояние 5
@msg You must choose a side pattern. Трябва да изберете страничен шаблон.
@msg invalid pattern tile choice невалиден избор на плочка за шаблон
@msg no pattern named `{choice}` няма шаблон с име `{choice}`
Delete the selected brushes? Да се изтрият избраните четки?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Щрихи използват една или повече от тези четки. Да се премахнат ли четките от тези щрихи и да се изтрият четките?
Remove Strokes Премахване на щрихи
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5931,6 +5931,9 @@ shape Open Otvoren
@msg `follow` must be "average", "a" or "b" `follow` mora biti "average", "a" ili "b"
@msg the paths have nothing to merge there putanje tu nemaju šta spojiti
@msg the path is locked or hidden putanja je zaključana ili skrivena
@msg You must choose a side pattern. Morate odabrati bočni uzorak.
@msg invalid pattern tile choice nevažeći izbor pločice uzorka
@msg no pattern named `{choice}` nema uzorka pod nazivom `{choice}`
Delete the selected brushes? Izbrisati odabrane četkice?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Potezi koriste jednu ili više ovih četkica. Ukloniti četkice s tih poteza i izbrisati četkice?
Remove Strokes Ukloni poteze
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5999,6 +5999,9 @@ breakText Glyphs Glifs
Gap 4 Separació 4
Dash 5 Guió 5
Gap 5 Separació 5
@msg You must choose a side pattern. Heu de triar un patró lateral.
@msg invalid pattern tile choice opció de mosaic de patró no vàlida
@msg no pattern named `{choice}` no hi ha cap patró anomenat `{choice}`
Delete the selected brushes? Voleu suprimir els pinzells seleccionats?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Hi ha traços que fan servir un o més d’aquests pinzells. Voleu treure els pinzells d’aquests traços i suprimir els pinzells?
Remove Strokes Elimina els traços
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6008,6 +6008,9 @@ breakText Glyphs Glyfy
Gap 4 Mezera 4
Dash 5 Čárka 5
Gap 5 Mezera 5
@msg You must choose a side pattern. Musíte zvolit boční vzorek.
@msg invalid pattern tile choice neplatná volba dlaždice vzorku
@msg no pattern named `{choice}` vzorek s názvem `{choice}` neexistuje
Delete the selected brushes? Odstranit vybrané štětce?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Tahy používají jeden nebo více z těchto štětců. Odebrat štětce z těchto tahů a odstranit štětce?
Remove Strokes Odebrat tahy
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5913,6 +5913,9 @@ breakText Glyphs Glyffer
Gap 4 Mellemrum 4
Dash 5 Streg 5
Gap 5 Mellemrum 5
@msg You must choose a side pattern. Du skal vælge et sidemønster.
@msg invalid pattern tile choice ugyldigt valg af mønsterflise
@msg no pattern named `{choice}` intet mønster med navnet `{choice}`
Delete the selected brushes? Slet de valgte pensler?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Streger bruger en eller flere af disse pensler. Fjern penslerne fra stregerne, og slet penslerne?
Remove Strokes Fjern streger
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5594,6 +5594,9 @@ breakText Glyphs Glyphen
Gap 4 Lücke 4
Dash 5 Strich 5
Gap 5 Lücke 5
@msg You must choose a side pattern. Sie müssen ein Seitenmuster auswählen.
@msg invalid pattern tile choice ungültige Auswahl einer Musterkachel
@msg no pattern named `{choice}` kein Muster mit dem Namen `{choice}`
Delete the selected brushes? Ausgewählte Pinsel löschen?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturen verwenden einen oder mehrere dieser Pinsel. Pinsel von diesen Konturen entfernen und die Pinsel löschen?
Remove Strokes Konturen entfernen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5961,6 +5961,9 @@ breakText Glyphs Γλύφοι
Gap 4 Διάκενο 4
Dash 5 Παύλα 5
Gap 5 Διάκενο 5
@msg You must choose a side pattern. Πρέπει να επιλέξετε πλευρικό μοτίβο.
@msg invalid pattern tile choice μη έγκυρη επιλογή πλακιδίου μοτίβου
@msg no pattern named `{choice}` δεν υπάρχει μοτίβο με όνομα `{choice}`
Delete the selected brushes? Να διαγραφούν τα επιλεγμένα πινέλα;
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Περιγράμματα χρησιμοποιούν ένα ή περισσότερα από αυτά τα πινέλα. Να αφαιρεθούν τα πινέλα από αυτά τα περιγράμματα και να διαγραφούν τα πινέλα;
Remove Strokes Αφαίρεση περιγραμμάτων
Can't render this file because it contains an unexpected character in line 164 and column 5.
+3
View File
@@ -5951,6 +5951,9 @@ breakText Glyphs Glifos
Gap 4 Hueco 4
Dash 5 Guion 5
Gap 5 Hueco 5
@msg You must choose a side pattern. Debe elegir un motivo lateral.
@msg invalid pattern tile choice selección de mosaico de motivo no válida
@msg no pattern named `{choice}` no hay ningún motivo llamado `{choice}`
Delete the selected brushes? ¿Eliminar los pinceles seleccionados?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Algunos trazos usan uno o más de estos pinceles. ¿Quitar los pinceles de esos trazos y eliminar los pinceles?
Remove Strokes Quitar trazos
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6039,6 +6039,9 @@ breakText Glyphs Merkit
Gap 4 Väli 4
Dash 5 Viiva 5
Gap 5 Väli 5
@msg You must choose a side pattern. Valitse sivukuvio.
@msg invalid pattern tile choice virheellinen kuviolaatan valinta
@msg no pattern named `{choice}` kuviota nimeltä `{choice}` ei löydy
Delete the selected brushes? Poistetaanko valitut siveltimet?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Viivat käyttävät yhtä tai useampaa näistä siveltimistä. Irrotetaanko siveltimet näistä viivoista ja poistetaanko siveltimet?
Remove Strokes Poista viivat
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6006,6 +6006,9 @@ breakText Glyphs Glyphes
Gap 4 Écart 4
Dash 5 Tiret 5
Gap 5 Écart 5
@msg You must choose a side pattern. Vous devez choisir un motif latéral.
@msg invalid pattern tile choice choix de tuile de motif non valide
@msg no pattern named `{choice}` aucun motif nommé `{choice}`
Delete the selected brushes? Supprimer les formes sélectionnées ?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Des contours utilisent une ou plusieurs de ces formes. Retirer les formes de ces contours et supprimer les formes ?
Remove Strokes Supprimer les contours
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5995,6 +5995,9 @@ breakText Glyphs Glifák
Gap 4 Hézag 4
Dash 5 Vonal 5
Gap 5 Hézag 5
@msg You must choose a side pattern. Válasszon oldalsó mintát.
@msg invalid pattern tile choice érvénytelen mintacsempe-választás
@msg no pattern named `{choice}` nincs `{choice}` nevű minta
Delete the selected brushes? Törlöd a kijelölt ecseteket?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Körvonalak használnak egyet vagy többet ezek közül az ecsetek közül. Leveszed az ecseteket ezekről a körvonalakról, és törlöd az ecseteket?
Remove Strokes Körvonalak eltávolítása
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5913,6 +5913,9 @@ breakText Glyphs Glif
Gap 4 Jarak 4
Dash 5 Garis 5
Gap 5 Jarak 5
@msg You must choose a side pattern. Anda harus memilih pola sisi.
@msg invalid pattern tile choice pilihan ubin pola tidak valid
@msg no pattern named `{choice}` tidak ada pola bernama `{choice}`
Delete the selected brushes? Hapus kuas yang dipilih?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Stroke menggunakan satu atau beberapa kuas ini. Lepaskan kuas dari stroke tersebut dan hapus kuasnya?
Remove Strokes Hapus Stroke
Can't render this file because it contains an unexpected character in line 2176 and column 5.
+3
View File
@@ -5628,6 +5628,9 @@ breakText Glyphs Glifi
Gap 4 Spazio 4
Dash 5 Tratto 5
Gap 5 Spazio 5
@msg You must choose a side pattern. Devi scegliere un motivo laterale.
@msg invalid pattern tile choice scelta della tessera motivo non valida
@msg no pattern named `{choice}` nessun motivo chiamato `{choice}`
Delete the selected brushes? Eliminare i pennelli selezionati?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Alcune tracce usano uno o più di questi pennelli. Rimuovere i pennelli da queste tracce ed eliminare i pennelli?
Remove Strokes Rimuovi tracce
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5694,6 +5694,9 @@ breakText Glyphs 字形
Gap 4 間隔 4
Dash 5 線分 5
Gap 5 間隔 5
@msg You must choose a side pattern. 側面パターンを選択してください。
@msg invalid pattern tile choice パターンタイルの選択が無効です
@msg no pattern named `{choice}` `{choice}` というパターンはありません
Delete the selected brushes? 選択したブラシを削除しますか?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? これらのブラシの 1 つ以上が線で使われています。それらの線からブラシを外して、ブラシを削除しますか?
Remove Strokes 線を削除
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5998,6 +5998,9 @@ breakText Glyphs გლიფები
Gap 4 შუალედი 4
Dash 5 ტირე 5
Gap 5 შუალედი 5
@msg You must choose a side pattern. აირჩიეთ გვერდითი ნიმუში.
@msg invalid pattern tile choice ნიმუშის ფილა არასწორად არის არჩეული
@msg no pattern named `{choice}` ნიმუში სახელით `{choice}` არ არსებობს
Delete the selected brushes? წაიშალოს არჩეული ფუნჯები?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? კონტურები იყენებს ამ ფუნჯებიდან ერთს ან მეტს. მოიხსნას ფუნჯები ამ კონტურებიდან და წაიშალოს ფუნჯები?
Remove Strokes კონტურების მოხსნა
Can't render this file because it is too large.
+3
View File
@@ -5591,6 +5591,9 @@ breakText Glyphs 글리프
Gap 4 간격 4
Dash 5 대시 5
Gap 5 간격 5
@msg You must choose a side pattern. 측면 패턴을 선택해야 합니다.
@msg invalid pattern tile choice 잘못된 패턴 타일 선택
@msg no pattern named `{choice}` `{choice}` 이름의 패턴이 없습니다
Delete the selected brushes? 선택한 브러시를 삭제하시겠습니까?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 선에서 이 브러시 중 하나 이상을 사용합니다. 해당 선에서 브러시를 제거하고 브러시를 삭제하시겠습니까?
Remove Strokes 선 제거
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5957,6 +5957,9 @@ breakText Glyphs Glyphs
Gap 4 Tussenruimte 4
Dash 5 Streepje 5
Gap 5 Tussenruimte 5
@msg You must choose a side pattern. Kies een zijpatroon.
@msg invalid pattern tile choice ongeldige keuze voor patroontegel
@msg no pattern named `{choice}` geen patroon met de naam `{choice}`
Delete the selected brushes? Geselecteerde kwasten verwijderen?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Contouren gebruiken een of meer van deze kwasten. De kwasten van deze contouren halen en de kwasten verwijderen?
Remove Strokes Contouren verwijderen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5864,6 +5864,9 @@ breakText Glyphs Glify
Gap 4 Odstęp 4
Dash 5 Kreska 5
Gap 5 Odstęp 5
@msg You must choose a side pattern. Musisz wybrać wzór boczny.
@msg invalid pattern tile choice nieprawidłowy wybór kafelka wzoru
@msg no pattern named `{choice}` nie ma wzoru o nazwie `{choice}`
Delete the selected brushes? Usunąć zaznaczone pędzle?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Obrysy używają co najmniej jednego z tych pędzli. Zdjąć pędzle z tych obrysów i usunąć pędzle?
Remove Strokes Usuń obrysy
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5928,6 +5928,9 @@ breakText Glyphs Glifos
Gap 4 Lacuna 4
Dash 5 Traço 5
Gap 5 Lacuna 5
@msg You must choose a side pattern. Você deve escolher um padrão lateral.
@msg invalid pattern tile choice escolha inválida de ladrilho de padrão
@msg no pattern named `{choice}` não há padrão chamado `{choice}`
Delete the selected brushes? Excluir os pincéis selecionados?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Traçados usam um ou mais destes pincéis. Remover os pincéis desses traçados e excluir os pincéis?
Remove Strokes Remover Traçados
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -5986,6 +5986,9 @@ breakText Glyphs Глифы
Gap 4 Зазор 4
Dash 5 Штрих 5
Gap 5 Зазор 5
@msg You must choose a side pattern. Выберите боковой узор.
@msg invalid pattern tile choice недопустимый выбор плитки узора
@msg no pattern named `{choice}` узор с именем `{choice}` не найден
Delete the selected brushes? Удалить выбранные кисти?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обводки используют одну или несколько из этих кистей. Убрать кисти с этих обводок и удалить кисти?
Remove Strokes Убрать обводки
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -6061,6 +6061,9 @@ breakText Glyphs Glyfer
Gap 4 Mellanrum 4
Dash 5 Streck 5
Gap 5 Mellanrum 5
@msg You must choose a side pattern. Du måste välja ett sidomönster.
@msg invalid pattern tile choice ogiltigt val av mönsterplatta
@msg no pattern named `{choice}` inget mönster med namnet `{choice}`
Delete the selected brushes? Radera de valda penslarna?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturer använder en eller flera av de här penslarna. Ta bort penslarna från konturerna och radera penslarna?
Remove Strokes Ta bort konturer
Can't render this file because it contains an unexpected character in line 5 and column 25.
+3
View File
@@ -5966,6 +5966,9 @@ breakText Glyphs Glifler
Gap 4 Boşluk 4
Dash 5 Çizgi 5
Gap 5 Boşluk 5
@msg You must choose a side pattern. Bir yan desen seçmelisiniz.
@msg invalid pattern tile choice geçersiz desen döşemesi seçimi
@msg no pattern named `{choice}` `{choice}` adlı desen yok
Delete the selected brushes? Seçili fırçalar silinsin mi?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturlar bu fırçalardan birini veya daha fazlasını kullanıyor. Fırçalar bu konturlardan kaldırılsın ve fırçalar silinsin mi?
Remove Strokes Konturları Kaldır
Can't render this file because it contains an unexpected character in line 5 and column 25.
+3
View File
@@ -5608,6 +5608,9 @@ breakText Glyphs Гліфи
Gap 4 Проміжок 4
Dash 5 Штрих 5
Gap 5 Проміжок 5
@msg You must choose a side pattern. Виберіть бічний візерунок.
@msg invalid pattern tile choice недійсний вибір плитки візерунка
@msg no pattern named `{choice}` візерунка з назвою `{choice}` немає
Delete the selected brushes? Видалити вибрані пензлі?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обведення використовують один або кілька з цих пензлів. Забрати пензлі з цих обведень і видалити пензлі?
Remove Strokes Забрати обведення
Can't render this file because it contains an unexpected character in line 178 and column 5.
+3
View File
@@ -5610,6 +5610,9 @@ breakText Glyphs Glyph
Gap 4 Khoảng hở 4
Dash 5 Nét 5
Gap 5 Khoảng hở 5
@msg You must choose a side pattern. Bạn phải chọn mẫu bên.
@msg invalid pattern tile choice lựa chọn ô mẫu không hợp lệ
@msg no pattern named `{choice}` không có mẫu tên `{choice}`
Delete the selected brushes? Xóa các cọ đã chọn?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Có nét đang dùng một hoặc nhiều cọ này. Gỡ cọ khỏi các nét đó và xóa cọ?
Remove Strokes Gỡ nét
Can't render this file because it contains an unexpected character in line 200 and column 5.
+3
View File
@@ -5850,6 +5850,9 @@ breakText Glyphs 字形
Gap 4 间隔 4
Dash 5 虚线 5
Gap 5 间隔 5
@msg You must choose a side pattern. 必须选择侧边图案。
@msg invalid pattern tile choice 图案拼块选项无效
@msg no pattern named `{choice}` 没有名为 `{choice}` 的图案
Delete the selected brushes? 删除选中的画笔吗?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有描边使用了其中一个或多个画笔。要从这些描边移除画笔并删除画笔吗?
Remove Strokes 移除描边
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3
View File
@@ -5647,6 +5647,9 @@ breakText Glyphs 字符
Gap 4 間隙 4
Dash 5 虛線 5
Gap 5 間隙 5
@msg You must choose a side pattern. 必須選擇側邊圖樣。
@msg invalid pattern tile choice 圖樣拼塊選項無效
@msg no pattern named `{choice}` 沒有名為 `{choice}` 的圖樣
Delete the selected brushes? 要刪除選取的筆刷嗎?
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有筆畫使用了其中一個或多個筆刷。要從這些筆畫移除筆刷並刪除筆刷嗎?
Remove Strokes 移除筆畫
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -40,7 +40,7 @@ pub use color::{
pub use drag::{PanelDrag, SwatchRows, art_drop, doc_preview, drag_source};
#[cfg(test)]
use dropdowns::combo_button_id;
pub(crate) use dropdowns::{combo, combo_menu};
pub(crate) use dropdowns::{combo, combo_menu, combo_named};
pub use dropdowns::{
dropdown, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll,
panel_scroll, popover, set_menu_floor, strip_scroll,
+3 -5
View File
@@ -536,13 +536,11 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
dragging one onto the Brushes panel adds it; Save Brushes / Save Brush Library writes the document's brushes
as a `.vectorcraft` library into the user library folder (listed under User Defined) or a file
(`brush.libraries`, `brush.addFromLibrary`, `brush.saveLibrary`, `brush.loadLibrary`).
- **Missing:** the Art brush's Overlap, auto-generated Pattern brush corners, new tile art in Pattern Brush
Options; more built-in brush, symbol and graphic-style libraries (original, generated in code, never
Adobe's); dynamic symbols; Global Edit of similar groups (edits to an object inside a group reaching the
matching objects of similar groups, [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)),
- **Missing:** the Art brush's Overlap; more built-in brush, symbol and graphic-style libraries (original,
generated in code, never Adobe's); dynamic symbols; Global Edit of similar groups (edits to an object inside a
group reaching the matching objects of similar groups, [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)),
rotated copies as matches, Shift-click to leave a match out and highlighting matches before the start;
drawing the 9-slice guides on the canvas in symbol edit mode and scaling by them.
Options; more built-in brush and symbol libraries (original, never Adobe's); dynamic symbols; Start Global Edit; drawing the 9-slice guides on the canvas in symbol edit mode and scaling by them.
- **Estimate:** 15–26 h.
## G3 3D and Materials
+6 -1
View File
@@ -1578,7 +1578,12 @@ already walks Image Trace. Short agent entry points:
what that kind of corner draws when the brush has no `outer_corner` or `inner_corner` art for it: `none` draws no
corner tile, and `autoCentered`, `autoBetween`, `autoSliced` and `autoOverlap` make one from the side tile. Without
a source, the corner draws the other kind of corner's art. `brush.options {params: {outer_corner: null}}` removes
the art.
the art. Pattern Brush Options also accepts `side_tile`, `outer_corner_tile`, `inner_corner_tile`,
`start_tile` and `end_tile` in `params`. Each takes `none`, `original` or the name of a pattern
swatch in the active document, and `{"pattern": "<name>"}` names a swatch even when its name is one of those
words. Corner tiles also take `autoCentered`, `autoBetween`, `autoSliced` and `autoOverlap`. A swatch choice copies its whole art and tile frame into the brush. `original`
keeps the current art and clears its swatch label. `side_tile: "none"` fails with "You must choose a
side pattern." An unknown swatch name also fails; neither error edits the brush or adds an Undo step.
- Variables: `variable.list`, `variable.define {name, kind: text|visibility|image|graph}`, `variable.bind {variable, ids?}`,
`variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic`,
`variable.unbind`, `variable.rename`, `variable.delete`, `variable.highlight` (image values are file paths, graph
+2 -1
View File
@@ -954,7 +954,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke.
- **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method (<https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html>) and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush).
Art Brush Options' Width (1% to 1000%) and Pattern Brush Options' Scale (1% to 10000%) each have a slider beside the field, over the same range in whole percents; screen readers name the slider as its row and read the field's value.
Art Brush Options' Width has a Fixed or Pressure menu, from the pen tablet options of the Width pop-up menu on the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy). With Pressure, the art is Width wide at the lightest pen pressure and the second width at the heaviest, following the pressure the Paintbrush recorded along the stroke, and half way between them on a stroke without recorded pressure (`brush.options` `width_mode` and `width_max`); a stroke inside the art takes the width at the middle of its object. The page does not say how the pressure maps to the width; taking the Scatter brush's minimum and maximum rule and the stroke rule are VectorCraft's own. Stylus Wheel, Tilt, Bearing and Rotation are not built.
@@ -968,6 +968,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`).
A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step.
In Pattern Brush Options, a menu below the Outer Corner Tile and the Inner Corner Tile sets each to the brush's own corner art (Original), None, or a tile made from the side tile: Auto-Centered stretches one side tile around the corner, Auto-Between bends a side tile on each side into the corner's diagonal, Auto-Sliced cuts a side tile on each side along the diagonal, and Auto-Overlap runs a side tile on each side past the corner. Auto-Sliced masks the tiles at the diagonal, so their outlines stay as drawn. Brushes saved without these choices draw their corners as before (`brush.options` `outer_source`, `inner_source`). Evidence: `tests::pattern_auto_corners_fill_each_corner_of_a_square`, `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), `dialogs::brush_options::tests::pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile` (vectorcraft-ui-egui).
- The [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) page describes up to five Pattern brush tiles: sides, inner corner, outer corner, beginning and end. The archived [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page says to add pattern tiles to the Swatches panel and select a swatch from the scroll list for each tile. Pattern Brush Options offers document pattern swatches in all five tile menus, including swatches named like built-in choices. The brush copies each swatch's whole art and tile frame. Side requires art; explicit None on a corner prevents fallback to the other corner's art. After a swatch is deleted, the brush keeps drawing its copied art and shows it as Original. The cited pages do not describe copying the whole art, requiring Side, suppressing corner fallback after explicit None, or showing deleted swatch art as Original. `brush.options` accepts swatch names and an object form for names that match built-in choices, and rejects unknown patterns before editing. Evidence: `tests::brushsym::pattern_tile_choices_copy_whole_swatch_art_and_reject_invalid_side` and `tests::brushsym::pattern_tile_object_selects_reserved_swatch_names` (vectorcraft-engine), `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), and `dialogs::brush_options::tests::pattern_tile_menus_use_document_patterns_and_side_none_keeps_dialog_open`, `dialogs::brush_options::tests::pattern_options_borrowed_swatch_corner_keeps_its_frame`, and `dialogs::brush_options::tests::pattern_options_selects_swatches_with_reserved_names` (vectorcraft-ui-egui).
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing.
An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds measures an image by its frame.