Files
vectorcraft/crates/doc/src/live_paint.rs
T
dotCooCoo f9759afa30 Live Paint: Gap Options find and close gaps in Live Paint groups
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.
2026-10-17 15:14:43 +07:00

131 lines
4.8 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.
//! 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() });
}
}