mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
Object > Live Paint > Gap Options was a stub, and paint leaked through any gap between the paths of a Live Paint group. The reference app's Gap Options stop paint at small gaps, close them with unpainted paths and preview them on the canvas. livePaint.gapOptions (Gap Options) sets a group's options: detect (Gap Detection), size (small 3 pt, medium 6 pt, large 12 pt, or a custom size over 0 up to 72 pt), color (the Gap Preview Color, #rrggbb or a layer color's name) and closeGaps, which adds an unpainted path across each gap to the group's paths. A gap runs from a loose end of a path to the nearest point of any path within the size, unless the paths already join the two close by. With detection on, the faces are made again as if a line closed each gap, and each face or edge keeps the look of the old one it lies in or on. The options are stored on the group, so they are saved, copied and undone with it, and Expand drops them. Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, Merge keeps it off. livePaint.info lists the options and the gaps up to the size wide that no path closes. The menu item opens a Gap Options dialog. With Preview on, the options apply live and the gaps show on the canvas in the preview color, and OK keeps them as one undo step. vectorcraft-pathops gains find_gaps and live_paint_with_gaps. Docs: docs/mcp.md, docs/target-app-parity.md. i18n: the 19 complete catalogs have rows for the dialog's labels, and bg's Gap Options menu label now names gaps instead of line spacing.
131 lines
4.8 KiB
Rust
131 lines
4.8 KiB
Rust
//! Live Paint groups' Gap Options (Object › Live Paint › Gap Options): whether paint stops at small
|
||
//! gaps between the group's paths, how wide a gap it stops at, and the color gaps preview in.
|
||
//! A group without options has Gap Detection off.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::node::{LAYER_COLORS, Node};
|
||
|
||
/// Paint Stops At: the widest gap Gap Detection closes.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum GapSize {
|
||
/// Small Gaps: [`GapSize::SMALL`] points.
|
||
#[default]
|
||
Small,
|
||
/// Medium Gaps: [`GapSize::MEDIUM`] points.
|
||
Medium,
|
||
/// Large Gaps: [`GapSize::LARGE`] points.
|
||
Large,
|
||
/// Custom: this many points, over 0 up to [`GapSize::MAX`].
|
||
Custom(f64),
|
||
}
|
||
|
||
impl GapSize {
|
||
pub const SMALL: f64 = 3.0;
|
||
pub const MEDIUM: f64 = 6.0;
|
||
pub const LARGE: f64 = 12.0;
|
||
/// The widest custom gap.
|
||
pub const MAX: f64 = 72.0;
|
||
|
||
/// The preset sizes with their ids, smallest first.
|
||
pub const PRESETS: [(Self, &'static str); 3] = [(Self::Small, "small"), (Self::Medium, "medium"), (Self::Large, "large")];
|
||
|
||
/// The widest gap it closes, in points.
|
||
pub fn points(self) -> f64 {
|
||
match self {
|
||
Self::Small => Self::SMALL,
|
||
Self::Medium => Self::MEDIUM,
|
||
Self::Large => Self::LARGE,
|
||
Self::Custom(pt) => pt,
|
||
}
|
||
}
|
||
|
||
/// The id of a preset size (`small`, `medium` or `large`); None for a custom size.
|
||
pub fn id(self) -> Option<&'static str> {
|
||
Self::PRESETS.iter().find(|(s, _)| *s == self).map(|(_, id)| *id)
|
||
}
|
||
|
||
/// A preset size by its id (any case), or a custom size from a number of points over 0 up to
|
||
/// [`GapSize::MAX`].
|
||
pub fn parse(v: &serde_json::Value) -> Option<Self> {
|
||
match v {
|
||
serde_json::Value::String(id) => Self::PRESETS.iter().find(|(_, i)| i.eq_ignore_ascii_case(id.trim())).map(|(s, _)| *s),
|
||
v => v.as_f64().filter(|pt| *pt > 0.0 && *pt <= Self::MAX).map(Self::Custom),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Gap Preview Color for a group that has none set: the layer color Red.
|
||
pub const DEFAULT_GAP_COLOR: [u8; 3] = LAYER_COLORS[1].1;
|
||
|
||
/// A Live Paint group's Gap Options.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct GapOptions {
|
||
/// Gap Detection: paint stops at gaps up to `size` wide.
|
||
pub detect: bool,
|
||
/// Paint Stops At.
|
||
pub size: GapSize,
|
||
/// Gap Preview Color: the color gaps show in.
|
||
pub color: [u8; 3],
|
||
}
|
||
|
||
impl Default for GapOptions {
|
||
fn default() -> Self {
|
||
Self { detect: false, size: GapSize::Small, color: DEFAULT_GAP_COLOR }
|
||
}
|
||
}
|
||
|
||
impl GapOptions {
|
||
/// The widest gap paint stops at, in points; None with Gap Detection off.
|
||
pub fn max_gap(&self) -> Option<f64> {
|
||
self.detect.then(|| self.size.points())
|
||
}
|
||
}
|
||
|
||
impl Node {
|
||
/// The Gap Options of a Live Paint group (the defaults when it has none set).
|
||
pub fn gap_options(&self) -> GapOptions {
|
||
self.extras().live_paint_gaps.as_deref().copied().unwrap_or_default()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serde_json::json;
|
||
|
||
#[test]
|
||
fn sizes_parse_from_ids_and_points() {
|
||
for (size, id) in GapSize::PRESETS {
|
||
assert_eq!(GapSize::parse(&json!(id)), Some(size));
|
||
assert_eq!(GapSize::parse(&json!(id.to_ascii_uppercase())), Some(size));
|
||
assert_eq!(size.id(), Some(id));
|
||
}
|
||
assert_eq!(GapSize::parse(&json!("Medium")).map(GapSize::points), Some(6.0));
|
||
assert_eq!(GapSize::parse(&json!("large")).map(GapSize::points), Some(12.0));
|
||
assert_eq!(GapSize::parse(&json!(4.5)), Some(GapSize::Custom(4.5)));
|
||
assert_eq!(GapSize::Custom(4.5).points(), 4.5);
|
||
assert_eq!(GapSize::Custom(4.5).id(), None);
|
||
for bad in [json!(0), json!(-1), json!(72.5), json!("tiny"), json!(null), json!(true)] {
|
||
assert_eq!(GapSize::parse(&bad), None, "{bad}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn options_round_trip_and_default_to_no_detection() {
|
||
let mut g = Node::group(crate::NodeId(1), vec![]);
|
||
assert_eq!(g.gap_options(), GapOptions::default());
|
||
assert_eq!(g.gap_options().max_gap(), None);
|
||
let set = GapOptions { detect: true, size: GapSize::Custom(5.0), color: [1, 2, 3] };
|
||
g.extras_mut().live_paint_gaps = Some(Box::new(set));
|
||
assert_eq!(g.gap_options().max_gap(), Some(5.0));
|
||
let text = serde_json::to_string(&g).unwrap();
|
||
let back: Node = serde_json::from_str(&text).unwrap();
|
||
assert_eq!(back.gap_options(), set);
|
||
let bare: GapOptions = serde_json::from_value(json!({"detect": true})).unwrap();
|
||
assert_eq!(bare, GapOptions { detect: true, ..GapOptions::default() });
|
||
}
|
||
}
|