vectorcraft/crates/brush/src/lib.rs

755 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! VectorCraft brushes: definitions (calligraphic, scatter, art, pattern, bristle), the default
//! library, and the geometry that turns a brushed stroke into filled art.
//!
//! Brush definitions live in the document under `Document::unknown["brushes"]` (a JSON array of
//! [`Brush`]). A document without that key uses the default library ([`defaults`]); the first
//! edit to the library materialises the full list into the document. Strokes refer to brushes by
//! name (`StrokeLayer::brush`).
//!
//! [`stroke_pieces`] is the single source of brush geometry: the renderer paints the pieces it
//! returns (caching them per node) and `object.expandBrush` inserts them as real objects.
#![forbid(unsafe_code)]
mod calli;
mod colorize;
mod defaults;
pub mod track;
mod warp;
use std::collections::HashMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, Rect};
pub use colorize::{art_colors, colorize, instance_art, stain, tint_node};
pub use defaults::defaults;
pub use serde_json;
/// Key of the brush library in `Document::unknown`.
pub const DOC_KEY: &str = "brushes";
/// Key of the current brush name (used by the Paintbrush tool) in `Document::unknown`.
pub const CURRENT_KEY: &str = "currentBrush";
/// A named brush.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Brush {
pub name: String,
#[serde(flatten)]
pub kind: BrushKind,
}
// Brushes are few and long-lived; boxing the art-carrying variants buys nothing.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum BrushKind {
Calligraphic(Calligraphic),
Scatter(Scatter),
Art(ArtBrush),
Pattern(PatternBrush),
Bristle(Bristle),
}
impl BrushKind {
pub fn label(&self) -> &'static str {
match self {
BrushKind::Calligraphic(_) => "Calligraphic",
BrushKind::Scatter(_) => "Scatter",
BrushKind::Art(_) => "Art",
BrushKind::Pattern(_) => "Pattern",
BrushKind::Bristle(_) => "Bristle",
}
}
pub fn type_id(&self) -> &'static str {
match self {
BrushKind::Calligraphic(_) => "calligraphic",
BrushKind::Scatter(_) => "scatter",
BrushKind::Art(_) => "art",
BrushKind::Pattern(_) => "pattern",
BrushKind::Bristle(_) => "bristle",
}
}
/// How the brush's art takes the stroke colour (Calligraphic and Bristle brushes paint with it).
pub fn colorization(&self) -> Colorization {
match self {
BrushKind::Art(a) => a.colorization,
BrushKind::Scatter(s) => s.colorization,
BrushKind::Pattern(p) => p.colorization,
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => Colorization::None,
}
}
}
/// How brush art takes the stroke colour.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "method")]
pub enum Colorization {
/// Keep the art's own colours.
#[default]
None,
/// Black becomes the stroke colour, lighter colours become tints of it, white stays white.
Tints,
/// Black and white stay; mid tones become the stroke colour, darker ones shades of it.
TintsAndShades,
/// The key colour becomes the stroke colour; other colours rotate by the same hue offset.
HueShift { key: vectorcraft_color::Color },
}
impl Colorization {
pub const IDS: [&'static str; 4] = ["none", "tints", "tintsAndShades", "hueShift"];
/// The name brush definitions use (`method`).
pub fn id(&self) -> &'static str {
match self {
Colorization::None => "none",
Colorization::Tints => "tints",
Colorization::TintsAndShades => "tintsAndShades",
Colorization::HueShift { .. } => "hueShift",
}
}
/// The method named `id` (`key`: the key colour of Hue Shift).
pub fn parse(id: &str, key: vectorcraft_color::Color) -> Option<Self> {
Some(match id {
"none" => Colorization::None,
"tints" => Colorization::Tints,
"tintsAndShades" => Colorization::TintsAndShades,
"hueShift" => Colorization::HueShift { key },
_ => return None,
})
}
}
/// Calligraphic brush: an elliptical nib swept along the path. Sizes are at 1 pt stroke weight
/// (the stroke weight scales the brush).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Calligraphic {
/// Nib angle in degrees, counter-clockwise from horizontal.
pub angle: f64,
/// Minor/major axis ratio in percent (1..=100).
pub roundness: f64,
/// Nib diameter in points.
pub size: f64,
/// How far angle (°), roundness (%) and size (pt) vary each way, as [`Self::modes`] say.
pub variation: [f64; 3],
/// How angle, roundness and size vary. `None` (brushes saved before there were modes): Random
/// where the variation is non-zero, else Fixed — see [`Calligraphic::modes`].
#[serde(skip_serializing_if = "Option::is_none")]
pub modes: Option<[Variation; 3]>,
}
impl Default for Calligraphic {
fn default() -> Self {
Self { angle: 0.0, roundness: 100.0, size: 3.0, variation: [0.0; 3], modes: None }
}
}
impl Calligraphic {
/// The ranges of angle (°), roundness (%) and size (pt), and the most each varies.
pub const RANGES: [(f64, f64); 3] = [(-180.0, 180.0), (0.0, 100.0), (0.0, 1296.0)];
pub const MAX_VARIATION: [f64; 3] = [180.0, 100.0, 1296.0];
/// How angle, roundness and size vary.
pub fn modes(&self) -> [Variation; 3] {
self.modes.unwrap_or_else(|| self.variation.map(|v| if v != 0.0 { Variation::Random } else { Variation::Fixed }))
}
/// Does the pen's pressure change the nib?
pub fn uses_pressure(&self) -> bool {
self.modes().contains(&Variation::Pressure)
}
}
/// How a Calligraphic brush's angle, roundness or size varies (Brush Options). Saved by name: new
/// modes are appended, never renamed.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Variation {
/// Always the value.
#[default]
Fixed,
/// A random value within the variation either side, new for each stroke.
Random,
/// From the value less the variation at the lightest pen pressure to the value plus the
/// variation at the heaviest.
Pressure,
}
impl Variation {
pub const ALL: [Variation; 3] = [Variation::Fixed, Variation::Random, Variation::Pressure];
/// The name brush definitions use.
pub fn id(self) -> &'static str {
match self {
Variation::Fixed => "fixed",
Variation::Random => "random",
Variation::Pressure => "pressure",
}
}
pub fn label(self) -> &'static str {
match self {
Variation::Fixed => "Fixed",
Variation::Random => "Random",
Variation::Pressure => "Pressure",
}
}
pub fn parse(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|v| v.id() == id)
}
}
/// Scatter brush: copies of the art along the path.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Scatter {
pub art: Node,
/// Size range in percent of the art.
pub size: (f64, f64),
/// Spacing range in percent of the (scaled) art width.
pub spacing: (f64, f64),
/// Perpendicular offset range in percent of the (scaled) art height.
pub scatter: (f64, f64),
/// Rotation range in degrees.
pub rotation: (f64, f64),
/// Rotation relative to the path direction (else to the page).
pub rotation_relative_to_path: bool,
pub colorization: Colorization,
/// How size, spacing, scatter and rotation vary between their two values: Fixed takes the
/// first, Random any value between them, Pressure the first at the lightest pen pressure and
/// the second at the heaviest. `None` (brushes saved before there were modes): Random where
/// the two differ, else Fixed — see [`Scatter::modes`].
#[serde(skip_serializing_if = "Option::is_none")]
pub modes: Option<[Variation; 4]>,
}
impl Default for Scatter {
fn default() -> Self {
Self {
art: defaults::dot_art(),
size: (100.0, 100.0),
spacing: (100.0, 100.0),
scatter: (0.0, 0.0),
rotation: (0.0, 0.0),
rotation_relative_to_path: false,
colorization: Colorization::None,
modes: None,
}
}
}
impl Scatter {
/// The ranges of size (%), spacing (%), scatter (%) and rotation (°).
pub const RANGES: [(f64, f64); 4] = [(1.0, 10000.0), (1.0, 10000.0), (-1000.0, 1000.0), (-180.0, 180.0)];
/// Size, spacing, scatter and rotation, each as its (first, second) values.
pub fn values(&self) -> [(f64, f64); 4] {
[self.size, self.spacing, self.scatter, self.rotation]
}
/// How size, spacing, scatter and rotation vary.
pub fn modes(&self) -> [Variation; 4] {
self.modes.unwrap_or_else(|| self.values().map(|(a, b)| if a != b { Variation::Random } else { Variation::Fixed }))
}
}
/// Direction the art runs in the brush definition.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Direction {
#[default]
LeftToRight,
RightToLeft,
TopToBottom,
BottomToTop,
}
/// How art brush art is scaled along the path.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "mode")]
pub enum ArtScale {
/// Keep the art's proportions (length follows the path; width scales with it).
Proportional,
/// Stretch the art to the path length.
#[default]
Stretch,
/// Keep the ends outside the guides at their size and stretch the middle.
/// `start`/`end` are fractions (0..1) of the art length.
BetweenGuides { start: f64, end: f64 },
}
/// Art brush: one copy of the art stretched along the whole path.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ArtBrush {
pub art: Node,
pub direction: Direction,
pub scale: ArtScale,
/// Width in percent.
pub width: f64,
pub flip_along: bool,
pub flip_across: bool,
pub colorization: Colorization,
}
impl ArtBrush {
/// The range of the width (%).
pub const WIDTH_RANGE: (f64, f64) = (1.0, 1000.0);
}
impl Default for ArtBrush {
fn default() -> Self {
Self {
art: defaults::taper_art(),
direction: Direction::LeftToRight,
scale: ArtScale::Stretch,
width: 100.0,
flip_along: false,
flip_across: false,
colorization: Colorization::Tints,
}
}
}
/// How pattern tiles fit the path length.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PatternFit {
/// Stretch tiles so a whole number fits.
#[default]
Stretch,
/// Keep tile size and add space between tiles.
AddSpace,
/// Keep tile size; the path is approximated (tiles may overrun slightly).
Approximate,
}
/// Pattern brush: tiles repeated along the path, with corner and end tiles.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PatternBrush {
pub side: Node,
#[serde(skip_serializing_if = "Option::is_none")]
pub outer_corner: Option<Node>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inner_corner: Option<Node>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start: Option<Node>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<Node>,
/// Scale in percent.
pub scale: f64,
/// Space between tiles in percent of the tile width.
pub spacing: f64,
pub fit: PatternFit,
pub flip_along: bool,
pub flip_across: bool,
pub colorization: Colorization,
}
impl PatternBrush {
/// The ranges of the scale and of the spacing (%).
pub const SCALE_RANGE: (f64, f64) = (1.0, 10000.0);
pub const SPACING_RANGE: (f64, f64) = (0.0, 10000.0);
}
impl Default for PatternBrush {
fn default() -> Self {
Self {
side: defaults::stitch_tile(),
outer_corner: None,
inner_corner: None,
start: None,
end: None,
scale: 100.0,
spacing: 0.0,
fit: PatternFit::Stretch,
flip_along: false,
flip_across: false,
colorization: Colorization::Tints,
}
}
}
/// Bristle tip shapes (names only affect the strand distribution here).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BristleShape {
#[default]
RoundPoint,
FlatPoint,
RoundBlunt,
FlatBlunt,
RoundCurve,
FlatCurve,
RoundAngle,
FlatAngle,
RoundFan,
FlatFan,
}
/// Bristle brush, approximated by several thin, semi-transparent calligraphic strands.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Bristle {
pub shape: BristleShape,
/// Brush size in points.
pub size: f64,
/// Bristle length in percent (25..300) — longer bristles start/end more raggedly.
pub length: f64,
/// Density in percent (1..100) — number of strands.
pub density: f64,
/// Thickness in percent (1..100) — strand width.
pub thickness: f64,
/// Paint opacity in percent (1..100).
pub opacity: f64,
/// Stiffness in percent (1..100) — how straight strands follow the path.
pub stiffness: f64,
}
impl Bristle {
/// The ranges of the size (pt), the bristle length (%) and the other percentages.
pub const SIZE_RANGE: (f64, f64) = (0.1, 1296.0);
pub const LENGTH_RANGE: (f64, f64) = (25.0, 300.0);
pub const PERCENT_RANGE: (f64, f64) = (1.0, 100.0);
}
impl Default for Bristle {
fn default() -> Self {
Self { shape: BristleShape::RoundPoint, size: 6.0, length: 100.0, density: 50.0, thickness: 40.0, opacity: 75.0, stiffness: 50.0 }
}
}
// ---------- validation ----------
/// `v` within `(lo, hi)`; `fallback` (kept within too) when it isn't a number.
fn within(v: f64, (lo, hi): (f64, f64), fallback: f64) -> f64 {
if v.is_finite() { v.clamp(lo, hi) } else { fallback.clamp(lo, hi) }
}
fn within_pair((a, b): (f64, f64), range: (f64, f64), fallback: f64) -> (f64, f64) {
(within(a, range, fallback), within(b, range, fallback))
}
impl Colorization {
/// A Hue Shift key colour that isn't a colour (components out of reach) becomes black.
fn sanitize(&mut self) {
if let Colorization::HueShift { key } = self
&& !key.to_rgb().iter().all(|c| c.is_finite())
{
*key = vectorcraft_color::Color::BLACK;
}
}
}
impl Brush {
/// The art the brush is made of: a scatter or art brush's art, a pattern brush's tiles.
pub fn art(&self) -> Vec<&Node> {
match &self.kind {
BrushKind::Scatter(s) => vec![&s.art],
BrushKind::Art(a) => vec![&a.art],
BrushKind::Pattern(p) => {
std::iter::once(&p.side).chain([&p.outer_corner, &p.inner_corner, &p.start, &p.end].into_iter().flatten()).collect()
}
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![],
}
}
/// Keep every value of the definition within its range (definitions come from files and
/// commands, so they are untrusted): numbers are clamped, non-numbers take the default.
pub fn sanitize(&mut self) {
match &mut self.kind {
BrushKind::Calligraphic(c) => {
let d = Calligraphic::default();
let [angle, roundness, size] = Calligraphic::RANGES;
c.angle = within(c.angle, angle, d.angle);
c.roundness = within(c.roundness, roundness, d.roundness);
c.size = within(c.size, size, d.size);
for (v, max) in c.variation.iter_mut().zip(Calligraphic::MAX_VARIATION) {
*v = within(*v, (0.0, max), 0.0);
}
}
BrushKind::Scatter(s) => {
let d = Scatter::default();
let [size, spacing, scatter, rotation] = Scatter::RANGES;
s.size = within_pair(s.size, size, d.size.0);
s.spacing = within_pair(s.spacing, spacing, d.spacing.0);
s.scatter = within_pair(s.scatter, scatter, 0.0);
s.rotation = within_pair(s.rotation, rotation, 0.0);
s.colorization.sanitize();
}
BrushKind::Art(a) => {
a.width = within(a.width, ArtBrush::WIDTH_RANGE, 100.0);
if let ArtScale::BetweenGuides { start, end } = &mut a.scale {
*start = within(*start, (0.0, 1.0), 0.0);
*end = within(*end, (0.0, 1.0), 1.0);
}
a.colorization.sanitize();
}
BrushKind::Pattern(p) => {
p.scale = within(p.scale, PatternBrush::SCALE_RANGE, 100.0);
p.spacing = within(p.spacing, PatternBrush::SPACING_RANGE, 0.0);
p.colorization.sanitize();
}
BrushKind::Bristle(b) => {
let d = Bristle::default();
b.size = within(b.size, Bristle::SIZE_RANGE, d.size);
b.length = within(b.length, Bristle::LENGTH_RANGE, d.length);
for (v, fallback) in
[(&mut b.density, d.density), (&mut b.thickness, d.thickness), (&mut b.opacity, d.opacity), (&mut b.stiffness, d.stiffness)]
{
*v = within(*v, Bristle::PERCENT_RANGE, fallback);
}
}
}
}
}
// ---------- library ----------
/// The brush library of `doc`: its stored brushes, or the defaults when it has none.
pub fn library(doc: &Document) -> Vec<Brush> {
match doc.unknown.get(DOC_KEY) {
Some(v) => parse_library(v),
None => defaults().to_vec(),
}
}
/// Parse a stored library value (malformed entries are skipped, values kept within their
/// ranges — see [`Brush::sanitize`]).
pub fn parse_library(v: &serde_json::Value) -> Vec<Brush> {
let parse = |b: &serde_json::Value| {
let mut b: Brush = serde_json::from_value(b.clone()).ok()?;
b.sanitize();
Some(b)
};
v.as_array().map(|a| a.iter().filter_map(parse).collect()).unwrap_or_default()
}
/// Store `lib` as the document's brush library.
pub fn store(doc: &mut Document, lib: &[Brush]) {
doc.unknown.insert(DOC_KEY.into(), serde_json::to_value(lib).unwrap_or_default());
}
/// Find a brush by name.
pub fn find(doc: &Document, name: &str) -> Option<Brush> {
library(doc).into_iter().find(|b| b.name == name)
}
/// The current brush name (Paintbrush tool, Brushes panel highlight).
pub fn current(doc: &Document) -> Option<String> {
doc.unknown.get(CURRENT_KEY).and_then(|v| v.as_str()).map(str::to_string)
}
/// A unique name based on `base` ("Base", "Base 2", …).
pub fn unique_name(lib: &[Brush], base: &str) -> String {
if !lib.iter().any(|b| b.name == base) {
return base.to_string();
}
// At most `lib.len()` names are taken, so one of the first `lib.len() + 1` candidates is free.
(2..=lib.len() + 2)
.map(|i| format!("{base} {i}"))
.find(|n| !lib.iter().any(|b| &b.name == n))
.unwrap_or_else(|| format!("{base} {}", lib.len() + 2))
}
// ---------- geometry ----------
/// Flattening tolerance for brush geometry, relative to its size.
fn tolerance(scale: f64) -> f64 {
(0.02 * scale).clamp(0.01, 0.25)
}
/// Filled art for one brushed stroke along `bp`, in `doc`. Pieces are in document space with id
/// 0, and never draw a brushed stroke (so they never recurse): their own strokes carry no brush,
/// and what they draw by name that would draw one is left out ([`leave_out_brushed`]).
/// Stroke-level opacity/blend are the caller's job (`StrokeLayer::opacity`).
pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
let w = if stroke.width > 0.0 { stroke.width } else { 1.0 };
let mut out = match &brush.kind {
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name),
BrushKind::Art(a) => warp::art(a, bp, w),
BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke.pressure.as_ref(), &brush.name),
BrushKind::Pattern(p) => warp::pattern(p, bp, w),
};
let colorization = brush.kind.colorization();
for n in &mut out {
colorize(n, colorization, &stroke.paint);
}
leave_out_brushed(doc, &mut out, &mut Known::new());
out
}
/// A symbol (`false`) or pattern (`true`) of a document, by name: what art draws by name.
type Named<'a> = (bool, &'a str);
/// What [`draws_brushed`] found of each symbol and pattern so far.
type Known = HashMap<(bool, String), bool>;
/// The symbol an instance draws and the patterns that paint `n` (not its children).
fn named(n: &Node) -> impl Iterator<Item = Named<'_>> {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => Some((false, symbol.as_str())),
_ => None,
};
let patterns = vectorcraft_doc::swatches::node_paints(n).filter_map(|p| match p {
Paint::Pattern { pattern, .. } => Some((true, pattern.as_str())),
_ => None,
});
symbol.into_iter().chain(patterns)
}
/// Does drawing the symbol or pattern `def` of `doc` draw a brushed stroke: in its art, or in
/// the art of the symbols and patterns that uses (and so on)? `known`: the answers so far.
fn draws_brushed(doc: &Document, def: Named<'_>, known: &mut Known) -> bool {
let key = (def.0, def.1.to_string());
if let Some(&answer) = known.get(&key) {
return answer;
}
let art = |(pattern, name): Named<'_>| -> Vec<&Node> {
match pattern {
false => doc.symbols.iter().filter(|s| s.name == name).take(1).map(|s| &*s.art).collect(),
true => doc.pattern(name).map(|p| p.art.iter().map(|n| &**n).collect()).unwrap_or_default(),
}
};
// Without recursion, each one looked at once: they may use one another without end.
let (mut seen, mut stack) = (std::collections::HashSet::from([key.clone()]), art(def));
let mut answer = false;
while let Some(n) = stack.pop() {
if has_brush(n) {
answer = true;
break;
}
stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| &**c));
for other in named(n) {
if seen.insert((other.0, other.1.to_string())) {
stack.extend(art(other));
}
}
}
known.insert(key, answer);
answer
}
/// Leave out of brush pieces `nodes` what they draw by name that draws a brushed stroke
/// ([`draws_brushed`]): such an instance goes, such a pattern paint becomes no paint. A symbol or
/// pattern holding art stroked with the brush itself would draw the brush again, without end.
fn leave_out_brushed(doc: &Document, nodes: &mut Vec<Node>, known: &mut Known) {
fn walk(doc: &Document, n: &mut Node, known: &mut Known) {
for paint in vectorcraft_doc::swatches::node_paints_mut(n) {
if let Paint::Pattern { pattern, .. } = paint
&& draws_brushed(doc, (true, pattern.as_str()), known)
{
*paint = Paint::None;
}
}
if let Some(children) = n.children_mut() {
children.retain(|c| !matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known)));
for c in children.iter_mut() {
walk(doc, Arc::make_mut(c), known);
}
}
if n.extras().mask.is_some()
&& let Some(mask) = n.extras_mut().mask.as_mut()
{
walk(doc, Arc::make_mut(&mut mask.art), known);
}
}
nodes.retain(|n| !matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known)));
for n in nodes.iter_mut() {
walk(doc, n, known);
}
}
/// Pieces for every brushed stroke of `n` (a path or compound path), in appearance order, each
/// with the stroke's opacity folded into the piece opacity. `None` if nothing is brushed.
pub fn node_pieces(doc: &Document, n: &Node) -> Option<Vec<Node>> {
let bp = node_bezpath(n)?;
let lib = library(doc);
let mut out = vec![];
let mut any = false;
for item in &n.appearance.items {
let AppearanceItem::Stroke(st) = item else { continue };
let Some(b) = st.brush.as_deref().and_then(|name| lib.iter().find(|x| x.name == name)) else { continue };
if !st.visible || st.paint.is_none() {
any = true;
continue;
}
any = true;
for mut p in stroke_pieces(doc, b, &bp, st) {
p.opacity *= st.opacity;
if st.blend != vectorcraft_color::BlendMode::Normal {
p.blend = st.blend;
}
out.push(p);
}
}
any.then_some(out)
}
/// The outline of a path or compound path as one BezPath.
pub fn node_bezpath(n: &Node) -> Option<BezPath> {
match &n.kind {
NodeKind::Path { path, .. } => Some(path.to_bezpath()),
NodeKind::Compound { children, .. } => {
let mut bp = BezPath::new();
for c in children {
if let Some(p) = c.path_data() {
bp.extend(p.to_bezpath());
}
}
Some(bp)
}
_ => None,
}
}
/// Does `n` have a stroke with a brush?
pub fn has_brush(n: &Node) -> bool {
n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && s.visible))
}
/// Expand a brushed path: a group of [the path with its fills and unbrushed strokes (if any
/// paint remains), the brush art…]. Ids are 0 (the caller re-ids). `None` if not brushed.
pub fn expand(doc: &Document, n: &Node) -> Option<Node> {
let pieces = node_pieces(doc, n)?;
let mut children: Vec<Arc<Node>> = vec![];
let mut base = n.clone();
base.appearance.items.retain(|i| !matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some()));
let painted = base.appearance.items.iter().any(|i| match i {
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(),
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0,
});
if painted {
base.id = NodeId(0);
children.push(Arc::new(base));
}
children.extend(pieces.into_iter().map(Arc::new));
let mut g = Node::group(NodeId(0), children);
g.opacity = n.opacity;
g.blend = n.blend;
g.name = n.name.clone();
Some(g)
}
/// A filled path node painted with `paint` (brush output).
pub(crate) fn filled(path: vectorcraft_geom::PathData, paint: &Paint) -> Node {
Node::path(NodeId(0), path, Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone()))], ..Default::default() })
}
/// A sample stroke for previews (a gentle S-curve in a `w`×`h` box).
pub fn preview_path(w: f64, h: f64) -> BezPath {
let mut bp = BezPath::new();
let (x0, x1) = (h * 0.5, w - h * 0.5);
bp.move_to((x0, h * 0.5));
bp.curve_to((x0 + (x1 - x0) * 0.35, h * 0.05), (x0 + (x1 - x0) * 0.65, h * 0.95), (x1, h * 0.5));
bp
}
/// Union of the geometric bounds of `nodes`.
pub fn pieces_bounds(nodes: &[Node]) -> Option<Rect> {
nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds()))
}
#[cfg(test)]
mod tests;