Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions crates/engine/src/cmd/buildcmds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Builder",
[],
None,
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the fill of the front-most object over the first region the drag touches that an object covers; the current fill when only lines enclose every region touched) | \"swatches\" (the current fill), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the fill of the front-most object over the first region the drag touches that an object covers; the current fill when only lines enclose every region touched) | \"swatches\" (the current fill), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
has_selection_or_ids,
sb_merge
),
Expand All @@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Builder Regions",
[],
None,
"{ids?|id?, openFilledAsClosed?: true (as shapeBuilder.merge)} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them, and filled ones too with openFilledAsClosed false; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
"{ids?|id?, gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (as shapeBuilder.merge)} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them, and filled ones too with openFilledAsClosed false; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
has_selection_or_ids,
sb_regions
),
Expand Down Expand Up @@ -236,6 +236,12 @@ fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Paint>> {
}
}

/// Gap Detection length in points, bounded like Live Paint's custom gap size.
fn gap_param(p: &Value) -> f64 {
let gap = f64_or(p, "gap", 0.0);
if gap.is_finite() { gap.clamp(0.0, po::SHAPE_BUILDER_MAX_GAP) } else { 0.0 }
}

fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "shapeBuilder.merge";
let pts = points_param(p, "points").ok_or_else(|| bad(C, "missing points [[x,y]…]"))?;
Expand All @@ -262,7 +268,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
let roots = root_ids(s, p)?;
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
let (ids, merged, touched) = s.edit(label, |d, sel| {
let (map, leaves) = BuilderMap::new(d, &roots, erase, open_filled_as_closed);
let (map, leaves) = BuilderMap::with_gap(d, &roots, erase, open_filled_as_closed, gap_param(p));
let leaves: Vec<Node> = leaves.into_iter().cloned().collect();
if map.shapes.is_empty() {
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
Expand All @@ -280,7 +286,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
// Edges come back from the planar map within a few hundredths of the outlines they follow.
let cut_tol = step * 0.2;
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
let faces = po::FaceMerger::new(&map.shapes);
let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p));
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
// The merged shape takes the look of the front-most object over the first region, or (for an
// area only lines enclose) of the front-most object and the current fill.
Expand Down Expand Up @@ -378,7 +384,7 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
let roots = root_ids(s, p)?;
let d = &s.doc()?.doc;
let (map, leaves) = BuilderMap::new(d, &roots, true, bool_or(p, "openFilledAsClosed", true));
let (map, leaves) = BuilderMap::with_gap(d, &roots, true, bool_or(p, "openFilledAsClosed", true), gap_param(p));
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
let regions: Vec<Value> = map
.regions
Expand Down
49 changes: 49 additions & 0 deletions crates/engine/src/tests_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,55 @@ fn shape_builder_fills_an_area_lines_enclose() {
assert_eq!(ids(&r).len(), 4, "the triangle + the three lines, which only bound it");
}

/// Two diagonals and a crossbar that stops about 3 pt short of each: the triangle they bound.
fn lines_with_gaps(s: &mut Session) {
let l1 = line(s, (0.0, 0.0), (100.0, 100.0));
let l2 = line(s, (100.0, 0.0), (0.0, 100.0));
let l3 = line(s, (24.0, 80.0), (76.0, 80.0));
s.execute("select.set", &json!({"ids": [l1.0, l2.0, l3.0]})).unwrap();
}

#[test]
fn shape_builder_gap_detection_closes_small_gaps_between_lines() {
let mut s = session();
lines_with_gaps(&mut s);
let regions = |s: &mut Session, p: Value| s.execute("shapeBuilder.regions", &p).unwrap()["regions"].as_array().unwrap().len();
assert_eq!(regions(&mut s, json!({})), 0, "off by default");
assert_eq!(regions(&mut s, json!({"gap": 2})), 0, "the gap is longer");
assert_eq!(regions(&mut s, json!({"gap": 3})), 1);
assert!(s.execute("shapeBuilder.merge", &json!({"points": [[50, 70]]})).is_err(), "no gap, no region");
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 70]], "gap": 3})).unwrap();
let merged = NodeId(r["merged"].as_u64().unwrap());
let a = area(&s, merged);
assert!(a > 850.0 && a < 900.0, "the triangle and its two bridges: {a}");
assert_eq!(ids(&r).len(), 4, "the bridges are no art: the merged shape and the three lines");
assert_eq!(open_paths(&s, &ids(&r)).len(), 3);
}

#[test]
fn shape_builder_tool_gap_detection_option() {
let mut s = session();
lines_with_gaps(&mut s);
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
assert_eq!((s.tool_options()["gapDetection"].clone(), s.tool_options()["gapLength"].clone()), (json!(false), json!(3.0)));
let click = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 70.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, 70.0), v).unwrap();
};
click(&mut s);
assert_eq!(layer_children(&s), 3, "Gap Detection is off");
s.set_tool_option_cmd(&json!({"values": {"gapDetection": true, "gapLength": 6.0}})).unwrap();
click(&mut s);
assert_eq!(layer_children(&s), 4, "the triangle");
// The options are kept when another tool is chosen.
s.select_tool("selection", v).unwrap();
assert_eq!(
(s.tool_options_of("shapeBuilder")["gapDetection"].clone(), s.tool_options_of("shapeBuilder")["gapLength"].clone()),
(json!(true), json!(6.0))
);
}

