Merge #610: Fix Shaper recognition and add editable Shaper groups

# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
This commit is contained in:
bflatastic
2026-10-09 05:13:59 +07:00
27 changed files with 1817 additions and 116 deletions
+23 -4
View File
@@ -9,8 +9,16 @@ use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("shape.rectangle", "Rectangle", [], None, "{x, y, width, height, radius?: pt} → {id}", has_doc, rectangle),
cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height} → {id}", has_doc, ellipse),
cmd!(
"shape.rectangle",
"Rectangle",
[],
None,
"{x, y, width, height, radius?: pt, rotation?: deg (about centre)} → {id}",
has_doc,
rectangle
),
cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height, rotation?: deg (about centre)} → {id}", has_doc, ellipse),
cmd!("shape.polygon", "Polygon", [], None, "{cx, cy, radius, sides=6, rotation?: deg} → {id}", has_doc, polygon),
cmd!("shape.star", "Star", [], None, "{cx, cy, radius1, radius2, points=5, rotation?: deg} → {id}", has_doc, star),
cmd!(
@@ -163,6 +171,17 @@ fn rect_of(p: &Value, cmd: &str) -> Result<Rect> {
Ok(Rect::new(x, y, x + w, y + h).abs())
}
fn rect_transform(r: Rect, p: &Value, cmd: &str) -> Result<Affine> {
let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", cmd)? } else { 0.0 };
if !rotation.is_finite() {
return Err(bad(cmd, "rotation must be finite"));
}
if rotation == 0.0 {
return Ok(Affine::translate(r.origin().to_vec2()));
}
Ok(Affine::translate(r.center().to_vec2()) * Affine::rotate(rotation.to_radians()) * Affine::translate((-r.width() / 2.0, -r.height() / 2.0)))
}
fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.rectangle")?;
let radius = f64_or(p, "radius", 0.0).max(0.0);
@@ -171,7 +190,7 @@ fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
h: r.height(),
radii: [radius; 4],
kinds: Default::default(),
xf: Affine::translate(r.origin().to_vec2()),
xf: rect_transform(r, p, "shape.rectangle")?,
};
let label = if radius > 0.0 { "Rounded Rectangle" } else { "Rectangle" };
add_art(s, label, path_kind(live.to_path(), Some(live)), None)
@@ -179,7 +198,7 @@ fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.ellipse")?;
let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie: (0.0, 360.0), xf: Affine::translate(r.origin().to_vec2()) };
let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie: (0.0, 360.0), xf: rect_transform(r, p, "shape.ellipse")? };
add_art(s, "Ellipse", path_kind(live.to_path(), Some(live)), None)
}
+2
View File
@@ -59,6 +59,7 @@ pub mod rasterfx;
mod recolor;
pub mod recovery;
mod select;
pub(crate) mod shaper;
pub(crate) mod slices;
mod stroke;
mod style;
@@ -231,6 +232,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(docinfo::specs());
v.extend(panelcmds::specs());
v.extend(buildcmds::specs());
v.extend(shaper::specs());
v.extend(brushsym::specs());
v.extend(patterncmds::specs());
v.extend(prefscmds::specs());
+4 -2
View File
@@ -722,7 +722,8 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
if st.doc.node(id).is_none_or(|n| !n.is_container()) {
return Err(bad("object.isolate", "only groups and layers can be isolated"));
}
st.isolation = Some(id);
let isolation = st.doc.node(id).filter(|n| n.shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id);
st.isolation = Some(isolation);
st.selection.clear();
st.revision += 1;
ok()
@@ -732,7 +733,8 @@ fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
if let Some(i) = st.isolation.take() {
super::distortcmds::finish_edit_text(st, i);
st.selection.set([i]);
let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.shaper.is_some())).unwrap_or(i);
st.selection.set([target]);
}
st.revision += 1;
ok()
+771
View File
@@ -0,0 +1,771 @@
//! Shaper compositions are groups of generated fills/strokes with transparent, editable source
//! art retained underneath. Recipes select topology regions; source edits re-evaluate them in
//! the same undo transaction. Rendering and exporters only see ordinary groups and paths.
use super::*;
use serde_json::{Value, json};
use std::{collections::BTreeMap, sync::Arc};
use vectorcraft_doc::shaper::{EdgeEdit, FILL_PREFIX, FaceEdit, FaceSelector, SOURCES, STROKE_PREFIX, ShaperSpec};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, Shape as _};
use vectorcraft_pathops as po;
use vectorcraft_tools::builder::{self as b, BuilderMap, Hit};
const MAX_SOURCES: usize = 64;
const MAX_POINTS: usize = 4096;
const MAX_EDITS: usize = 8192;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"shaper.scribble",
"Shaper",
[],
None,
"{points: [[x,y]…], ids?: [id…] (default: nearby art), tolerance?: pt (4)} interior scribbles remove fills; scribbles through several regions merge with the origin fill; ending outside removes fills and boundary strokes; outside scribbles erase stroke pieces. Originals remain editable → {id, regions, strokes}",
has_doc,
scribble
),
cmd!(
"shaper.select",
"Select Shaper Face",
[],
None,
"{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original → {id}",
has_doc,
select
),
cmd!(
"shaper.release",
"Release Shaper Group",
["Object", "Shaper"],
None,
"{id? (default: selection)} restore the original paths → {ids}",
has_doc,
release
),
cmd!(
"shaper.expand",
"Expand Shaper Group",
["Object", "Shaper"],
None,
"{id? (default: selection)} keep the visible result as ordinary paths → {id}",
has_doc,
expand
),
]
}
fn source_group(n: &Node) -> Option<&Node> {
n.shaper.as_ref()?;
n.children()?.first().map(AsRef::as_ref).filter(|g| g.name.as_deref() == Some(SOURCES))
}
fn sources(n: &Node) -> Vec<Arc<Node>> {
source_group(n).and_then(Node::children).cloned().unwrap_or_default()
}
fn finite(p: Point) -> bool {
p.x.is_finite() && p.y.is_finite() && p.x.abs() <= 1e9 && p.y.abs() <= 1e9
}
fn map_of(src: &[Arc<Node>]) -> Result<BuilderMap> {
if src.is_empty() || src.len() > MAX_SOURCES {
return Err(bad("shaper.scribble", "Shaper needs 1–64 source paths"));
}
let mut count = 0usize;
let mut shapes = vec![];
for (i, n) in src.iter().enumerate() {
let (mut path, rule) = b::node_outline(n).ok_or_else(|| bad("shaper.scribble", "use paths or compound paths"))?;
count = count.saturating_add(path.subpaths.iter().map(|s| s.segment_count()).sum::<usize>());
if count > 4096 || path.subpaths.iter().flat_map(|s| &s.anchors).any(|a| !finite(a.p) || !finite(a.h_in) || !finite(a.h_out)) {
return Err(bad("shaper.scribble", "source paths are too large for Shaper"));
}
if !n.visible {
path = PathData::default();
} else if !n.appearance.fill_paint().is_none() {
for sub in path.subpaths.iter_mut().filter(|s| !s.closed) {
sub.closed = po::encloses_area(sub);
}
}
shapes.push(po::Shape::new(path, rule, i as u64));
}
let arrangement = po::shape_builder(&shapes, true);
let bounded = |pieces: Vec<po::Shape>| pieces.into_iter().filter_map(|p| p.path.bounds().map(|b| (p, b))).collect();
let regions = arrangement
.regions
.into_iter()
.filter_map(|r| {
let bounds = r.path.bounds()?;
let path = r.path.to_bezpath();
Some((r, path, bounds))
})
.collect();
let map = BuilderMap { shapes, regions, edges: bounded(arrangement.edges), lines: bounded(arrangement.lines) };
if map.regions.is_empty() && map.lines.is_empty() && map.edges.is_empty() {
return Err(bad("shaper.scribble", "these paths are too degenerate for Shaper"));
}
Ok(map)
}
fn relative(src: &[Arc<Node>], i: usize, p: Point) -> Point {
let b = src.get(i).and_then(|n| n.geometric_bounds()).unwrap_or(Rect::ZERO);
Point::new((p.x - b.x0) / b.width().max(1e-9), (p.y - b.y0) / b.height().max(1e-9))
}
fn absolute(src: &[Arc<Node>], i: usize, p: Point) -> Point {
let b = src.get(i).and_then(|n| n.geometric_bounds()).unwrap_or(Rect::ZERO);
Point::new(b.x0 + p.x * b.width(), b.y0 + p.y * b.height())
}
fn face_selector(map: &BuilderMap, src: &[Arc<Node>], i: usize) -> Option<FaceSelector> {
let (r, _, bounds) = map.regions.get(i)?;
let source = *r.sources.first()?;
let point = po::interior_point(&r.path).unwrap_or(bounds.center());
Some(FaceSelector { sources: r.sources.clone(), anchor: relative(src, source, point) })
}
fn selected_face(map: &BuilderMap, src: &[Arc<Node>], selector: &FaceSelector) -> Option<usize> {
let point = absolute(src, *selector.sources.first()?, selector.anchor);
map.regions
.iter()
.enumerate()
.filter(|(_, (r, _, _))| r.sources == selector.sources)
.min_by(|(_, a), (_, b)| {
let distance = |r: &b::Face| if r.1.winding(point) != 0 { 0.0 } else { r.2.center().distance(point) };
distance(a).total_cmp(&distance(b))
})
.map(|(i, _)| i)
}
fn piece_point(path: &PathData) -> Option<Point> {
use vectorcraft_geom::ParamCurve as _;
let sub = path.subpaths.first()?;
(sub.segment_count() > 0).then(|| sub.segment(sub.segment_count() / 2).eval(0.5))
}
fn pieces(map: &BuilderMap) -> Vec<&po::Shape> {
map.edges.iter().chain(&map.lines).map(|(p, _)| p).collect()
}
fn side_sources(map: &BuilderMap, p: &po::Shape) -> [Vec<usize>; 2] {
let mut sides = map.sides(p).map(|i| i.and_then(|i| map.regions.get(i)).map_or(vec![], |r| r.0.sources.clone()));
sides.sort();
sides
}
fn edge_selector(map: &BuilderMap, src: &[Arc<Node>], piece: &po::Shape) -> Option<EdgeEdit> {
let source = usize::try_from(piece.key).ok()?;
Some(EdgeEdit { source, sides: side_sources(map, piece), anchor: relative(src, source, piece_point(&piece.path)?), erase: true, paint: None })
}
fn selected_edge(map: &BuilderMap, src: &[Arc<Node>], edit: &EdgeEdit) -> Option<usize> {
let point = absolute(src, edit.source, edit.anchor);
pieces(map)
.iter()
.enumerate()
.filter(|(_, p)| p.key == edit.source as u64 && side_sources(map, p) == edit.sides)
.filter_map(|(i, p)| p.path.nearest(point).map(|(_, _, _, _, distance)| (i, distance)))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|p| p.0)
}
fn face_edits<'a>(map: &BuilderMap, src: &[Arc<Node>], spec: &'a ShaperSpec) -> BTreeMap<usize, &'a FaceEdit> {
spec.faces.iter().filter_map(|e| selected_face(map, src, &e.selector).map(|i| (i, e))).collect()
}
fn fill_source(src: &[Arc<Node>], r: &po::Region) -> Option<usize> {
r.sources.iter().rev().copied().find(|i| src.get(*i).is_some_and(|s| s.visible && !s.appearance.fill_paint().is_none()))
}
/// Regions separated only by covered strokes are one visible face. A visible line
/// through a circle still separates two faces, even though their fills have the same source.
fn visible_components(map: &BuilderMap, src: &[Arc<Node>], edits: &BTreeMap<usize, &FaceEdit>) -> Vec<usize> {
fn root(parents: &[usize], mut i: usize) -> usize {
for _ in 0..parents.len() {
let Some(&next) = parents.get(i) else { return i };
if next == i {
return i;
}
i = next;
}
i
}
let owner = |i: usize| {
let edit = edits.get(&i);
if edit.is_some_and(|e| e.erase || e.paint.as_ref().is_some_and(|a| a.fill_paint().is_none())) {
return None;
}
edit.and_then(|e| e.paint_source).or_else(|| map.regions.get(i).and_then(|r| fill_source(src, &r.0)))
};
let mut parents: Vec<usize> = (0..map.regions.len()).collect();
for piece in pieces(map) {
let [Some(a), Some(b)] = map.sides(piece) else { continue };
let (Some(left), Some(right)) = (owner(a), owner(b)) else { continue };
let merge = |i| edits.get(&i).and_then(|e| e.merge);
let merged = merge(a).is_some() && merge(a) == merge(b);
let covered = left as u64 > piece.key && right as u64 > piece.key;
let unstroked = src.get(piece.key as usize).is_none_or(|n| !n.visible || n.appearance.stroke_paint().is_none());
if (left == right || merged) && (merged || covered || unstroked) {
let (a, b) = (root(&parents, a), root(&parents, b));
if let Some(parent) = parents.get_mut(a) {
*parent = b;
}
}
}
(0..parents.len()).map(|i| root(&parents, i)).collect()
}
fn fill_style(mut a: Appearance) -> Appearance {
a.items.retain(AppearanceItem::is_fill);
a
}
fn stroke_style(mut a: Appearance) -> Appearance {
a.items.retain(|i| !i.is_fill());
a
}
/// Join compatible strokes at two-way junctions. Branches stay separate; a line
/// crossing a circle must not turn into the circle's outline at the intersection.
fn join_strokes(nodes: Vec<Arc<Node>>) -> Vec<Arc<Node>> {
const TOL: f64 = 1e-5;
type End = (usize, bool);
let mut buckets: BTreeMap<(i64, i64), Vec<(End, Point)>> = BTreeMap::new();
let key = |p: Point| finite(p).then(|| ((p.x / TOL).floor() as i64, (p.y / TOL).floor() as i64));
for (i, n) in nodes.iter().enumerate() {
let Some(sp) = n.path_data().filter(|p| p.subpaths.len() == 1).and_then(|p| p.subpaths.first()).filter(|p| !p.closed) else { continue };
for (last, a) in [(false, sp.anchors.first()), (true, sp.anchors.last())] {
if let Some(a) = a
&& let Some(key) = key(a.p)
{
buckets.entry(key).or_default().push(((i, last), a.p));
}
}
}
let mut connections: BTreeMap<End, End> = BTreeMap::new();
for entries in buckets.values() {
for &(end, p) in entries {
let Some((x, y)) = key(p) else { continue };
let mut near = vec![];
for dx in -1..=1 {
for dy in -1..=1 {
if let Some(entries) = buckets.get(&(x + dx, y + dy)) {
near.extend(entries.iter().filter(|(_, q)| p.distance(*q) <= TOL).map(|(end, _)| *end));
}
}
}
if near.len() != 2 {
continue;
}
let Some(other) = near.into_iter().find(|e| *e != end) else { continue };
let Some((a, b)) = nodes.get(end.0).zip(nodes.get(other.0)) else { continue };
if a.appearance == b.appearance && a.opacity == b.opacity && a.blend == b.blend {
connections.insert(end, other);
}
}
}
let mut seen = std::collections::BTreeSet::new();
let mut out = vec![];
// Open chains first, then closed loops. This avoids beginning in a chain's middle.
let starts = nodes
.iter()
.enumerate()
.flat_map(|(i, _)| [(i, false), (i, true)])
.filter(|e| !connections.contains_key(e))
.chain(nodes.iter().enumerate().map(|(i, _)| (i, false)));
for start in starts {
if seen.contains(&start.0) {
continue;
}
if let Some(node) = nodes.get(start.0).filter(|n| n.path_data().is_none_or(|p| p.subpaths.len() != 1)) {
seen.insert(start.0);
out.push(node.clone());
continue;
}
let mut end = start;
let mut prototype = start.0;
let mut path = vectorcraft_geom::SubPath::default();
loop {
if !seen.insert(end.0) {
break;
}
prototype = prototype.min(end.0);
let Some(mut next) = nodes.get(end.0).and_then(|n| n.path_data()).and_then(|p| p.subpaths.first()).cloned() else { break };
if end.1 {
next.reverse();
}
if path.anchors.is_empty() {
path = next;
} else {
if let Some((a, b)) = path.anchors.last_mut().zip(next.anchors.first()) {
*a = vectorcraft_geom::Anchor::with_handles(a.p, a.h_in, b.h_out);
}
path.anchors.extend(next.anchors.into_iter().skip(1));
}
let Some(&next) = connections.get(&(end.0, !end.1)) else { break };
if next == start {
if let Some(last) = path.anchors.pop()
&& let Some(first) = path.anchors.first_mut()
{
*first = vectorcraft_geom::Anchor::with_handles(first.p, last.h_in, first.h_out);
}
path.closed = true;
break;
}
end = next;
}
if let Some(mut node) = nodes.get(prototype).cloned() {
if let Some(p) = Arc::make_mut(&mut node).path_data_mut() {
*p = PathData::new(vec![path]);
}
out.push(node);
}
}
out
}
fn generated(d: &mut Document, src: &[Arc<Node>], spec: &ShaperSpec, map: &BuilderMap) -> Result<Vec<Arc<Node>>> {
if spec.faces.len() + spec.edges.len() > MAX_EDITS {
return Err(bad("shaper.scribble", "too many Shaper edits"));
}
let edits = face_edits(map, src, spec);
let mut fills: BTreeMap<(Option<u64>, usize), Vec<usize>> = BTreeMap::new();
for (i, (r, _, _)) in map.regions.iter().enumerate() {
let edit = edits.get(&i);
if edit.is_some_and(|e| e.erase) {
continue;
}
if edit.and_then(|e| e.paint_source).or_else(|| fill_source(src, r)).is_none() {
continue;
}
let merge = edit.and_then(|e| e.merge);
fills.entry((merge, if merge.is_some() { 0 } else { i })).or_default().push(i);
}
let mut out = vec![];
for indices in fills.values() {
let Some(&i) = indices.first() else { continue };
let Some((region, _, _)) = map.regions.get(i) else { continue };
let edit = edits.get(&i);
let Some(source) = edit.and_then(|e| e.paint_source).or_else(|| fill_source(src, region)).and_then(|i| src.get(i)) else { continue };
let path = po::unite_all(&indices.iter().filter_map(|i| map.regions.get(*i).map(|r| (&r.0.path, FillRule::NonZero))).collect::<Vec<_>>());
let style = edit.and_then(|e| e.paint.clone()).unwrap_or_else(|| source.appearance.clone());
let mut n = Node::path(d.alloc_id(), path, fill_style(style));
n.opacity = source.opacity;
n.blend = source.blend;
n.name = Some(format!("{FILL_PREFIX}{i}"));
out.push(Arc::new(n));
}
let edge_edits: BTreeMap<usize, &EdgeEdit> = spec.edges.iter().filter_map(|e| selected_edge(map, src, e).map(|i| (i, e))).collect();
let mut strokes = vec![];
for (i, piece) in pieces(map).into_iter().enumerate() {
let sides = map.sides(piece);
let merge = |r: Option<usize>| r.and_then(|r| edits.get(&r)).and_then(|e| e.merge);
let merged_inside = merge(sides[0]).is_some() && merge(sides[0]) == merge(sides[1]);
// An exiting scribble removes the exposed stroke around the deleted area.
// Its shared boundary with surviving art still closes that art's contour.
let erased_boundary =
sides.iter().any(Option::is_some) && sides.iter().all(|r| r.is_none_or(|r| edits.get(&r).is_some_and(|e| e.erase && e.erase_edges)));
let occluded = sides
.iter()
.all(|r| r.and_then(|r| map.regions.get(r)).and_then(|r| fill_source(src, &r.0)).is_some_and(|source| source as u64 > piece.key));
if merged_inside || erased_boundary || occluded || edge_edits.get(&i).is_some_and(|e| e.erase) {
continue;
}
let Some(source) = src.get(piece.key as usize) else { continue };
let style = edge_edits.get(&i).and_then(|e| e.paint.clone()).unwrap_or_else(|| source.appearance.clone());
if style.stroke_paint().is_none() {
continue;
}
let mut n = Node::path(d.alloc_id(), piece.path.clone(), stroke_style(style));
n.opacity = source.opacity;
n.blend = source.blend;
n.name = Some(format!("{STROKE_PREFIX}{i}"));
strokes.push(Arc::new(n));
}
out.extend(join_strokes(strokes));
Ok(out)
}
/// Re-evaluate only groups whose original art changed. Errors reach Session::edit's rollback.
pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
let mut ids = vec![];
d.walk(|n| {
if n.shaper.is_some() {
ids.push(n.id)
}
});
for id in ids {
let Some(current) = d.node(id).cloned() else { continue };
let Some(old) = before.node(id) else { continue };
let src = sources(&current);
let source_changed = sources(old) != src;
let output_changed = current.children().is_some_and(|children| {
children
.iter()
.skip(1)
.any(|n| old.children().and_then(|c| c.iter().find(|o| o.id == n.id)).is_some_and(|o| o.appearance != n.appearance))
});
if !source_changed && !output_changed {
continue;
}
if src.is_empty() {
d.remove(id)?;
continue;
}
let map = map_of(&src)?;
let mut spec = current.shaper.as_deref().cloned().unwrap_or_default();
let previous = sources(old);
if previous.iter().map(|s| s.id).ne(src.iter().map(|s| s.id)) {
// Source insertion, deletion or reordering updates the index-based recipes.
let index = |i: usize| previous.get(i).and_then(|old| src.iter().position(|n| n.id == old.id));
spec.faces.retain_mut(|e| {
let Some(indices) = e.selector.sources.iter().map(|i| index(*i)).collect::<Option<Vec<_>>>() else { return false };
let paint_source = match e.paint_source {
Some(i) => {
let Some(i) = index(i) else { return false };
Some(i)
}
None => None,
};
e.selector.sources = indices;
e.selector.sources.sort();
e.paint_source = paint_source;
true
});
spec.edges.retain_mut(|e| {
let Some(source) = index(e.source) else { return false };
for side in &mut e.sides {
let Some(indices) = side.iter().map(|i| index(*i)).collect::<Option<Vec<_>>>() else { return false };
*side = indices;
side.sort();
}
e.sides.sort();
e.source = source;
true
});
}
if output_changed {
for n in current.children().into_iter().flatten().skip(1) {
let Some(o) = old.children().and_then(|c| c.iter().find(|o| o.id == n.id)) else { continue };
if o.appearance == n.appearance {
continue;
}
if let Some(i) = n.name.as_deref().and_then(|s| s.strip_prefix(FILL_PREFIX)).and_then(|s| s.parse::<usize>().ok()) {
let existing = face_edits(&map, &src, &spec);
let merge = existing.get(&i).and_then(|e| e.merge);
let components = visible_components(&map, &src, &existing);
let related: Vec<usize> = if let Some(m) = merge {
existing.iter().filter(|(_, e)| e.merge == Some(m)).map(|(i, _)| *i).collect()
} else {
components.iter().enumerate().filter(|(_, c)| Some(*c) == components.get(i)).map(|(i, _)| i).collect()
};
for i in related {
let Some(selector) = face_selector(&map, &src, i) else { continue };
if let Some(e) = spec.faces.iter_mut().rev().find(|e| e.selector == selector) {
e.paint = Some(n.appearance.clone());
} else {
spec.faces.push(FaceEdit {
selector,
erase: false,
erase_edges: false,
paint_source: None,
merge,
paint: Some(n.appearance.clone()),
});
}
}
} else if n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX)) {
// One joined output may contain pieces from several original paths.
for piece in pieces(&map) {
let contained = piece
.path
.subpaths
.iter()
.flat_map(|s| &s.anchors)
.all(|a| n.path_data().and_then(|p| p.nearest(a.p)).is_some_and(|p| p.4 < 1e-5))
&& piece_point(&piece.path).is_some_and(|point| n.path_data().and_then(|p| p.nearest(point)).is_some_and(|p| p.4 < 1e-5));
if contained && let Some(mut e) = edge_selector(&map, &src, piece) {
e.erase = false;
e.paint = Some(n.appearance.clone());
spec.edges.push(e);
}
}
}
}
}
let mut children = current.children().and_then(|c| c.first()).cloned().into_iter().collect::<Vec<_>>();
let old_ids: BTreeMap<&str, NodeId> =
current.children().into_iter().flatten().skip(1).filter_map(|n| n.name.as_deref().map(|name| (name, n.id))).collect();
for mut n in generated(d, &src, &spec, &map)? {
if let Some(id) = n.name.as_deref().and_then(|name| old_ids.get(name)) {
Arc::make_mut(&mut n).id = *id;
}
children.push(n);
}
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
n.shaper = Some(Box::new(spec));
if let Some(ch) = n.children_mut() {
*ch = children;
}
}
Ok(())
}
fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result<Vec<NodeId>> {
let st = s.doc()?;
let area = points.iter().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| b.union_pt(*p)).inflate(tolerance, tolerance);
let supplied = p.get("ids").and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_u64).map(NodeId).collect::<Vec<_>>());
let expand = supplied.is_none() && st.selection.objects.len() < 2;
let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() });
let eligible: Vec<NodeId> = ids
.into_iter()
.filter(|id| {
st.doc.is_editable(*id)
&& st.doc.node(*id).is_some_and(|n| {
!matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. })
&& (n.shaper.is_some() || b::node_outline(n).is_some())
})
})
.collect();
let bounds = |id| st.doc.node(id).and_then(Node::geometric_bounds).map(|b| b.inflate(tolerance, tolerance));
let overlaps = |a: Rect, b: Rect| a.intersect(b).area() > 0.0;
let mut ids: Vec<NodeId> = eligible.iter().copied().filter(|id| bounds(*id).is_some_and(|b| overlaps(b, area))).collect();
if expand {
// A protruding line must still be split at the shape it passes through, even when
// the scribble itself never enters that shape. Include the connected neighbourhood.
loop {
let next: Vec<NodeId> = eligible
.iter()
.copied()
.filter(|id| {
!ids.contains(id)
&& bounds(*id).is_some_and(|bb| {
ids.iter()
.any(|other| st.doc.parent_of(*other) == st.doc.parent_of(*id) && bounds(*other).is_some_and(|b| overlaps(bb, b)))
})
})
.collect();
if next.is_empty() {
break;
}
ids.extend(next);
if ids.len() > MAX_SOURCES {
return Err(bad("shaper.scribble", "too many overlapping paths for Shaper"));
}
}
}
let ids = b::sorted_roots(&st.doc, &ids);
let parent = ids.first().and_then(|id| st.doc.position(*id)).map(|p| p.0);
if ids.iter().any(|id| st.doc.position(*id).map(|p| p.0) != parent) {
return Err(bad("shaper.scribble", "overlapping Shaper paths must be in the same layer or group"));
}
Ok(ids)
}
fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "shaper.scribble";
let input = p
.get("points")
.and_then(Value::as_array)
.filter(|a| !a.is_empty() && a.len() <= MAX_POINTS)
.ok_or_else(|| bad(C, "points must contain 1–4096 coordinates"))?;
let pts = input
.iter()
.map(|v| point_param(&json!({"p": v}), "p").filter(|p| finite(*p)).ok_or_else(|| bad(C, "invalid point")))
.collect::<Result<Vec<_>>>()?;
let tolerance = f64_or(p, "tolerance", 4.0);
if !tolerance.is_finite() || !(0.01..=1000.0).contains(&tolerance) {
return Err(bad(C, "invalid tolerance"));
}
let step = (tolerance / 2.0).max(0.5);
let sample_count = pts.windows(2).map(|w| (w[0].distance(w[1]) / step).ceil()).sum::<f64>();
if sample_count > 65_536.0 {
return Err(bad(C, "scribble is too long"));
}
let points = b::sample_polyline(&pts, step);
let roots = candidates(s, p, &points, tolerance)?;
if roots.is_empty() {
return Ok(json!({"id": null, "regions": 0, "strokes": 0}));
}
let mut src = vec![];
let mut spec = ShaperSpec::default();
let mut next_merge = 1u64;
for id in &roots {
let n = s.doc()?.doc.node(*id).ok_or(EngineError::NoNode(*id))?;
if let Some(previous) = n.shaper.as_deref() {
let offset = src.len();
let merge_offset = next_merge;
for mut e in previous.faces.clone() {
for i in &mut e.selector.sources {
*i = i.saturating_add(offset);
}
e.paint_source = e.paint_source.map(|i| i.saturating_add(offset));
e.merge = e.merge.map(|i| i.saturating_add(merge_offset));
next_merge = next_merge.max(e.merge.unwrap_or(0).saturating_add(1));
spec.faces.push(e);
}
for mut e in previous.edges.clone() {
e.source = e.source.saturating_add(offset);
for side in &mut e.sides {
for i in side {
*i = i.saturating_add(offset);
}
}
spec.edges.push(e);
}
src.extend(sources(n));
} else {
src.push(Arc::new(n.clone()));
}
}
let map = map_of(&src)?;
let edits = face_edits(&map, &src, &spec);
let filled = |i: usize| edits.get(&i).is_none_or(|e| !e.erase) && map.regions.get(i).and_then(|r| fill_source(&src, &r.0)).is_some();
let components = visible_components(&map, &src, &edits);
let faces: std::collections::BTreeSet<usize> =
points.iter().filter_map(|p| map.region_at(*p)).filter(|i| filled(*i)).filter_map(|i| components.get(i).copied()).collect();
let touched: Vec<usize> = components.iter().enumerate().filter(|(_, c)| faces.contains(c)).map(|(i, _)| i).collect();
let first = points.first().copied().unwrap_or(Point::ZERO);
let last = points.last().copied().unwrap_or(first);
let origin = map.region_at(first).filter(|i| filled(*i));
let exits = map.region_at(last).is_none();
let merge = origin.is_some() && faces.len() > 1 && !exits;
let paint_source =
origin.and_then(|i| edits.get(&i).and_then(|e| e.paint_source).or_else(|| map.regions.get(i).and_then(|r| fill_source(&src, &r.0))));
let origin_paint = origin.and_then(|i| edits.get(&i).and_then(|e| e.paint.clone()));
for &i in &touched {
if let Some(selector) = face_selector(&map, &src, i) {
spec.faces.push(FaceEdit {
selector,
erase: !merge,
erase_edges: exits,
paint_source,
merge: merge.then_some(next_merge),
paint: origin_paint.clone(),
});
}
}
let mut strokes = 0;
if touched.is_empty() {
let mut piece_ids = vec![];
for point in &points {
let index = match map.hit(*point, Some(tolerance)) {
Some(Hit::Line(i)) => Some(map.edges.len() + i),
Some(Hit::Edge(i)) => Some(i),
_ => None,
};
if let Some(i) = index
&& !piece_ids.contains(&i)
{
piece_ids.push(i);
}
}
for i in piece_ids {
if let Some(e) = pieces(&map).get(i).and_then(|p| edge_selector(&map, &src, p)) {
spec.edges.push(e);
strokes += 1;
}
}
}
if touched.is_empty() && strokes == 0 {
return Ok(json!({"id": null, "regions": 0, "strokes": 0}));
}
let id = s.edit("Shaper", |d, sel| {
let top = *roots.last().ok_or_else(|| bad(C, "no source art"))?;
let (parent, index, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let earlier = roots.iter().filter(|id| **id != top && d.position(**id).is_some_and(|p| p.1 < index)).count();
let source_id = d.alloc_id();
let mut source = Node::group(source_id, src.clone());
source.name = Some(SOURCES.into());
source.opacity = 0.0;
let mut children = vec![Arc::new(source)];
children.extend(generated(d, &src, &spec, &map)?);
let id = d.alloc_id();
let mut group = Node::group(id, children);
group.name = Some("Shaper Group".into());
group.shaper = Some(Box::new(spec));
for id in &roots {
d.remove(*id)?;
}
d.insert(parent, index.saturating_sub(earlier), group)?;
sel.set([id]);
Ok(id)
})?;
s.doc_mut()?.isolation = None;
Ok(json!({"id": id.0, "regions": touched.len(), "strokes": strokes}))
}
fn group_at(d: &Document, point: Point, construction: bool) -> Option<NodeId> {
let mut groups = vec![];
d.walk(|n| {
if n.shaper.is_some()
&& d.is_editable(n.id)
&& (if construction { source_group(n).and_then(Node::geometric_bounds) } else { n.geometric_bounds() })
.is_some_and(|b| b.inflate(6.0, 6.0).contains(point))
{
groups.push(n.id)
}
});
groups.last().copied()
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let point = point_param(p, "point").filter(|p| finite(*p)).ok_or_else(|| bad("shaper.select", "missing point"))?;
let st = s.doc()?;
let source = bool_or(p, "source", false);
let Some(group) = group_at(&st.doc, point, source) else { return Ok(json!({"id": null})) };
let node = st.doc.node(group).ok_or(EngineError::NoNode(group))?;
let target = if source {
sources(node)
.iter()
.rev()
.find(|n| {
b::node_outline(n)
.is_some_and(|(path, _)| path.to_bezpath().winding(point) != 0 || path.nearest(point).is_some_and(|(_, _, _, _, d)| d < 6.0))
})
.map(|n| n.id)
.unwrap_or(group)
} else if st.selection.contains(group) {
node.children()
.into_iter()
.flatten()
.skip(1)
.find(|n| {
n.name.as_deref().is_some_and(|n| n.starts_with(FILL_PREFIX)) && n.path_data().is_some_and(|p| p.to_bezpath().winding(point) != 0)
})
.map_or(group, |n| n.id)
} else {
group
};
let isolation = source.then(|| source_group(node).map(|n| n.id)).flatten();
s.select(|_, sel| sel.set([target]))?;
if let Some(id) = isolation {
s.doc_mut()?.isolation = Some(id);
}
Ok(json!({"id": target.0}))
}
fn target(s: &Session, p: &Value) -> Result<NodeId> {
let id = id_param(p, "id")
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
.ok_or_else(|| bad("shaper.release", "select a Shaper group"))?;
s.doc()?.doc.node(id).filter(|n| n.shaper.is_some()).ok_or_else(|| bad("shaper.release", "select a Shaper group"))?;
Ok(id)
}
fn release(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p)?;
let ids = s.edit("Release Shaper Group", |d, sel| {
let original = d.node(id).map(sources).ok_or(EngineError::NoNode(id))?;
let (parent, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
d.remove(id)?;
let mut ids = vec![];
for (i, n) in original.into_iter().enumerate() {
ids.push(d.insert(parent, index + i, (*n).clone())?);
}
sel.set(ids.iter().copied());
Ok(ids)
})?;
s.doc_mut()?.isolation = None;
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p)?;
s.edit("Expand Shaper Group", |d, sel| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
n.shaper = None;
n.name = None;
if let Some(children) = n.children_mut()
&& children.first().is_some_and(|c| c.name.as_deref() == Some(SOURCES))
{
children.remove(0);
}
sel.set([id]);
Ok(())
})?;
s.doc_mut()?.isolation = None;
Ok(json!({"id": id.0}))
}
+25 -20
View File
@@ -1100,27 +1100,30 @@ impl Session {
let before_sel = st.selection.clone();
let doc = Arc::make_mut(&mut st.doc);
let result = match f(doc, &mut st.selection) {
Ok(v) => {
// Text Wrap: area type follows its wrap objects; then threads re-flow.
cmd::textwrap::refresh(Arc::make_mut(&mut st.doc));
// Opacity-mask editing: the mask follows its art on the editing layer.
if st.doc.mask_edit.is_some() {
cmd::maskedit::sync(Arc::make_mut(&mut st.doc));
Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) {
Err(e) => Err(e),
Ok(()) => {
// Text Wrap: area type follows its wrap objects; then threads re-flow.
cmd::textwrap::refresh(Arc::make_mut(&mut st.doc));
// Opacity-mask editing: the mask follows its art on the editing layer.
if st.doc.mask_edit.is_some() {
cmd::maskedit::sync(Arc::make_mut(&mut st.doc));
}
// Threaded text re-flows when any of its frames changed.
if !st.doc.text_threads.is_empty() {
cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc));
}
// Asset Export: assets let go of deleted art.
if !st.doc.assets.is_empty() {
Arc::make_mut(&mut st.doc).prune_assets();
}
if doc_sane(&st.doc, &st.selection) {
Ok(v)
} else {
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
}
}
// Threaded text re-flows when any of its frames changed.
if !st.doc.text_threads.is_empty() {
cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc));
}
// Asset Export: assets let go of deleted art.
if !st.doc.assets.is_empty() {
Arc::make_mut(&mut st.doc).prune_assets();
}
if doc_sane(&st.doc, &st.selection) {
Ok(v)
} else {
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
}
}
},
Err(e) => Err(e),
};
match result {
@@ -1493,6 +1496,8 @@ mod tests_saveoptions;
#[cfg(test)]
mod tests_scalestrokes;
#[cfg(test)]
mod tests_shaper;
#[cfg(test)]
mod tests_slices;
#[cfg(test)]
mod tests_smartguides;
+79 -2
View File
@@ -1,6 +1,7 @@
use serde_json::json;
use super::*;
use vectorcraft_geom::Point;
use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
fn session() -> Session {
@@ -695,7 +696,7 @@ fn snap_to_pixel_rounds_drawing_and_moves() {
}
#[test]
fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_delete() {
fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_punch_fills() {
let mut s = session();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
@@ -725,10 +726,86 @@ fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_delete() {
"{:?}",
n.kind_label()
);
// A zig-zag over it deletes it.
// A zig-zag inside it clears its fill while retaining the editable original and stroke.
let zig: Vec<(f64, f64)> = (0..30).map(|i| (150.0 + (i % 2) as f64 * 80.0, 120.0 + i as f64 * 2.0)).collect();
stroke(&mut s, &zig);
assert_eq!(s.doc().unwrap().doc.node(id).unwrap(), &n);
let group = s.doc().unwrap().doc.node(s.doc().unwrap().selection.objects[0]).unwrap();
assert!(group.shaper.is_some());
assert!(group.children().unwrap().iter().skip(1).all(|n| n.appearance.fill_paint().is_none()));
assert!(group.children().unwrap().iter().skip(1).any(|n| !n.appearance.stroke_paint().is_none()));
}
#[test]
fn shaper_square_started_mid_edge_closes_on_pointer_release_and_undoes_once() {
let mut s = session();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
let undo = s.doc().unwrap().history.undo.len();
for (kind, x, y) in [
(PointerKind::Down, 150.0, 100.0),
(PointerKind::Drag, 200.0, 105.0),
(PointerKind::Drag, 194.0, 200.0),
(PointerKind::Drag, 110.0, 191.0),
(PointerKind::Drag, 100.0, 100.0),
(PointerKind::Up, 150.0, 100.0),
] {
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
}
let id = *s.doc().unwrap().selection.objects.first().expect("recognized square");
assert!(matches!(
s.doc().unwrap().doc.node(id).unwrap().kind,
vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Rectangle { .. }), .. }
));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).is_none());
s.execute("edit.redo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).is_some());
}
#[test]
fn shaper_creates_upright_or_inverted_live_triangles_with_one_undo_step() {
let v = ViewInfo { smart_guides: false, ..Default::default() };
for direction in [0.0_f64, 180.0] {
let mut s = session();
s.select_tool("shaper", v).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let corners: Vec<Point> = (0..3)
.map(|i| {
let angle = (direction + 7.0 - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 3.0;
Point::new(200.0 + 70.0 * angle.cos(), 200.0 + 70.0 * angle.sin())
})
.collect();
let mut pts = vec![];
for i in 0..3 {
for j in 0..20 {
pts.push(corners[i] + (corners[(i + 1) % 3] - corners[i]) * (j as f64 / 20.0));
}
}
// Begin midway along an edge; the release closes the final segment.
pts.rotate_left(7);
s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y), v).unwrap();
for p in &pts[1..] {
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x, p.y), v).unwrap();
}
s.pointer(&PointerEvent::new(PointerKind::Up, pts[0].x, pts[0].y), v).unwrap();
let id = *s.doc().unwrap().selection.objects.first().expect("recognized triangle");
let node = s.doc().unwrap().doc.node(id).unwrap().clone();
let vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Polygon { sides: 3, xf, .. }), .. } = &node.kind else {
panic!("expected live triangle, got {:?}", node.kind_label());
};
let [a, b, c, d, _, _] = xf.as_coeffs();
let (sin, cos) = direction.to_radians().sin_cos();
for (actual, expected) in [(a, cos), (b, sin), (c, -sin), (d, cos)] {
assert!((actual - expected).abs() < 1e-12, "direction {direction}, transform {xf:?}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).is_none());
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(id).unwrap(), &node);
}
}
#[test]
+347
View File
@@ -0,0 +1,347 @@
use super::*;
use serde_json::json;
use vectorcraft_doc::shaper::{FILL_PREFIX, SOURCES, STROKE_PREFIX};
use vectorcraft_doc::{LiveShape, Node, NodeKind};
use vectorcraft_geom::{Point, Shape as _};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap();
s
}
fn rectangle(s: &mut Session, x: f64, fill: &str) -> NodeId {
s.execute("paint.setFill", &json!({"color": fill, "ids": []})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x":x,"y":0,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap();
NodeId(id)
}
fn scribble(s: &mut Session, pts: &[[f64; 2]]) -> NodeId {
NodeId(s.execute("shaper.scribble", &json!({"points":pts})).unwrap()["id"].as_u64().unwrap())
}
fn node(s: &Session, id: NodeId) -> &Node {
s.doc().unwrap().doc.node(id).unwrap()
}
fn fill(s: &Session, id: NodeId, x: f64, y: f64) -> Option<String> {
node(s, id)
.children()
.unwrap()
.iter()
.filter(|n| n.name.as_deref() != Some(SOURCES))
.rev()
.find(|n| {
n.name.as_deref().is_some_and(|n| n.starts_with(FILL_PREFIX))
&& n.path_data().is_some_and(|p| p.to_bezpath().winding(Point::new(x, y)) != 0)
})
.map(|n| n.appearance.fill_paint().label())
}
fn stroke(s: &Session, id: NodeId, x: f64, y: f64) -> bool {
node(s, id).children().unwrap().iter().filter(|n| n.name.as_deref() != Some(SOURCES)).any(|n| {
n.name.as_deref().is_some_and(|n| n.starts_with(STROKE_PREFIX))
&& !n.appearance.stroke_paint().is_none()
&& n.path_data().and_then(|p| p.nearest(Point::new(x, y))).is_some_and(|(_, _, _, _, d)| d < 0.1)
})
}
#[test]
fn shaper_merges_with_origin_color_keeps_live_originals_and_undoes_once() {
for reverse in [false, true] {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
let b = rectangle(&mut s, 50.0, "#0000ff");
let originals = [node(&s, a).clone(), node(&s, b).clone()];
let undo = s.doc().unwrap().history.undo.len();
let mut pts = vec![[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0], [75.0, 80.0], [125.0, 85.0]];
if reverse {
pts.reverse();
}
let g = scribble(&mut s, &pts);
let color = if reverse { "#0000ff" } else { "#ff0000" };
for x in [25.0, 75.0, 125.0] {
assert_eq!(fill(&s, g, x, 50.0).as_deref(), Some(color));
}
for n in &originals {
assert_eq!(node(&s, n.id), n);
}
assert_eq!(node(&s, g).children().unwrap()[0].name.as_deref(), Some(SOURCES));
assert!(!stroke(&s, g, 50.0, 50.0));
assert!(stroke(&s, g, 0.0, 50.0));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(g).is_none());
for n in &originals {
assert_eq!(node(&s, n.id), n);
}
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some(color));
}
}
#[test]
fn shaper_interior_punch_removes_the_whole_visible_front_face() {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
let b = rectangle(&mut s, 50.0, "#0000ff");
let originals = [node(&s, a).clone(), node(&s, b).clone()];
let g = scribble(&mut s, &[[65.0, 20.0], [85.0, 40.0], [65.0, 60.0], [85.0, 80.0]]);
assert_eq!(fill(&s, g, 75.0, 50.0), None);
assert_eq!(fill(&s, g, 25.0, 50.0).as_deref(), Some("#ff0000"));
assert_eq!(fill(&s, g, 125.0, 50.0), None);
assert!(stroke(&s, g, 50.0, 50.0) && stroke(&s, g, 150.0, 50.0));
for n in &originals {
assert_eq!(node(&s, n.id), n);
}
}
#[test]
fn shaper_exiting_scribble_erases_only_touched_fill_and_boundary_strokes() {
let mut s = session();
rectangle(&mut s, 0.0, "#ff0000");
rectangle(&mut s, 50.0, "#0000ff");
let g = scribble(&mut s, &[[110.0, 20.0], [140.0, 35.0], [110.0, 50.0], [140.0, 65.0], [200.0, 70.0]]);
assert_eq!(fill(&s, g, 125.0, 50.0), None);
assert!(!stroke(&s, g, 150.0, 50.0));
assert_eq!(fill(&s, g, 25.0, 50.0).as_deref(), Some("#ff0000"));
assert_eq!(fill(&s, g, 75.0, 50.0), None);
assert!(stroke(&s, g, 0.0, 50.0));
}
#[test]
fn shaper_erases_only_protruding_stroke_pieces_keeps_fill_and_original_line() {
let mut s = session();
s.execute("paint.setFill", &json!({"color":"#ffff00"})).unwrap();
let circle = NodeId(s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap());
let line = NodeId(s.execute("shape.line", &json!({"x1":50,"y1":150,"x2":250,"y2":150})).unwrap()["id"].as_u64().unwrap());
let original_line = node(&s, line).clone();
let g = scribble(&mut s, &[[60.0, 140.0], [70.0, 160.0], [80.0, 140.0], [90.0, 160.0]]);
assert!(!stroke(&s, g, 75.0, 150.0));
assert!(stroke(&s, g, 150.0, 150.0));
assert!(stroke(&s, g, 225.0, 150.0));
assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00"));
assert_eq!(node(&s, line), &original_line);
assert!(matches!(node(&s, circle).kind, NodeKind::Path { live: Some(LiveShape::Ellipse { .. }), .. }));
let g = scribble(&mut s, &[[210.0, 140.0], [220.0, 160.0], [230.0, 140.0], [240.0, 160.0]]);
assert!(!stroke(&s, g, 225.0, 150.0));
assert!(stroke(&s, g, 150.0, 150.0));
assert_eq!(node(&s, line), &original_line);
}
#[test]
fn shaper_source_move_resize_native_roundtrip_and_release_preserve_live_art() {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
let b = rectangle(&mut s, 50.0, "#0000ff");
let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
s.execute("object.move", &json!({"ids":[a.0],"dx":20,"dy":10})).unwrap();
assert_eq!(fill(&s, g, 30.0, 15.0).as_deref(), Some("#ff0000"));
assert_eq!(node(&s, a).geometric_bounds().unwrap().x0, 20.0);
s.execute("select.set", &json!({"ids":[a.0]})).unwrap();
s.execute("object.setBounds", &json!({"width":120.0,"height":110.0,"reference":0})).unwrap();
assert!(matches!(node(&s, a).kind, NodeKind::Path { live: Some(LiveShape::Rectangle { .. }), .. }));
let doc = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
assert_eq!(back.layers, doc.layers);
let originals = [node(&s, a).clone(), node(&s, b).clone()];
s.execute("shaper.release", &json!({"id":g.0})).unwrap();
assert!(s.doc().unwrap().doc.node(g).is_none());
for n in &originals {
assert_eq!(node(&s, n.id), n);
}
}
#[test]
fn shaper_face_paint_survives_source_edits_and_expand_discards_only_sources() {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
rectangle(&mut s, 50.0, "#0000ff");
let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
s.execute("shaper.select", &json!({"point":[75,50]})).unwrap();
s.execute("paint.setFill", &json!({"color":"#00ff00"})).unwrap();
s.execute("object.move", &json!({"ids":[a.0],"dx":5,"dy":0})).unwrap();
assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00"));
s.execute("shaper.expand", &json!({"id":g.0})).unwrap();
assert!(node(&s, g).shaper.is_none());
assert!(s.doc().unwrap().doc.node(a).is_none());
assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00"));
}
#[test]
fn shaper_double_click_selects_an_original_for_construction_editing() {
let mut s = session();
let a = rectangle(&mut s, 0.0, "#ff0000");
rectangle(&mut s, 50.0, "#0000ff");
let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
let requests = s.pointer(&PointerEvent::new(PointerKind::DoubleClick, 25.0, 50.0), v).unwrap();
assert!(requests.iter().any(|r| matches!(r, UiRequest::SwitchTool(tool) if tool == "selection")));
s.select_tool("selection", v).unwrap();
assert_eq!(s.tool_id(), "selection");
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(s.doc().unwrap().isolation, Some(node(&s, g).children().unwrap()[0].id));
assert!(s.doc().unwrap().doc.is_editable(a));
s.execute("object.move", &json!({"dx":10,"dy":0})).unwrap();
assert_eq!(fill(&s, g, 15.0, 50.0).as_deref(), Some("#ff0000"));
s.execute("object.exitIsolation", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![g]);
}
#[test]
fn shaper_lines_keep_free_angles_with_or_without_shift() {
for shift in [false, true] {
let mut s = session();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
for (kind, x, y) in [(PointerKind::Down, 30.0, 40.0), (PointerKind::Drag, 130.0, 70.0), (PointerKind::Up, 230.0, 100.0)] {
s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods { shift, ..Default::default() }), v).unwrap();
}
let n = node(&s, s.doc().unwrap().selection.objects[0]);
assert!(matches!(n.kind,NodeKind::Path{live:Some(LiveShape::Line{a,b}),..} if a==Point::new(30.0,40.0) && b==Point::new(230.0,100.0)));
}
}
#[test]
fn shaper_rejects_oversized_or_invalid_gestures_without_changing_art() {
let mut s = session();
rectangle(&mut s, 0.0, "#ff0000");
let before = s.doc().unwrap().doc.clone();
let history = s.doc().unwrap().history.undo.len();
for params in [json!({"points":vec![[1.0,1.0];4097]}), json!({"points":[[1,1],[2,2]],"tolerance":0}), json!({"points":[[1e100,0]]})] {
assert!(s.execute("shaper.scribble", &params).is_err());
assert_eq!(s.doc().unwrap().doc.layers, before.layers);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
}
#[test]
fn shaper_tool_trims_both_diagonal_ends_and_only_construction_can_pick_them() {
use vectorcraft_doc::hit::{HitOptions, hit_test};
let mut s = session();
s.execute("paint.setFill", &json!({"color":"#ffff00"})).unwrap();
s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap();
let rotate = |x: f64, y: f64| {
let (x, y) = (x - 150.0, y - 150.0);
Point::new(150.0 + (x + y) / 2.0f64.sqrt(), 150.0 + (y - x) / 2.0f64.sqrt())
};
let (a, b) = (rotate(50.0, 150.0), rotate(250.0, 150.0));
let line = NodeId(s.execute("shape.line", &json!({"x1":a.x,"y1":a.y,"x2":b.x,"y2":b.y})).unwrap()["id"].as_u64().unwrap());
let original = node(&s, line).clone();
let v = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("shaper", v).unwrap();
for offset in [0.0, 150.0] {
let points = [(60.0, 140.0), (70.0, 160.0), (80.0, 140.0), (90.0, 160.0)];
for (i, (x, y)) in points.into_iter().enumerate() {
let p = rotate(x + offset, y);
let kind = if i == 0 {
PointerKind::Down
} else if i == 3 {
PointerKind::Up
} else {
PointerKind::Drag
};
s.pointer(&PointerEvent::new(kind, p.x, p.y), v).unwrap();
}
}
let g = s.doc().unwrap().selection.objects[0];
assert!(node(&s, g).shaper.is_some());
assert_eq!(node(&s, line), &original);
assert!(stroke(&s, g, 150.0, 150.0));
assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00"));
let sources = node(&s, g).children().unwrap()[0].id;
let doc = &s.doc().unwrap().doc;
for x in [75.0, 225.0] {
let p = rotate(x, 150.0);
assert!(!stroke(&s, g, p.x, p.y));
assert!(!node(&s, g).geometric_bounds().unwrap().contains(p));
assert!(!node(&s, g).visual_bounds().unwrap().contains(p));
assert!(hit_test(doc, p, HitOptions { tol: 0.5, ..Default::default() }).is_none());
assert_eq!(hit_test(doc, p, HitOptions { tol: 0.5, scope: Some(sources), ..Default::default() }).unwrap().leaf, line);
}
let p = rotate(75.0, 150.0);
s.execute("shaper.select", &json!({"point":[p.x,p.y],"source":true})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![line]);
}
#[test]
fn shaper_three_circles_erase_a_visible_fill_and_keep_the_complete_shared_curve() {
for exits in [false, true] {
let mut s = session();
let mut originals = vec![];
for (x, y, fill) in [(100.0, 20.0, "#ff0000"), (20.0, 130.0, "#ffffff"), (170.0, 130.0, "#ffff00")] {
s.execute("paint.setFill", &json!({"color":fill,"ids":[]})).unwrap();
let id = NodeId(s.execute("shape.ellipse", &json!({"x":x,"y":y,"width":200,"height":200})).unwrap()["id"].as_u64().unwrap());
originals.push(node(&s, id).clone());
}
let mut points = vec![[290.0, 210.0], [330.0, 225.0], [290.0, 240.0], [330.0, 255.0]];
if exits {
points.push([410.0, 260.0]);
}
let g = scribble(&mut s, &points);
for (x, y) in [(330.0, 230.0), (190.0, 230.0), (250.0, 180.0)] {
assert_eq!(fill(&s, g, x, y), None, "all of the visible yellow face is erased, including covered intersections");
}
assert_eq!(stroke(&s, g, 370.0, 230.0), !exits);
for angle in [145.0_f64, 160.0, 180.0, 200.0, 220.0, 240.0, 260.0, 280.0] {
let (sin, cos) = angle.to_radians().sin_cos();
assert!(stroke(&s, g, 270.0 + 100.0 * cos, 230.0 + 100.0 * sin), "shared curve remains complete at {angle}°, exiting {exits}");
}
if exits {
// The two new corners must be actual path joins, rather than meeting butt caps.
for corner in [Point::new(298.975, 134.288), Point::new(195.0, 296.144)] {
assert!(
node(&s, g)
.children()
.unwrap()
.iter()
.filter(|n| n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX)))
.flat_map(|n| n.path_data().unwrap().subpaths.iter())
.flat_map(|sp| &sp.anchors)
.any(|a| a.p.distance(corner) < 0.1 && a.has_in() && a.has_out()),
"joined corner at {corner:?}"
);
}
}
assert_eq!(fill(&s, g, 110.0, 230.0).as_deref(), Some("#ffffff"));
assert_eq!(fill(&s, g, 200.0, 80.0).as_deref(), Some("#ff0000"));
for n in originals {
assert_eq!(node(&s, n.id), &n);
}
}
}
#[test]
fn shaper_visible_dividing_stroke_keeps_fill_erasure_on_one_side() {
let mut s = session();
s.execute("paint.setFill", &json!({"color":"#ffff00","ids":[]})).unwrap();
s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap();
s.execute("shape.line", &json!({"x1":100,"y1":150,"x2":200,"y2":150})).unwrap();
let g = scribble(&mut s, &[[140.0, 125.0], [160.0, 135.0], [140.0, 140.0], [160.0, 145.0]]);
assert_eq!(fill(&s, g, 150.0, 130.0), None);
assert_eq!(fill(&s, g, 150.0, 170.0).as_deref(), Some("#ffff00"));
assert!(stroke(&s, g, 150.0, 150.0));
}
#[test]
fn shaper_joined_outline_keeps_its_stroke_paint_after_source_edits() {
let mut s = session();
let mut ids = vec![];
for (x, color) in [(0, "#ff0000"), (50, "#ffff00")] {
s.execute("paint.setFill", &json!({"color":color,"ids":[]})).unwrap();
ids.push(NodeId(s.execute("shape.ellipse", &json!({"x":x,"y":0,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap()));
}
let g = scribble(&mut s, &[[20.0, 40.0], [70.0, 45.0], [30.0, 50.0], [125.0, 55.0], [75.0, 60.0], [125.0, 65.0]]);
let outlines: Vec<_> =
node(&s, g).children().unwrap().iter().filter(|n| n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX))).collect();
assert_eq!(outlines.len(), 1);
assert!(outlines[0].path_data().unwrap().subpaths[0].closed);
let outline = outlines[0].id;
s.execute("paint.setStroke", &json!({"color":"#00ff00","ids":[outline.0]})).unwrap();
s.execute("object.move", &json!({"ids":[ids[0].0],"dx":10,"dy":0})).unwrap();
let outlines: Vec<_> =
node(&s, g).children().unwrap().iter().filter(|n| n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX))).collect();
assert_eq!(outlines.len(), 1);
assert_eq!(outlines[0].appearance.stroke_paint().label(), "#00ff00");
assert!(outlines[0].path_data().unwrap().subpaths[0].closed);
for id in ids {
assert_ne!(node(&s, id).appearance.stroke_paint().label(), "#00ff00");
}
}