mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
Merge pull request #2424 from dotCooCoo/feat/live-paint-gap-options
Live Paint: Gap Options find and close gaps in Live Paint groups
This commit is contained in:
@@ -29,6 +29,7 @@ pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
|
||||
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
|
||||
pub mod links;
|
||||
pub mod live;
|
||||
pub mod live_paint;
|
||||
pub mod marks;
|
||||
mod mem;
|
||||
pub mod metadata;
|
||||
@@ -96,6 +97,7 @@ pub use graph::{
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::{LinkInfo, PlacementOptions};
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
pub use live_paint::{GapOptions, GapSize};
|
||||
pub use metadata::{CopyrightStatus, DocMetadata};
|
||||
pub use node::Knockout;
|
||||
pub use node::Scaling;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! 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() });
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,7 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
|
||||
if let Some(e) = n.extras_box() {
|
||||
b += ALLOC + size_of::<NodeExtras>();
|
||||
b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective);
|
||||
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
|
||||
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
|
||||
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
|
||||
}
|
||||
|
||||
@@ -570,6 +570,8 @@ pub struct NodeExtras {
|
||||
/// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates,
|
||||
/// filled non-zero) where this object is drawn over the group's other members.
|
||||
pub intertwine: Option<Box<PathData>>,
|
||||
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
|
||||
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -587,6 +589,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
perspective: None,
|
||||
dimension: None,
|
||||
intertwine: None,
|
||||
live_paint_gaps: None,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
@@ -720,6 +723,7 @@ impl Serialize for Node {
|
||||
e.perspective.is_none(),
|
||||
e.dimension.is_none(),
|
||||
e.intertwine.is_none(),
|
||||
e.live_paint_gaps.is_none(),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
@@ -757,6 +761,7 @@ impl Serialize for Node {
|
||||
field!("perspective", &e.perspective);
|
||||
field!("dimension", &e.dimension);
|
||||
field!("intertwine", &e.intertwine);
|
||||
field!("live_paint_gaps", &e.live_paint_gaps);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
@@ -814,6 +819,8 @@ struct NodeWire {
|
||||
dimension: Option<Box<crate::dimension::Dimension>>,
|
||||
#[serde(default)]
|
||||
intertwine: Option<Box<PathData>>,
|
||||
#[serde(default)]
|
||||
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -832,6 +839,7 @@ impl From<NodeWire> for Node {
|
||||
perspective: w.perspective,
|
||||
dimension: w.dimension,
|
||||
intertwine: w.intertwine,
|
||||
live_paint_gaps: w.live_paint_gaps,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
@@ -2055,6 +2063,7 @@ mod tests {
|
||||
e.attrs = Some(Box::default());
|
||||
e.slice = Some(Box::default());
|
||||
e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0)));
|
||||
e.live_paint_gaps = Some(Box::default());
|
||||
}
|
||||
/// The top-level keys of a JSON object, in the order written.
|
||||
struct Keys(Vec<String>);
|
||||
@@ -2096,6 +2105,7 @@ mod tests {
|
||||
"slice",
|
||||
"bbox_angle",
|
||||
"perspective",
|
||||
"live_paint_gaps",
|
||||
];
|
||||
assert_eq!(keys, order);
|
||||
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_doc::{Appearance, Document, GapOptions, GapSize, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{
|
||||
@@ -88,6 +88,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_or_ids,
|
||||
lp_expand
|
||||
),
|
||||
cmd!(
|
||||
"livePaint.gapOptions",
|
||||
"Gap Options",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}",
|
||||
has_selection_or_ids,
|
||||
lp_gap_options
|
||||
),
|
||||
cmd!(
|
||||
"livePaint.fill",
|
||||
"Live Paint Bucket",
|
||||
@@ -111,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Info",
|
||||
[],
|
||||
None,
|
||||
"{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources}",
|
||||
"{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources, gapOptions: {detect, size: \"small\"|\"medium\"|\"large\"|pt, points, color: #rrggbb} (as livePaint.gapOptions sets them), gaps: [[x0,y0,x1,y1]…] (as livePaint.gapOptions returns them)}",
|
||||
has_doc,
|
||||
lp_info
|
||||
),
|
||||
@@ -383,10 +392,11 @@ fn source_nodes(n: &Node) -> Vec<Arc<Node>> {
|
||||
if is_live_paint(n) { b::sources(n).and_then(|g| g.children().cloned()).unwrap_or_default() } else { vec![Arc::new(n.clone())] }
|
||||
}
|
||||
|
||||
/// Planar map children (sources group, faces, edges) for `sources`.
|
||||
fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<Node>>, usize, usize)> {
|
||||
/// The planar map's inputs from `sources` (paths and compound paths, back → front), with the
|
||||
/// fill, stroke and stroke width of each.
|
||||
fn source_shapes(sources: &[Arc<Node>]) -> (Vec<po::Shape>, Vec<(Paint, Paint, f64)>) {
|
||||
let mut lv = vec![];
|
||||
for n in &sources {
|
||||
for n in sources {
|
||||
b::leaf_shapes(n, &mut lv);
|
||||
}
|
||||
let mut shapes = vec![];
|
||||
@@ -400,6 +410,13 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
|
||||
styles.push((a.fill_paint(), a.stroke_paint(), a.stroke().map(|s| s.width).unwrap_or(1.0)));
|
||||
}
|
||||
}
|
||||
(shapes, styles)
|
||||
}
|
||||
|
||||
/// Planar map children (sources group, faces, edges) for `sources`; with `max_gap` (Gap
|
||||
/// Detection), paint stops at the gaps up to that wide.
|
||||
fn build_children(d: &mut Document, sources: Vec<Arc<Node>>, max_gap: Option<f64>) -> Result<(Vec<Arc<Node>>, usize, usize)> {
|
||||
let (shapes, styles) = source_shapes(&sources);
|
||||
if shapes.is_empty() {
|
||||
return Err(EngineError::Other("Live Paint: select paths or compound paths".into()));
|
||||
}
|
||||
@@ -410,7 +427,13 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
|
||||
src_group.visible = false;
|
||||
children.push(Arc::new(src_group));
|
||||
// Every path is an edge, open ones included; faces are the areas they enclose.
|
||||
let (regs, edges) = po::live_paint(&shapes);
|
||||
let (regs, edges) = match max_gap {
|
||||
Some(gap) => {
|
||||
let (regs, edges, _) = po::live_paint_with_gaps(&shapes, gap);
|
||||
(regs, edges)
|
||||
}
|
||||
None => po::live_paint(&shapes),
|
||||
};
|
||||
if regs.is_empty() && edges.is_empty() {
|
||||
return Err(EngineError::Other("Live Paint: these paths are too degenerate to paint".into()));
|
||||
}
|
||||
@@ -433,6 +456,51 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
|
||||
Ok((children, nfaces, nedges))
|
||||
}
|
||||
|
||||
/// Give the faces and edges of `g`, a Live Paint group made again from the sources of `old`, the
|
||||
/// look of `old`'s face or edge where they lie: each face takes everything but the outline (its
|
||||
/// appearance, none included, its transparency and the rest) of the old face its inside point is
|
||||
/// in, and each edge that of the old edge its middle lies on. Faces and edges where `old` had none
|
||||
/// keep the paint of their sources.
|
||||
fn repaint_from(g: &mut Node, old: &Node) {
|
||||
/// How far an edge's middle may be from the old edge it lies on.
|
||||
const ON_EDGE: f64 = 1e-3;
|
||||
let Some(ch) = g.children_mut() else { return };
|
||||
for c in ch.iter_mut() {
|
||||
let Some(pd) = c.path_data() else { continue };
|
||||
let was = match c.name.as_deref() {
|
||||
Some(FACE_NAME) => po::interior_point(pd).and_then(|pt| face_at(old, pt)),
|
||||
Some(EDGE_NAME) => pd
|
||||
.subpaths
|
||||
.first()
|
||||
.filter(|sp| sp.segment_count() > 0)
|
||||
.and_then(|sp| edge_near(old, kurbo::ParamCurve::eval(&sp.segment(0), 0.5), ON_EDGE)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(was) = was {
|
||||
let mut n = (**was).clone();
|
||||
// Gradients fitted to the old outline keep that fit on the new one.
|
||||
n.pin_gradients();
|
||||
n.id = c.id;
|
||||
n.kind = c.kind.clone();
|
||||
n.bbox_angle = c.bbox_angle;
|
||||
*c = Arc::new(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `options` as stored on a Live Paint group: none for the defaults.
|
||||
fn stored_gap_options(options: GapOptions) -> Option<Box<GapOptions>> {
|
||||
(options != GapOptions::default()).then(|| Box::new(options))
|
||||
}
|
||||
|
||||
/// The Gap Options of Live Paint groups merged into one, given bottom-most first: the bottom-most
|
||||
/// group's when its Gap Detection is on; else Gap Detection on with Small Gaps when another
|
||||
/// group's is on; else the bottom-most group's.
|
||||
fn merged_gap_options(groups: &[GapOptions]) -> GapOptions {
|
||||
let Some(&bottom) = groups.first() else { return GapOptions::default() };
|
||||
if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom }
|
||||
}
|
||||
|
||||
/// Make a Live Paint group from `roots`; returns (group, faces, edges).
|
||||
fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> {
|
||||
let mut sources = vec![];
|
||||
@@ -440,7 +508,7 @@ fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<
|
||||
let n = d.node(*r).ok_or(EngineError::NoNode(*r))?;
|
||||
sources.extend(source_nodes(n));
|
||||
}
|
||||
let (children, nf, ne) = build_children(d, sources)?;
|
||||
let (children, nf, ne) = build_children(d, sources, None)?;
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.name = Some(LIVE_PAINT_NAME.into());
|
||||
@@ -484,11 +552,15 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?));
|
||||
}
|
||||
}
|
||||
let (children, nf, ne) = build_children(d, sources)?;
|
||||
// Merge applies the Gap Options' merge rules to the groups' options, bottom-most first.
|
||||
let groups: Vec<GapOptions> = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect();
|
||||
let options = merged_gap_options(&groups);
|
||||
let (children, nf, ne) = build_children(d, sources, options.max_gap())?;
|
||||
let mut g = Node::group(target, children);
|
||||
g.name = old.name.clone();
|
||||
g.opacity = old.opacity;
|
||||
g.blend = old.blend;
|
||||
g.extras_mut().live_paint_gaps = stored_gap_options(options);
|
||||
for (pt, paint) in keep_faces {
|
||||
if let Some(fid) = face_at(&g, pt).map(|f| f.id)
|
||||
&& let Some(ch) = g.children_mut()
|
||||
@@ -553,7 +625,11 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for g in &targets {
|
||||
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
|
||||
n.name = None;
|
||||
n.extras_mut().trace = None;
|
||||
{
|
||||
let mut e = n.extras_mut();
|
||||
e.trace = None;
|
||||
e.live_paint_gaps = None;
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
ch.retain(|c| match c.name.as_deref() {
|
||||
Some(SOURCES_NAME) => false,
|
||||
@@ -569,6 +645,81 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids_json(&targets) }))
|
||||
}
|
||||
|
||||
/// Gaps as `[x0, y0, x1, y1]` (the loose end, then where it closes to).
|
||||
fn gaps_json(gaps: &[[Point; 2]]) -> Value {
|
||||
json!(gaps.iter().map(|[a, b]| [a.x, a.y, b.x, b.y]).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// A group's Gap Options as `livePaint.info` gives them.
|
||||
fn gap_options_json(o: GapOptions) -> Value {
|
||||
let [r, g, b] = o.color;
|
||||
let size = o.size.id().map_or_else(|| json!(o.size.points()), |id| json!(id));
|
||||
json!({ "detect": o.detect, "size": size, "points": o.size.points(), "color": format!("#{r:02x}{g:02x}{b:02x}") })
|
||||
}
|
||||
|
||||
/// The gaps up to `options`' size wide among a Live Paint group's sources.
|
||||
fn open_gaps(group: &Node, options: GapOptions) -> Vec<[Point; 2]> {
|
||||
po::find_gaps(&source_shapes(&source_nodes(group)).0, options.size.points())
|
||||
}
|
||||
|
||||
fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "livePaint.gapOptions";
|
||||
let detect = match p.get("detect").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "detect must be true or false"))?),
|
||||
None => None,
|
||||
};
|
||||
let size =
|
||||
match p.get("size").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(GapSize::parse(v).ok_or_else(|| {
|
||||
bad(C, format!("size must be \"small\", \"medium\", \"large\" or a number of points over 0 up to {}", GapSize::MAX))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
let color = match p.get("color").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()),
|
||||
None => None,
|
||||
};
|
||||
let close = bool_or(p, "closeGaps", false);
|
||||
let targets = live_targets(s, p)?;
|
||||
let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| {
|
||||
let (mut faces, mut gaps, mut closed) = (0, vec![], 0);
|
||||
let mut changed = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let old = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let mut options = old.gap_options();
|
||||
options.detect = detect.unwrap_or(options.detect);
|
||||
options.size = size.unwrap_or(options.size);
|
||||
options.color = color.unwrap_or(options.color);
|
||||
let mut sources = source_nodes(&old);
|
||||
if close {
|
||||
// Close Gaps With Paths adds an unpainted line across each gap to the group's paths.
|
||||
for gap in po::find_gaps(&source_shapes(&sources).0, options.size.points()) {
|
||||
let line = PathData::single(SubPath::polyline(&gap, false));
|
||||
sources.push(Arc::new(Node::path(d.alloc_id(), line, Appearance::default())));
|
||||
closed += 1;
|
||||
}
|
||||
}
|
||||
let mut n = old.clone();
|
||||
// The faces change only with what paint stops at, or with new paths.
|
||||
if close || options.max_gap() != old.gap_options().max_gap() {
|
||||
let (children, _, _) = build_children(d, sources, options.max_gap())?;
|
||||
if let Some(ch) = n.children_mut() {
|
||||
*ch = children;
|
||||
}
|
||||
repaint_from(&mut n, &old);
|
||||
}
|
||||
n.extras_mut().live_paint_gaps = stored_gap_options(options);
|
||||
faces += b::faces(&n).len();
|
||||
gaps.extend(open_gaps(&n, options));
|
||||
changed.put(d, *g, vec![n])?;
|
||||
}
|
||||
changed.finish(d)?;
|
||||
sel.set(targets.iter().copied());
|
||||
Ok((faces, gaps, closed))
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": closed }))
|
||||
}
|
||||
|
||||
fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "livePaint.fill";
|
||||
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
|
||||
@@ -637,7 +788,14 @@ fn lp_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() }))
|
||||
.collect();
|
||||
let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0);
|
||||
Ok(json!({ "faces": faces, "edges": edges, "sources": sources }))
|
||||
let options = n.gap_options();
|
||||
Ok(json!({
|
||||
"faces": faces,
|
||||
"edges": edges,
|
||||
"sources": sources,
|
||||
"gapOptions": gap_options_json(options),
|
||||
"gaps": gaps_json(&open_gaps(n, options)),
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- Image Trace ----------
|
||||
|
||||
@@ -286,7 +286,7 @@ pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node {
|
||||
}
|
||||
|
||||
/// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case).
|
||||
fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
|
||||
pub(super) fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
|
||||
match v {
|
||||
Value::Number(n) => match n.as_u64() {
|
||||
Some(i) if (i as usize) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)),
|
||||
|
||||
@@ -577,6 +577,232 @@ fn live_paint_merge_keeps_paint_and_adds_faces() {
|
||||
assert_eq!(layer_children(&s), 1);
|
||||
}
|
||||
|
||||
/// A Live Paint group of one open path round the square 0..100, its end 2 pt short of its start.
|
||||
fn live_square_with_a_gap(s: &mut Session) -> NodeId {
|
||||
let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 2)].map(|(x, y)| json!({"x": x, "y": y}));
|
||||
let p = s.execute("path.create", &json!({ "anchors": anchors })).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [p["id"]]})).unwrap();
|
||||
live_group_only(s)
|
||||
}
|
||||
|
||||
fn gap_options(s: &Session, g: NodeId) -> vectorcraft_doc::GapOptions {
|
||||
node(s, g).gap_options()
|
||||
}
|
||||
|
||||
/// The gaps of a `livePaint.*` reply, rounded to 1e-6.
|
||||
fn gaps_of(r: &Value) -> Vec<Vec<f64>> {
|
||||
r["gaps"].as_array().unwrap().iter().map(|g| g.as_array().unwrap().iter().map(|v| (v.as_f64().unwrap() * 1e6).round() / 1e6).collect()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_gap_detection_stops_paint_at_a_gap() {
|
||||
let mut s = session();
|
||||
let g = live_square_with_a_gap(&mut s);
|
||||
// Paint leaks through the gap: there is no face to fill.
|
||||
assert!(vectorcraft_tools::builder::faces(&node(&s, g)).is_empty());
|
||||
assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 50]})).is_err());
|
||||
let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap();
|
||||
assert_eq!(info["gapOptions"], json!({"detect": false, "size": "small", "points": 3.0, "color": "#ff4f4f"}));
|
||||
assert_eq!(gaps_of(&info), [[0.0, 0.0, 0.0, 2.0]], "Small Gaps (3 pt) finds the gap");
|
||||
// Gap Detection with Small Gaps: the square is one face, filled in one undo step.
|
||||
let depth = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"detect": true, "size": "small"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), depth + 1);
|
||||
assert_eq!((ids(&r), &r["faces"], &r["closed"]), (vec![g], &json!(1), &json!(0)));
|
||||
assert_eq!(gaps_of(&r), [[0.0, 0.0, 0.0, 2.0]]);
|
||||
let f = s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 50], "color": "#00ff00"})).unwrap();
|
||||
let face = node(&s, NodeId(f["face"].as_u64().unwrap()));
|
||||
assert!((vectorcraft_pathops::area(face.path_data().unwrap(), FillRule::NonZero).abs() - 10000.0).abs() < 1e-6);
|
||||
assert_eq!(vectorcraft_tools::builder::edges(&node(&s, g)).len(), 1, "the gap is no edge");
|
||||
// A color change keeps the face and its paint.
|
||||
s.execute("livePaint.gapOptions", &json!({"id": g.0, "color": "Green"})).unwrap();
|
||||
let opts = gap_options(&s, g);
|
||||
assert_eq!((opts.detect, opts.size, opts.color), (true, vectorcraft_doc::GapSize::Small, [0x4f, 0xff, 0x4f]));
|
||||
let gn = node(&s, g);
|
||||
let kept = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(50.0, 50.0)).unwrap();
|
||||
assert_eq!(kept.appearance.fill_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap()));
|
||||
// A custom size narrower than the gap lets paint leak again, and so does Gap Detection off.
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"size": 1.5})).unwrap();
|
||||
assert_eq!((&r["faces"], gaps_of(&r).len()), (&json!(0), 0));
|
||||
s.execute("livePaint.gapOptions", &json!({"size": 12})).unwrap();
|
||||
assert_eq!(vectorcraft_tools::builder::faces(&node(&s, g)).len(), 1);
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"detect": false})).unwrap();
|
||||
assert_eq!((&r["faces"], gaps_of(&r).len()), (&json!(0), 1));
|
||||
// Bad values change nothing.
|
||||
for bad in [json!({"size": 0}), json!({"size": 73}), json!({"size": "huge"}), json!({"detect": "yes"}), json!({"color": "#12"})] {
|
||||
assert!(s.execute("livePaint.gapOptions", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(gap_options(&s, g).size, vectorcraft_doc::GapSize::Custom(12.0));
|
||||
// Plain paths are no Live Paint group.
|
||||
let r = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
|
||||
assert!(s.execute("livePaint.gapOptions", &json!({"id": r.0, "detect": true})).is_err());
|
||||
// Expand leaves a plain group, without the options.
|
||||
s.execute("livePaint.expand", &json!({"id": g.0})).unwrap();
|
||||
assert!(node(&s, g).extras().live_paint_gaps.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_and_large_gaps_stop_paint_at_wider_gaps() {
|
||||
// Squares whose ends stop 5 pt and 9 pt short of their starts: each size closes the gaps up
|
||||
// to its width (Small 3 pt, Medium 6 pt, Large 12 pt).
|
||||
for (gap, closed_by) in [(5, ["medium", "large"]), (9, ["large", "large"])] {
|
||||
let mut s = session();
|
||||
let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, gap)].map(|(x, y)| json!({"x": x, "y": y}));
|
||||
let p = s.execute("path.create", &json!({ "anchors": anchors })).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [p["id"]]})).unwrap();
|
||||
let g = live_group_only(&mut s);
|
||||
for size in ["small", "medium", "large"] {
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"detect": true, "size": size})).unwrap();
|
||||
let faces = if closed_by.contains(&size) { 1 } else { 0 };
|
||||
assert_eq!(r["faces"], json!(faces), "a {gap} pt gap with {size}");
|
||||
}
|
||||
let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap();
|
||||
assert_eq!(info["gapOptions"], json!({"detect": true, "size": "large", "points": 12.0, "color": "#ff4f4f"}));
|
||||
s.execute("livePaint.gapOptions", &json!({"size": "Medium"})).unwrap();
|
||||
assert_eq!(gap_options(&s, g).size, vectorcraft_doc::GapSize::Medium);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_options_keep_the_paint_of_split_faces_and_of_unpainted_faces() {
|
||||
let mut s = session();
|
||||
// A red square with a divider that stops 2 pt short of its top edge.
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
set_fill(&mut s, r, Color::rgb(1.0, 0.0, 0.0));
|
||||
let l = line(&mut s, (50.0, 2.0), (50.0, 100.0));
|
||||
s.execute("select.set", &json!({"ids": [r.0, l.0]})).unwrap();
|
||||
let g = live_group_only(&mut s);
|
||||
assert_eq!(vectorcraft_tools::builder::faces(&node(&s, g)).len(), 1);
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "color": "#00ff00"})).unwrap();
|
||||
let fill_at = |s: &Session, x: f64| {
|
||||
let gn = node(s, g);
|
||||
vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(x, 50.0)).map(|f| f.appearance.fill_paint())
|
||||
};
|
||||
let green = Paint::solid(Color::from_hex("#00ff00").unwrap());
|
||||
// Closing the gap splits the face in two, and both halves keep its paint.
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"detect": true})).unwrap();
|
||||
assert_eq!(r["faces"], 2, "{r}");
|
||||
assert_eq!((fill_at(&s, 25.0), fill_at(&s, 75.0)), (Some(green.clone()), Some(green.clone())));
|
||||
// A face painted with none stays unpainted when the options change, and a face keeps its
|
||||
// transparency and the rest of its look when the faces are made again.
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "none": true})).unwrap();
|
||||
s.execute("livePaint.gapOptions", &json!({"color": "Blue"})).unwrap();
|
||||
assert_eq!((fill_at(&s, 25.0), fill_at(&s, 75.0)), (Some(green.clone()), Some(Paint::None)));
|
||||
let left = vectorcraft_tools::builder::face_at(&node(&s, g), vectorcraft_geom::Point::new(25.0, 50.0)).unwrap().id;
|
||||
s.edit("t", |d, _| {
|
||||
d.node_mut(left).unwrap().opacity = 0.5;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("livePaint.gapOptions", &json!({"size": 12})).unwrap();
|
||||
let gn = node(&s, g);
|
||||
let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(25.0, 50.0)).unwrap();
|
||||
assert_eq!((f.opacity, f.appearance.fill_paint()), (0.5, green));
|
||||
assert_eq!(fill_at(&s, 75.0), Some(Paint::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_face_split_by_a_closed_gap_keeps_one_gradient_across_both_halves() {
|
||||
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
||||
s.edit("t", |d, _| {
|
||||
d.node_mut(r).unwrap().appearance.set_fill(gradient);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let l = line(&mut s, (50.0, 2.0), (50.0, 100.0));
|
||||
s.execute("select.set", &json!({"ids": [r.0, l.0]})).unwrap();
|
||||
let g = live_group_only(&mut s);
|
||||
s.execute("livePaint.gapOptions", &json!({"detect": true})).unwrap();
|
||||
let gn = node(&s, g);
|
||||
let geoms: Vec<_> = vectorcraft_tools::builder::faces(&gn)
|
||||
.iter()
|
||||
.map(|f| match f.appearance.fill_paint() {
|
||||
Paint::Gradient(gp) => gp.geom,
|
||||
other => panic!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(geoms.len(), 2);
|
||||
assert!(geoms[0].is_some() && geoms[0] == geoms[1], "both halves keep the square's fit: {geoms:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_gaps_with_paths_adds_an_unpainted_path_across_each_gap() {
|
||||
let mut s = session();
|
||||
let g = live_square_with_a_gap(&mut s);
|
||||
let r = s.execute("livePaint.gapOptions", &json!({"closeGaps": true})).unwrap();
|
||||
assert_eq!((&r["faces"], &r["closed"], gaps_of(&r).len()), (&json!(1), &json!(1), 0), "{r}");
|
||||
assert!(!gap_options(&s, g).detect, "Gap Detection stays off: the path closes the gap");
|
||||
let gn = node(&s, g);
|
||||
let src = vectorcraft_tools::builder::sources(&gn).unwrap();
|
||||
let added = src.children().unwrap().last().unwrap();
|
||||
assert!(added.appearance.fill_paint().is_none() && added.appearance.stroke_paint().is_none(), "unpainted");
|
||||
let bb = added.geometric_bounds().unwrap();
|
||||
assert!(bb.x0.abs() < 1e-9 && bb.x1.abs() < 1e-9 && bb.y0.abs() < 1e-9 && (bb.y1 - 2.0).abs() < 1e-9, "{bb:?}");
|
||||
assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 50]})).is_ok());
|
||||
// Release gives the closing path back with the others.
|
||||
assert_eq!(ids(&s.execute("livePaint.release", &json!({"id": g.0})).unwrap()).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_options_survive_save_copy_and_undo() {
|
||||
let mut s = session();
|
||||
let g = live_square_with_a_gap(&mut s);
|
||||
s.execute("livePaint.gapOptions", &json!({"detect": true, "size": 4.5, "color": "#123456"})).unwrap();
|
||||
let want = vectorcraft_doc::GapOptions { detect: true, size: vectorcraft_doc::GapSize::Custom(4.5), color: [0x12, 0x34, 0x56] };
|
||||
assert_eq!(gap_options(&s, g), want);
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
||||
assert_eq!(back.node(g).unwrap().gap_options(), want);
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let pasted = s.doc().unwrap().selection.objects[0];
|
||||
assert_ne!(pasted, g);
|
||||
assert_eq!(gap_options(&s, pasted), want);
|
||||
assert_eq!(vectorcraft_tools::builder::faces(&node(&s, pasted)).len(), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(gap_options(&s, g), vectorcraft_doc::GapOptions::default());
|
||||
assert!(vectorcraft_tools::builder::faces(&node(&s, g)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_live_paint_groups_keep_the_bottom_groups_gap_options() {
|
||||
use vectorcraft_doc::{GapOptions, GapSize};
|
||||
// Two groups side by side, the left one at the bottom, merged with each Gap Detection setting.
|
||||
let merged = |bottom: Value, top: Value| {
|
||||
let mut s = session();
|
||||
let groups: Vec<NodeId> = [0.0, 200.0]
|
||||
.into_iter()
|
||||
.zip([bottom, top])
|
||||
.map(|(x, opts)| {
|
||||
let r = rect(&mut s, x, 0.0, 100.0, 100.0);
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
let g = live_group_only(&mut s);
|
||||
s.execute("livePaint.gapOptions", &opts).unwrap();
|
||||
g
|
||||
})
|
||||
.collect();
|
||||
s.execute("select.set", &json!({"ids": [groups[0].0, groups[1].0]})).unwrap();
|
||||
let r = s.execute("livePaint.merge", &json!({})).unwrap();
|
||||
assert_eq!(NodeId(r["id"].as_u64().unwrap()), groups[0]);
|
||||
gap_options(&s, groups[0])
|
||||
};
|
||||
let six = json!({"detect": true, "size": 6, "color": "Blue"});
|
||||
let twelve = json!({"detect": true, "size": 12});
|
||||
let off = json!({"detect": false, "size": 12, "color": "Blue"});
|
||||
let blue = [0x4f, 0x4f, 0xff];
|
||||
// The bottom group's options, with its Gap Detection on.
|
||||
assert_eq!(merged(six.clone(), twelve), GapOptions { detect: true, size: GapSize::Custom(6.0), color: blue });
|
||||
// Detection on with Small Gaps when only a group above has it on.
|
||||
assert_eq!(merged(off.clone(), six), GapOptions { detect: true, size: GapSize::Small, color: blue });
|
||||
// With detection off in every group, the bottom group's options.
|
||||
assert_eq!(merged(off, json!({"detect": false})), GapOptions { detect: false, size: GapSize::Custom(12.0), color: blue });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_bucket_tool_fills_face() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -20,7 +20,10 @@ pub use edit::{
|
||||
};
|
||||
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
|
||||
pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
|
||||
pub use planar::{BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, has_open_path, interior_point, live_paint, shape_builder};
|
||||
pub use planar::{
|
||||
BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point, live_paint,
|
||||
live_paint_with_gaps, shape_builder,
|
||||
};
|
||||
|
||||
/// Errors from fallible operations.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, PathSeg, Point, Shape as _};
|
||||
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape as _};
|
||||
use linesweeper::topology::{Topology, WindingNumber};
|
||||
use vectorcraft_geom::{FillRule, PathData, SubPath};
|
||||
|
||||
@@ -307,6 +307,279 @@ pub fn live_paint(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
|
||||
build(shapes).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`live_paint`] with Gap Detection: the faces are those of `shapes` with a line across each gap
|
||||
/// [`find_gaps`] finds up to `max_gap`, so paint stops at the gaps, and the edges are those of
|
||||
/// `shapes` alone (the lines add none). Also returns the gaps.
|
||||
pub fn live_paint_with_gaps(shapes: &[Shape], max_gap: f64) -> (Vec<Region>, Vec<Shape>, Vec<[Point; 2]>) {
|
||||
let gaps = find_gaps(shapes, max_gap);
|
||||
if gaps.is_empty() {
|
||||
let (faces, edges) = live_paint(shapes);
|
||||
return (faces, edges, gaps);
|
||||
}
|
||||
let mut closed = shapes.to_vec();
|
||||
let lines = PathData::new(gaps.iter().map(|g| SubPath::polyline(g, false)).collect());
|
||||
closed.push(Shape::new(lines, FillRule::NonZero, u64::MAX));
|
||||
let (faces, _) = live_paint(&closed);
|
||||
let (_, edges) = live_paint(shapes);
|
||||
(faces, edges, gaps)
|
||||
}
|
||||
|
||||
/// How many times longer than a gap the way between its two ends along the paths must be for it to
|
||||
/// count: a loose end that crosses a path just before it (an overshoot), or a path that curls back
|
||||
/// close to its own end (a hook), leaves no gap there.
|
||||
const GAP_DETOUR: f64 = 8.0;
|
||||
|
||||
/// How exactly [`find_gaps`] measures lengths along the paths.
|
||||
const GAP_ARC_ACCURACY: f64 = 1e-4;
|
||||
|
||||
/// How many points along a loose end's own segment [`find_gaps`] tries.
|
||||
const GAP_SAMPLES: u32 = 64;
|
||||
|
||||
/// One subpath's segments for [`find_gaps`], with the length along it where each starts.
|
||||
struct Track {
|
||||
segs: Vec<PathSeg>,
|
||||
/// The length at the start of each segment, then the whole length.
|
||||
at: Vec<f64>,
|
||||
closed: bool,
|
||||
bounds: kurbo::Rect,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
fn new(sp: &SubPath) -> Option<Self> {
|
||||
if sp.anchors.len() < 2 || !sp.anchors.iter().all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut bp = BezPath::new();
|
||||
sp.to_bezpath_into(&mut bp);
|
||||
let segs: Vec<PathSeg> = bp.segments().collect();
|
||||
let mut at = Vec::with_capacity(segs.len() + 1);
|
||||
let mut len = 0.0;
|
||||
for s in &segs {
|
||||
at.push(len);
|
||||
len += s.arclen(GAP_ARC_ACCURACY);
|
||||
}
|
||||
at.push(len);
|
||||
(!segs.is_empty()).then(|| Self { bounds: bp.bounding_box(), segs, at, closed: sp.closed })
|
||||
}
|
||||
|
||||
fn len(&self) -> f64 {
|
||||
self.at.last().copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// The length along the subpath to parameter `t` of segment `k`.
|
||||
fn pos(&self, k: usize, t: f64) -> f64 {
|
||||
let Some(s) = self.segs.get(k) else { return self.len() };
|
||||
self.at.get(k).copied().unwrap_or(0.0) + s.subsegment(0.0..t).arclen(GAP_ARC_ACCURACY)
|
||||
}
|
||||
|
||||
/// The length along the subpath between two places on it (the shorter way round a closed one).
|
||||
fn along(&self, a: f64, b: f64) -> f64 {
|
||||
let d = (a - b).abs();
|
||||
if self.closed { d.min(self.len() - d) } else { d }
|
||||
}
|
||||
}
|
||||
|
||||
/// Do two boxes (lines' boxes included, which have no area) touch or overlap?
|
||||
fn boxes_meet(a: kurbo::Rect, b: kurbo::Rect) -> bool {
|
||||
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
|
||||
}
|
||||
|
||||
/// Where track `o`, within `reach` along it of `from`, crosses the segments `near` of track `q`:
|
||||
/// (length along `o`, length along `q`) of each crossing.
|
||||
fn crossings(o: &Track, from: f64, reach: f64, q: &Track, near: impl Iterator<Item = usize>) -> Vec<(f64, f64)> {
|
||||
let near: Vec<(usize, &PathSeg)> = near.filter_map(|j| Some((j, q.segs.get(j)?))).collect();
|
||||
let mut out = vec![];
|
||||
// `first` is the first segment that ends within reach.
|
||||
let first = o.at.partition_point(|&a| a < from - reach).saturating_sub(1);
|
||||
for (k, s) in o.segs.iter().enumerate().skip(first) {
|
||||
let (Some(&a0), Some(&a1)) = (o.at.get(k), o.at.get(k + 1)) else { continue };
|
||||
if a0 > from + reach {
|
||||
break;
|
||||
}
|
||||
if a1 < from - reach || !boxes_meet(s.bounding_box(), q.bounds) {
|
||||
continue;
|
||||
}
|
||||
// A curve is tried as 16 chords.
|
||||
let n = if matches!(s, PathSeg::Line(_)) { 1 } else { 16 };
|
||||
let mut prev = s.start();
|
||||
for i in 1..=n {
|
||||
let (t0, t1) = (f64::from(i - 1) / f64::from(n), f64::from(i) / f64::from(n));
|
||||
let p = s.eval(t1);
|
||||
let chord = kurbo::Line::new(prev, p);
|
||||
let cb = chord.bounding_box();
|
||||
for &(j, qs) in &near {
|
||||
if !boxes_meet(qs.bounding_box(), cb) {
|
||||
continue;
|
||||
}
|
||||
for x in qs.intersect_line(chord) {
|
||||
out.push((o.pos(k, t0 + x.line_t * (t1 - t0)), q.pos(j, x.segment_t)));
|
||||
}
|
||||
}
|
||||
prev = p;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Most grid cells one segment is listed in by [`SegGrid`]; a longer one is tried for every point.
|
||||
const GAP_GRID_SPAN: i64 = 1024;
|
||||
|
||||
/// The farthest cell from the grid's origin, on each axis, that [`SegGrid`] numbers a point in.
|
||||
const GAP_GRID_LIMIT: f64 = 1_048_576.0;
|
||||
|
||||
/// The segments of [`find_gaps`]' tracks by the cells of a square grid that their bounds cover.
|
||||
struct SegGrid {
|
||||
origin: Point,
|
||||
cell: f64,
|
||||
/// (track, segment) by cell.
|
||||
cells: HashMap<(i64, i64), Vec<(usize, usize)>>,
|
||||
/// Segments whose bounds cover more than [`GAP_GRID_SPAN`] cells, near every point.
|
||||
wide: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl SegGrid {
|
||||
fn new(tracks: &[Track], origin: Point, cell: f64) -> Self {
|
||||
let mut grid = Self { origin, cell, cells: HashMap::new(), wide: vec![] };
|
||||
for (j, t) in tracks.iter().enumerate() {
|
||||
for (k, s) in t.segs.iter().enumerate() {
|
||||
let b = s.bounding_box();
|
||||
let ((x0, y0), (x1, y1)) = (grid.cell_of(Point::new(b.x0, b.y0)), grid.cell_of(Point::new(b.x1, b.y1)));
|
||||
if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > GAP_GRID_SPAN {
|
||||
grid.wide.push((j, k));
|
||||
continue;
|
||||
}
|
||||
for x in x0..=x1 {
|
||||
for y in y0..=y1 {
|
||||
grid.cells.entry((x, y)).or_default().push((j, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
grid
|
||||
}
|
||||
|
||||
/// The cell `p` is in, counted from the origin (at most [`GAP_GRID_LIMIT`] cells away on each
|
||||
/// axis).
|
||||
fn cell_of(&self, p: Point) -> (i64, i64) {
|
||||
let at = |v: f64, o: f64| ((v - o) / self.cell).floor().clamp(-GAP_GRID_LIMIT, GAP_GRID_LIMIT) as i64;
|
||||
(at(p.x, self.origin.x), at(p.y, self.origin.y))
|
||||
}
|
||||
|
||||
/// The segments whose bounds come within `r` of `p` (and some farther ones), each once, in
|
||||
/// track order.
|
||||
fn near(&self, p: Point, r: f64) -> Vec<(usize, usize)> {
|
||||
let mut out = self.wide.clone();
|
||||
let ((x0, y0), (x1, y1)) = (self.cell_of(Point::new(p.x - r, p.y - r)), self.cell_of(Point::new(p.x + r, p.y + r)));
|
||||
for x in x0..=x1 {
|
||||
for y in y0..=y1 {
|
||||
out.extend(self.cells.get(&(x, y)).into_iter().flatten());
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The point a gap from the loose end `e` of track `own` (at `from` along it) closes to: the
|
||||
/// nearest point of any path within `max_gap` of it, leaving out points the paths already join to
|
||||
/// `e` along a way less than [`GAP_DETOUR`] times as long as the gap. None when there is no such
|
||||
/// point, or when `e` lies on a path (it is no loose end).
|
||||
fn gap_from(tracks: &[Track], grid: &SegGrid, own: usize, e: Point, from: f64, max_gap: f64, eps: f64) -> Option<Point> {
|
||||
use kurbo::ParamCurveNearest;
|
||||
let o = tracks.get(own)?;
|
||||
let reach = GAP_DETOUR * max_gap;
|
||||
let mut best: Option<(f64, Point)> = None;
|
||||
// `around` holds the segments a way of up to `reach` from `e` can cross, and `crossed` the
|
||||
// crossings with each track.
|
||||
let mut around: Option<Vec<(usize, usize)>> = None;
|
||||
let mut crossed: HashMap<usize, Vec<(f64, f64)>> = HashMap::new();
|
||||
for (j, k) in grid.near(e, max_gap) {
|
||||
let Some(q) = tracks.get(j) else { continue };
|
||||
let Some(s) = q.segs.get(k) else { continue };
|
||||
let b = s.bounding_box();
|
||||
if e.x < b.x0 - max_gap || e.x > b.x1 + max_gap || e.y < b.y0 - max_gap || e.y > b.y1 + max_gap {
|
||||
continue;
|
||||
}
|
||||
let near = s.nearest(e, 1e-9);
|
||||
let d = near.distance_sq.sqrt();
|
||||
if d > max_gap {
|
||||
continue;
|
||||
}
|
||||
let at = q.pos(k, near.t);
|
||||
let way = if j == own {
|
||||
o.along(from, at)
|
||||
} else {
|
||||
let xs = crossed.entry(j).or_insert_with(|| {
|
||||
let segs = around.get_or_insert_with(|| grid.near(e, reach + max_gap));
|
||||
crossings(o, from, reach, q, segs.iter().filter(|c| c.0 == j).map(|c| c.1))
|
||||
});
|
||||
xs.iter().map(|&(a, b)| o.along(from, a) + q.along(b, at)).fold(f64::INFINITY, f64::min)
|
||||
};
|
||||
if d <= eps {
|
||||
// On another path, or on its own away from the end: the end is joined there.
|
||||
if j != own || way > 4.0 * eps.max(GAP_ARC_ACCURACY) {
|
||||
return None;
|
||||
}
|
||||
// The end's own segment: a curve that comes back round can still pass near it.
|
||||
for i in 0..=GAP_SAMPLES {
|
||||
let t = f64::from(i) / f64::from(GAP_SAMPLES);
|
||||
let p = s.eval(t);
|
||||
let d = p.distance(e);
|
||||
if d > eps && d <= max_gap && o.along(from, q.pos(k, t)) > GAP_DETOUR * d && best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, p));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if way > GAP_DETOUR * d && best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, s.eval(near.t)));
|
||||
}
|
||||
}
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
|
||||
/// The gaps Live Paint's Gap Detection closes in `shapes`: from each loose end of an open subpath
|
||||
/// (an end that lies on no path) to the nearest point of any path, its own included, at most
|
||||
/// `max_gap` away, unless the paths already join the two along a way less than [`GAP_DETOUR`] times
|
||||
/// the gap's length. Each gap is a pair of points: the loose end, then where it closes to. A gap
|
||||
/// between two loose ends that close to each other is listed once.
|
||||
pub fn find_gaps(shapes: &[Shape], max_gap: f64) -> Vec<[Point; 2]> {
|
||||
if !(max_gap.is_finite() && max_gap > 0.0) {
|
||||
return vec![];
|
||||
}
|
||||
let tracks: Vec<Track> = shapes.iter().flat_map(|s| &s.path.subpaths).filter_map(Track::new).collect();
|
||||
let eps = (max_gap * 1e-4).max(1e-6);
|
||||
// The grid cells are as wide as the gap, or 1/4096 of the art where that is wider.
|
||||
let bounds = tracks.iter().map(|t| t.bounds).reduce(|a, b| a.union(b)).unwrap_or_default();
|
||||
let grid = SegGrid::new(&tracks, bounds.origin(), max_gap.max(bounds.width().max(bounds.height()) / 4096.0));
|
||||
let mut gaps: Vec<[Point; 2]> = vec![];
|
||||
// `by_end` lists the gaps by the cell of their loose end.
|
||||
let mut by_end: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
|
||||
for (i, o) in tracks.iter().enumerate() {
|
||||
let (Some(first), Some(last)) = (o.segs.first(), o.segs.last()) else { continue };
|
||||
// An open subpath that ends where it starts encloses its area already.
|
||||
if o.closed || first.start().distance(last.end()) <= eps {
|
||||
continue;
|
||||
}
|
||||
for (e, from) in [(first.start(), 0.0), (last.end(), o.len())] {
|
||||
let Some(to) = gap_from(&tracks, &grid, i, e, from, max_gap, eps) else { continue };
|
||||
let (cx, cy) = grid.cell_of(to);
|
||||
let listed = (cx - 1..=cx + 1)
|
||||
.flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y)))
|
||||
.filter_map(|c| by_end.get(&c))
|
||||
.flatten()
|
||||
.filter_map(|&g| gaps.get(g))
|
||||
.any(|g| g[0].distance(to) <= eps && g[1].distance(e) <= eps);
|
||||
if !listed {
|
||||
by_end.entry(grid.cell_of(e)).or_default().push(gaps.len());
|
||||
gaps.push([e, to]);
|
||||
}
|
||||
}
|
||||
}
|
||||
gaps
|
||||
}
|
||||
|
||||
/// Most segments the Shape Builder sweeps into its planar map (open paths as edges, the gaps closed
|
||||
/// paths enclose, outline edges): the map costs far more than the filled areas' arrangement, so past
|
||||
/// this only the filled areas count.
|
||||
@@ -961,6 +1234,101 @@ mod tests {
|
||||
assert_eq!(edges.iter().filter(|e| e.key == 2).count(), 3);
|
||||
}
|
||||
|
||||
fn open(points: &[(f64, f64)], key: u64) -> Shape {
|
||||
let pts: Vec<Point> = points.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
||||
Shape::new(PathData::single(SubPath::polyline(&pts, false)), FillRule::NonZero, key)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_detection_closes_a_square_drawn_with_a_gap() {
|
||||
// One open path round a square, its end 2 pt short of its start.
|
||||
let s = [open(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0), (0.0, 2.0)], 0)];
|
||||
assert!(live_paint(&s).0.is_empty(), "paint leaks through the gap");
|
||||
let (faces, edges, gaps) = live_paint_with_gaps(&s, 3.0);
|
||||
assert_eq!(gaps.len(), 1, "{gaps:?}");
|
||||
let g = gaps[0];
|
||||
assert!(g[0].distance(Point::new(0.0, 0.0)) < 1e-9 && g[1].distance(Point::new(0.0, 2.0)) < 1e-6, "{g:?}");
|
||||
assert_eq!(faces.len(), 1, "{faces:?}");
|
||||
assert!((area(&faces[0]) - 10000.0).abs() < 1e-6, "{}", area(&faces[0]));
|
||||
assert_eq!(edges, live_paint(&s).1, "the gap adds no edge");
|
||||
// A gap wider than the size stays open.
|
||||
let (faces, _, gaps) = live_paint_with_gaps(&s, 1.5);
|
||||
assert!(faces.is_empty() && gaps.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaps_close_to_curves_and_between_paths() {
|
||||
// A line across a circle, each end 2 pt short of it: the circle splits in two.
|
||||
let circle = Shape::new(shapes::ellipse(Rect::new(50.0, 0.0, 150.0, 100.0)), FillRule::NonZero, 0);
|
||||
let s = [circle, line((52.0, 50.0), (148.0, 50.0), 1)];
|
||||
assert_eq!(live_paint(&s).0.len(), 1);
|
||||
let (faces, _, gaps) = live_paint_with_gaps(&s, 3.0);
|
||||
assert_eq!(gaps.len(), 2, "{gaps:?}");
|
||||
for g in &gaps {
|
||||
assert!((g[0].distance(g[1]) - 2.0).abs() < 1e-6, "{g:?}");
|
||||
}
|
||||
assert_eq!(faces.len(), 2, "{faces:?}");
|
||||
assert_eq!(at(&faces, 100.0, 25.0).len(), 1);
|
||||
assert_eq!(at(&faces, 100.0, 75.0).len(), 1);
|
||||
// Four lines round a square whose corners miss by 1 pt: the gaps are listed once each.
|
||||
let s = [
|
||||
line((0.0, 0.0), (99.0, 0.0), 0),
|
||||
line((100.0, 1.0), (100.0, 99.0), 1),
|
||||
line((99.0, 100.0), (1.0, 100.0), 2),
|
||||
line((0.0, 99.0), (0.0, 1.0), 3),
|
||||
];
|
||||
let (faces, _, gaps) = live_paint_with_gaps(&s, 3.0);
|
||||
assert_eq!(gaps.len(), 4, "{gaps:?}");
|
||||
assert_eq!(faces.len(), 1, "{faces:?}");
|
||||
// One curve that comes back round to 2 pt from its start.
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
bp.curve_to((100.0, 0.0), (100.0, 100.0), (0.0, 2.0));
|
||||
let s = [Shape::new(PathData::from_bezpath(&bp), FillRule::NonZero, 0)];
|
||||
let (faces, _, gaps) = live_paint_with_gaps(&s, 3.0);
|
||||
assert_eq!(gaps.len(), 1, "{gaps:?}");
|
||||
assert!((gaps[0][0].distance(gaps[0][1]) - 2.0).abs() < 1e-9, "{gaps:?}");
|
||||
assert_eq!(faces.len(), 1, "{faces:?}");
|
||||
// A line far longer than the gap, and many lines far from each other.
|
||||
let mut s = vec![line((0.0, 0.0), (100000.0, 0.0), 0), line((50000.0, 2.0), (50000.0, 100.0), 1)];
|
||||
s.extend((0..2000).map(|k| {
|
||||
let (x, y) = (f64::from(k % 50) * 40.0, 1000.0 + f64::from(k / 50) * 40.0);
|
||||
line((x, y), (x + 10.0, y), 2)
|
||||
}));
|
||||
let gaps = find_gaps(&s, 3.0);
|
||||
assert_eq!(gaps.len(), 1, "{gaps:?}");
|
||||
assert!(gaps[0][0].distance(Point::new(50000.0, 2.0)) < 1e-9 && gaps[0][1].distance(Point::new(50000.0, 0.0)) < 1e-9, "{gaps:?}");
|
||||
// Paths far from the origin: one gap between two loose ends, listed once.
|
||||
let s = [line((1e20, 0.0), (1e20, 100.0), 0), line((1e20, 102.0), (1e20, 200.0), 1)];
|
||||
assert_eq!(find_gaps(&s, 3.0).len(), 1, "{:?}", find_gaps(&s, 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overshoots_and_joined_ends_are_no_gaps() {
|
||||
// Ends that cross a line just before they stop, square to it and at 30 degrees.
|
||||
let t = 30f64.to_radians();
|
||||
let s = [
|
||||
line((-10.0, 0.0), (110.0, 0.0), 0),
|
||||
line((20.0, 100.0), (20.0, -1.0), 1),
|
||||
line((80.0 + 100.0 * t.cos(), 100.0 * t.sin()), (80.0 - t.cos(), -t.sin()), 2),
|
||||
];
|
||||
assert!(find_gaps(&s, 3.0).is_empty(), "{:?}", find_gaps(&s, 3.0));
|
||||
// An end on another path, or on its own, is joined there.
|
||||
let s = [line((0.0, 0.0), (100.0, 0.0), 0), line((50.0, 0.0), (50.0, 100.0), 1)];
|
||||
assert!(find_gaps(&s, 3.0).is_empty(), "{:?}", find_gaps(&s, 3.0));
|
||||
// Another path 2 pt away does not make a gap from the joined end.
|
||||
let s = [line((0.0, 0.0), (100.0, 0.0), 0), line((50.0, 0.0), (50.0, 100.0), 1), line((40.0, -2.0), (60.0, -2.0), 2)];
|
||||
let gaps = find_gaps(&s, 3.0);
|
||||
assert!(!gaps.is_empty() && gaps.iter().all(|g| g[0].distance(Point::new(50.0, 0.0)) > 1e-9), "{gaps:?}");
|
||||
let six = [open(&[(0.0, 0.0), (0.0, 100.0), (50.0, 100.0), (50.0, 50.0), (0.0, 50.0)], 0)];
|
||||
assert!(find_gaps(&six, 3.0).is_empty(), "{:?}", find_gaps(&six, 3.0));
|
||||
// Sizes that are not a length find nothing.
|
||||
let square = [open(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0), (0.0, 2.0)], 0)];
|
||||
for size in [0.0, -3.0, f64::NAN, f64::INFINITY] {
|
||||
assert!(find_gaps(&square, size).is_empty(), "{size}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grid_of_lines_makes_its_cells() {
|
||||
let mut s = vec![];
|
||||
|
||||
@@ -487,11 +487,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let revolve_edit = app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active);
|
||||
let view_info = app.view_info();
|
||||
// View → Hide Gradient Annotator hides the Gradient tool's annotator.
|
||||
let overlays = if !revolve_edit && (app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator) {
|
||||
let mut overlays = if !revolve_edit && (app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator) {
|
||||
app.session.overlays(view_info)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
// Gap Options' Preview shows the gaps it finds.
|
||||
overlays.extend(crate::dialogs::live_paint_gaps::overlays(app));
|
||||
if app.ui.view.edges && !revolve_edit {
|
||||
hover_highlight(app, &painter, &xf);
|
||||
selection_overlay(app, &painter, &xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. })));
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Object › Live Paint › Gap Options: Gap Detection, Paint Stops At (Small, Medium or Large Gaps,
|
||||
//! or Custom with its size), Gap Preview Color (a layer color or a custom color) and Close Gaps
|
||||
//! With Paths for the selected Live Paint groups, opened on the first group's options. With Preview
|
||||
//! on, the options apply to the groups live and the gaps they find draw on the canvas in the preview
|
||||
//! color; OK keeps them as one undo step (`livePaint.gapOptions`).
|
||||
//!
|
||||
//! Fields: `detect`, `size` (`small` | `medium` | `large` | `custom`), `custom` (pt), `color`
|
||||
//! (`#rrggbb`, or a preset's name such as "Red"), `closeGaps`, `preview`, `__ids` (the groups) and
|
||||
//! `__gaps` (the gaps the last preview found, as `livePaint.gapOptions` returns them).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::{GapSize, LAYER_COLORS};
|
||||
use vectorcraft_geom::{BezPath, Point};
|
||||
use vectorcraft_tools::Overlay;
|
||||
|
||||
use super::tile_edge_color::resolve;
|
||||
use super::{DialogSpec, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of Gap Options.
|
||||
pub const KIND: &str = "livePaintGapOptions";
|
||||
|
||||
const CMD: &str = "livePaint.gapOptions";
|
||||
|
||||
/// Width of the label column.
|
||||
const LABEL_W: f32 = 120.0;
|
||||
|
||||
/// Paint Stops At: (value, label).
|
||||
const SIZES: [(&str, &str); 4] = [("small", "Small Gaps"), ("medium", "Medium Gaps"), ("large", "Large Gaps"), ("custom", "Custom")];
|
||||
|
||||
/// The private field holding the gaps of the last preview.
|
||||
const GAPS: &str = "__gaps";
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| tl!("Gap Options").into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
|
||||
|
||||
/// Open Gap Options on the selected Live Paint groups, filled in from the first one's options.
|
||||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let groups: Vec<u64> = app
|
||||
.session
|
||||
.doc()
|
||||
.map(|d| {
|
||||
let doc = &d.doc;
|
||||
d.selection.objects.iter().filter(|id| doc.node(**id).is_some_and(vectorcraft_tools::builder::is_live_paint)).map(|id| id.0).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let Some(&first) = groups.first() else { return Err("select a Live Paint group".into()) };
|
||||
let info = app.session.execute("livePaint.info", &json!({ "group": first })).map_err(|e| e.to_string())?;
|
||||
let o = &info["gapOptions"];
|
||||
let (size, custom) = match o["size"].as_str() {
|
||||
Some(id) => (id.to_string(), o["points"].clone()),
|
||||
None => ("custom".to_string(), o["size"].clone()),
|
||||
};
|
||||
app.ui.dialog = Some(Dialog::new(
|
||||
KIND,
|
||||
json!({
|
||||
"detect": o["detect"],
|
||||
"size": size,
|
||||
"custom": custom,
|
||||
"color": o["color"],
|
||||
"closeGaps": false,
|
||||
"preview": false,
|
||||
"__ids": groups,
|
||||
}),
|
||||
));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// `livePaint.gapOptions` parameters from the fields.
|
||||
fn params(d: &Dialog) -> Value {
|
||||
let size = match d.str("size").as_str() {
|
||||
"custom" => json!(d.f64("custom", GapSize::SMALL)),
|
||||
id => json!(id),
|
||||
};
|
||||
json!({
|
||||
"ids": d.fields.get("__ids").cloned().unwrap_or_default(),
|
||||
"detect": d.bool("detect"),
|
||||
"size": size,
|
||||
"color": d.str("color"),
|
||||
"closeGaps": d.bool("closeGaps"),
|
||||
})
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
form::check(ui, d, "detect", tl!("Gap Detection"));
|
||||
ui.add_space(6.0);
|
||||
let size = d.str("size");
|
||||
let shown = SIZES.iter().find(|(v, _)| *v == size).map_or(SIZES[0].1, |(_, l)| l);
|
||||
let labels = SIZES.map(|(_, l)| l);
|
||||
widgets::label_row(ui, tl!("Paint Stops At:"), LABEL_W, |ui| {
|
||||
if let Some((value, _)) = widgets::dropdown(ui, "gap-size", shown, &labels, 130.0).and_then(|i| SIZES.get(i)) {
|
||||
d.fields.insert("size".into(), json!(value));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
// The custom size field is enabled while Custom is chosen.
|
||||
ui.add_enabled_ui(size == "custom", |ui| form::length(ui, d, "custom", app.session.general_unit(), 72.0));
|
||||
});
|
||||
let color = resolve(&d.str("color")).unwrap_or(Color::BLACK);
|
||||
let [r, g, b, _] = color.to_rgba8(1.0);
|
||||
let preset = LAYER_COLORS.iter().position(|(_, c)| *c == [r, g, b]);
|
||||
let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect();
|
||||
widgets::label_row(ui, tl!("Gap Preview Color:"), LABEL_W, |ui| {
|
||||
if let Some((_, [r, g, b])) =
|
||||
widgets::dropdown(ui, "gap-color", names[preset.unwrap_or(LAYER_COLORS.len())], &names, 130.0).and_then(|i| LAYER_COLORS.get(i))
|
||||
{
|
||||
d.fields.insert("color".into(), json!(Color::rgb8(*r, *g, *b).to_hex()));
|
||||
}
|
||||
// The color button sets any other color; the dropdown then shows Custom.
|
||||
let mut rgb = [r, g, b];
|
||||
if ui.color_edit_button_srgb(&mut rgb).changed() {
|
||||
d.fields.insert("color".into(), json!(Color::rgb8(rgb[0], rgb[1], rgb[2]).to_hex()));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
form::check(ui, d, "closeGaps", tl!("Close Gaps With Paths"));
|
||||
let p = params(d);
|
||||
if let Some(r) = form::preview_result(app, ui, d, "Gap Options", CMD, p) {
|
||||
d.fields.insert(GAPS.into(), r["gaps"].clone());
|
||||
}
|
||||
if !d.bool("preview") {
|
||||
d.fields.remove(GAPS);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
form::commit_preview(app, CMD, params(d))
|
||||
}
|
||||
|
||||
/// The gaps the open Gap Options previews, as lines in its preview color (none while Preview is
|
||||
/// off or another dialog is open).
|
||||
pub(crate) fn overlays(app: &VectorcraftApp) -> Vec<Overlay> {
|
||||
let Some(d) = app.ui.dialog.as_ref().filter(|d| d.kind == KIND && d.bool("preview")) else { return vec![] };
|
||||
let [r, g, b, _] = resolve(&d.str("color")).unwrap_or(Color::BLACK).to_rgba8(1.0);
|
||||
let gaps = d.fields.get(GAPS).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
gaps.iter()
|
||||
.filter_map(|gap| {
|
||||
let v: Vec<f64> = gap.as_array()?.iter().filter_map(Value::as_f64).collect();
|
||||
let [x0, y0, x1, y1] = v.as_slice() else { return None };
|
||||
let mut path = BezPath::new();
|
||||
path.move_to(Point::new(*x0, *y0));
|
||||
path.line_to(Point::new(*x1, *y1));
|
||||
Some(Overlay::Path { path, color: [r, g, b], width: 2.0, dashed: false })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_doc::{GapOptions, NodeId};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn options(app: &VectorcraftApp, g: u64) -> GapOptions {
|
||||
app.session.doc().unwrap().doc.node(NodeId(g)).unwrap().gap_options()
|
||||
}
|
||||
|
||||
fn faces(app: &VectorcraftApp, g: u64) -> usize {
|
||||
vectorcraft_tools::builder::faces(app.session.doc().unwrap().doc.node(NodeId(g)).unwrap()).len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_item_opens_gap_options_which_preview_the_gaps_and_ok_keeps_them() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
// An open path round a square, its end 2 pt short of its start.
|
||||
let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 2)].map(|(x, y)| json!({"x": x, "y": y}));
|
||||
let p = app.run("path.create", json!({ "anchors": anchors })).unwrap();
|
||||
app.run("select.set", json!({"ids": [p["id"]]})).unwrap();
|
||||
// Without a Live Paint group the dialog does not open.
|
||||
assert!(app.run("ui.livePaintGapOptions", json!({})).is_err());
|
||||
let g = app.run("livePaint.make", json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
// Object › Live Paint › Gap Options… runs the command that opens the dialog.
|
||||
let tree = crate::menus::menu_tree();
|
||||
let (_, object) = tree.iter().find(|(t, _)| *t == "Object").unwrap();
|
||||
let live = object.iter().find_map(|i| if let crate::menus::Item::Sub("Live Paint", items) = i { Some(items) } else { None }).unwrap();
|
||||
assert!(live.iter().any(|i| matches!(i, crate::menus::Item::Cmd("Gap Options…", "ui.livePaintGapOptions", _))), "{live:?}");
|
||||
crate::menus::invoke(&mut app, "ui.livePaintGapOptions", json!({}));
|
||||
let d = app.ui.dialog.as_ref().expect("Object › Live Paint › Gap Options… opens the dialog");
|
||||
assert_eq!(d.kind, KIND);
|
||||
assert_eq!((d.bool("detect"), d.str("size"), d.str("color")), (false, "small".to_string(), "#ff4f4f".to_string()));
|
||||
frame(&mut app);
|
||||
assert!(overlays(&app).is_empty(), "no preview yet");
|
||||
// Preview with Gap Detection and Small Gaps: the square fills as one face and its gap shows.
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("detect".into(), json!(true));
|
||||
d.fields.insert("size".into(), json!("small"));
|
||||
d.fields.insert("color".into(), json!("Green"));
|
||||
d.fields.insert("preview".into(), json!(true));
|
||||
frame(&mut app);
|
||||
assert_eq!(faces(&app, g), 1, "previewed");
|
||||
let shown = overlays(&app);
|
||||
assert_eq!(shown.len(), 1);
|
||||
assert!(matches!(&shown[0], Overlay::Path { color: [0x4f, 0xff, 0x4f], .. }), "{shown:?}");
|
||||
// Cancel rolls the preview back.
|
||||
crate::dialogs::cancel(&mut app);
|
||||
assert_eq!((faces(&app, g), options(&app, g)), (0, GapOptions::default()));
|
||||
assert!(overlays(&app).is_empty());
|
||||
// OK keeps a custom size as one undo step.
|
||||
app.run("ui.livePaintGapOptions", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("detect".into(), json!(true));
|
||||
d.fields.insert("size".into(), json!("custom"));
|
||||
d.fields.insert("custom".into(), json!(2.5));
|
||||
frame(&mut app);
|
||||
let depth = app.session.doc().unwrap().history.undo.len();
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), depth + 1);
|
||||
assert_eq!(options(&app, g).size, GapSize::Custom(2.5));
|
||||
assert_eq!(faces(&app, g), 1);
|
||||
// The dialog opens again on those options; Close Gaps With Paths adds the closing path.
|
||||
app.run("ui.livePaintGapOptions", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert_eq!((d.str("size"), d.f64("custom", 0.0)), ("custom".to_string(), 2.5));
|
||||
d.fields.insert("detect".into(), json!(false));
|
||||
d.fields.insert("closeGaps".into(), json!(true));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(faces(&app, g), 1, "closed by a path");
|
||||
let info = app.run("livePaint.info", json!({"group": g})).unwrap();
|
||||
assert_eq!((info["sources"].as_u64(), info["gaps"].as_array().map(Vec::len)), (Some(2), Some(0)));
|
||||
// OK keeps Medium Gaps, and the dialog opens again on Large Gaps.
|
||||
app.run("ui.livePaintGapOptions", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("size".into(), json!("medium"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(options(&app, g).size, GapSize::Medium);
|
||||
app.run("livePaint.gapOptions", json!({"id": g, "size": "large"})).unwrap();
|
||||
app.run("ui.livePaintGapOptions", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.str("size"), d.f64("custom", 0.0)), ("large".to_string(), GapSize::LARGE));
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ pub mod javascript_files;
|
||||
pub mod layer_options;
|
||||
pub mod layers_panel_options;
|
||||
pub mod liquify;
|
||||
pub mod live_paint_gaps;
|
||||
pub mod missing_fonts;
|
||||
pub mod missing_links;
|
||||
pub(crate) mod modal;
|
||||
@@ -314,6 +315,7 @@ registry! {
|
||||
PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC,
|
||||
PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC,
|
||||
BlendOptions: [blend_options::KIND] => blend_options::SPEC,
|
||||
LivePaintGaps: [live_paint_gaps::KIND] => live_paint_gaps::SPEC,
|
||||
BrushOptions: [brush_options::KIND] => brush_options::SPEC,
|
||||
EditSelection: [edit_selection::KIND] => edit_selection::SPEC,
|
||||
PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC,
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
}
|
||||
|
||||
/// `color` (a preset's name or a hex colour) as a colour.
|
||||
fn resolve(color: &str) -> Option<Color> {
|
||||
pub(super) fn resolve(color: &str) -> Option<Color> {
|
||||
match LAYER_COLORS.iter().find(|(name, _)| name.eq_ignore_ascii_case(color.trim())) {
|
||||
Some((_, [r, g, b])) => Some(Color::rgb8(*r, *g, *b)),
|
||||
None => Color::from_hex(color),
|
||||
|
||||
@@ -1160,7 +1160,15 @@
|
||||
Gamut Check Проверка за цветова гама
|
||||
Gamut Warning Предупреждение за цветова гама
|
||||
gap междуредрие
|
||||
Gap Options… Опции за междуредрие…
|
||||
Gap Options… Опции за пролуки…
|
||||
Gap Options Опции за пролуки
|
||||
Gap Detection Откриване на пролуки
|
||||
Paint Stops At: Оцветяването спира при:
|
||||
Small Gaps Малки пролуки
|
||||
Medium Gaps Средни пролуки
|
||||
Large Gaps Големи пролуки
|
||||
Gap Preview Color: Цвят за преглед на пролуките:
|
||||
Close Gaps With Paths Затваряне на пролуките с пътища
|
||||
Gaussian Blur… Гаусово размазване…
|
||||
General Общи
|
||||
General › Placement › X Общи › Разположение › X
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -255,6 +255,14 @@
|
||||
Live Paint Živá malba
|
||||
Merge Sloučit
|
||||
Gap Options… Možnosti mezer…
|
||||
Gap Options Možnosti mezer
|
||||
Gap Detection Detekce mezer
|
||||
Paint Stops At: Barva se zastaví u:
|
||||
Small Gaps Malé mezery
|
||||
Medium Gaps Střední mezery
|
||||
Large Gaps Velké mezery
|
||||
Gap Preview Color: Barva náhledu mezer:
|
||||
Close Gaps With Paths Uzavřít mezery cestami
|
||||
Image Trace Vektorizace obrazu
|
||||
Image Trace (Make and Expand) Vektorizace obrazu (vytvořit a rozložit)
|
||||
Release Image Trace Uvolnit vektorizaci obrazu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1179,6 +1179,14 @@ file-filter Templates Vorlagen
|
||||
Gamut Warning Farbumfang-Warnung
|
||||
gap Lücke
|
||||
Gap Options… Lückenoptionen…
|
||||
Gap Options Lückenoptionen
|
||||
Gap Detection Lückenerkennung
|
||||
Paint Stops At: Farbe stoppt bei:
|
||||
Small Gaps Kleine Lücken
|
||||
Medium Gaps Mittlere Lücken
|
||||
Large Gaps Große Lücken
|
||||
Gap Preview Color: Vorschaufarbe für Lücken:
|
||||
Close Gaps With Paths Lücken mit Pfaden schließen
|
||||
Gaussian Blur… Gaußscher Weichzeichner…
|
||||
General Allgemein
|
||||
General › Placement › X Allgemein › Platzierung › X
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1126,6 +1126,14 @@
|
||||
Gamut Warning Προειδοποίηση χρωματικής γκάμας
|
||||
gap διάκενο
|
||||
Gap Options… Επιλογές διακένου…
|
||||
Gap Options Επιλογές διακένου
|
||||
Gap Detection Εντοπισμός διακένων
|
||||
Paint Stops At: Η βαφή σταματά σε:
|
||||
Small Gaps Μικρά διάκενα
|
||||
Medium Gaps Μεσαία διάκενα
|
||||
Large Gaps Μεγάλα διάκενα
|
||||
Gap Preview Color: Χρώμα προεπισκόπησης διακένων:
|
||||
Close Gaps With Paths Κλείσιμο διακένων με διαδρομές
|
||||
Gaussian Blur… Θόλωση Γκάους…
|
||||
General Γενικά
|
||||
General › Placement › X Γενικά › Τοποθέτηση › X
|
||||
|
||||
|
@@ -1186,6 +1186,14 @@
|
||||
Gamut Warning Aviso de gama
|
||||
gap hueco
|
||||
Gap Options… Opciones de huecos…
|
||||
Gap Options Opciones de huecos
|
||||
Gap Detection Detección de huecos
|
||||
Paint Stops At: La pintura se detiene en:
|
||||
Small Gaps Huecos pequeños
|
||||
Medium Gaps Huecos medianos
|
||||
Large Gaps Huecos grandes
|
||||
Gap Preview Color: Color de previsualización de huecos:
|
||||
Close Gaps With Paths Cerrar huecos con trazados
|
||||
Gaussian Blur… Desenfoque gaussiano…
|
||||
General General
|
||||
General › Placement › X General › Colocación › X
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -252,6 +252,14 @@
|
||||
Live Paint Elävä maalaus
|
||||
Merge Yhdistä
|
||||
Gap Options… Aukkojen asetukset…
|
||||
Gap Options Aukkojen asetukset
|
||||
Gap Detection Aukkojen tunnistus
|
||||
Paint Stops At: Maalaus pysähtyy:
|
||||
Small Gaps Pienet aukot
|
||||
Medium Gaps Keskikokoiset aukot
|
||||
Large Gaps Suuret aukot
|
||||
Gap Preview Color: Aukkojen esikatseluväri:
|
||||
Close Gaps With Paths Sulje aukot poluilla
|
||||
Image Trace Kuvan jäljitys
|
||||
Image Trace (Make and Expand) Kuvan jäljitys (luo ja laajenna)
|
||||
Release Image Trace Vapauta kuvan jäljitys
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1185,6 +1185,14 @@
|
||||
Gamut Warning Alerte de gamme
|
||||
gap espace
|
||||
Gap Options… Options d’espacement…
|
||||
Gap Options Options d’espacement
|
||||
Gap Detection Détection des espaces
|
||||
Paint Stops At: Arrêt de la peinture à :
|
||||
Small Gaps Petits espaces
|
||||
Medium Gaps Espaces moyens
|
||||
Large Gaps Grands espaces
|
||||
Gap Preview Color: Couleur d’aperçu des espaces :
|
||||
Close Gaps With Paths Fermer les espaces avec des tracés
|
||||
Gaussian Blur… Flou gaussien…
|
||||
General Général
|
||||
General › Placement › X Général › Placement › X
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1491,6 +1491,14 @@ file-filter Templates Sablonok
|
||||
Gamut Warning Színtartomány-figyelmeztetés
|
||||
gap hézag
|
||||
Gap Options… Hézagbeállítások…
|
||||
Gap Options Hézagbeállítások
|
||||
Gap Detection Hézagészlelés
|
||||
Paint Stops At: Festés határa:
|
||||
Small Gaps Kis hézagok
|
||||
Medium Gaps Közepes hézagok
|
||||
Large Gaps Nagy hézagok
|
||||
Gap Preview Color: Hézagok előnézeti színe:
|
||||
Close Gaps With Paths Hézagok lezárása útvonalakkal
|
||||
Gaussian Blur Gauss-elmosás
|
||||
Gaussian Blur… Gauss-elmosás…
|
||||
General Általános
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1195,6 +1195,14 @@ file-filter Templates Modelli
|
||||
Gamut Warning Avviso gamut
|
||||
gap spazio
|
||||
Gap Options… Opzioni spazi vuoti…
|
||||
Gap Options Opzioni spazi vuoti
|
||||
Gap Detection Rilevamento spazi vuoti
|
||||
Paint Stops At: La pittura si ferma a:
|
||||
Small Gaps Spazi vuoti piccoli
|
||||
Medium Gaps Spazi vuoti medi
|
||||
Large Gaps Spazi vuoti grandi
|
||||
Gap Preview Color: Colore anteprima spazi vuoti:
|
||||
Close Gaps With Paths Chiudi spazi vuoti con tracciati
|
||||
Gaussian Blur… Sfocatura gaussiana…
|
||||
General Generale
|
||||
General › Placement › X Generale › Posizionamento › X
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -252,6 +252,14 @@
|
||||
Live Paint ライブペイント
|
||||
Merge 結合
|
||||
Gap Options… 間隔オプション…
|
||||
Gap Options 間隔オプション
|
||||
Gap Detection 間隔の検出
|
||||
Paint Stops At: ペイントが止まる間隔:
|
||||
Small Gaps 小さい間隔
|
||||
Medium Gaps 中程度の間隔
|
||||
Large Gaps 大きい間隔
|
||||
Gap Preview Color: 間隔のプレビューカラー:
|
||||
Close Gaps With Paths パスで間隔を閉じる
|
||||
Image Trace 画像トレース
|
||||
Image Tracing 画像トレースオブジェクト
|
||||
Tracing Presets トレースプリセット
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -248,6 +248,14 @@
|
||||
Live Paint 라이브 페인트
|
||||
Merge 병합
|
||||
Gap Options… 간격 옵션…
|
||||
Gap Options 간격 옵션
|
||||
Gap Detection 간격 감지
|
||||
Paint Stops At: 페인트 중지 간격:
|
||||
Small Gaps 작은 간격
|
||||
Medium Gaps 중간 간격
|
||||
Large Gaps 큰 간격
|
||||
Gap Preview Color: 간격 미리 보기 색상:
|
||||
Close Gaps With Paths 패스로 간격 닫기
|
||||
Image Trace 이미지 추적
|
||||
Image Tracing 이미지 추적
|
||||
Tracing Presets 추적 프리셋
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -220,6 +220,14 @@
|
||||
Live Paint Aktywne malowanie
|
||||
Merge Scal
|
||||
Gap Options… Opcje przerw…
|
||||
Gap Options Opcje przerw
|
||||
Gap Detection Wykrywanie przerw
|
||||
Paint Stops At: Farba zatrzymuje się na:
|
||||
Small Gaps Małe przerwy
|
||||
Medium Gaps Średnie przerwy
|
||||
Large Gaps Duże przerwy
|
||||
Gap Preview Color: Kolor podglądu przerw:
|
||||
Close Gaps With Paths Zamknij przerwy ścieżkami
|
||||
Image Trace Obrys obrazu
|
||||
Text Wrap Oblewanie tekstem
|
||||
Text Wrap Options… Opcje oblewania tekstem…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1044,6 +1044,14 @@
|
||||
Gamut Check Verificação de Gamut
|
||||
Gamut Warning Aviso de Gamut
|
||||
Gap Options… Opções de Lacuna…
|
||||
Gap Options Opções de Lacuna
|
||||
Gap Detection Detecção de Lacunas
|
||||
Paint Stops At: Pintura Para Em:
|
||||
Small Gaps Lacunas Pequenas
|
||||
Medium Gaps Lacunas Médias
|
||||
Large Gaps Lacunas Grandes
|
||||
Gap Preview Color: Cor de Visualização das Lacunas:
|
||||
Close Gaps With Paths Fechar Lacunas com Caminhos
|
||||
Gaussian Blur… Desfoque Gaussiano…
|
||||
General Geral
|
||||
Generate CSS Gerar CSS
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1193,6 +1193,14 @@ file-filter Templates Шаблоны
|
||||
Gamut Warning Предупреждение о цветовом охвате
|
||||
gap зазор
|
||||
Gap Options… Параметры зазоров…
|
||||
Gap Options Параметры зазоров
|
||||
Gap Detection Обнаружение зазоров
|
||||
Paint Stops At: Заливка останавливается на:
|
||||
Small Gaps Малые зазоры
|
||||
Medium Gaps Средние зазоры
|
||||
Large Gaps Большие зазоры
|
||||
Gap Preview Color: Цвет просмотра зазоров:
|
||||
Close Gaps With Paths Закрыть зазоры контурами
|
||||
Gaussian Blur… Размытие по Гауссу…
|
||||
General Общие
|
||||
General › Placement › X Общие › Размещение › X
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1163,6 +1163,14 @@
|
||||
Gamut Warning Färgomfångsvarning
|
||||
gap mellanrum
|
||||
Gap Options… Alternativ för mellanrum…
|
||||
Gap Options Alternativ för mellanrum
|
||||
Gap Detection Identifiering av mellanrum
|
||||
Paint Stops At: Målningen stannar vid:
|
||||
Small Gaps Små mellanrum
|
||||
Medium Gaps Medelstora mellanrum
|
||||
Large Gaps Stora mellanrum
|
||||
Gap Preview Color: Förhandsgranskningsfärg för mellanrum:
|
||||
Close Gaps With Paths Stäng mellanrum med banor
|
||||
Gaussian Blur… Gaussisk oskärpa…
|
||||
General Allmänt
|
||||
General › Placement › X Allmänt › Placering › X
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -1189,6 +1189,14 @@ file-filter Templates Шаблони
|
||||
Gamut Warning Попередження про колірне охоплення
|
||||
gap зазор
|
||||
Gap Options… Параметри проміжків…
|
||||
Gap Options Параметри проміжків
|
||||
Gap Detection Виявлення проміжків
|
||||
Paint Stops At: Заливка зупиняється на:
|
||||
Small Gaps Малі проміжки
|
||||
Medium Gaps Середні проміжки
|
||||
Large Gaps Великі проміжки
|
||||
Gap Preview Color: Колір перегляду проміжків:
|
||||
Close Gaps With Paths Закрити проміжки контурами
|
||||
Gaussian Blur… Розмиття за Гауссом…
|
||||
General Загальні
|
||||
General › Placement › X Загальні › Розміщення › X
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 722 and column 40.
|
@@ -1367,6 +1367,14 @@ file-filter Templates Mẫu
|
||||
Gamut Warning Cảnh báo ngoài dải màu
|
||||
gap khoảng cách
|
||||
Gap Options… Tùy chọn khoảng cách…
|
||||
Gap Options Tùy chọn khoảng cách
|
||||
Gap Detection Phát hiện khoảng cách
|
||||
Paint Stops At: Ngừng tô tại:
|
||||
Small Gaps Khoảng cách nhỏ
|
||||
Medium Gaps Khoảng cách vừa
|
||||
Large Gaps Khoảng cách lớn
|
||||
Gap Preview Color: Màu xem trước khoảng cách:
|
||||
Close Gaps With Paths Đóng khoảng cách bằng đường dẫn
|
||||
Gaussian Blur Mờ Gaussian
|
||||
Gaussian Blur… Làm mờ Gaussian…
|
||||
General Chung
|
||||
|
||||
|
@@ -1142,6 +1142,14 @@ file-filter Templates 模板
|
||||
Gamut Warning 色域警告
|
||||
gap 间隔
|
||||
Gap Options… 间隙选项…
|
||||
Gap Options 间隙选项
|
||||
Gap Detection 间隙检测
|
||||
Paint Stops At: 上色停止于:
|
||||
Small Gaps 小间隙
|
||||
Medium Gaps 中等间隙
|
||||
Large Gaps 大间隙
|
||||
Gap Preview Color: 间隙预览颜色:
|
||||
Close Gaps With Paths 用路径封闭间隙
|
||||
Gaussian Blur… 高斯模糊…
|
||||
General 常规
|
||||
General › Placement › X 一般 › 位置 › X
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1166,6 +1166,14 @@ file-filter Templates 範本
|
||||
Gamut Warning 色域警告
|
||||
gap 間隙
|
||||
Gap Options… 間隙選項…
|
||||
Gap Options 間隙選項
|
||||
Gap Detection 間隙偵測
|
||||
Paint Stops At: 上色停止於:
|
||||
Small Gaps 小間隙
|
||||
Medium Gaps 中等間隙
|
||||
Large Gaps 大間隙
|
||||
Gap Preview Color: 間隙預覽顏色:
|
||||
Close Gaps With Paths 以路徑封閉間隙
|
||||
Gaussian Blur… 高斯模糊…
|
||||
General 一般
|
||||
General › Placement › X 一般 › 位置 › X
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -977,6 +977,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{} open Blend Options (Object › Blend › Blend Options…, the Blend tool's double-click, Alt-click and toolbar button; dialog `blendOptions`: spacing smooth|steps|distance, steps, distance (pt), orientation page|path, preview) on the selected blend's options, previewed live: OK runs object.blend.options as one undo step; with no blend selected it sets what new blends start with",
|
||||
),
|
||||
(
|
||||
"ui.livePaintGapOptions",
|
||||
"Gap Options…",
|
||||
"",
|
||||
"{} open Gap Options (Object › Live Paint › Gap Options…; dialog `livePaintGapOptions`: detect, size small|medium|large|custom, custom (pt), color (#rrggbb or a layer color's name), closeGaps, preview) on the selected Live Paint groups, filled in from the first; with preview on, the options apply live and the gaps found draw on the canvas in the preview color: OK runs livePaint.gapOptions as one undo step",
|
||||
),
|
||||
(
|
||||
"ui.perspectivePlane",
|
||||
"Perspective Plane Options…",
|
||||
@@ -1647,6 +1653,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
None => Err("no such perspective grid preset".into()),
|
||||
},
|
||||
"ui.blendOptions" => crate::dialogs::blend_options::open(app),
|
||||
"ui.livePaintGapOptions" => crate::dialogs::live_paint_gaps::open(app),
|
||||
"ui.perspectivePlane" => crate::dialogs::perspective_plane::open(app, p),
|
||||
_ => return None,
|
||||
};
|
||||
@@ -2559,7 +2566,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
||||
c("Merge", "livePaint.merge"),
|
||||
c("Release", "livePaint.release"),
|
||||
Sep,
|
||||
todo("Gap Options…"),
|
||||
c("Gap Options…", "ui.livePaintGapOptions"),
|
||||
Sep,
|
||||
c("Expand", "livePaint.expand"),
|
||||
],
|
||||
|
||||
@@ -1116,6 +1116,14 @@ already walks Image Trace. Short agent entry points:
|
||||
action on every file in a folder or every data set (the Actions panel menu's Batch…).
|
||||
- Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`),
|
||||
`imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`.
|
||||
- Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`,
|
||||
`livePaint.info {group}`, `livePaint.release`, `livePaint.expand`, and `livePaint.gapOptions {ids?, detect?, size?,
|
||||
color?, closeGaps?}` (Object › Live Paint › Gap Options). With `detect: true`, paint stops at the gaps that run
|
||||
from a loose end of a path to the nearest path, up to `size` wide (`small` 3 pt, `medium` 6 pt, `large` 12 pt, or a
|
||||
number of points up to 72); `closeGaps: true` adds an unpainted path across each of them. The options are saved with the group, and the reply
|
||||
and `livePaint.info` list the gaps up to `size` wide that no path closes, as `[x0, y0, x1, y1]`. `livePaint.merge`
|
||||
keeps the bottom group's options when its Gap Detection is on, and otherwise turns Gap Detection on with `small`
|
||||
when another merged group has it on. When Gap Detection is off in every merged group, it stays off.
|
||||
|
||||
`dataset.select`, `dataset.next` and `dataset.prev` reply `{name, applied, skipped}`. They add `overflow: [id…]`
|
||||
when area type or type on a path whose text they rewrote doesn't fit, as `text.setText` does (see
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user