#[test]
fn shape_builder_closes_filled_open_paths() {
let mut s = session();
Expand Down
53 changes: 53 additions & 0 deletions crates/engine/tests/command_sweep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,21 @@ fn structured_junk() {
("symbol.options", json!({"name": "Dot", "nineSlice": true, "nineSliceGuides": [1e308, -1e308, 0, 0]})),
("symbol.options", json!({"nineSlice": 7, "nineSliceGuides": []})),
("symbol.options", json!({"nineSlice": false, "nineSliceGuides": null})),
// Shape Builder Gap Detection: the `gap` param and the tool options.
("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": 1e308})),
("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80], [120, 90]], "gap": -1e308})),
("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": f64::MAX})),
("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": "NaN"})),
("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": "1e999"})),
("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": null})),
("shapeBuilder.merge", json!({"points": [[100, 80]], "gap": [1, 2]})),
("shapeBuilder.merge", json!({"points": [[100, 80]], "gap": {"a": 1}, "erase": true})),
("shapeBuilder.merge", json!({"points": [[100, 80]], "gap": 1e-300, "erase": true, "tolerance": 1e308})),
("shapeBuilder.regions", json!({"gap": 1e308})),
("shapeBuilder.regions", json!({"ids": [0, 1, 2, 3], "gap": -1})),
("shapeBuilder.regions", json!({"ids": [2, 3], "gap": "x"})),
("shapeBuilder.regions", json!({"gap": true})),
("shapeBuilder.regions", json!({"gap": 12, "ids": [u64::MAX]})),
];
let mut failures = vec![];
for fx in Fixture::ALL {
Expand All @@ -501,6 +516,44 @@ fn structured_junk() {
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}

/// Shape Builder Tool Options (Gap Detection) with junk values: never a panic, and the tool still
/// answers with options and works on a drag afterwards.
#[test]
fn shape_builder_options_junk() {
let cases = [
json!({"values": {"gapDetection": true, "gapLength": 1e308}}),
json!({"values": {"gapDetection": "yes", "gapLength": -1e308}}),
json!({"values": {"gapDetection": null, "gapLength": "NaN"}}),
json!({"values": {"gapDetection": [true], "gapLength": [1, 2]}}),
json!({"values": {"gapDetection": true, "gapLength": "1e999"}}),
json!({"values": {"gapLength": {"a": 1}, "bogus": 1}}),
json!({"key": "gapLength", "value": f64::MAX}),
json!({"key": "gapLength", "value": f64::MIN_POSITIVE}),
json!({"key": "gapDetection"}),
json!({"key": "gapDetection", "value": 1}),
json!({"values": [1, 2]}),
json!({"values": "x"}),
];
let mut failures = vec![];
for fx in Fixture::ALL {
for p in &cases {
let mut s = fx.session();
let r = catch_quiet(|| {
let _ = s.select_tool("shapeBuilder", Default::default());
let _ = s.set_tool_option_cmd(p);
let _ = s.set_tool_option_cmd(&json!({"values": {"gapDetection": true}}));
no_panic(s.execute("shapeBuilder.regions", &json!({})))
});
if let Err(msg) = r {
failures.push(format!("PANIC {p} [{fx:?}]: {msg}"));
} else if let Err(e) = check_session(&s) {
failures.push(format!("{p} [{fx:?}]: {e}"));
}
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}

/// Area Type Options with junk fit values, on selected area type (the fixtures have none), with
/// text that overflows: never a panic, the document stays sound, and editing still works.
#[test]
Expand Down
4 changes: 2 additions & 2 deletions crates/pathops/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ 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, find_gaps, has_open_path, interior_point, live_paint,
live_paint_with_gaps, shape_builder,
BuilderArrangement, SHAPE_BUILDER_MAX_GAP, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point,
live_paint, live_paint_with_gaps, shape_builder, shape_builder_with_gap,
};

/// Errors from fallible operations.
Expand Down
6 changes: 6 additions & 0 deletions crates/pathops/src/pathfinder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,12 @@ impl FaceMerger {
Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len(), lines, snap: std::cell::OnceCell::new() }
}

/// Merge faces found with Gap Detection using the same temporary bridges as Live Paint.
/// Source indices stay those of the original inputs; bridges have no fill and are never art.
pub fn with_gap(shapes: &[Shape], gap: f64) -> Self {
Self::new(&crate::planar::gap_inputs(shapes, gap))
}

/// The union of `faces`.
pub fn merge(&self, faces: &[&Region]) -> PathData {
self.exact(faces).or_else(|| self.planar(faces)).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces))
Expand Down
Loading
Loading