mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 22:28:09 +00:00
Follow-up to #5293. draws_brushed rejected masks, symbol instances and pattern paints whose art names a brush the library doesn't have, though such a stroke draws plain. It now counts only brush names that resolve.
1009 lines
40 KiB
Rust
1009 lines
40 KiB
Rust
//! 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)
|
||
}
|
||
/// Does the pen (its pressure, tilt, bearing or rotation) change the nib?
|
||
pub fn uses_pen(&self) -> bool {
|
||
self.modes().iter().any(|m| m.follows_pen())
|
||
}
|
||
/// Value `i` (0 angle, 1 roundness, 2 size) moved by `by`: the angle turns round past ±180°,
|
||
/// roundness and size stay within their [`Self::RANGES`]. A result that is not a number is
|
||
/// `value`, kept within its range.
|
||
pub fn moved(i: usize, value: f64, by: f64) -> f64 {
|
||
let (lo, hi) = Self::RANGES.get(i).copied().unwrap_or((f64::MIN, f64::MAX));
|
||
let x = value + by;
|
||
if !x.is_finite() {
|
||
value.clamp(lo, hi)
|
||
} else if i == 0 {
|
||
vectorcraft_geom::normalize_deg(x)
|
||
} else {
|
||
x.clamp(lo, hi)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
/// From the value less the variation with the pen lying flat to the value plus the variation
|
||
/// with it upright.
|
||
Tilt,
|
||
/// The value plus the variation times the pen's bearing over 180°: the bearing is which way the
|
||
/// pen leans, counter-clockwise on the page from leaning to the right (0°) round to ±180°.
|
||
Bearing,
|
||
/// The value plus the variation times the pen barrel's rotation over 180°, counter-clockwise
|
||
/// from where the pen reports 0° round to ±180°.
|
||
Rotation,
|
||
}
|
||
|
||
impl Variation {
|
||
pub const ALL: [Variation; 6] =
|
||
[Variation::Fixed, Variation::Random, Variation::Pressure, Variation::Tilt, Variation::Bearing, Variation::Rotation];
|
||
/// The name brush definitions use.
|
||
pub fn id(self) -> &'static str {
|
||
match self {
|
||
Variation::Fixed => "fixed",
|
||
Variation::Random => "random",
|
||
Variation::Pressure => "pressure",
|
||
Variation::Tilt => "tilt",
|
||
Variation::Bearing => "bearing",
|
||
Variation::Rotation => "rotation",
|
||
}
|
||
}
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
Variation::Fixed => "Fixed",
|
||
Variation::Random => "Random",
|
||
Variation::Pressure => "Pressure",
|
||
Variation::Tilt => "Tilt",
|
||
Variation::Bearing => "Bearing",
|
||
Variation::Rotation => "Rotation",
|
||
}
|
||
}
|
||
pub fn parse(id: &str) -> Option<Self> {
|
||
Self::ALL.into_iter().find(|v| v.id() == id)
|
||
}
|
||
/// Does the value follow something the pen records along a stroke (Pressure, Tilt, Bearing,
|
||
/// Rotation)?
|
||
pub fn follows_pen(self) -> bool {
|
||
matches!(self, Variation::Pressure | Variation::Tilt | Variation::Bearing | Variation::Rotation)
|
||
}
|
||
}
|
||
|
||
/// What the pen recorded at one point of a stroke, each as the 0..1 that [`Variation`]'s pen modes
|
||
/// read: 0 gives the value less the variation (a Scatter brush's first value), 1 the value plus it
|
||
/// (the second value), and ½ the value itself, as a stroke without that recording does.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub(crate) struct PenAt {
|
||
/// The pressure, lightest 0.
|
||
pressure: f64,
|
||
/// 1 upright, 0 lying flat.
|
||
tilt: f64,
|
||
/// ½ plus the bearing over 360°.
|
||
bearing: f64,
|
||
/// ½ plus the counter-clockwise barrel rotation over 360°.
|
||
rotation: f64,
|
||
}
|
||
|
||
impl PenAt {
|
||
/// Every input at `c`.
|
||
pub(crate) fn uniform(c: f64) -> Self {
|
||
Self { pressure: c, tilt: c, bearing: c, rotation: c }
|
||
}
|
||
/// What `stroke` recorded at `f` (the fraction of its path's length).
|
||
pub(crate) fn of(stroke: &StrokeLayer, f: f64) -> Self {
|
||
let mid = vectorcraft_doc::PressureProfile::MID;
|
||
let turn = |p: Option<&vectorcraft_doc::AngleProfile>| 0.5 + p.map_or(0.0, |p| p.at(f)) / 360.0;
|
||
Self {
|
||
pressure: stroke.pressure.as_ref().map_or(mid, |p| p.at(f)),
|
||
tilt: 1.0 - stroke.tilt.as_ref().map_or(mid, |p| p.at(f)),
|
||
bearing: turn(stroke.bearing.as_ref()),
|
||
rotation: turn(stroke.rotation.as_ref()),
|
||
}
|
||
}
|
||
/// The 0..1 a value on `mode` takes, or `None` for a mode that doesn't follow the pen.
|
||
pub(crate) fn control(&self, mode: Variation) -> Option<f64> {
|
||
let c = match mode {
|
||
Variation::Fixed | Variation::Random => return None,
|
||
Variation::Pressure => self.pressure,
|
||
Variation::Tilt => self.tilt,
|
||
Variation::Bearing => self.bearing,
|
||
Variation::Rotation => self.rotation,
|
||
};
|
||
Some(if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.5 })
|
||
}
|
||
}
|
||
|
||
/// 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, Tilt the first with the pen lying flat and the second upright,
|
||
/// Bearing and Rotation the first at −180° and the second at 180° (half way at 0°). `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,
|
||
/// How the width varies along a stroke: `None` (Fixed) always `width`; Pressure goes from
|
||
/// `width` at the lightest pen pressure to [`Self::width_max`] at the heaviest.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub width_mode: Option<Variation>,
|
||
/// The width at the heaviest pen pressure (%), for Pressure (`width` without one).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub width_max: Option<f64>,
|
||
pub flip_along: bool,
|
||
pub flip_across: bool,
|
||
/// When true, art is placed on each run between sharp corners so folds do not overlap.
|
||
pub overlap: bool,
|
||
pub colorization: Colorization,
|
||
}
|
||
|
||
impl ArtBrush {
|
||
/// The range of the width (%).
|
||
pub const WIDTH_RANGE: (f64, f64) = (1.0, 1000.0);
|
||
|
||
/// The widths (%) at the lightest and the heaviest pen pressure: `width` for both unless the
|
||
/// width follows the pressure.
|
||
pub fn width_range(&self) -> (f64, f64) {
|
||
match self.width_mode {
|
||
Some(Variation::Pressure) => (self.width, self.width_max.unwrap_or(self.width)),
|
||
_ => (self.width, self.width),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for ArtBrush {
|
||
fn default() -> Self {
|
||
Self {
|
||
art: defaults::taper_art(),
|
||
direction: Direction::LeftToRight,
|
||
scale: ArtScale::Stretch,
|
||
width: 100.0,
|
||
width_mode: None,
|
||
width_max: None,
|
||
flip_along: false,
|
||
flip_across: false,
|
||
overlap: 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,
|
||
}
|
||
|
||
/// What a pattern brush draws at a kind of corner it has no corner art for: no tile, or a tile
|
||
/// made from the side tile.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum CornerSource {
|
||
/// No corner tile: the side tiles run up to the corner.
|
||
None,
|
||
/// One side tile stretched around the corner and centered on it.
|
||
AutoCentered,
|
||
/// A side tile on each side reaching into the corner, stretched so the two meet on the
|
||
/// corner's diagonal.
|
||
AutoBetween,
|
||
/// A side tile on each side cut on the corner's diagonal, the two meeting like a mitered frame.
|
||
AutoSliced,
|
||
/// A side tile on each side running past the corner, the two overlapping there.
|
||
AutoOverlap,
|
||
}
|
||
|
||
impl CornerSource {
|
||
pub const ALL: [CornerSource; 5] =
|
||
[CornerSource::None, CornerSource::AutoCentered, CornerSource::AutoBetween, CornerSource::AutoSliced, CornerSource::AutoOverlap];
|
||
|
||
/// The id in a brush definition and in `brush.options` params.
|
||
pub fn id(self) -> &'static str {
|
||
match self {
|
||
CornerSource::None => "none",
|
||
CornerSource::AutoCentered => "autoCentered",
|
||
CornerSource::AutoBetween => "autoBetween",
|
||
CornerSource::AutoSliced => "autoSliced",
|
||
CornerSource::AutoOverlap => "autoOverlap",
|
||
}
|
||
}
|
||
|
||
pub fn parse(s: &str) -> Option<CornerSource> {
|
||
CornerSource::ALL.into_iter().find(|a| a.id() == s)
|
||
}
|
||
}
|
||
|
||
/// 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(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")]
|
||
pub outer_source: Option<CornerSource>,
|
||
/// What the inner corners take when there is no `inner_corner` art. Without art or a source,
|
||
/// they take the `outer_corner` art.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
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.
|
||
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(),
|
||
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,
|
||
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);
|
||
/// The most characters `brush.new` and `brush.options` take for a new bristle brush name.
|
||
pub const MAX_NAME_CHARS: usize = 31;
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
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) {
|
||
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![],
|
||
}
|
||
}
|
||
|
||
/// [`Brush::art`], to change (relink the resources it uses).
|
||
pub fn art_mut(&mut self) -> Vec<&mut Node> {
|
||
match &mut self.kind {
|
||
BrushKind::Scatter(s) => vec![&mut s.art],
|
||
BrushKind::Art(a) => vec![&mut a.art],
|
||
BrushKind::Pattern(p) => std::iter::once(&mut p.side)
|
||
.chain([&mut p.outer_corner, &mut p.inner_corner, &mut p.start, &mut p.end].into_iter().flatten())
|
||
.collect(),
|
||
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![],
|
||
}
|
||
}
|
||
|
||
/// A brush definition from JSON (a stored library entry or a command's params), its values
|
||
/// kept within their ranges ([`Brush::sanitize`]). A Hue Shift colorization without a `key`
|
||
/// takes the color the art's fills and strokes use most (the first of [`art_colors`]), or
|
||
/// black when they use no solid color.
|
||
pub fn from_json(mut v: serde_json::Value) -> Result<Brush, serde_json::Error> {
|
||
let keyless = v.get("colorization").is_some_and(|c| {
|
||
c.get("method").and_then(serde_json::Value::as_str) == Some("hueShift") && c.get("key").is_none_or(serde_json::Value::is_null)
|
||
});
|
||
if keyless {
|
||
// Read the art with the colorization field left out.
|
||
let mut plain = v.clone();
|
||
if let Some(o) = plain.as_object_mut() {
|
||
o.remove("colorization");
|
||
}
|
||
let b: Brush = serde_json::from_value(plain)?;
|
||
let key = art_colors(b.art()).first().copied().unwrap_or(vectorcraft_color::Color::BLACK);
|
||
if let Some(c) = v.get_mut("colorization").and_then(serde_json::Value::as_object_mut) {
|
||
c.insert("key".into(), serde_json::to_value(key)?);
|
||
}
|
||
}
|
||
let mut b: Brush = serde_json::from_value(v)?;
|
||
b.sanitize();
|
||
Ok(b)
|
||
}
|
||
|
||
/// 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);
|
||
// The width follows the pen's pressure or stays fixed.
|
||
if a.width_mode != Some(Variation::Pressure) {
|
||
a.width_mode = None;
|
||
}
|
||
a.width_max = a.width_max.map(|w| within(w, ArtBrush::WIDTH_RANGE, a.width));
|
||
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);
|
||
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) => {
|
||
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::from_json`]).
|
||
pub fn parse_library(v: &serde_json::Value) -> Vec<Brush> {
|
||
v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).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.
|
||
// Each is looked up in a set: scanning the library per candidate is quadratic in its size.
|
||
let taken: std::collections::HashSet<&str> = lib.iter().map(|b| b.name.as_str()).collect();
|
||
(2..=lib.len() + 2).map(|i| format!("{base} {i}")).find(|n| !taken.contains(n.as_str())).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, stroke.pressure.as_ref()),
|
||
BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke, &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));
|
||
// A brush name the library lacks draws a plain stroke ([`node_pieces`]), which runs no brush.
|
||
let mut names: Option<std::collections::HashSet<String>> = None;
|
||
let mut resolves = |n: &Node| {
|
||
let names = names.get_or_insert_with(|| library(doc).into_iter().map(|b| b.name).collect());
|
||
n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && s.brush.as_ref().is_some_and(|b| names.contains(b))))
|
||
};
|
||
let mut answer = false;
|
||
while let Some(n) = stack.pop() {
|
||
if has_brush(n) && resolves(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);
|
||
}
|
||
}
|
||
// A mask's art is drawn too. Its root is filtered like any other node: a symbol instance
|
||
// there that draws a brushed stroke would run the brush again from inside the mask, so the
|
||
// mask goes, as such an instance goes from the pieces and their children.
|
||
let rejected_root = matches!(
|
||
n.extras().mask.as_ref().map(|m| &m.art.kind),
|
||
Some(NodeKind::SymbolInstance { symbol, .. }) if draws_brushed(doc, (false, symbol.as_str()), known)
|
||
);
|
||
if rejected_root {
|
||
n.extras_mut().mask = None;
|
||
} else 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)?;
|
||
// The library is parsed for a stroke that names a brush, not for every path.
|
||
if !n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some())) {
|
||
return None;
|
||
}
|
||
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;
|